feat(maestro-tui): attach lands in Insert + real help + blinking cursors (NAP-288)
ci / rust (push) Successful in 8m45s

Three dogfood-driven UX fixes:

1. `AppState::attach_to_focused` now sets `mode = Mode::Insert` along with
   `focus = Focus::Pane`, so pressing Enter on a sidebar row drops the user
   straight into typing the remote shell. The leader (default `ctrl-a`)
   returns to Normal — same surface as tmux's prefix key.

2. `Keymap::entries(mode)` exposes the live binding table; `App::dispatch
   (Action::ShowHelp)` (and `App::new`) rebuild `HelpOverlay::lines` from
   it. Users see the actual Normal + Insert bindings + the leader hint,
   not just "? to close".

3. Visible blinking cursor in both surfaces that accept text:
   - `Pane::render` calls `Frame::set_cursor_position` at the vt100 cursor
     position when the pane is focused (and the screen hasn't hidden the
     cursor itself). Terminals do the blink.
   - `SpawnModal::render` places the cursor at the end of whichever text
     field is currently focused (cwd / command / label). Daemon dropdown
     and the Submit button still rely on the REVERSED style.

Workspace clippy + 85 unit tests passing.
This commit is contained in:
2026-05-29 09:19:30 +00:00
parent f27adf5aba
commit f208aa441d
5 changed files with 97 additions and 15 deletions
+33 -4
View File
@@ -70,15 +70,16 @@ impl std::fmt::Debug for App {
impl App {
pub fn new(cfg: TuiConfig, catalog: CatalogStore, daemon_base_url: String) -> Self {
let chord_timeout = cfg.chord_timeout;
let help = HelpOverlay {
lines: render_help_lines(&cfg),
};
Self {
cfg,
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
spawn: SpawnModal::default(),
help: HelpOverlay {
lines: vec!["? to close ctrl-w q to quit".into()],
},
help,
chord: ChordState::new(chord_timeout),
daemon_base_url,
region: RegionMap::default(),
@@ -219,7 +220,12 @@ impl App {
Action::PrevTab => self.state.prev_tab(),
Action::GotoTab(n) => self.state.goto_tab(n),
Action::ToggleSidebar => self.state.toggle_sidebar(),
Action::ShowHelp => self.state.overlay = Some(OverlayKind::Help),
Action::ShowHelp => {
self.help = HelpOverlay {
lines: render_help_lines(&self.cfg),
};
self.state.overlay = Some(OverlayKind::Help);
}
Action::OpenSpawnModal => {
// Rebuild spawn modal against the current catalog so the
// daemon dropdown is fresh.
@@ -436,6 +442,29 @@ fn ws_base(http: &str) -> String {
}
}
/// Build the lines shown by the help overlay from the live keymap, so the
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
let mut out = vec![format!("{label} mode"), String::new()];
for (input, action) in cfg.keymap.entries(mode) {
out.push(format!(" {input:<24} {action}"));
}
out.push(String::new());
out
}
let leader = cfg.leader.canonical();
let mut lines = vec![
format!("Leader: {leader} (Insert → Normal)"),
String::new(),
];
lines.extend(section(Mode::Normal, "Normal", cfg));
lines.extend(section(Mode::Insert, "Insert", cfg));
lines.push(String::from("(any key dismisses this overlay)"));
lines
}
#[cfg(test)]
mod tests {
use super::*;
+13
View File
@@ -57,6 +57,19 @@ impl Keymap {
self.table(mode).keys().cloned().collect()
}
/// Every `(canonical input, action)` pair currently bound for `mode`,
/// sorted lexicographically by input string. Used by the help overlay
/// so the rendering order is stable and discoverable.
pub fn entries(&self, mode: Mode) -> Vec<(String, Action)> {
let mut out: Vec<(String, Action)> = self
.table(mode)
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn table(&self, mode: Mode) -> &HashMap<String, Action> {
match mode {
Mode::Normal => &self.normal,
+21
View File
@@ -167,6 +167,27 @@ impl SpawnModal {
)])),
rows[4],
);
// Place the cursor at the end of the currently-focused text field so
// the user can see where their typing lands. Label is fixed-width
// 8 chars + 1 space = column 9 inside the row.
let cursor_col_for = |value: &str| -> u16 {
inner
.x
.saturating_add(9)
.saturating_add(value.chars().count() as u16)
};
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cursor_col_for(&self.cwd), rows[1].y)),
SpawnField::Command => {
f.set_cursor_position((cursor_col_for(&self.command), rows[2].y))
}
SpawnField::Label => f.set_cursor_position((cursor_col_for(&self.label), rows[3].y)),
SpawnField::Daemon | SpawnField::Submit => {
// Non-text fields are indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
+22 -9
View File
@@ -52,21 +52,34 @@ impl Pane {
pub fn render(&self, f: &mut Frame, area: Rect) {
let parser = self.screen.lock().expect("vt100 parser mutex");
let screen = parser.screen();
let buf = f.buffer_mut();
let (screen_rows, screen_cols) = screen.size();
let rows = area.height.min(screen_rows);
let cols = area.width.min(screen_cols);
for r in 0..rows {
for c in 0..cols {
if let Some(cell) = screen.cell(r, c) {
let s = cell.contents();
let s = if s.is_empty() { " " } else { &s };
buf[(area.x + c, area.y + r)]
.set_symbol(s)
.set_style(translate_cell_style(cell));
// Write cells. Scoped so the buffer borrow ends before set_cursor.
{
let buf = f.buffer_mut();
for r in 0..rows {
for c in 0..cols {
if let Some(cell) = screen.cell(r, c) {
let s = cell.contents();
let s = if s.is_empty() { " " } else { &s };
buf[(area.x + c, area.y + r)]
.set_symbol(s)
.set_style(translate_cell_style(cell));
}
}
}
}
// Place the terminal cursor at vt100's cursor position when we own
// focus and the cursor is on-screen. Terminals blink it on their own.
if self.focused && !screen.hide_cursor() {
let (cr, cc) = screen.cursor_position();
if cr < rows && cc < cols {
f.set_cursor_position((area.x + cc, area.y + cr));
}
}
}
}
+8 -2
View File
@@ -114,10 +114,13 @@ impl AppState {
self.active_tab_idx = idx;
}
/// Bind a session to the active tab and move focus into the pane.
/// Bind a session to the active tab, move focus into the pane, and
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// Move the sidebar highlight by `delta` (positive = down) within the
@@ -252,12 +255,15 @@ mod tests {
}
#[test]
fn attach_binds_to_active_tab_and_focuses_pane() {
fn attach_binds_focuses_pane_and_enters_insert_mode() {
let mut state = AppState::new();
let sid = Uuid::from_u128(7);
state.attach_to_focused(sid);
assert_eq!(state.tabs[0].pane, Some(sid));
assert_eq!(state.focus, Focus::Pane);
// The user expects to type into the session right away; the leader
// (default ctrl-a) is the way back.
assert_eq!(state.mode, Mode::Insert);
}
#[test]