Skip to content

Commit e2dfe1a

Browse files
committed
feat: truncate long descriptions in interactive task select to terminal width
Prevents line wrapping that breaks cursor-based clearing when navigating the interactive task list. Descriptions exceeding terminal width are truncated with an ellipsis. Non-interactive (piped) output is unaffected. Moves the long-cmd test into a dedicated task-select-truncate fixture so it does not appear in every task-select snapshot.
1 parent 0fb47d2 commit e2dfe1a

10 files changed

Lines changed: 238 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ Test fixtures and snapshots:
4848
- If a feature can't work on a platform, it shouldn't be added
4949

5050
2. **Windows Cross-Testing from macOS**:
51+
`cargo xtest` cross-compiles the test binary and runs it on a real remote Windows environment (not emulation). The filesystem is bridged so the test can access local fixture files.
5152
```bash
52-
# Test on Windows (aarch64) from macOS via cross-compilation
5353
cargo xtest --builder cargo-xwin --target aarch64-pc-windows-msvc -p <package> --test <test>
5454

5555
# Examples:

crates/vite_select/src/interactive.rs

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ pub struct RenderParams<'a> {
113113
pub query: Option<&'a str>,
114114
/// `"\r\n"` for raw mode, `"\n"` for normal.
115115
pub line_ending: &'a str,
116+
/// Maximum visible width per line. Descriptions are truncated to prevent
117+
/// line wrapping, which would break cursor-based clearing in interactive mode.
118+
/// Use `usize::MAX` to disable truncation (non-interactive / piped output).
119+
pub max_line_width: usize,
116120
}
117121

118122
/// Render the item list. Shared rendering logic used by both interactive
@@ -129,6 +133,7 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho
129133
header,
130134
query,
131135
line_ending,
136+
max_line_width: _,
132137
} = params;
133138

134139
let mut lines = 0usize;
@@ -164,8 +169,24 @@ pub fn render_items(writer: &mut impl Write, params: &RenderParams<'_>) -> anyho
164169
let item_idx = filtered[vi];
165170
let item = &items[item_idx];
166171
let is_selected = *selected_in_filtered == Some(vi);
172+
173+
// Truncate description to prevent line wrapping.
174+
// Line layout: prefix (2: "> " or " ") + label + ": " (2) + description
175+
let prefix_and_label_width = 2 + item.label.chars().count() + 2;
176+
let max_desc_chars = params.max_line_width.saturating_sub(prefix_and_label_width);
167177
let desc_str = item.description.as_str();
168-
let desc = desc_str.if_supports_color(Stream::Stdout, |s| s.cyan());
178+
let desc_char_count = desc_str.chars().count();
179+
let truncated;
180+
let display_desc = if desc_char_count > max_desc_chars {
181+
let take = max_desc_chars.saturating_sub(1); // room for "…"
182+
#[expect(clippy::disallowed_types, reason = "intermediate collect for char truncation")]
183+
let prefix: std::string::String = desc_str.chars().take(take).collect();
184+
truncated = vite_str::format!("{prefix}\u{2026}");
185+
truncated.as_str()
186+
} else {
187+
desc_str
188+
};
189+
let desc = display_desc.if_supports_color(Stream::Stdout, |s| s.cyan());
169190

170191
if is_selected {
171192
write!(
@@ -214,6 +235,9 @@ fn render(
214235
)?;
215236
}
216237

238+
// Query terminal width on each render to handle resize
239+
let max_line_width = terminal::size().map_or(80, |(w, _)| w as usize);
240+
217241
let lines = render_items(
218242
stdout,
219243
&RenderParams {
@@ -225,6 +249,7 @@ fn render(
225249
header,
226250
query: Some(&state.query),
227251
line_ending: "\r\n",
252+
max_line_width,
228253
},
229254
)?;
230255

@@ -320,3 +345,121 @@ fn cleanup(stdout: &mut impl Write, state: &State<'_>) -> anyhow::Result<()> {
320345
stdout.flush()?;
321346
Ok(())
322347
}
348+
349+
#[cfg(test)]
350+
mod tests {
351+
use super::*;
352+
353+
fn make_items(items: &[(&str, &str)]) -> Vec<SelectItem> {
354+
items
355+
.iter()
356+
.map(|(label, desc)| SelectItem { label: (*label).into(), description: (*desc).into() })
357+
.collect()
358+
}
359+
360+
/// Strip ANSI escape sequences from output for easier assertions.
361+
#[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")]
362+
fn strip_ansi(s: &str) -> String {
363+
let mut result = String::new();
364+
let mut chars = s.chars();
365+
while let Some(c) = chars.next() {
366+
if c == '\x1b' {
367+
// Skip until we hit a letter (end of escape sequence)
368+
for c in chars.by_ref() {
369+
if c.is_ascii_alphabetic() {
370+
break;
371+
}
372+
}
373+
} else {
374+
result.push(c);
375+
}
376+
}
377+
result
378+
}
379+
380+
#[expect(clippy::disallowed_types, reason = "test helper building arbitrary output string")]
381+
fn render_to_string(items: &[SelectItem], max_line_width: usize) -> String {
382+
let filtered: Vec<usize> = (0..items.len()).collect();
383+
let len = filtered.len();
384+
let mut buf = Vec::new();
385+
render_items(
386+
&mut buf,
387+
&RenderParams {
388+
items,
389+
filtered: &filtered,
390+
selected_in_filtered: Some(0),
391+
visible_range: 0..len,
392+
hidden_count: 0,
393+
header: None,
394+
query: None,
395+
line_ending: "\n",
396+
max_line_width,
397+
},
398+
)
399+
.unwrap();
400+
strip_ansi(&String::from_utf8(buf).unwrap())
401+
}
402+
403+
#[test]
404+
fn truncates_long_description() {
405+
let items = make_items(&[("build", "a]really long command that exceeds the width limit")]);
406+
// " build: a really long..." = 2 + 5 + 2 + desc
407+
// max_line_width = 30 => max_desc = 30 - 9 = 21 chars
408+
let output = render_to_string(&items, 30);
409+
let line = output.lines().next().unwrap();
410+
// "> " (2) + "build" (5) + ": " (2) + desc (21) = 30
411+
assert!(
412+
line.chars().count() <= 30,
413+
"line should be at most 30 chars, got {}: {line:?}",
414+
line.chars().count()
415+
);
416+
assert!(line.contains('\u{2026}'), "truncated line should contain ellipsis: {line:?}");
417+
}
418+
419+
#[test]
420+
fn does_not_truncate_short_description() {
421+
let items = make_items(&[("build", "echo ok")]);
422+
let output = render_to_string(&items, 80);
423+
let line = output.lines().next().unwrap();
424+
assert!(!line.contains('\u{2026}'), "short line should not be truncated: {line:?}");
425+
assert!(line.contains("echo ok"), "full description should appear: {line:?}");
426+
}
427+
428+
#[test]
429+
fn max_line_width_max_disables_truncation() {
430+
let long_desc = "x".repeat(500);
431+
let items = make_items(&[("build", &long_desc)]);
432+
let output = render_to_string(&items, usize::MAX);
433+
let line = output.lines().next().unwrap();
434+
assert!(!line.contains('\u{2026}'), "usize::MAX should disable truncation: {line:?}");
435+
assert!(line.contains(&long_desc), "full 500-char description should appear");
436+
}
437+
438+
#[test]
439+
fn each_line_fits_within_max_width() {
440+
let items = make_items(&[
441+
("build", "tsc -p tsconfig.build.json && echo done"),
442+
("lint", "oxlint --fix"),
443+
("test", "vitest run --reporter=verbose --coverage"),
444+
]);
445+
let max_width = 40;
446+
let output = render_to_string(&items, max_width);
447+
for line in output.lines() {
448+
assert!(
449+
line.chars().count() <= max_width,
450+
"line exceeds max width {max_width}: ({}) {line:?}",
451+
line.chars().count()
452+
);
453+
}
454+
}
455+
456+
#[test]
457+
fn truncation_preserves_label() {
458+
let items = make_items(&[("my-task", "very long description here")]);
459+
// " my-task: very..." => prefix(2) + label(7) + sep(2) + desc
460+
// max_line_width = 20 => max_desc = 20 - 11 = 9 chars
461+
let output = render_to_string(&items, 20);
462+
let line = output.lines().next().unwrap();
463+
assert!(line.contains("my-task"), "label should always be preserved: {line:?}");
464+
}
465+
}

crates/vite_select/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ fn non_interactive(
106106
header,
107107
query: None,
108108
line_ending: "\n",
109+
max_line_width: usize::MAX,
109110
},
110111
)?;
111112

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "task-select-truncate-test",
3+
"private": true
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "app",
3+
"private": true
4+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"tasks": {
3+
"build": {
4+
"command": "echo build app"
5+
},
6+
"lint": {
7+
"command": "echo lint app"
8+
},
9+
"long-cmd": {
10+
"command": "echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
11+
},
12+
"test": {
13+
"command": "echo test app"
14+
}
15+
}
16+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
packages:
2+
- packages/*
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Interactive: long commands are truncated to terminal width (no line wrapping)
2+
[[e2e]]
3+
name = "interactive long command truncated"
4+
cwd = "packages/app"
5+
steps = [
6+
{ command = "vp run", interactions = [{ "expect-milestone" = "task-select::0" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::1" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::2" }, { "write-key" = "down" }, { "expect-milestone" = "task-select::3" }, { "write-key" = "up" }, { "expect-milestone" = "task-select::2" }, { "write-key" = "enter" }] },
7+
]
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
> vp run
6+
@ expect-milestone: task-select::0
7+
Search task (↑/to move, enter to select):
8+
> build: echo build app
9+
lint: echo lint app
10+
long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
11+
test: echo test app
12+
@ write-key: down
13+
@ expect-milestone: task-select::1
14+
Search task (↑/to move, enter to select):
15+
build: echo build app
16+
> lint: echo lint app
17+
long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
18+
test: echo test app
19+
@ write-key: down
20+
@ expect-milestone: task-select::2
21+
Search task (↑/to move, enter to select):
22+
build: echo build app
23+
lint: echo lint app
24+
> long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
25+
test: echo test app
26+
@ write-key: down
27+
@ expect-milestone: task-select::3
28+
Search task (↑/to move, enter to select):
29+
build: echo build app
30+
lint: echo lint app
31+
long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
32+
> test: echo test app
33+
@ write-key: up
34+
@ expect-milestone: task-select::2
35+
Search task (↑/to move, enter to select):
36+
build: echo build app
37+
lint: echo lint app
38+
> long-cmd: echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
39+
test: echo test app
40+
@ write-key: enter
41+
~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaacache disabled: built-in command
42+
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
43+
44+
45+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46+
Vite+ Task RunnerExecution Summary
47+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48+
49+
Statistics: 1 tasks0 cache hits0 cache misses1 cache disabled
50+
Performance: 0% cache hit rate
51+
52+
Task Details:
53+
────────────────────────────────────────────────
54+
[1] app#long-cmd: ~/packages/app$ echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
55+
Cache disabled for built-in command
56+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"tasks": {}
3+
}

0 commit comments

Comments
 (0)