Compare commits

..
Author SHA1 Message Date
Miguel Palhas ca585447ff fix(maestro): dir autocomplete dismissal and key handling
Escape closes the popup, refocuses the input and stops propagation so it
can't also discard a surrounding draft pane. Tab commits the highlighted
entry and lets focus move to the next field. Blur / clicking outside
dismisses, and a dismissed popup stays closed until the operator types
again.
2026-08-23 20:38:55 +01:00
Miguel Palhas 71301861c0 chore(maestro): add happy-dom for DOM-level web tests 2026-08-23 20:38:55 +01:00
Miguel Palhas 2ce1dc43fe merge: tui inline draft pane + paste forwarding fix (#63) 2026-08-23 20:31:18 +01:00
Miguel Palhas e99e21260b Merge remote-tracking branch 'origin/blitz/spawn-v2' into spawn-v2/63-tui-draft-pane 2026-08-23 20:26:26 +01:00
Miguel Palhas cea06f9204 fix(maestro): forward bracketed pastes into the attached session
forward_to_session dropped Paste(_) on the floor, so pastes into a
session vanished once terminal.rs enabled bracketed paste. Split the
arm: Paste forwards the payload verbatim (the remote readline
reassembles it), only Mouse events have no wire encoding.
2026-08-23 20:25:57 +01:00
Miguel Palhas 1199596528 feat(maestro): inline draft pane replaces the SpawnModal overlay
Action::OpenSpawnModal becomes NewDraftPane: pressing s turns the empty
pane into an inline draft form (or opens a new tab when the pane shows a
session). The form is a pane-level widget with its own field focus; a
successful submit replaces the draft with the new session in place,
matching the web client's draft panes. SpawnModal and OverlayKind::Spawn
are removed; kill-confirm and help overlays stay.
2026-08-23 20:25:46 +01:00
Miguel Palhas 6d21061799 merge: worktree placement, existing branches, guarded cleanup (#56) 2026-08-23 20:21:08 +01:00
Miguel Palhas 95f0e42df5 feat(maestro): add draft-pane widget with its own field focus 2026-08-23 20:16:43 +01:00
13 changed files with 911 additions and 397 deletions
+13 -2
View File
@@ -38,7 +38,7 @@ pub enum Action {
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
ToggleAttachHighlighted,
OpenSpawnModal,
NewDraftPane,
KillHighlighted,
// Tabs
@@ -78,7 +78,7 @@ impl Action {
"SidebarPrev" => return Ok(Action::SidebarPrev),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"OpenSpawnModal" => return Ok(Action::OpenSpawnModal),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
@@ -202,6 +202,7 @@ mod tests {
for a in [
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
@@ -209,4 +210,14 @@ mod tests {
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
}
}
/// The spawn modal is gone (issue #63); its old action string must not
/// resolve to anything.
#[test]
fn the_removed_spawn_modal_action_no_longer_parses() {
assert_eq!(
Action::parse("OpenSpawnModal").unwrap_err(),
ActionParseError::Unknown("OpenSpawnModal".into())
);
}
}
+170 -84
View File
@@ -6,9 +6,9 @@
//! changes, and a 30Hz heartbeat.
//!
//! Actions mutate `AppState` directly. Side effects that talk to the daemon
//! (`AttachHighlighted` spawning a `SessionTask`, `OpenSpawnModal` submit
//! firing a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / `handle_overlay_input`.
//! (`AttachHighlighted` spawning a `SessionTask`, a draft-pane submit firing
//! a POST, `KillHighlighted` confirm firing a DELETE) live inline in
//! `dispatch` / the Insert-mode pane routing.
use std::collections::{HashMap, HashSet};
use std::io::Stdout;
@@ -24,15 +24,16 @@ use uuid::Uuid;
use crate::action::Action;
use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::draft::DraftInputResult;
use crate::emulators::SessionEmulators;
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, SpawnModal};
use crate::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::TuiError;
@@ -49,7 +50,6 @@ pub struct App {
catalog: CatalogStore,
emulators: SessionEmulators,
state: AppState,
spawn: SpawnModal,
help: HelpOverlay,
chord: ChordState,
daemon: DaemonClient,
@@ -90,7 +90,6 @@ impl App {
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
spawn: SpawnModal::default(),
help,
chord: ChordState::new(chord_timeout),
daemon,
@@ -152,7 +151,15 @@ impl App {
self.perform(effects).await;
} else if self.state.mode == Mode::Insert && matches!(self.state.focus, Focus::Pane)
{
if let Some(effect) = self.forward_to_session(&key) {
// Insert mode over a focused pane: a draft eats the key
// into its form, a session gets the raw bytes.
let effect = if matches!(self.state.active_tab().pane, Some(PaneRef::Draft(_)))
{
self.handle_draft_input(&key)
} else {
self.forward_to_session(&key)
};
if let Some(effect) = effect {
self.perform(vec![effect]).await;
}
}
@@ -179,27 +186,7 @@ impl App {
}
fn handle_overlay_input(&mut self, key: &InputKey) -> (OverlayDecision, Vec<Effect>) {
use crate::overlays::SpawnInputResult;
match self.state.overlay().cloned() {
Some(OverlayKind::Spawn) => {
let mut effects = Vec::new();
match self.spawn.on_input(key) {
SpawnInputResult::Consumed => {}
SpawnInputResult::Cancel => {
self.state.dismiss_overlay();
}
SpawnInputResult::Submit => {
effects.push(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(self.spawn.cwd.value().to_string()),
command: self.spawn.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}));
}
}
(OverlayDecision::Consumed, effects)
}
Some(OverlayKind::KillConfirm(id)) => match key {
InputKey::Char { c: 'y', .. } => {
self.state.dismiss_overlay();
@@ -265,14 +252,7 @@ impl App {
};
self.state.show_overlay(OverlayKind::Help);
}
Action::OpenSpawnModal => {
self.spawn = SpawnModal {
cwd: Default::default(),
command: Default::default(),
focused_field: Default::default(),
};
self.state.show_overlay(OverlayKind::Spawn);
}
Action::NewDraftPane => self.state.open_draft_pane(),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
@@ -322,44 +302,38 @@ impl App {
/// Encode a keystroke for the focused pane. `None` when there is nothing
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_tab().pane?;
let sid = self.state.active_session()?;
self.emulators.get(sid)?;
let bytes = match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
out
}
InputKey::Enter => b"\r".to_vec(),
InputKey::Tab => b"\t".to_vec(),
InputKey::BackTab => b"\x1b[Z".to_vec(),
InputKey::Backspace => vec![0x7f],
InputKey::Esc => vec![0x1b],
InputKey::Up => b"\x1b[A".to_vec(),
InputKey::Down => b"\x1b[B".to_vec(),
InputKey::Right => b"\x1b[C".to_vec(),
InputKey::Left => b"\x1b[D".to_vec(),
InputKey::Home => b"\x1b[H".to_vec(),
InputKey::End => b"\x1b[F".to_vec(),
InputKey::Delete => b"\x1b[3~".to_vec(),
InputKey::Paste(_) | InputKey::Mouse { .. } => return None,
};
let bytes = encode_key(key)?;
Some(Effect::Forward { id: sid, bytes })
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted the form.
fn handle_draft_input(&mut self, key: &InputKey) -> Option<Effect> {
let PaneRef::Draft(draft) = self.state.active_tab_mut().pane.as_mut()? else {
return None;
};
if draft.on_input(key) != DraftInputResult::Submit {
return None;
}
Some(Effect::Spawn(CreateSessionRequest {
cwd: std::path::PathBuf::from(draft.cwd.value().to_string()),
command: draft.command.value().to_string(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
}))
}
async fn submit_spawn(&mut self, req: CreateSessionRequest) {
match self.daemon.create_session(&req).await {
Ok(_) => {
self.state.dismiss_overlay();
Ok(session) => {
// Fill the pane in place with the new session (web parity,
// #57) and attach to it right away.
let sid = session.id;
self.state.complete_spawn(sid);
self.attach(sid);
}
Err(e) => self.state.toast(format!("spawn failed: {e}")),
}
@@ -377,7 +351,12 @@ impl App {
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
let snap = self.catalog.snapshot();
let attached: HashSet<Uuid> = self.state.tabs.iter().filter_map(|t| t.pane).collect();
let attached: HashSet<Uuid> = self
.state
.tabs
.iter()
.filter_map(crate::state::Tab::session)
.collect();
let sidebar = Sidebar {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
@@ -387,7 +366,7 @@ impl App {
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
active_idx: self.state.active_tab_idx,
};
let active_sid = self.state.active_tab().pane;
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
screen: h.screen.clone(),
@@ -395,13 +374,17 @@ impl App {
focused: matches!(self.state.focus, Focus::Pane),
})
});
let draft = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Draft(d)) => Some(d),
_ => None,
};
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: pane.as_ref(),
state: &self.state,
spawn: &self.spawn,
draft,
help: &self.help,
};
@@ -449,6 +432,43 @@ fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
}
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
/// with no byte representation (mouse events). A bracketed paste is forwarded
/// verbatim — the remote readline reassembles it, since the pane's PTY also
/// has bracketed paste enabled.
fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
match key {
InputKey::Char { c, ctrl, alt, .. } => {
let mut out = Vec::with_capacity(8);
if *alt {
out.push(0x1b);
}
if *ctrl && c.is_ascii_alphabetic() {
out.push((c.to_ascii_uppercase() as u8) - b'@');
} else {
let mut b = [0u8; 4];
let s = c.encode_utf8(&mut b);
out.extend_from_slice(s.as_bytes());
}
Some(out)
}
InputKey::Enter => Some(b"\r".to_vec()),
InputKey::Tab => Some(b"\t".to_vec()),
InputKey::BackTab => Some(b"\x1b[Z".to_vec()),
InputKey::Backspace => Some(vec![0x7f]),
InputKey::Esc => Some(vec![0x1b]),
InputKey::Up => Some(b"\x1b[A".to_vec()),
InputKey::Down => Some(b"\x1b[B".to_vec()),
InputKey::Right => Some(b"\x1b[C".to_vec()),
InputKey::Left => Some(b"\x1b[D".to_vec()),
InputKey::Home => Some(b"\x1b[H".to_vec()),
InputKey::End => Some(b"\x1b[F".to_vec()),
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
InputKey::Paste(text) => Some(text.as_bytes().to_vec()),
InputKey::Mouse { .. } => None,
}
}
/// 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).
@@ -516,10 +536,33 @@ mod tests {
}
#[test]
fn open_spawn_modal_sets_overlay() {
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
assert!(matches!(app.state.overlay(), Some(OverlayKind::Spawn)));
app.dispatch(Action::NewDraftPane);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.focus, Focus::Pane);
assert_eq!(app.state.mode, Mode::Insert);
assert!(app.state.overlay().is_none(), "no overlay involved");
}
/// A tab already showing a session gets a new tab for the draft.
#[test]
fn new_draft_pane_over_a_session_opens_a_new_tab() {
let sid = Uuid::from_u128(10);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane);
assert_eq!(app.state.tabs.len(), 2);
assert_eq!(app.state.active_tab_idx, 1);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.tabs[0].session(), Some(sid));
}
/// Seed the catalog with one session and highlight it.
@@ -554,7 +597,7 @@ mod tests {
app.dispatch(Action::AttachHighlighted),
vec![Effect::Attach(sid)]
);
assert_eq!(app.state.active_tab().pane, Some(sid));
assert_eq!(app.state.active_tab().session(), Some(sid));
}
#[test]
@@ -587,23 +630,35 @@ mod tests {
}
#[test]
fn submitting_the_spawn_modal_carries_the_typed_fields() {
fn typing_into_the_draft_then_enter_carries_the_typed_fields() {
let mut app = make_app();
app.dispatch(Action::OpenSpawnModal);
app.spawn.cwd = crate::text_input::TextInput::with_value("/srv/work");
app.spawn.command = crate::text_input::TextInput::with_value("claude");
app.spawn.focused_field = crate::overlays::SpawnField::Submit;
app.dispatch(Action::NewDraftPane);
let (_, effects) = app.handle_overlay_input(&InputKey::Enter);
match effects.as_slice() {
[Effect::Spawn(req)] => {
for c in "/srv/work".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // cwd → command
for c in "claude".chars() {
app.handle_draft_input(&InputKey::char(c));
}
app.handle_draft_input(&InputKey::Tab); // command → submit
match app.handle_draft_input(&InputKey::Enter) {
Some(Effect::Spawn(req)) => {
assert_eq!(req.cwd, std::path::PathBuf::from("/srv/work"));
assert_eq!(req.command, "claude");
}
other => panic!("expected one Spawn effect, got {other:?}"),
other => panic!("expected a Spawn effect, got {other:?}"),
}
}
/// Keys fed while no draft exists must be ignored, not panic.
#[test]
fn draft_input_is_a_no_op_without_a_draft() {
let mut app = make_app();
assert!(app.handle_draft_input(&InputKey::char('x')).is_none());
}
/// Regression: `Toast` returned `Pass` and nothing ever cleared it, so a
/// failure message stayed on screen until another overlay replaced it.
#[test]
@@ -632,6 +687,37 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// A draft pane is not a session: forwarding must not try to reach it.
#[test]
fn forwarding_is_a_no_op_with_a_draft_pane() {
let mut app = make_app();
app.dispatch(Action::NewDraftPane);
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
/// paste into an attached session vanished once bracketed paste was
/// enabled — the payload must be forwarded verbatim.
#[test]
fn a_bracketed_paste_is_forwarded_verbatim_to_the_session() {
assert_eq!(
encode_key(&InputKey::Paste("cd /tmp/foo".into())),
Some(b"cd /tmp/foo".to_vec())
);
}
#[test]
fn mouse_events_have_no_wire_encoding() {
use crate::input::{MouseKind, Region};
assert!(encode_key(&InputKey::Mouse {
kind: MouseKind::Click,
region: Region::Pane,
col: 3,
row: 4,
})
.is_none());
}
#[test]
fn kill_highlighted_opens_confirm() {
use chrono::Utc;
+5 -1
View File
@@ -45,7 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("up", "SidebarPrev"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "OpenSpawnModal"),
("s", "NewDraftPane"),
("x", "KillHighlighted"),
// Tabs
("ctrl-t", "NewTab"),
@@ -155,6 +155,10 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
);
}
#[test]
+272
View File
@@ -0,0 +1,272 @@
//! Draft pane — the spawn form living in an ordinary pane (issue #63).
//!
//! An empty pane renders a [`DraftPane`] instead of nothing; submitting hands
//! the caller a built request and the pane is replaced with the new session,
//! matching the web client's inline draft panes (#57). Field editing goes
//! through [`crate::text_input::TextInput`]; Tab / BackTab walk the fields.
//!
//! Scope note: presets, the project list, generated names, and the worktree
//! section land with issues #64/#65 — this is cwd + command only.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::input::InputKey;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum DraftField {
#[default]
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DraftPane {
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: DraftField,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DraftInputResult {
Consumed,
Submit,
}
impl DraftPane {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
DraftField::Cwd => DraftField::Command,
DraftField::Command => DraftField::Submit,
DraftField::Submit => DraftField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
DraftField::Cwd => DraftField::Submit,
DraftField::Command => DraftField::Cwd,
DraftField::Submit => DraftField::Command,
};
}
/// Handle one keystroke while this draft owns input (Insert mode, pane
/// focused). Returns [`DraftInputResult::Submit`] when the user accepts;
/// the caller fires the POST and replaces the draft with the session.
pub fn on_input(&mut self, key: &InputKey) -> DraftInputResult {
match key {
InputKey::Tab => {
self.next_field();
DraftInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
DraftInputResult::Consumed
}
InputKey::Enter => match self.focused_field {
DraftField::Submit => DraftInputResult::Submit,
_ => {
self.next_field();
DraftInputResult::Consumed
}
},
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
DraftField::Cwd => self.cwd.handle_key(other),
DraftField::Command => self.command.handle_key(other),
DraftField::Submit => {}
}
DraftInputResult::Consumed
}
}
}
/// Draw into the pane area. `pane_focused` gates the field highlight and
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let block = Block::default().title(" draft ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let style_for = |field: DraftField| {
if pane_focused && self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label = |text: &str| Paragraph::new(Line::from(Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field =
|f: &mut Frame, input: &crate::text_input::TextInput, row: Rect, style: Style| -> u16 {
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
let cwd_col = draw_field(f, &self.cwd, rows[0], style_for(DraftField::Cwd));
let cmd_col = draw_field(f, &self.command, rows[1], style_for(DraftField::Command));
f.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
"[ Spawn ]",
style_for(DraftField::Submit),
)])),
rows[2],
);
if pane_focused {
match self.focused_field {
DraftField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
DraftField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
DraftField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(w: u16, h: u16, draw: impl FnOnce(&mut Frame)) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn draft_pane_renders_field_labels_and_values() {
let draft = DraftPane {
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: DraftField::Cwd,
};
let rendered = render_to_string(80, 20, |f| draft.render(f, f.area(), true));
for s in ["cwd", "command", "draft", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn field_rotation_wraps_in_both_directions() {
let mut draft = DraftPane::default();
assert_eq!(draft.focused_field, DraftField::Cwd);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Command);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Submit);
draft.next_field();
assert_eq!(draft.focused_field, DraftField::Cwd);
draft.prev_field();
assert_eq!(draft.focused_field, DraftField::Submit);
draft.on_input(&InputKey::BackTab);
assert_eq!(draft.focused_field, DraftField::Command);
draft.on_input(&InputKey::Tab);
draft.on_input(&InputKey::Tab);
assert_eq!(draft.focused_field, DraftField::Cwd);
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut draft = DraftPane::default();
assert_eq!(draft.focused_field, DraftField::Cwd);
for k in [
InputKey::char('/'),
InputKey::char('t'),
InputKey::char('m'),
InputKey::char('p'),
] {
draft.on_input(&k);
}
draft.on_input(&InputKey::Left);
draft.on_input(&InputKey::Backspace);
assert_eq!(draft.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
draft.on_input(&InputKey::Tab);
assert_eq!(draft.focused_field, DraftField::Command);
draft.on_input(&InputKey::Paste("/tmp/foo".into()));
assert_eq!(draft.command.value(), "/tmp/foo");
draft.on_input(&InputKey::ctrl_char('w'));
assert_eq!(draft.command.value(), "");
assert_eq!(draft.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// Enter walks forward until the submit field, where it submits.
#[test]
fn enter_walks_fields_then_submits_from_submit() {
let mut draft = DraftPane::default();
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
assert_eq!(draft.focused_field, DraftField::Command);
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Consumed);
assert_eq!(draft.focused_field, DraftField::Submit);
assert_eq!(draft.on_input(&InputKey::Enter), DraftInputResult::Submit);
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, block border takes column 0).
#[test]
fn render_places_the_cursor_after_the_typed_text_when_focused() {
let draft = DraftPane {
cwd: crate::text_input::TextInput::with_value("abc"),
..DraftPane::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(1 + 9 + 3, 1));
// Unfocused pane: no field highlight either.
let rendered_unfocused = render_to_string(80, 20, |f| draft.render(f, f.area(), false));
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), false)).unwrap();
let buf = terminal.backend().buffer();
assert!(
!buf.content()
.iter()
.any(|c| c.modifier.contains(ratatui::style::Modifier::REVERSED)),
"unfocused draft must not highlight fields:\n{rendered_unfocused}"
);
}
}
+1
View File
@@ -7,6 +7,7 @@ pub mod app;
pub mod catalog;
pub mod cli;
pub mod config;
pub mod draft;
pub mod emulators;
pub mod input;
pub mod keymap;
+7 -249
View File
@@ -1,161 +1,16 @@
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
//! transient toast.
//! Overlay widgets: kill-confirm prompt, help overlay, transient toast.
//!
//! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]).
//!
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Cwd,
Command,
Submit,
}
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub cwd: crate::text_input::TextInput,
pub command: crate::text_input::TextInput,
pub focused_field: SpawnField,
}
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Submit,
SpawnField::Submit => SpawnField::Cwd,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Cwd => SpawnField::Submit,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Submit => SpawnField::Command,
};
}
/// Handle one keystroke while this modal owns input. Returns
/// [`SpawnInputResult::Submit`] when the user accepts; the caller fires
/// the POST and dismisses the modal.
pub fn on_input(&mut self, key: &crate::input::InputKey) -> SpawnInputResult {
use crate::input::InputKey;
match key {
InputKey::Tab => {
self.next_field();
SpawnInputResult::Consumed
}
InputKey::BackTab => {
self.prev_field();
SpawnInputResult::Consumed
}
InputKey::Esc => SpawnInputResult::Cancel,
InputKey::Enter => match self.focused_field {
SpawnField::Submit => SpawnInputResult::Submit,
_ => {
self.next_field();
SpawnInputResult::Consumed
}
},
// Everything else — characters, motion, editing, paste — goes to
// the focused field's text input.
other => {
match self.focused_field {
SpawnField::Cwd => self.cwd.handle_key(other),
SpawnField::Command => self.command.handle_key(other),
SpawnField::Submit => {}
}
SpawnInputResult::Consumed
}
}
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 10);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let highlight = |field: SpawnField| {
if self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
// Each text field renders through the shared `TextInput`, which
// handles horizontal scrolling and reports where the terminal cursor
// belongs. Label is fixed-width 8 chars + 1 space = column 9.
let label =
|text: &str| Paragraph::new(Line::from(ratatui::text::Span::raw(format!("{text:8} "))));
f.render_widget(label("cwd:"), rows[0]);
f.render_widget(label("command:"), rows[1]);
let draw_field = |f: &mut Frame,
input: &crate::text_input::TextInput,
row: Rect,
focused: bool|
-> u16 {
let style = if focused {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
let area = Rect {
x: row.x.saturating_add(9),
y: row.y,
width: row.width.saturating_sub(9),
height: 1,
};
input.render(area, f.buffer_mut(), style)
};
let cwd_col = draw_field(f, &self.cwd, rows[0], self.focused_field == SpawnField::Cwd);
let cmd_col = draw_field(
f,
&self.command,
rows[1],
self.focused_field == SpawnField::Command,
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[2],
);
match self.focused_field {
SpawnField::Cwd => f.set_cursor_position((cwd_col, rows[0].y)),
SpawnField::Command => f.set_cursor_position((cmd_col, rows[1].y)),
SpawnField::Submit => {
// The submit button is indicated by the REVERSED style; no
// cursor placement.
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnInputResult {
Consumed,
Cancel,
Submit,
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
@@ -262,33 +117,6 @@ mod tests {
out
}
#[test]
fn spawn_modal_renders_field_labels() {
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("/x"),
command: crate::text_input::TextInput::with_value("bash"),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in ["cwd", "command", "Spawn", "/x", "bash"] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Command);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
#[test]
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
@@ -306,81 +134,11 @@ mod tests {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s OpenSpawnModal".into(),
"s NewDraftPane".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("OpenSpawnModal"));
}
/// Shift-Tab walked nowhere: `prev_field` existed with no caller, so the
/// spawn modal could only be cycled forwards.
#[test]
fn shift_tab_walks_the_spawn_modal_backwards() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Submit);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Command);
}
#[test]
fn tab_and_shift_tab_are_inverses() {
let mut modal = SpawnModal::default();
for _ in 0..3 {
modal.on_input(&crate::input::InputKey::Tab);
modal.on_input(&crate::input::InputKey::BackTab);
assert_eq!(modal.focused_field, SpawnField::Cwd);
}
}
/// Editing keys must reach the focused field's text input, not a bare
/// String — this is what gives the operator cursor motion and word ops.
#[test]
fn editing_keys_reach_the_focused_text_input() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Cwd);
for k in [
crate::input::InputKey::char('/'),
crate::input::InputKey::char('t'),
crate::input::InputKey::char('m'),
crate::input::InputKey::char('p'),
] {
modal.on_input(&k);
}
modal.on_input(&crate::input::InputKey::Left);
modal.on_input(&crate::input::InputKey::Backspace);
assert_eq!(modal.cwd.value(), "/tp");
// Tab to command, then a paste and ctrl-w land there, not in cwd.
modal.on_input(&crate::input::InputKey::Tab);
assert_eq!(modal.focused_field, SpawnField::Command);
modal.on_input(&crate::input::InputKey::Paste("/tmp/foo".into()));
assert_eq!(modal.command.value(), "/tmp/foo");
modal.on_input(&crate::input::InputKey::ctrl_char('w'));
assert_eq!(modal.command.value(), "");
assert_eq!(modal.cwd.value(), "/tp", "cwd untouched while unfocused");
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (label is 8 chars + 1 space, modal is centered on screen).
#[test]
fn render_places_the_cursor_after_the_typed_text() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let modal = SpawnModal {
cwd: crate::text_input::TextInput::with_value("abc"),
..SpawnModal::default()
};
let backend = TestBackend::new(80, 20);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
// Modal: x = (80-60)/2 = 10; block border → inner.x = 11; label col 9.
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(pos, ratatui::layout::Position::new(11 + 9 + 3, 6));
assert!(rendered.contains("NewDraftPane"));
}
}
+60 -7
View File
@@ -10,7 +10,7 @@ use ratatui::Frame;
use crate::input::Region;
use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
@@ -63,8 +63,9 @@ pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the active tab's pane is a draft (issue #63).
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub spawn: &'a SpawnModal,
pub help: &'a HelpOverlay,
}
@@ -131,6 +132,9 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
@@ -138,10 +142,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
render_status_bar(f, status_area, inputs.state);
match inputs.state.overlay() {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::KillConfirm(id)) => {
let kc = KillConfirm {
label: id.to_string(),
@@ -186,7 +186,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
Focus::Pane => "pane",
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: spawn • ?: help • ^Wq: quit",
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
};
let mode_style = match state.mode {
@@ -266,4 +266,57 @@ mod tests {
assert_eq!(map.region_at(15, 10), Region::Modal);
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
#[test]
fn a_draft_pane_renders_the_spawn_form_in_its_pane_area() {
use crate::overlays::HelpOverlay;
use crate::state::PaneRef;
use std::collections::HashSet;
let mut state = AppState::new();
state.open_draft_pane();
let draft = match &state.tabs[0].pane {
Some(PaneRef::Draft(d)) => d,
other => panic!("expected a draft pane, got {other:?}"),
};
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
pane: None,
draft: Some(draft),
state: &state,
help: &help,
};
let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
draw(f, inputs);
})
.unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
for s in ["draft", "cwd:", "command:", "[ Spawn ]"] {
assert!(out.contains(s), "missing {s:?}:\n{out}");
}
}
}
+109 -15
View File
@@ -1,14 +1,16 @@
//! `AppState` — the top-level mutable state of the running TUI.
//!
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
//! session), an active tab index, focus split between sidebar and pane, the
//! current input mode, and an optional overlay (spawn modal, kill confirm,
//! help, transient toast).
//! session or a draft form), an active tab index, focus split between sidebar
//! and pane, the current input mode, and an optional overlay (kill confirm,
//! help, transient toast). The spawn form is no longer an overlay — an empty
//! pane is a draft pane (issue #63).
//!
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
use uuid::Uuid;
use crate::draft::DraftPane;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -17,15 +19,31 @@ pub enum Focus {
Pane,
}
/// What a tab's pane slot holds: a live session, or an inline draft form.
#[derive(Debug, Clone, PartialEq)]
pub enum PaneRef {
Session(Uuid),
Draft(DraftPane),
}
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
pub pane: Option<Uuid>,
pub pane: Option<PaneRef>,
}
impl Tab {
/// The session this tab shows, if it shows one. Draft panes have none.
pub fn session(&self) -> Option<Uuid> {
match &self.pane {
Some(PaneRef::Session(sid)) => Some(*sid),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
Spawn,
KillConfirm(Uuid),
Help,
Toast(String),
@@ -108,7 +126,8 @@ impl AppState {
///
/// Returns the session that tab was showing, if any, so the caller can
/// release its emulator. Without that the tab's WebSocket task and vt100
/// parser outlive the tab forever.
/// parser outlive the tab forever. A draft pane holds no session and
/// releases nothing.
pub fn close_tab(&mut self) -> Option<Uuid> {
if self.tabs.len() <= 1 {
return None;
@@ -117,9 +136,10 @@ impl AppState {
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
let sid = closed.session()?;
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.pane == closed.pane);
closed.pane.filter(|_| !still_shown)
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
(!still_shown).then_some(sid)
}
pub fn next_tab(&mut self) {
@@ -147,11 +167,37 @@ impl AppState {
/// 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.active_tab_mut().pane = Some(PaneRef::Session(session_id));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
/// starts immediately, same flow as [`Self::attach_to_focused`].
pub fn open_draft_pane(&mut self) {
if self.active_tab().pane.is_some() {
self.new_tab();
}
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// A draft submit succeeded: replace whatever the active tab showed with
/// the new session in place, matching the web client's behaviour (#57).
pub fn complete_spawn(&mut self, sid: Uuid) {
self.active_tab_mut().pane = Some(PaneRef::Session(sid));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
}
/// The session shown by the active tab's pane, if any.
pub fn active_session(&self) -> Option<Uuid> {
self.active_tab().session()
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
@@ -288,7 +334,7 @@ mod tests {
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.tabs[0].session(), 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.
@@ -314,7 +360,7 @@ mod tests {
let mut state = AppState::new();
let sid = Uuid::from_u128(3);
state.new_tab();
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), Some(sid));
}
@@ -324,9 +370,9 @@ mod tests {
fn close_tab_keeps_a_session_another_tab_still_shows() {
let mut state = AppState::new();
let sid = Uuid::from_u128(4);
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
state.new_tab();
state.active_tab_mut().pane = Some(sid);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), None);
}
@@ -334,7 +380,7 @@ mod tests {
#[test]
fn close_tab_is_a_no_op_on_the_last_tab() {
let mut state = AppState::new();
state.active_tab_mut().pane = Some(Uuid::from_u128(5));
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
@@ -347,4 +393,52 @@ mod tests {
assert!(state.dismiss_overlay());
assert!(state.overlay().is_none());
}
/// Issue #63: `s` over an empty pane turns that pane into a draft —
/// focused, Insert mode on, typing starts immediately.
#[test]
fn open_draft_reuses_the_empty_active_pane() {
let mut state = AppState::new();
state.open_draft_pane();
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
assert_eq!(state.tabs.len(), 1);
}
/// A tab already showing a session gets a fresh tab for the draft; v1
/// has no split tree, so a new tab is the "split".
#[test]
fn open_draft_over_a_session_opens_a_new_tab() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
// The session tab is untouched.
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
}
/// Web parity (#57): submitting fills the pane in place.
#[test]
fn complete_spawn_replaces_the_draft_with_the_session() {
let mut state = AppState::new();
state.open_draft_pane();
let sid = Uuid::from_u128(21);
state.complete_spawn(sid);
assert_eq!(state.active_tab().session(), Some(sid));
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
}
/// A closed draft tab holds no session, so nothing is released.
#[test]
fn close_tab_reports_nothing_for_a_draft_pane() {
let mut state = AppState::new();
state.open_draft_pane();
state.new_tab();
state.active_tab_idx = 0;
assert_eq!(state.close_tab(), None);
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ use ratatui::style::Style;
use crate::input::InputKey;
#[derive(Debug, Default, Clone)]
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct TextInput {
value: String,
/// Cursor position as a char offset into `value`.
+1
View File
@@ -17,6 +17,7 @@
"@xterm/xterm": "^5.5.0"
},
"devDependencies": {
"happy-dom": "^20.11.6",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.9"
+93 -11
View File
@@ -15,15 +15,18 @@ importers:
specifier: ^5.5.0
version: 5.5.0
devDependencies:
happy-dom:
specifier: ^20.11.6
version: 20.11.6
typescript:
specifier: ^5.6.3
version: 5.9.3
vite:
specifier: ^5.4.10
version: 5.4.21
version: 5.4.21(@types/node@26.2.0)
vitest:
specifier: ^2.1.9
version: 2.1.9
version: 2.1.9(@types/node@26.2.0)(happy-dom@20.11.6)
packages:
@@ -296,6 +299,15 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/node@26.2.0':
resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitest/expect@2.1.9':
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
@@ -337,6 +349,10 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -362,6 +378,10 @@ packages:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -382,6 +402,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
happy-dom@20.11.6:
resolution: {integrity: sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg==}
engines: {node: '>=20.0.0'}
loupe@3.2.1:
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
@@ -451,6 +475,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
vite-node@2.1.9:
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -512,11 +539,27 @@ packages:
jsdom:
optional: true
whatwg-mimetype@3.0.0:
resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
engines: {node: '>=12'}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
'@esbuild/aix-ppc64@0.21.5':
@@ -667,6 +710,16 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/node@26.2.0':
dependencies:
undici-types: 8.3.0
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 26.2.0
'@vitest/expect@2.1.9':
dependencies:
'@vitest/spy': 2.1.9
@@ -674,13 +727,13 @@ snapshots:
chai: 5.3.3
tinyrainbow: 1.2.0
'@vitest/mocker@2.1.9(vite@5.4.21)':
'@vitest/mocker@2.1.9(vite@5.4.21(@types/node@26.2.0))':
dependencies:
'@vitest/spy': 2.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 5.4.21
vite: 5.4.21(@types/node@26.2.0)
'@vitest/pretty-format@2.1.9':
dependencies:
@@ -715,6 +768,10 @@ snapshots:
assertion-error@2.0.1: {}
buffer-image-size@0.6.4:
dependencies:
'@types/node': 26.2.0
cac@6.7.14: {}
chai@5.3.3:
@@ -733,6 +790,8 @@ snapshots:
deep-eql@5.0.2: {}
entities@7.0.1: {}
es-module-lexer@1.7.0: {}
esbuild@0.21.5:
@@ -770,6 +829,19 @@ snapshots:
fsevents@2.3.3:
optional: true
happy-dom@20.11.6:
dependencies:
'@types/node': 26.2.0
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.3
transitivePeerDependencies:
- bufferutil
- utf-8-validate
loupe@3.2.1: {}
magic-string@0.30.21:
@@ -843,13 +915,15 @@ snapshots:
typescript@5.9.3: {}
vite-node@2.1.9:
undici-types@8.3.0: {}
vite-node@2.1.9(@types/node@26.2.0):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 1.1.2
vite: 5.4.21
vite: 5.4.21(@types/node@26.2.0)
transitivePeerDependencies:
- '@types/node'
- less
@@ -861,18 +935,19 @@ snapshots:
- supports-color
- terser
vite@5.4.21:
vite@5.4.21(@types/node@26.2.0):
dependencies:
esbuild: 0.21.5
postcss: 8.5.15
rollup: 4.60.4
optionalDependencies:
'@types/node': 26.2.0
fsevents: 2.3.3
vitest@2.1.9:
vitest@2.1.9(@types/node@26.2.0)(happy-dom@20.11.6):
dependencies:
'@vitest/expect': 2.1.9
'@vitest/mocker': 2.1.9(vite@5.4.21)
'@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@26.2.0))
'@vitest/pretty-format': 2.1.9
'@vitest/runner': 2.1.9
'@vitest/snapshot': 2.1.9
@@ -888,9 +963,12 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.1.1
tinyrainbow: 1.2.0
vite: 5.4.21
vite-node: 2.1.9
vite: 5.4.21(@types/node@26.2.0)
vite-node: 2.1.9(@types/node@26.2.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.2.0
happy-dom: 20.11.6
transitivePeerDependencies:
- less
- lightningcss
@@ -902,7 +980,11 @@ snapshots:
- supports-color
- terser
whatwg-mimetype@3.0.0: {}
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.21.3: {}
+143
View File
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
vi.mock("./api.js", () => ({ listDir: vi.fn() }));
import { attachDirAutocomplete, type DirAutocompleteHandle } from "./dirAutocomplete.js";
import { listDir } from "./api.js";
const listDirMock = vi.mocked(listDir);
let input: HTMLInputElement;
let nextField: HTMLInputElement;
let docKeydown: ReturnType<typeof vi.fn>;
let ac: DirAutocompleteHandle | undefined;
function popup(): HTMLUListElement {
return document.querySelector(".dir-ac") as HTMLUListElement;
}
function items(): HTMLElement[] {
return [...popup().querySelectorAll<HTMLElement>(".dir-ac-item")];
}
async function type(value: string): Promise<void> {
input.focus();
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
await vi.advanceTimersByTimeAsync(100); // past the fetch debounce
}
/** Dispatch a cancelable, bubbling keydown on the input; report whether the
* default browser behaviour (focus move, form submit) was suppressed. */
function press(key: string): { defaultPrevented: boolean } {
const evt = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
input.dispatchEvent(evt);
return { defaultPrevented: evt.defaultPrevented };
}
beforeEach(() => {
vi.useFakeTimers();
document.body.replaceChildren();
input = document.createElement("input");
nextField = document.createElement("input");
document.body.append(input, nextField);
docKeydown = vi.fn();
document.addEventListener("keydown", docKeydown);
listDirMock.mockReset().mockResolvedValue({ path: "/tmp", entries: ["proj-a", "proj-b", "other"], truncated: false });
});
afterEach(() => {
ac?.destroy();
document.removeEventListener("keydown", docKeydown);
vi.useRealTimers();
});
describe("dirAutocomplete dismissal", () => {
it("Escape closes the popup, keeps the typed value and does not propagate", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
const { defaultPrevented } = press("Escape");
expect(defaultPrevented).toBe(true);
expect(popup().hidden).toBe(true);
expect(input.value).toBe("/tmp/pro");
expect(document.activeElement).toBe(input);
expect(docKeydown).not.toHaveBeenCalled(); // a draft pane's Escape handler must not fire
});
it("blur dismisses the popup", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
expect(popup().hidden).toBe(false);
input.blur();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
});
it("does not reopen on refocus until the operator types again", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro");
input.blur();
await vi.advanceTimersByTimeAsync(150);
input.focus();
await vi.advanceTimersByTimeAsync(150);
expect(popup().hidden).toBe(true);
await type("/tmp/ot");
expect(popup().hidden).toBe(false);
});
});
describe("dirAutocomplete keyboard model", () => {
it("Tab commits the highlighted entry and lets focus move to the next field", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/pro"); // highlight defaults to the first match: proj-a
nextField.focus();
const { defaultPrevented } = press("Tab");
expect(input.value).toBe("/tmp/proj-a/");
expect(defaultPrevented).toBe(false); // native tab navigation proceeds
expect(document.activeElement).toBe(nextField); // focus was not yanked back
});
it("Tab with nothing highlighted just moves on", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/nope-matches-nothing");
expect(popup().hidden).toBe(true);
const { defaultPrevented } = press("Tab");
expect(defaultPrevented).toBe(false);
expect(input.value).toBe("/tmp/nope-matches-nothing");
});
it("arrows move the highlight and Enter commits it without submitting", async () => {
ac = attachDirAutocomplete(input);
await type("/tmp/");
press("ArrowDown");
expect(items()[1].classList.contains("is-active")).toBe(true);
const enter = press("Enter");
expect(enter.defaultPrevented).toBe(true); // no form submit
expect(input.value).toBe("/tmp/proj-b/");
});
});
describe("dirAutocomplete opening", () => {
it("still opens on focus when not previously dismissed", async () => {
ac = attachDirAutocomplete(input);
input.focus();
await vi.advanceTimersByTimeAsync(50);
expect(popup().hidden).toBe(false);
expect(listDirMock).toHaveBeenCalled();
});
});
+36 -27
View File
@@ -4,15 +4,19 @@
// per directory, and filters the trailing path segment locally — so typing within a segment is instant and only crossing a `/`
// hits the network. Keyboard model mirrors `cd dir/sub<tab>`:
//
// Tab complete the longest common prefix; if one match, accept it (and
// append `/` so the next level loads); if nothing to extend, cycle.
// Tab commit the highlighted entry and let focus move on; with nothing
// highlighted, plain tab navigation
// ↑/↓ move the highlight
// Enter accept the highlighted entry (and suppress form submit)
// Esc close the dropdown
// Esc close the dropdown, refocus the input, and swallow the key so it
// can't also discard a surrounding draft pane
//
// Once dismissed (Esc or blur) the popup stays closed until the operator types
// again — refocusing alone doesn't reopen it.
//
// Failures (offline daemon, permission denied) degrade to no suggestions.
import { longestCommonPrefix, splitPath } from "./paths.js";
import { splitPath } from "./paths.js";
import { listDir } from "./api.js";
export interface DirAutocompleteHandle {
@@ -42,6 +46,9 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
let fetchSeq = 0;
let debounce: number | undefined;
let open = false;
// Set when the operator dismissed the popup (Esc, blur, outside click); a
// refocus must not reopen it — only new typing does.
let dismissed = false;
function close(): void {
@@ -54,6 +61,11 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
window.removeEventListener("resize", reposition);
}
function dismiss(): void {
dismissed = true;
close();
}
function reposition(): void {
const r = input.getBoundingClientRect();
dropdown.style.left = `${r.left}px`;
@@ -132,21 +144,28 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
}
// Accept `name` as the next path segment: complete it and append `/` so the
// child directory's listing loads immediately (the drill-in gesture).
function accept(name: string): void {
// child directory's listing loads on the next interaction (the drill-in
// gesture). Unlike `accept`, doesn't steal focus — Tab uses this to commit
// and move to the next field in one gesture.
function commit(name: string): void {
const { dir } = splitPath(input.value);
input.value = `${dir}${name}/`;
input.focus();
// Notify other listeners (spawn.ts marks the field user-edited) and load
// the child level.
// Notify other listeners (spawnForm marks the field user-edited).
input.dispatchEvent(new Event("input", { bubbles: true }));
}
function accept(name: string): void {
commit(name);
input.focus();
}
function onInput(): void {
dismissed = false;
scheduleRefresh();
}
function onFocus(): void {
if (dismissed) return;
refresh();
}
@@ -154,27 +173,17 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
if (e.key === "Escape") {
if (open) {
e.preventDefault();
close();
// Swallow the key: an Escape that bubbles would also discard the
// draft pane hosting this form.
e.stopPropagation();
dismiss();
input.focus();
}
return;
}
if (e.key === "Tab") {
if (!open || filtered.length === 0) return; // let focus move normally
e.preventDefault();
if (filtered.length === 1) {
accept(filtered[0]);
return;
}
const { dir, prefix } = splitPath(input.value);
const lcp = longestCommonPrefix(filtered);
if (lcp.length > prefix.length) {
input.value = `${dir}${lcp}`;
input.dispatchEvent(new Event("input", { bubbles: true }));
} else {
// Nothing to extend — cycle the highlight like a second shell <tab>.
highlight = (highlight + 1) % filtered.length;
render();
}
if (!open || highlight < 0 || highlight >= filtered.length) return; // plain tab navigation
commit(filtered[highlight]); // no preventDefault — focus moves to the next field
return;
}
if (!open) return;
@@ -196,7 +205,7 @@ export function attachDirAutocomplete(input: HTMLInputElement): DirAutocompleteH
function onBlur(): void {
// Delay so a dropdown mousedown can complete first.
window.setTimeout(close, 120);
window.setTimeout(dismiss, 120);
}
input.addEventListener("input", onInput);