Skip to content

Commit 6d2d3e6

Browse files
montfortclaude
andauthored
fix(cli): allocate table columns by water-fill, not proportionally (#54)
When a Markdown table had one very wide column (e.g. `Description`, `Remediation`) next to several narrow ones (`CWE`, `Severity`), the old allocator distributed the terminal budget proportionally to each column's natural width. The narrow columns therefore received more space than they could ever use, while the wide column had to wrap across many rows. As the terminal was resized, columns alternated between "just wide enough" and "wrapping heavily" in a pattern that felt like the fix kept toggling on and off. Replace that pass with classic water-filling: sort columns by natural width ascending and, for each in turn, give it the smaller of its natural width and a fair share of the remaining budget. Narrow columns settle early with exactly what they need; all leftover budget rolls over to the wide columns. Result: `CWE` gets 7 cols, `Severity` gets 8, and `Description` absorbs everything else, no matter how the terminal is resized. Added two tests: - `water_fill_narrow_columns_do_not_hoard` — regression for the exact SEC-doc table layout the user reported. - `water_fill_tight_budget_does_not_overflow` — ensures the total content width never exceeds the budget even when the sum of naturals does, and no column is inflated past its natural. Bump CLI to 3.2.5. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1eda03c commit 6d2d3e6

10 files changed

Lines changed: 134 additions & 49 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## CLI 3.2.5 — Smarter Table Column Allocation in `explore`
11+
12+
### Fixed (CLI)
13+
- Table column widths in `devtrail explore` are now allocated with a water-fill strategy: narrow columns (e.g. `CWE`, `Severity`) receive exactly their natural width and the excess flows to the columns that need it (e.g. `Description`, `Remediation`). Previously, a proportional pass gave every column a slice of the terminal budget regardless of need, which caused the narrow columns to hoard space and the wide ones to wrap unnecessarily. This is what produced the "fixes itself, breaks, fixes itself again" behavior users saw while resizing the terminal.
14+
15+
---
16+
1017
## CLI 3.2.4 — Unicode-Safe Rendering Across TUI and Commands
1118

1219
### Fixed (CLI)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ DevTrail uses independent version tags for each component:
150150
| Component | Tag prefix | Example | Includes |
151151
|-----------|-----------|---------|----------|
152152
| Framework | `fw-` | `fw-4.2.0` | Templates (12 types), governance, directives |
153-
| CLI | `cli-` | `cli-3.2.4` | The `devtrail` binary |
153+
| CLI | `cli-` | `cli-3.2.5` | The `devtrail` binary |
154154

155155
Check installed versions with `devtrail status` or `devtrail about`.
156156

cli/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "devtrail-cli"
3-
version = "3.2.4"
3+
version = "3.2.5"
44
edition = "2021"
55
description = "CLI tool for DevTrail - Documentation Governance for AI-Assisted Development"
66
license = "MIT"

cli/src/tui/markdown.rs

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -354,8 +354,9 @@ fn compute_column_widths(
354354
}
355355
}
356356
}
357+
const MIN_COL: usize = 3;
357358
for w in &mut natural {
358-
*w = (*w).max(3);
359+
*w = (*w).max(MIN_COL);
359360
}
360361

361362
// Overhead: indent is handled outside; here we account for borders
@@ -368,32 +369,36 @@ fn compute_column_widths(
368369
return natural;
369370
}
370371

