Skip to content

Commit bad8334

Browse files
branchseerclaude
andauthored
feat: tree-view task selector with grouped packages (#219)
## Summary Redesigns the interactive task selector (`vp run`) to group tasks by package in a tree view, making it much easier to navigate workspaces with many packages. ### Before (flat list) ``` › app#build: echo build app app#lint: echo lint app app#test: echo test app lib#build: echo build lib lib#lint: echo lint lib lib#test: echo test lib lib#typecheck: echo typecheck lib task-select-test#check: echo check root ``` ### After (tree view, grouped by package) When running from `packages/app`: ``` › build: echo build app lint: echo lint app test: echo test app lib (packages/lib) build: echo build lib lint: echo lint lib test: echo test lib typecheck: echo typecheck lib task-select-test (workspace root) check: echo check root clean: echo clean root deploy: echo deploy root (…5 more) ``` **Current package tasks appear first** without a header, so you immediately see the most relevant tasks. Other packages show as dimmed headers with their relative path. ### Selecting a task from another package Navigate into another package's group and press Enter — the output shows the full `package#task` identifier: ``` lib (packages/lib) › build: echo build lib ``` → `Selected task: lib#build` ### Fuzzy search still works - Type a task name to filter across all packages - Type `lib#build` to search with package qualifier - When using `#`, packages are sorted by best fuzzy match instead of current-package-first ### Colors Matches the `vp` command picker for visual consistency: - **Selected**: blue bold label, dark grey marker and description - **Non-selected**: default label, dark grey description - **Package headers**: dimmed ### Non-interactive mode unchanged Piped input (`echo '' | vp run`) still outputs the flat `package#task: command` format. ## Test plan - [x] Unit tests for display row generation and group ordering - [x] E2E snapshot tests for interactive selection, cross-package selection, search, scrolling - [x] Non-interactive output snapshots verified unchanged format - [x] `cargo test -p vite_select` and `cargo test -p vite_task_bin --test e2e_snapshots -- task-select` pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9e71183 commit bad8334

19 files changed

+913
-414
lines changed

crates/vite_select/src/interactive.rs

Lines changed: 485 additions & 121 deletions
Large diffs are not rendered by default.

crates/vite_select/src/lib.rs

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,20 @@ mod interactive;
44
use std::io::Write;
55

66
pub use fuzzy::fuzzy_match;
7-
use interactive::{RenderParams, render_items};
7+
use interactive::{RenderParams, build_display_rows, render_items};
88
use vite_str::Str;
99

1010
/// An item in the selection list.
1111
pub struct SelectItem {
12-
/// Display label, e.g. `"build"` or `"app#build"`.
12+
/// Searchable label, e.g. `"build"` or `"app#build"`. Used for fuzzy matching.
1313
pub label: Str,
14-
/// Description shown next to the label, e.g. `"echo build app"`.
14+
/// Display name shown in the list, e.g. `"build"` (tree view) or `"app#build"` (flat).
15+
pub display_name: Str,
16+
/// Description shown next to the display name, e.g. `"echo build app"`.
1517
pub description: Str,
18+
/// Group header text. Items sharing the same group render together under a
19+
/// header line. `None` = top-level (no header).
20+
pub group: Option<Str>,
1621
}
1722

1823
/// Selection mode.
@@ -63,7 +68,6 @@ pub fn select_list(
6368
writer: &mut impl Write,
6469
params: &SelectParams<'_>,
6570
mode: Mode<'_>,
66-
before_render: impl FnMut(&mut Vec<usize>, &str),
6771
after_render: impl FnMut(&RenderState<'_>),
6872
) -> anyhow::Result<()> {
6973
match mode {
@@ -73,12 +77,9 @@ pub fn select_list(
7377
selected_index,
7478
params.header,
7579
params.page_size,
76-
before_render,
7780
after_render,
7881
),
79-
Mode::NonInteractive => {
80-
non_interactive(writer, params.items, params.query, params.header, before_render)
81-
}
82+
Mode::NonInteractive => non_interactive(writer, params.items, params.query, params.header),
8283
}
8384
}
8485

@@ -87,34 +88,33 @@ fn non_interactive(
8788
items: &[SelectItem],
8889
query: Option<&str>,
8990
header: Option<&str>,
90-
mut before_render: impl FnMut(&mut Vec<usize>, &str),
9191
) -> anyhow::Result<()> {
92-
let labels: Vec<&str> = items.iter().map(|item| item.label.as_str()).collect();
93-
let mut filtered: Vec<usize> =
94-
query.map_or_else(|| (0..items.len()).collect(), |q| fuzzy_match(q, &labels));
95-
before_render(&mut filtered, query.unwrap_or_default());
96-
let len = filtered.len();
92+
let display_rows = build_display_rows(items, query.unwrap_or_default());
9793

9894
// When there are no matching items, just print the header (if any) and
9995
// return early — avoids showing a redundant "No matching tasks." line
10096
// after a "not found" header.
101-
if filtered.is_empty() {
97+
let has_items = display_rows.iter().any(interactive::DisplayRow::is_item);
98+
if !has_items {
10299
if let Some(h) = header {
103100
writeln!(writer, "{h}")?;
104101
}
105102
return Ok(());
106103
}
107104

105+
let row_count = display_rows.len();
106+
108107
render_items(
109108
writer,
110109
&RenderParams {
111110
items,
112-
filtered: &filtered,
113-
selected_in_filtered: None,
114-
visible_range: 0..len,
111+
display_rows: &display_rows,
112+
selected: None,
113+
visible_row_range: 0..row_count,
115114
hidden_count: 0,
116115
header,
117116
query: None,
117+
show_group_headers: false,
118118
line_ending: "\n",
119119
max_line_width: usize::MAX,
120120
},

crates/vite_task/src/session/mod.rs

Lines changed: 105 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@ use vite_select::SelectItem;
2121
use vite_str::Str;
2222
use vite_task_graph::{
2323
IndexedTaskGraph, TaskGraph, TaskGraphLoadError, config::user::UserCacheConfig,
24-
loader::UserConfigLoader,
24+
loader::UserConfigLoader, query::TaskQuery,
2525
};
2626
use vite_task_plan::{
2727
ExecutionGraph, TaskGraphLoader,
28-
plan_request::{PlanRequest, ScriptCommand, SyntheticPlanRequest},
28+
plan_request::{
29+
PlanOptions, PlanRequest, QueryPlanRequest, ScriptCommand, SyntheticPlanRequest,
30+
},
2931
prepend_path_env,
3032
};
31-
use vite_workspace::{WorkspaceRoot, find_workspace_root};
33+
use vite_workspace::{WorkspaceRoot, find_workspace_root, package_graph::PackageQuery};
3234

3335
use crate::cli::{CacheSubcommand, Command, ResolvedCommand, ResolvedRunCommand, RunCommand};
3436

@@ -272,7 +274,7 @@ impl<'a> Session<'a> {
272274
match command.into_resolved() {
273275
ResolvedCommand::Cache { ref subcmd } => self.handle_cache_command(subcmd),
274276
ResolvedCommand::RunLastDetails => self.show_last_run_details(),
275-
ResolvedCommand::Run(mut run_command) => {
277+
ResolvedCommand::Run(run_command) => {
276278
let is_interactive =
277279
std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
278280

@@ -286,9 +288,8 @@ impl<'a> Session<'a> {
286288
// No tasks matched. With is_cwd_only (no scope flags) the
287289
// task name is a typo — show the selector. Otherwise error.
288290
if is_cwd_only {
289-
self.handle_no_task(is_interactive, &mut run_command).await?;
290-
let cwd = Arc::clone(&self.cwd);
291-
self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0
291+
let qpr = self.handle_no_task(is_interactive, &run_command).await?;
292+
self.plan_from_query(qpr).await?
292293
} else {
293294
return Err(vite_task_plan::Error::NoTasksMatched(
294295
task_specifier.clone(),
@@ -307,9 +308,8 @@ impl<'a> Session<'a> {
307308
if run_command != bare {
308309
return Err(vite_task_plan::Error::MissingTaskSpecifier.into());
309310
}
310-
self.handle_no_task(is_interactive, &mut run_command).await?;
311-
let cwd = Arc::clone(&self.cwd);
312-
self.plan_from_cli_run_resolved(cwd, run_command.clone()).await?.0
311+
let qpr = self.handle_no_task(is_interactive, &run_command).await?;
312+
self.plan_from_query(qpr).await?
313313
};
314314

315315
let builder = LabeledReporterBuilder::new(
@@ -334,23 +334,27 @@ impl<'a> Session<'a> {
334334
Ok(())
335335
}
336336

337-
/// Show the task selector or list, and update the run command with the selected task.
337+
/// Show the task selector or list, and return a plan request for the selected task.
338338
///
339339
/// In interactive mode, shows a fuzzy-searchable selection list. On selection,
340-
/// updates `run_command.task_specifier` and returns `Ok(())` so the caller
341-
/// can plan and execute the selected task.
340+
/// returns `Ok(QueryPlanRequest)` using the selected entry's filesystem path
341+
/// (not its display name) for package matching.
342342
///
343343
/// In non-interactive mode, prints the task list (or "did you mean" suggestions)
344344
/// and returns `Err(SessionError::EarlyExit(_))` — no further execution needed.
345345
#[expect(
346346
clippy::future_not_send,
347347
reason = "session is single-threaded, futures do not need to be Send"
348348
)]
349+
#[expect(
350+
clippy::too_many_lines,
351+
reason = "builds interactive/non-interactive select items and handles selection"
352+
)]
349353
async fn handle_no_task(
350354
&mut self,
351355
is_interactive: bool,
352-
run_command: &mut ResolvedRunCommand,
353-
) -> Result<(), SessionError> {
356+
run_command: &ResolvedRunCommand,
357+
) -> Result<QueryPlanRequest, SessionError> {
354358
let not_found_name = run_command.task_specifier.as_deref();
355359
let cwd = Arc::clone(&self.cwd);
356360
let task_graph = self.ensure_task_graph_loaded().await?;
@@ -363,18 +367,48 @@ impl<'a> Session<'a> {
363367
.then_with(|| a.task_display.task_name.cmp(&b.task_display.task_name))
364368
});
365369

370+
let workspace_path = self.workspace_path();
371+
366372
// Build items: current package tasks use unqualified names (no '#'),
367373
// other packages use qualified "package#task" names.
374+
// Interactive mode uses tree view (grouped by package); non-interactive is flat.
368375
let select_items: Vec<SelectItem> = entries
369376
.iter()
370377
.map(|entry| {
371-
let label =
372-
if current_package_path.as_ref() == Some(&entry.task_display.package_path) {
373-
entry.task_display.task_name.clone()
378+
let is_current =
379+
current_package_path.as_ref() == Some(&entry.task_display.package_path);
380+
let label = if is_current {
381+
entry.task_display.task_name.clone()
382+
} else {
383+
vite_str::format!("{}", entry.task_display)
384+
};
385+
386+
let group = if is_current {
387+
None
388+
} else {
389+
let rel_path = entry
390+
.task_display
391+
.package_path
392+
.strip_prefix(&*workspace_path)
393+
.ok()
394+
.flatten()
395+
.map(|p| Str::from(p.as_str()))
396+
.unwrap_or_default();
397+
let pkg_name = &entry.task_display.package_name;
398+
let display_path =
399+
if rel_path.is_empty() { Str::from("workspace root") } else { rel_path };
400+
Some(if pkg_name.is_empty() {
401+
display_path
374402
} else {
375-
vite_str::format!("{}", entry.task_display)
376-
};
377-
SelectItem { label, description: entry.command.clone() }
403+
vite_str::format!("{pkg_name} ({display_path})")
404+
})
405+
};
406+
let display_name = if is_interactive {
407+
entry.task_display.task_name.clone()
408+
} else {
409+
label.clone()
410+
};
411+
SelectItem { label, display_name, description: entry.command.clone(), group }
378412
})
379413
.collect();
380414

@@ -410,28 +444,15 @@ impl<'a> Session<'a> {
410444
page_size: 12,
411445
};
412446

413-
vite_select::select_list(
414-
&mut stdout,
415-
&params,
416-
mode,
417-
|filtered, query| {
418-
// When the query doesn't contain '#', move current-package tasks (those
419-
// without '#' in their label) to the top. `sort_by_key` is a stable sort,
420-
// so the fuzzy rating order is preserved within each group.
421-
if !query.contains('#') {
422-
filtered.sort_by_key(|&idx| select_items[idx].label.contains('#'));
423-
}
424-
},
425-
|state| {
426-
use std::io::Write;
427-
let milestone_name =
428-
vite_str::format!("task-select:{}:{}", state.query, state.selected_index);
429-
let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name);
430-
let mut out = std::io::stdout();
431-
let _ = out.write_all(&milestone_bytes);
432-
let _ = out.flush();
433-
},
434-
)?;
447+
vite_select::select_list(&mut stdout, &params, mode, |state| {
448+
use std::io::Write;
449+
let milestone_name =
450+
vite_str::format!("task-select:{}:{}", state.query, state.selected_index);
451+
let milestone_bytes = pty_terminal_test_client::encoded_milestone(&milestone_name);
452+
let mut out = std::io::stdout();
453+
let _ = out.write_all(&milestone_bytes);
454+
let _ = out.flush();
455+
})?;
435456

436457
let Some(selected_index) = selected_index else {
437458
// Non-interactive, the list was printed.
@@ -444,7 +465,9 @@ impl<'a> Session<'a> {
444465
}));
445466
};
446467

447-
// Interactive: print selected task and run it
468+
// Interactive: print selected task and build a QueryPlanRequest using the
469+
// entry's filesystem path (not its display name) for package matching.
470+
let entry = &entries[selected_index];
448471
let selected_label = &select_items[selected_index].label;
449472
{
450473
use std::io::Write as _;
@@ -457,8 +480,20 @@ impl<'a> Session<'a> {
457480
selected_label,
458481
)?;
459482
}
460-
run_command.task_specifier = Some(selected_label.clone());
461-
Ok(())
483+
484+
let package_query =
485+
PackageQuery::containing_package(Arc::clone(&entry.task_display.package_path));
486+
Ok(QueryPlanRequest {
487+
query: TaskQuery {
488+
package_query,
489+
task_name: entry.task_display.task_name.clone(),
490+
include_explicit_deps: !run_command.flags.ignore_depends_on,
491+
},
492+
plan_options: PlanOptions {
493+
extra_args: run_command.additional_args.clone().into(),
494+
cache_override: run_command.flags.cache_override(),
495+
},
496+
})
462497
}
463498

464499
/// Lazily initializes and returns the execution cache.
@@ -670,4 +705,28 @@ impl<'a> Session<'a> {
670705
.await?;
671706
Ok((graph, is_cwd_only))
672707
}
708+
709+
/// Plan execution from a pre-built [`QueryPlanRequest`].
710+
///
711+
/// Used by the interactive task selector, which constructs the request
712+
/// directly (bypassing CLI specifier parsing).
713+
#[expect(
714+
clippy::future_not_send,
715+
reason = "session is single-threaded, futures do not need to be Send"
716+
)]
717+
async fn plan_from_query(
718+
&mut self,
719+
request: QueryPlanRequest,
720+
) -> Result<ExecutionGraph, vite_task_plan::Error> {
721+
let cwd = Arc::clone(&self.cwd);
722+
vite_task_plan::plan_query(
723+
request,
724+
&self.workspace_path,
725+
&cwd,
726+
&self.envs,
727+
&mut self.plan_request_parser,
728+
&mut self.lazy_task_graph,
729+
)
730+
.await
731+
}
673732
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/task-list/snapshots/vp run in script.snap

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ expression: e2e_outputs
77
$ vp runcache disabled
88
Select a task (↑/↓, Enter to run, Esc to clear):
99

10-
hello: echo hello from root
11-
list-tasks: vp run
12-
app#build: echo build app
13-
app#lint: echo lint app
14-
app#test: echo test app
15-
lib#build: echo build lib
10+
hello: echo hello from root
11+
list-tasks: vp run
12+
app (packages/app)
13+
build: echo build app
14+
lint: echo lint app
15+
test: echo test app
16+
lib (packages/lib)
17+
build: echo build lib
1618
@ write-key: enter
1719
$ vp runcache disabled
1820
Selected task: hello

0 commit comments

Comments
 (0)