feat(maestro-tui): attach lands in Insert + real help + blinking cursors (NAP-288)
ci / rust (push) Successful in 8m45s
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:
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user