371-
// Distribute available width proportionally
372+
// Water-fill allocation (classic: assign in ascending order of demand).
373+
// For each column in order of natural width ascending, give it the
374+
// smaller of its natural width and a fair share of the remaining
375+
// budget. Narrow columns settle early with exactly what they need; the
376+
// leftover rolls over to the wider columns that still have deficit.
377+
// The old proportional pass gave every column a slice of the budget
378+
// regardless of need, so narrow columns hoarded space while wide ones
379+
// wrapped unnecessarily — exactly the "growing then shrinking" behavior
380+
// users saw when resizing the terminal.
381+
let mut order: Vec<usize> = (0..num_cols).collect();
382+
order.sort_by_key(|&i| natural[i]);
383+
372384
let mut widths = vec![0usize; num_cols];
373-
for (i, &nat) in natural.iter().enumerate() {
374-
widths[i] = ((nat as f64 / total_natural as f64) * content_budget as f64).floor() as usize;
375-
widths[i] = widths[i].max(3);
376-
}
377-
378-
// Distribute any remaining space to the largest columns
379-
let assigned: usize = widths.iter().sum();
380-
let mut remaining = content_budget.saturating_sub(assigned);
381-
while remaining > 0 {
382-
// Find column with largest deficit
383-
let mut best = 0;
384-
let mut best_deficit = 0usize;
385-
for (i, (&nat, &w)) in natural.iter().zip(widths.iter()).enumerate() {
386-
let deficit = nat.saturating_sub(w);
387-
if deficit > best_deficit {
388-
best_deficit = deficit;
389-
best = i;
390-
}
391-
}
392-
if best_deficit == 0 {
393-
break;
394-
}
395-
widths[best] += 1;
396-
remaining -= 1;
385+
let mut remaining_budget = content_budget;
386+
let mut cols_left = num_cols;
387+
388+
for &i in &order {
389+
let fair_share = if cols_left > 0 { remaining_budget / cols_left } else { 0 };
390+
let alloc = if natural[i] <= fair_share {
391+
// Column fits in its fair share — give it the full natural width.
392+
natural[i]
393+
} else {
394+
// Column wants more than fair share — give it the fair share,
395+
// but never less than MIN_COL (so it stays visible) and never
396+
// more than its natural width.
397+
fair_share.max(MIN_COL).min(natural[i])
398+
};
399+
widths[i] = alloc;
400+
remaining_budget = remaining_budget.saturating_sub(alloc);
401+
cols_left -= 1;
397402
}
398403

399404
widths
@@ -716,4 +721,77 @@ mod tests {
716721
let content: usize = widths.iter().sum();
717722
assert!(content + border_overhead <= available);
718723
}
724+
725+
/// Regression test for the "wide column starves" bug. With a mix of one
726+
/// very wide column and several narrow ones, the old proportional
727+
/// allocator gave every column a share of the budget regardless of
728+
/// need, so narrow columns ended up with more space than they could
729+
/// use while the wide one still wrapped. Water-fill should give each
730+
/// narrow column exactly its natural width and pour the rest into the
731+
/// wide column.
732+
#[test]
733+
fn water_fill_narrow_columns_do_not_hoard() {
734+
let header: Vec<String> = vec![
735+
"Vuln ID".to_string(),
736+
"CWE".to_string(),
737+
"Severity".to_string(),
738+
"Description".to_string(),
739+
];
740+
let body: Vec<Vec<String>> = vec![
741+
vec![
742+
"VULN-001".to_string(),
743+
"CWE-863".to_string(),
744+
"7.1".to_string(),
745+
// Very long description that demands most of the budget.
746+
"RevokeAPIKey does not validate that key_id belongs to the service_id parameter. SQL query UpdateAPIKeyStatus filters only by key_id.".to_string(),
747+
],
748+
];
749+
let available = 120;
750+
let widths = compute_column_widths(&header, &body, available);
751+
assert_eq!(widths.len(), 4);
752+
753+
// Natural widths: Vuln=max("Vuln ID"=7, "VULN-001"=8)=8, CWE=7,
754+
// Severity=8, Description≈137. Narrow columns must receive exactly
755+
// their natural width; the rest flows to Description.
756+
assert_eq!(widths[0], 8, "Vuln ID column got {} cols, expected 8", widths[0]);
757+
assert_eq!(widths[1], 7, "CWE column got {} cols, expected 7", widths[1]);
758+
assert_eq!(widths[2], 8, "Severity column got {} cols, expected 8", widths[2]);
759+
760+
let border_overhead = 2 + (4 - 1) * 3 + 2; // 13
761+
let expected_desc = available - border_overhead - (8 + 7 + 8);
762+
assert_eq!(
763+
widths[3], expected_desc,
764+
"Description got {} cols, expected {} (all leftover)",
765+
widths[3], expected_desc,
766+
);
767+
}
768+
769+
#[test]
770+
fn water_fill_tight_budget_does_not_overflow() {
771+
// Budget smaller than sum of naturals; no column should exceed its
772+
// natural width and the total must fit in the content budget.
773+
let header: Vec<String> = vec!["A".into(), "B".into(), "C".into(), "D".into()];
774+
let body: Vec<Vec<String>> = vec![vec![
775+
"short".into(),
776+
"mediumtext".into(),
777+
"wide column content here".into(),
778+
"xxx".into(),
779+
]];
780+
let available = 40;
781+
let widths = compute_column_widths(&header, &body, available);
782+
let border_overhead = 2 + (widths.len() - 1) * 3 + 2;
783+
let total: usize = widths.iter().sum();
784+
assert!(
785+
total + border_overhead <= available,
786+
"total {} + overhead {} exceeds budget {}",
787+
total,
788+
border_overhead,
789+
available,
790+
);
791+
// No column exceeds its natural width.
792+
let naturals = [5usize, 10, 24, 3];
793+
for (i, w) in widths.iter().enumerate() {
794+
assert!(*w <= naturals[i].max(3), "col {i} exceeded its natural");
795+
}
796+
}
719797
}

