From 622e0fec1b13b5557b850305330e0b53ff6edfea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:36:24 +0000 Subject: [PATCH 1/2] Type to narrow the harness list in the New session picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness picker (`n` on the grid, the PR session picker, and the quick prompt's Tab) now takes the same fuzzy type-ahead as its model and effort submenus: letters narrow the rows, best match first, so a long list of harnesses is a few letters away instead of a scroll. ↑/↓ move, Backspace widens, Esc clears the text before it closes. Closes #117. Because letters now type, `j`/`k`/`h`/`l` and `s` go into the query in the picker. The arrows still move and drill, `?` still opens the hovered harness's settings, and `s` still jumps from a PR row's menu, which has no filter. Two small fixes ride along: - Clearing a filter on a list with no ✓ row keeps the cursor on the row it was on instead of throwing it to the top. - The footer for a filtered menu names the right Esc (close, back, or back to the box) and puts the picker's own keys first, so a narrow footer cuts ↑/↓ before it cuts `?: settings`. Tests: - picker_types_to_filter_harnesses: narrowing, refused letter, cursor kept on widen, two-stage Esc, Enter launches the match. - s_types_in_the_pickers_and_jumps_from_a_pr_row_menu replaces the old `s` jump test. - Picker row tests move with ↓ instead of `j`. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015YwEubMYyd514WumnkrEBp --- crates/nebula-tui/src/agent_picker.rs | 13 +- crates/nebula-tui/src/app.rs | 9 +- crates/nebula-tui/src/event_loop/overlays.rs | 10 +- crates/nebula-tui/src/event_loop/tests/mod.rs | 164 +++++++++++++++--- crates/nebula-tui/src/ui.rs | 19 +- docs/keys.md | 4 +- docs/sessions.md | 2 +- 7 files changed, 176 insertions(+), 45 deletions(-) diff --git a/crates/nebula-tui/src/agent_picker.rs b/crates/nebula-tui/src/agent_picker.rs index 7a424f3f..3233f9b3 100644 --- a/crates/nebula-tui/src/agent_picker.rs +++ b/crates/nebula-tui/src/agent_picker.rs @@ -5,9 +5,10 @@ //! `ContextMenu` with a row per harness still enabled on the AGENTS TAB (a //! disabled one is absent, not greyed), every row a //! `MenuAction::NewAgentOfKind` carrying the surface's launch context, so -//! `→` drills into the same MODEL / EFFORT submenus everywhere. +//! `→` drills into the same MODEL / EFFORT submenus everywhere. Every one +//! takes type-ahead: letters narrow the harnesses to their fuzzy matches. -use crate::app::{App, ContextMenu, MenuAction, MenuItem, Overlay}; +use crate::app::{App, ContextMenu, MenuAction, MenuFilter, MenuItem, Overlay}; use crate::config::Config; use crate::pull_request::{OpenPr, PrLaunch}; use crate::quick_prompt::QuickReturn; @@ -203,6 +204,12 @@ pub(crate) fn open_kind_picker(app: &mut App, picker: KindPicker) { quick.as_deref(), harness_label, ); + // Type-ahead, as in the MODEL / EFFORT submenus: with a long list of + // harnesses a few letters beat scrolling. + let filter = Some(MenuFilter { + query: String::new(), + all: items.clone(), + }); app.modals.overlay = Some(Overlay::Menu(ContextMenu { title: Some(title), items, @@ -210,7 +217,7 @@ pub(crate) fn open_kind_picker(app: &mut App, picker: KindPicker) { hover, area: ratatui::layout::Rect::default(), parent: None, - filter: None, + filter, })); } diff --git a/crates/nebula-tui/src/app.rs b/crates/nebula-tui/src/app.rs index 8cf28862..62731511 100644 --- a/crates/nebula-tui/src/app.rs +++ b/crates/nebula-tui/src/app.rs @@ -463,8 +463,9 @@ pub struct ContextMenu { pub area: Rect, /// The menu ← returns to when this one is a submenu. pub parent: Option>, - /// Type-ahead over the rows — set on the MODEL / EFFORT submenus, where - /// letters filter instead of moving (↑/↓ move there); None elsewhere. + /// Type-ahead over the rows — set on the harness pickers and their + /// MODEL / EFFORT submenus (and a few other pickers), where letters + /// filter instead of moving (↑/↓ move there); None elsewhere. pub filter: Option, } @@ -491,10 +492,14 @@ impl ContextMenu { return false; } let items: Vec = ranked.iter().map(|(i, _)| filter.all[*i].clone()).collect(); + // A list with no ✓ (the harness pickers) keeps the cursor on the + // row it was on, so clearing a query never jumps it to the top. + let hovered = self.items.get(self.hover).map(|i| i.label.as_str()); self.hover = if query.trim().is_empty() { items .iter() .position(|i| i.label.ends_with(" ✓")) + .or_else(|| items.iter().position(|i| Some(i.label.as_str()) == hovered)) .unwrap_or(0) } else { 0 diff --git a/crates/nebula-tui/src/event_loop/overlays.rs b/crates/nebula-tui/src/event_loop/overlays.rs index 081d7daf..00587bda 100644 --- a/crates/nebula-tui/src/event_loop/overlays.rs +++ b/crates/nebula-tui/src/event_loop/overlays.rs @@ -161,10 +161,9 @@ fn handle_menu_key(app: &mut App, key: KeyEvent, out: &mut Vec) { // starts a session jumps to that harness's Agents section, // where its defaults live. Ahead of type-ahead on purpose: // `?` is a jump on these rows, never a filter letter (no row - // contains one); `s` only jumps without a filter, since in - // the model/effort submenus it narrows the list. `s` keeps - // its global meaning — settings — so the picker agrees with - // the panels. + // contains one); `s` only jumps without a filter — the + // harness pickers and their model/effort submenus type it + // into the query — so it still jumps from a PR row's menu. KeyCode::Char(c) if (c == '?' || (c == 's' && menu.filter.is_none())) && menu.hovered_agent_kind().is_some() => @@ -174,7 +173,8 @@ fn handle_menu_key(app: &mut App, key: KeyEvent, out: &mut Vec) { .expect("the guard checked the hovered row"); open_harness_settings(app, kind, custom); } - // Type-ahead in the MODEL / EFFORT submenus: letters narrow the + // Type-ahead in the harness pickers and their MODEL / EFFORT + // submenus (and the other filtered pickers): letters narrow the // rows (so ↑/↓ move there, not j/k), Backspace widens, and Esc // clears the text before it backs out. A letter no row matches // is refused, so the list never empties. diff --git a/crates/nebula-tui/src/event_loop/tests/mod.rs b/crates/nebula-tui/src/event_loop/tests/mod.rs index 63384c04..26086c69 100644 --- a/crates/nebula-tui/src/event_loop/tests/mod.rs +++ b/crates/nebula-tui/src/event_loop/tests/mod.rs @@ -6063,7 +6063,7 @@ fn tab_in_a_claude_submenu_toggles_cloud_for_the_whole_launch() { assert_eq!(menu(&app).hovered_claude_cloud(), Some(false)); assert_eq!( ui::menu_footer_hint(menu(&app)).as_deref(), - Some("Tab: cloud off type to filter ↑/↓: move Backspace: widen ?: settings Enter: pick Esc: back") + Some("Tab: cloud off ?: settings type to filter Enter: pick Esc: back ↑/↓: move Backspace: widen") ); press(&mut app, KeyCode::Tab, KeyModifiers::NONE, &mut out); assert_eq!(menu(&app).items[2].label, "opus", "a model row stays one"); @@ -6380,28 +6380,30 @@ fn question_mark_jumps_from_picker_to_harness_settings() { }) } -/// `s` jumps like `?` on the filter-less picker, but types in the -/// model submenu, where it narrows the list instead. +/// `s` types in the harness pickers and their model submenus, where +/// it narrows the list, and still jumps like `?` from a PR row's +/// menu, which has no filter to eat it. #[test] -fn s_jumps_on_the_picker_and_types_in_submenus() { +fn s_types_in_the_pickers_and_jumps_from_a_pr_row_menu() { use crate::config::{agents_tab, locate_agent, HarnessField}; with_default_config(|| { let mut app = App::new(); seed_tree(&mut app); app.nav.focus = Focus::Sessions; let mut out = Vec::new(); + let worktree = app.selected_worktree().unwrap().id.clone(); + // In the picker `s` is a filter letter: Muse is the one match. open_picker(&mut app); - press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); press(&mut app, KeyCode::Char('s'), KeyModifiers::NONE, &mut out); - let Some(Overlay::Settings(view)) = &app.modals.overlay else { - panic!("expected settings, got {:?}", app.modals.overlay); + let Some(Overlay::Menu(menu)) = &app.modals.overlay else { + panic!("`s` narrows instead of jumping: {:?}", app.modals.overlay); }; - let (tab, row) = locate_agent("codex", HarnessField::Enabled).unwrap(); - assert_eq!(tab, agents_tab()); - assert_eq!((view.tab, view.selected), (tab, row)); + assert_eq!(menu.filter_query(), "s"); + assert_eq!(menu.items[menu.hover].label, "Muse"); - // In a model submenu `s` is a filter letter, not a jump. + // In a model submenu too. + press(&mut app, KeyCode::Esc, KeyModifiers::NONE, &mut out); press(&mut app, KeyCode::Esc, KeyModifiers::NONE, &mut out); open_picker(&mut app); press(&mut app, KeyCode::Right, KeyModifiers::NONE, &mut out); @@ -6415,6 +6417,35 @@ fn s_jumps_on_the_picker_and_types_in_submenus() { .any(|r| matches!(r, ClientRequest::CreateAgent { .. })), "filtering must not create anything" ); + + // A PR row's menu has no filter: `s` jumps there. + let pr = crate::pull_request::OpenPr { + number: 7, + title: "Attach links".into(), + url: pr_url(7), + is_draft: false, + health: Default::default(), + base: "main".into(), + head: "pr-7-head".into(), + }; + let mut items = crate::agent_picker::pr_session_menu_rows(worktree, &pr); + items.truncate(2); + app.modals.overlay = Some(Overlay::Menu(ContextMenu { + title: None, + items, + at: None, + hover: 1, + area: ratatui::layout::Rect::default(), + parent: None, + filter: None, + })); + press(&mut app, KeyCode::Char('s'), KeyModifiers::NONE, &mut out); + let Some(Overlay::Settings(view)) = &app.modals.overlay else { + panic!("expected settings, got {:?}", app.modals.overlay); + }; + let (tab, row) = locate_agent("codex", HarnessField::Enabled).unwrap(); + assert_eq!(tab, agents_tab()); + assert_eq!((view.tab, view.selected), (tab, row)); }) } @@ -6449,7 +6480,7 @@ fn question_mark_ignores_menus_without_session_rows() { let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); let text = buffer_text(&terminal); - assert!(text.contains("s/?: settings"), "footer names it:\n{text}"); + assert!(text.contains("?: settings"), "footer names it:\n{text}"); }) } @@ -6582,7 +6613,7 @@ fn picker_second_row_creates_codex_agent() { let mut out = Vec::new(); open_picker(&mut app); - for code in [KeyCode::Char('j'), KeyCode::Enter] { + for code in [KeyCode::Down, KeyCode::Enter] { handle_key(&mut app, KeyEvent::new(code, KeyModifiers::NONE), &mut out); } assert!( @@ -6610,7 +6641,7 @@ fn picker_third_row_creates_cursor_agent() { let mut out = Vec::new(); open_picker(&mut app); - for code in [KeyCode::Char('j'), KeyCode::Char('j'), KeyCode::Enter] { + for code in [KeyCode::Down, KeyCode::Down, KeyCode::Enter] { handle_key(&mut app, KeyEvent::new(code, KeyModifiers::NONE), &mut out); } assert!( @@ -6894,7 +6925,7 @@ fn picker_omits_a_disabled_harness() { ); // The second row is now Cursor: the rows shift, nothing is dead. - press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE, &mut out); + press(&mut app, KeyCode::Down, KeyModifiers::NONE, &mut out); press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); assert!( app.modals.overlay.is_none(), @@ -18198,8 +18229,9 @@ fn the_harness_picker_is_no_wider_than_its_rows_and_keeps_its_keys_in_the_footer }; let (on_claude, text) = drawn(&mut app); let (title_w, rows_w) = match &app.modals.overlay { + // The title wears the type-ahead's bare ` ⌕`. Some(Overlay::Menu(m)) => ( - m.title.as_deref().unwrap().chars().count() + 4, + m.title.as_deref().unwrap().chars().count() + 6, m.items .iter() .map(|i| i.label.chars().count()) @@ -18223,7 +18255,7 @@ fn the_harness_picker_is_no_wider_than_its_rows_and_keeps_its_keys_in_the_footer ); let footer = lines .iter() - .position(|l| l.contains("Tab: cloud off") && l.contains("s/?: settings")) + .position(|l| l.contains("Tab: cloud off") && l.contains("?: settings")) .unwrap_or_else(|| panic!("the footer names the picker's keys:\n{text}")); assert!(footer > bottom, "below the modal, in the footer bar"); @@ -18232,7 +18264,7 @@ fn the_harness_picker_is_no_wider_than_its_rows_and_keeps_its_keys_in_the_footer let (on_codex, text) = drawn(&mut app); assert_eq!(on_codex, on_claude, "hovering another row never resizes it"); assert!(!text.contains("Tab: cloud"), "{text}"); - assert!(text.contains("s/?: settings"), "{text}"); + assert!(text.contains("?: settings"), "{text}"); }) } @@ -19184,6 +19216,92 @@ fn cursor_preset_effort_follows_the_model_family() { }); } +/// The harness picker itself takes type-ahead (#117): a few letters +/// pick a harness out of a long list instead of scrolling to it. +#[test] +fn picker_types_to_filter_harnesses() { + with_default_config(|| { + let mut app = App::new(); + seed_tree(&mut app); + app.nav.focus = Focus::Sessions; + let mut out = Vec::new(); + fn menu(app: &App) -> &ContextMenu { + match &app.modals.overlay { + Some(Overlay::Menu(menu)) => menu, + other => panic!("expected the picker, got {other:?}"), + } + } + + open_picker(&mut app); + let all = menu(&app).items.len(); + assert!(menu(&app).filter.is_some(), "the picker takes type-ahead"); + let mut terminal = Terminal::new(TestBackend::new(180, 40)).unwrap(); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + let text = buffer_text(&terminal); + assert!( + text.contains("New session ⌕"), + "bare ⌕ in the title:\n{text}" + ); + assert!(text.contains("type to filter"), "footer hint:\n{text}"); + assert!( + text.contains("Esc: close"), + "Esc closes the picker:\n{text}" + ); + + // "gro" narrows to Grok Build, the cursor on it. + type_text(&mut app, "gro", &mut out); + assert_eq!(labels_of(menu(&app)), vec!["Grok Build"]); + assert_eq!(menu(&app).hover, 0); + terminal.draw(|f| ui::draw(f, &mut app)).unwrap(); + let text = buffer_text(&terminal); + assert!( + text.contains("New session ⌕ gro"), + "query in the title:\n{text}" + ); + + // A letter no harness matches is refused and flashed. + type_text(&mut app, "z", &mut out); + assert_eq!(menu(&app).filter_query(), "gro"); + assert!(app + .chrome + .flash + .as_deref() + .is_some_and(|f| f.contains("groz"))); + + // Backspace widens back to every row, the cursor kept on the + // harness it was on rather than thrown to the top. + for _ in 0..3 { + press(&mut app, KeyCode::Backspace, KeyModifiers::NONE, &mut out); + } + assert_eq!(menu(&app).items.len(), all); + assert_eq!(menu(&app).items[menu(&app).hover].label, "Grok Build"); + + // Esc with nothing typed closes; Esc with text clears it first. + type_text(&mut app, "cod", &mut out); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE, &mut out); + assert_eq!(menu(&app).filter_query(), ""); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE, &mut out); + assert!(app.modals.overlay.is_none()); + + // Enter on a match launches it. + open_picker(&mut app); + type_text(&mut app, "codex", &mut out); + assert_eq!(menu(&app).items[menu(&app).hover].label, "Codex"); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE, &mut out); + assert!(matches!( + out.last(), + Some(ClientRequest::CreateAgent { + kind: AgentKind::Codex, + .. + }) + )); + }); +} + +fn labels_of(menu: &ContextMenu) -> Vec<&str> { + menu.items.iter().map(|i| i.label.as_str()).collect() +} + #[test] fn picker_model_submenu_types_to_filter() { with_default_config(|| { @@ -20320,13 +20438,9 @@ fn a_click_on_a_list_row_is_enter_on_it() { // Drawn, as the clicked one is: the pane's size rides // every Attach. drawn_list_area(&mut by_keys); - // Letters type ahead in the palette and the preset - // lists, so ↓ moves there. - let down = if name == "palette" || name.ends_with("presets") { - KeyCode::Down - } else { - KeyCode::Char('j') - }; + // Letters type ahead in every one of these lists, so + // ↓ moves. + let down = KeyCode::Down; // The palette opens with its cursor on the top // session, under the project header drawn above it: // walk up to the first row before counting down. diff --git a/crates/nebula-tui/src/ui.rs b/crates/nebula-tui/src/ui.rs index fbd3f12e..24d35647 100644 --- a/crates/nebula-tui/src/ui.rs +++ b/crates/nebula-tui/src/ui.rs @@ -415,9 +415,20 @@ pub(crate) fn menu_footer_hint(menu: &crate::app::ContextMenu) -> Option "Tab: cloud off " } }); + // A submenu backs out; a picker opened from the QUICK PROMPT is owed + // its box back. + let esc = if menu.parent.is_some() { + "Esc: back" + } else if crate::event_loop::menu_quick_return(menu).is_some_and(|back| back.from_box) { + "Esc: back to the box" + } else { + "Esc: close" + }; + // The picker's own keys lead, the self-evident ones trail, so a + // narrow footer cuts ↑/↓ before it cuts `?`. if menu.filter.is_some() { return Some(format!( - "{}type to filter ↑/↓: move Backspace: widen {}Enter: pick Esc: back", + "{}{}type to filter Enter: pick {esc} ↑/↓: move Backspace: widen", cloud.unwrap_or(""), if agent_jump { "?: settings " } else { "" } )); @@ -425,12 +436,6 @@ pub(crate) fn menu_footer_hint(menu: &crate::app::ContextMenu) -> Option if cloud.is_none() && !agent_jump { return None; } - // A picker opened from the QUICK PROMPT is owed its box back. - let esc = if crate::event_loop::menu_quick_return(menu).is_some_and(|back| back.from_box) { - "Esc: back to the box" - } else { - "Esc: close" - }; Some(format!( "{}s/?: settings Enter: pick {esc}", cloud.unwrap_or("") diff --git a/docs/keys.md b/docs/keys.md index e7a205cc..26cce629 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -35,12 +35,12 @@ The grid isn't the only view. With a session selected: | The grid | `i` | GitHub issues: the ISSUES MODAL for the selected project (above) — read an issue, type to filter the list by `#15 title`, `Enter` prompts an agent on it, `Shift+Tab` launches an AGENT PRESET on it, `Ctrl+c` leaves a comment on it, `Ctrl+e` edits its title and description, `Ctrl+o` or the pane's `↗ open in browser` button opens it in the browser, `Ctrl+r` refreshes | | The grid | `v` | GitHub pull requests: the PULL REQUESTS MODAL for the selected project (above) — read a pull request, type to filter the list by `#42 title`, `Enter` prompts a PR SESSION on it, `Shift+Tab` launches an AGENT PRESET on it, `Tab` picks a harness, `Ctrl+c` comments, `Ctrl+g` opens its diff, `Ctrl+o`, the pane's `↗ open in browser` button or a double-click on the row opens it in the browser, `Ctrl+r` refreshes; a list GitHub could not be asked for says `couldn't refresh (^r retries)` under the filter, over the last rows that worked | | New worktree | type a sentence, or `Enter` on the empty prompt | the branch name is slugified (`fix login redirect` → `fix-login-redirect`); empty takes a random `--`. The branch starts at the freshly fetched `origin/HEAD` (normally `origin/main`), untracked — never at the root checkout's HEAD; with no `origin`, or a fetch that fails, it falls back to HEAD. **Worktree base branch** (Settings → General; `worktree_base_branch` in CONFIG.JSON) names another start — `master`, `develop` — resolved the same way: origin's fetched copy first, the local branch of that name otherwise, and `origin/HEAD` again in a repo that has neither | -| The grid | `n` | new session, harness first: the NEW SESSION PICKER lists the enabled harnesses (`→` drills into a harness's model and effort, `Enter` takes your defaults for whatever you did not drill into), and the pick opens the QUICK PROMPT set to it — the same box `p` opens (below), aimed at the same checkout, with your harness on its title instead of the Settings → Agents one. The cursor starts on that Settings → Agents harness, so `Enter` at once is `p`. Type the task and `Enter` launches; `Enter` on the box empty starts the session bare, and you type the first prompt in the CLI. `Esc` on the picker closes it and opens nothing; text parked by an earlier box comes back into the picked box, the pick kept. A shell terminal is `t` | +| The grid | `n` | new session, harness first: the NEW SESSION PICKER lists the enabled harnesses (type to narrow them, fuzzy and best first — `gro` is Grok Build — with `↑`/`↓` to move, `Backspace` to widen and `Esc` clearing the text before it closes; `→` drills into a harness's model and effort, `Enter` takes your defaults for whatever you did not drill into), and the pick opens the QUICK PROMPT set to it — the same box `p` opens (below), aimed at the same checkout, with your harness on its title instead of the Settings → Agents one. The cursor starts on that Settings → Agents harness, so `Enter` at once is `p`. Type the task and `Enter` launches; `Enter` on the box empty starts the session bare, and you type the first prompt in the CLI. `Esc` on the picker closes it and opens nothing; text parked by an earlier box comes back into the picked box, the pick kept. A shell terminal is `t` | | The grid (a card) | `Ctrl+d` / `Ctrl+u` | jump two rows of cards down / up, stopping at the first and last row, with the grid scrolling after the cursor so the card landed on is in view and the pane previewing it. Same rebindable actions, `half_page_down` / `half_page_up` | | New session picker (Claude) | `Tab` | toggle Claude Cloud; `Enter` then opens the QUICK PROMPT as a cloud box — `harness claude · cloud` — whose `Enter` launches `claude --cloud=` on what you typed (`Shift+Enter` or `Ctrl+J` inserts a line); sent empty it closes instead, a cloud session being nothing without its task | | Quick prompt's harness picker (Claude) | `Tab` | the same Claude Cloud toggle, in the picker `Tab` opens from the quick prompt: `Enter` hands the box back as a cloud one — `harness claude · cloud`, **Claude Cloud** in its title — with your text kept, and the box's `Enter` launches `claude --cloud=` on what you typed. Not offered in a box for an issue or a pull request, which Claude Cloud cannot take | | Claude model / effort list | `Tab` | the same Claude Cloud toggle, from the list itself — reached with `→` on a picker's Claude row or opened by the box's `^O`. It is the launch's, not one row's: the rows keep their names, the title reads `Claude model · cloud`, the footer says `Tab: cloud on`, a typed filter keeps it, and `←` backs out to a Claude row that agrees. Offered wherever the picker's Claude row offers it | -| New session picker (any row) | `?` or `s` | open Settings → Agents parked on the highlighted harness's section, where its Enabled switch and default model and effort live (`s` types in the model/effort submenus instead, where the filter owns letters) | +| New session picker (any row) | `?` | open Settings → Agents parked on the highlighted harness's section, where its Enabled switch and default model and effort live (`s` does the same from a pull request row's right-click menu; in the pickers it is a filter letter) | | The grid (a cloud card) | `Enter`, right-click | `Enter` — or a click on the link in the CLOUD SESSION PANEL — opens the session's page in the browser; the right-click menu offers **Open in browser** and **Send to cloud session**, which queues a message on it. No **Attach** or **Restart**: the agent runs in the cloud, not in a terminal here | | The grid (a card) | `Space` | **follow-up prompt.** A small modal opens over the grid and what you type there goes to the agent already running in the selected session as its next turn: `Enter` sends it, `Shift+Enter` / `Option+Enter` / `Ctrl+J` break a line, `Esc` closes without sending. The right-click menu's **Follow-up prompt** row does the same. Only a live local agent has one: an archived session, a Claude Cloud card (whose right-click menu queues a message instead) and a terminal have nothing to follow up on. A session whose CLI is not up is booted first and the box kept as it is, so nothing is typed into a process that is still starting — press `Enter` again once it is. Rebindable as `follow_up` | | The grid | `r`, `a`, `u`, `d`, `A` | rename, archive, unarchive, delete, toggle archived. `Delete` and `Backspace` are bound alongside `d` and do the same thing, here and everywhere else `d` deletes something — and `a` and the row menu's Archive ask first, in the CONFIRM DIALOG `d` goes behind (`Enter` or `y` archives, `Esc` or `n` keeps the session), so typing aimed at an agent that lands on the grid archives nothing; the dialog says what makes saying yes cheap: `u` brings the session back. **Confirm on archive** (Settings → Sessions, on by default) off archives at once. In the nested layout, `d` and `a` on a thread's root that has children take the whole thread: `d` deletes every prompt and terminal nested under it, `a` archives every prompt and closes its terminals — each behind a confirm that counts and lists them, `a`'s asking even with the setting off; the root checkout's thread included, and a project left with nothing is the welcome. `d` on the last live card of a linked worktree asks about the checkout in the same confirm, before anything is deleted — `Nothing else is left in worktree 'feature': delete it from disk too?` — with three answers: `y` deletes the card and then the checkout as `d` on its band would, `n` deletes the card and keeps the empty checkout, `Esc` keeps the card alive; **Delete emptied worktree** (Settings → Sessions, off by default) skips that question and `Enter` deletes both, except when archived sessions are still filed under the checkout. With **Show all worktrees** on (Settings → Sessions, off by default) the question is never asked: the card goes alone, its checkout stays as an EMPTY BAND, and `d` on that band — a checkout with nothing running — asks to delete the worktree itself (`Delete worktree 'feature' from disk?`), as **Delete worktree** in the band's right-click menu does. `Shift+R` refreshes the pull requests (above). **A held `a` opens one dialog** and archives nothing by itself: its repeats land on the dialog, where `a` is nothing — with the confirm off, it archives one card, as `u` unarchives one. **A held `u` unarchives one card**, however long it is held: on a terminal with the kitty keyboard protocol (Ghostty, kitty, WezTerm, foot) nebula watches the key until it comes up, swallowing the repeats the terminal sends meanwhile, and the next card needs the key pressed again; a terminal without it (Terminal.app, `nebula browser`, tmux) reports a held key as one press after another, and there a long hold still walks the row | diff --git a/docs/sessions.md b/docs/sessions.md index 9fd27d67..075fe709 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -22,7 +22,7 @@ into the id, so nebula launches `--model claude-opus-5-thinking-high-fast`; the merged with `--list-models`, cached in `cursor_models.json` beside `config.json` and refreshed daily; Pi's model is a fuzzy `--model` pattern — `opus`, `sonnet`, or a `provider/id` you set in `config.json` — and its effort is the `--thinking` level, `off` through `max`). -In those submenus you type to filter — `opus` narrows the rows to the Opus families, `↑`/`↓` move, `Backspace` widens, `Esc` clears — and the preset editor's Harness / Model / Effort rows take the same type-ahead. +The menu and those submenus take type-ahead: letters narrow the rows to their fuzzy matches, best first — `gro` is Grok Build, `opus` narrows a model list to the Opus families — `↑`/`↓` move, `Backspace` widens, `Esc` clears the text before it closes; the preset editor's Harness / Model / Effort rows take the same type-ahead. Because letters type, `j`/`k` and `h`/`l` do too: the arrows move and drill. `Enter` anywhere takes your configured defaults. On the Claude row — and on any row of the Claude model and effort lists under it, whose title then reads `Claude model · cloud` — `Tab` toggles Cloud mode, the footer naming the key and the state it is in From af0a768884348db893581336a357a34f3325337d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:55:33 +0000 Subject: [PATCH 2/2] Add a screenshot scene for the filtered harness picker `make shot SCENE=harness-picker-filter` opens the New session picker and types `gro`, the screen the PR's screenshot shows. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_015YwEubMYyd514WumnkrEBp --- scripts/shot/scenes/harness-picker-filter.keys | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 scripts/shot/scenes/harness-picker-filter.keys diff --git a/scripts/shot/scenes/harness-picker-filter.keys b/scripts/shot/scenes/harness-picker-filter.keys new file mode 100644 index 00000000..c20b40c8 --- /dev/null +++ b/scripts/shot/scenes/harness-picker-filter.keys @@ -0,0 +1,3 @@ +# `n` on the grid: the New session picker, narrowed as you type. +n +gro