docs/adopters/CLI-REFERENCE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ DevTrail uses **independent version tags** for each component:
4949
| Component | Tag prefix | Example | What it includes |
5050
|-----------|-----------|---------|------------------|
5151
| Framework | `fw-` | `fw-4.2.0` | Templates (12 types), governance docs, directives |
52-
| CLI | `cli-` | `cli-3.2.4` | The `devtrail` binary |
52+
| CLI | `cli-` | `cli-3.2.5` | The `devtrail` binary |
5353

5454
Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa.
5555

@@ -110,7 +110,7 @@ $ devtrail update
110110
Updating framework...
111111
✔ Framework updated to fw-4.2.0
112112
Updating CLI...
113-
✔ CLI updated to cli-3.2.4
113+
✔ CLI updated to cli-3.2.5
114114
```
115115

116116
---
@@ -143,11 +143,11 @@ Use `--method` to override auto-detection: `--method=github` or `--method=cargo`
143143

144144
```bash
145145
$ devtrail update-cli
146-
✔ CLI updated to cli-3.2.4
146+
✔ CLI updated to cli-3.2.5
147147

148148
$ devtrail update-cli --method=cargo
149149
Compiling from source, this may take a few minutes...
150-
✔ CLI updated to cli-3.2.4
150+
✔ CLI updated to cli-3.2.5
151151
```
152152

153153
---
@@ -210,7 +210,7 @@ $ devtrail status
210210
┌───────────┬──────────────────────────┐
211211
│ Path │ /home/user/my-project │
212212
│ Framework │ fw-4.2.0 │
213-
│ CLI │ cli-3.2.4
213+
│ CLI │ cli-3.2.5
214214
│ Language │ en │
215215
└───────────┴──────────────────────────┘
216216
@@ -634,7 +634,7 @@ Show version, authorship, and license information.
634634
```bash
635635
$ devtrail about
636636
DevTrail CLI
637-
CLI version: cli-3.2.4
637+
CLI version: cli-3.2.5
638638
Framework version: fw-4.2.0
639639
Author: Strange Days Tech, S.A.S.
640640
License: MIT

docs/i18n/es/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ DevTrail usa tags de versión independientes para cada componente:
150150
| Componente | Prefijo de tag | Ejemplo | Incluye |
151151
|------------|---------------|---------|---------|
152152
| Framework | `fw-` | `fw-4.2.0` | Plantillas (12 tipos), gobernanza, directivas |
153-
| CLI | `cli-` | `cli-3.2.4` | El binario `devtrail` |
153+
| CLI | `cli-` | `cli-3.2.5` | El binario `devtrail` |
154154

155155
Verifica las versiones instaladas con `devtrail status` o `devtrail about`.
156156

docs/i18n/es/adopters/CLI-REFERENCE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ DevTrail usa **tags de versión independientes** para cada componente:
4949
| Componente | Prefijo de tag | Ejemplo | Qué incluye |
5050
|------------|---------------|---------|-------------|
5151
| Framework | `fw-` | `fw-4.2.0` | Plantillas (12 tipos), docs de gobernanza, directivas |
52-
| CLI | `cli-` | `cli-3.2.4` | El binario `devtrail` |
52+
| CLI | `cli-` | `cli-3.2.5` | El binario `devtrail` |
5353

5454
Framework y CLI se publican de forma independiente. Una actualización del framework no requiere actualización del CLI, y viceversa.
5555

@@ -109,7 +109,7 @@ $ devtrail update
109109
Updating framework...
110110
✔ Framework updated to fw-4.2.0
111111
Updating CLI...
112-
✔ CLI updated to cli-3.2.4
112+
✔ CLI updated to cli-3.2.5
113113
```
114114

115115
---
@@ -142,11 +142,11 @@ Usa `--method` para forzar el método: `--method=github` o `--method=cargo`.
142142

143143
```bash
144144
$ devtrail update-cli
145-
✔ CLI updated to cli-3.2.4
145+
✔ CLI updated to cli-3.2.5
146146

147147
$ devtrail update-cli --method=cargo
148148
Compiling from source, this may take a few minutes...
149-
✔ CLI updated to cli-3.2.4
149+
✔ CLI updated to cli-3.2.5
150150
```
151151

152152
---
@@ -204,7 +204,7 @@ DevTrail Status
204204
───────────────
205205
Path: /home/user/my-project
206206
Framework version: fw-4.2.0
207-
CLI version: cli-3.2.4
207+
CLI version: cli-3.2.5
208208
Language: en
209209
Structure: ✔ Complete
210210

@@ -513,7 +513,7 @@ Muestra información de versión, autoría y licencia.
513513
```bash
514514
$ devtrail about
515515
DevTrail CLI
516-
CLI version: cli-3.2.4
516+
CLI version: cli-3.2.5
517517
Framework version: fw-4.2.0
518518
Author: Strange Days Tech, S.A.S.
519519
License: MIT

docs/i18n/zh-CN/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ DevTrail 为每个组件使用独立的版本标签:
150150
| 组件 | 标签前缀 | 示例 | 包含内容 |
151151
|------|----------|------|----------|
152152
| Framework | `fw-` | `fw-4.2.0` | 模板(12 种类型)、治理文档、指令 |
153-
| CLI | `cli-` | `cli-3.2.4` | `devtrail` 二进制文件 |
153+
| CLI | `cli-` | `cli-3.2.5` | `devtrail` 二进制文件 |
154154

155155
使用 `devtrail status``devtrail about` 查看已安装的版本。
156156

docs/i18n/zh-CN/adopters/CLI-REFERENCE.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ DevTrail 为每个组件使用**独立的版本标签**:
4949
| 组件 | 标签前缀 | 示例 | 包含内容 |
5050
|------|----------|------|----------|
5151
| Framework | `fw-` | `fw-4.2.0` | 模板(12 种类型)、治理文档、指令 |
52-
| CLI | `cli-` | `cli-3.2.4` | `devtrail` 二进制文件 |
52+
| CLI | `cli-` | `cli-3.2.5` | `devtrail` 二进制文件 |
5353

5454
Framework 和 CLI 独立发布。Framework 更新不需要 CLI 更新,反之亦然。
5555

@@ -110,7 +110,7 @@ $ devtrail update
110110
Updating framework...
111111
✔ Framework updated to fw-4.2.0
112112
Updating CLI...
113-
✔ CLI updated to cli-3.2.4
113+
✔ CLI updated to cli-3.2.5
114114
```
115115

116116
---
@@ -143,11 +143,11 @@ $ devtrail update-framework
143143

144144
```bash
145145
$ devtrail update-cli
146-
✔ CLI updated to cli-3.2.4
146+
✔ CLI updated to cli-3.2.5
147147

148148
$ devtrail update-cli --method=cargo
149149
Compiling from source, this may take a few minutes...
150-
✔ CLI updated to cli-3.2.4
150+
✔ CLI updated to cli-3.2.5
151151
```
152152

153153
---
@@ -210,7 +210,7 @@ $ devtrail status
210210
┌───────────┬──────────────────────────┐
211211
│ Path │ /home/user/my-project │
212212
│ Framework │ fw-4.2.0 │
213-
│ CLI │ cli-3.2.4
213+
│ CLI │ cli-3.2.5
214214
│ Language │ en │
215215
└───────────┴──────────────────────────┘
216216
@@ -634,7 +634,7 @@ $ devtrail explore
634634
```bash
635635
$ devtrail about
636636
DevTrail CLI
637-
CLI version: cli-3.2.4
637+
CLI version: cli-3.2.5
638638
Framework version: fw-4.2.0
639639
Author: Strange Days Tech, S.A.S.
640640
License: MIT

0 commit comments

Comments
 (0)