diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c2ae0..a159aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Chat pane input: `Home`/`End`/`Delete` now work; `Up`/`Down` recall previously sent messages (draft preserved); typing `/` as the first character opens a filtering command popup (`Up`/`Down` select, `Tab` completes). - Added recursive split panes: any pane can be split side by side (`alt-\`) or top/bottom (`alt--`), repeatedly and in any direction, for arbitrary tiled layouts. `alt-w` closes the focused pane (its sibling reclaims the space), `alt-r` rotates a split, `alt-o` cycles focus. Replaces the previous two-pane toggle; keybinding actions are now `split_pane_right`, `split_pane_down`, `close_pane`, `rotate_split`, `focus_next_pane`. - Added screen-style multi-session support: each `linkshell` starts its own detached server, `linkshell ls` lists live sessions (id, name, pid, status), and `linkshell -r ` reattaches to a specific one. `linkshell new [name]` names a session; `linkshell -r` with no id attaches the sole running session. - Fixed 100% CPU usage caused by full-screen agent TUIs (notably OpenCode) that repaint continuously: session output now triggers a redraw only when a visible session's screen actually changes, and the partial-line heartbeat only when a session's inferred state changes. diff --git a/docs/chat.md b/docs/chat.md index a485c61..bb96812 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -25,6 +25,12 @@ it is copied to the clipboard on release, like the session panes. Pasting into the chat input works too; multi-line pastes are delivered to sessions via bracketed paste so they arrive as one message. Dock the pane with `alt-g`. +The input supports the usual line-editing keys (`Home`/`End`/`Delete` alongside +arrows and backspace). `Up`/`Down` recall previously sent messages, restoring +any in-progress draft when you scroll back down. Typing `/` as the first +character opens a command popup that narrows as you type — `Up`/`Down` pick an +entry, `Tab` completes it, `Enter` sends. + ## Answering permission prompts When an AI session stops on a permission dialog or y/n question, the prompt is diff --git a/src/app.rs b/src/app.rs index 4bf48b1..5d013ca 100644 --- a/src/app.rs +++ b/src/app.rs @@ -265,6 +265,14 @@ pub struct ChatState { /// Per-local-agent conversation history (role, content), oldest first. pub histories: std::collections::HashMap>, pub pending: Vec, + /// Previously sent inputs, oldest first (Up/Down recall). + pub history: Vec, + /// Index into `history` while browsing with Up/Down; None = live input. + pub history_pos: Option, + /// The in-progress input stashed when Up starts browsing history. + pub history_draft: String, + /// Slash-command completion popup (populated while input starts with '/'). + pub palette: PaletteState, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -4368,6 +4376,21 @@ impl App { } } KeyCode::Enter => self.chat_send(), + KeyCode::Tab if !self.chat.palette.matches.is_empty() => { + if let Some(entry) = self.chat.palette.matches.get(self.chat.palette.selected) { + self.chat.input = entry.insert.clone(); + self.chat.cursor = self.chat.input.len(); + self.refresh_chat_palette(); + } + } + KeyCode::Up if !self.chat.palette.matches.is_empty() => { + self.chat_palette_move(-1); + } + KeyCode::Down if !self.chat.palette.matches.is_empty() => { + self.chat_palette_move(1); + } + KeyCode::Up => self.chat_history_prev(), + KeyCode::Down => self.chat_history_next(), KeyCode::Backspace if self.chat.cursor > 0 => { let mut i = self.chat.cursor - 1; while i > 0 && !self.chat.input.is_char_boundary(i) { @@ -4375,7 +4398,20 @@ impl App { } self.chat.input.replace_range(i..self.chat.cursor, ""); self.chat.cursor = i; + self.chat.history_pos = None; + self.refresh_chat_palette(); } + KeyCode::Delete if self.chat.cursor < self.chat.input.len() => { + let mut i = self.chat.cursor + 1; + while i < self.chat.input.len() && !self.chat.input.is_char_boundary(i) { + i += 1; + } + self.chat.input.replace_range(self.chat.cursor..i, ""); + self.chat.history_pos = None; + self.refresh_chat_palette(); + } + KeyCode::Home => self.chat.cursor = 0, + KeyCode::End => self.chat.cursor = self.chat.input.len(), KeyCode::Left if self.chat.cursor > 0 => { let mut i = self.chat.cursor - 1; while i > 0 && !self.chat.input.is_char_boundary(i) { @@ -4395,11 +4431,106 @@ impl App { KeyCode::Char(c) => { self.chat.input.insert(self.chat.cursor, c); self.chat.cursor += c.len_utf8(); + self.chat.history_pos = None; + self.refresh_chat_palette(); } _ => {} } } + fn chat_palette_move(&mut self, delta: isize) { + if self.chat.palette.matches.is_empty() { + self.chat.palette.selected = 0; + return; + } + self.chat.palette.selected = (self.chat.palette.selected as isize + delta) + .clamp(0, self.chat.palette.matches.len() as isize - 1) + as usize; + } + + /// Populate the slash-command popup while the input starts with '/'. + /// Offers the chat-only commands plus everything in the command palette + /// (all runnable from chat via the '/' prefix). + fn refresh_chat_palette(&mut self) { + const CHAT_COMMANDS: &[(&str, &str, &str)] = &[ + ("agents", "List chat-addressable targets", "agents"), + ( + "approve", + "Approve the pending orchestrator proposal", + "approve", + ), + ( + "deny [reason]", + "Deny the pending orchestrator proposal", + "deny ", + ), + ( + "confirm-kill", + "Approve a pending kill request", + "confirm-kill", + ), + ("deny-kill", "Refuse a pending kill request", "deny-kill"), + ]; + let Some(rest) = self.chat.input.strip_prefix('/') else { + self.chat.palette = PaletteState::default(); + return; + }; + let query = rest.trim().to_lowercase(); + let mut matches: Vec<(i32, PaletteEntry)> = CHAT_COMMANDS + .iter() + .chain(COMMAND_PALETTE.iter()) + .filter_map(|(template, summary, insert)| { + fuzzy_score(&query, &template.to_lowercase()).map(|score| { + ( + score, + PaletteEntry { + template: format!("/{}", template), + summary: (*summary).into(), + insert: format!("/{}", insert), + }, + ) + }) + }) + .collect(); + matches.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.template.cmp(&b.1.template))); + self.chat.palette.matches = matches.into_iter().map(|(_, entry)| entry).collect(); + self.chat.palette.selected = self + .chat + .palette + .selected + .min(self.chat.palette.matches.len().saturating_sub(1)); + } + + fn chat_history_prev(&mut self) { + if self.chat.history.is_empty() { + return; + } + let pos = match self.chat.history_pos { + None => { + self.chat.history_draft = self.chat.input.clone(); + self.chat.history.len() - 1 + } + Some(p) => p.saturating_sub(1), + }; + self.chat.history_pos = Some(pos); + self.chat.input = self.chat.history[pos].clone(); + self.chat.cursor = self.chat.input.len(); + } + + fn chat_history_next(&mut self) { + let Some(p) = self.chat.history_pos else { + return; + }; + if p + 1 < self.chat.history.len() { + self.chat.history_pos = Some(p + 1); + self.chat.input = self.chat.history[p + 1].clone(); + } else { + self.chat.history_pos = None; + self.chat.input = std::mem::take(&mut self.chat.history_draft); + } + self.chat.cursor = self.chat.input.len(); + } + pub fn chat_scroll_up(&mut self, lines: usize) { self.chat.scroll = (self.chat.scroll + lines).min(self.chat_scroll_max); } @@ -4420,6 +4551,8 @@ impl App { .collect(); self.chat.input.insert_str(self.chat.cursor, &cleaned); self.chat.cursor += cleaned.len(); + self.chat.history_pos = None; + self.refresh_chat_palette(); } fn chat_system(&mut self, text: impl Into) { @@ -4441,9 +4574,15 @@ impl App { self.chat.cursor = 0; self.chat.scroll = 0; let raw = raw.trim().to_string(); + self.chat.palette = PaletteState::default(); if raw.is_empty() { return; } + if self.chat.history.last() != Some(&raw) { + self.chat.history.push(raw.clone()); + } + self.chat.history_pos = None; + self.chat.history_draft.clear(); if raw == "/agents" { let mut targets: Vec = Vec::new(); @@ -6446,6 +6585,92 @@ mod tests { assert_eq!(app.panes, vec![Some(0)]); } + fn chat_press(app: &mut App, code: crossterm::event::KeyCode) { + app.chat_key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + )); + } + + #[test] + fn chat_home_end_delete_edit_the_input() { + use crossterm::event::KeyCode; + let mut app = make_app(); + app.chat.input = "hello".into(); + app.chat.cursor = 3; + + chat_press(&mut app, KeyCode::Home); + assert_eq!(app.chat.cursor, 0); + chat_press(&mut app, KeyCode::Delete); + assert_eq!(app.chat.input, "ello"); + chat_press(&mut app, KeyCode::End); + assert_eq!(app.chat.cursor, 4); + chat_press(&mut app, KeyCode::Delete); // at end: no-op + assert_eq!(app.chat.input, "ello"); + } + + #[test] + fn chat_up_down_recall_history_and_restore_the_draft() { + use crossterm::event::KeyCode; + let mut app = make_app(); + app.chat.input = "first".into(); + app.chat_send(); + app.chat.input = "second".into(); + app.chat_send(); + + app.chat.input = "draft".into(); + app.chat.cursor = 5; + chat_press(&mut app, KeyCode::Up); + assert_eq!(app.chat.input, "second"); + chat_press(&mut app, KeyCode::Up); + assert_eq!(app.chat.input, "first"); + chat_press(&mut app, KeyCode::Up); // at oldest: stays + assert_eq!(app.chat.input, "first"); + chat_press(&mut app, KeyCode::Down); + assert_eq!(app.chat.input, "second"); + chat_press(&mut app, KeyCode::Down); + assert_eq!(app.chat.input, "draft", "leaving history restores draft"); + + // Sending the same line twice records it once. + app.chat.input = "second".into(); + app.chat_send(); + assert_eq!(app.chat.history, vec!["first", "second"]); + } + + #[test] + fn chat_slash_opens_a_filtering_palette_and_tab_completes() { + use crossterm::event::KeyCode; + let mut app = make_app(); + assert!(app.chat.palette.matches.is_empty()); + + for c in "/agen".chars() { + chat_press(&mut app, KeyCode::Char(c)); + } + assert!(app + .chat + .palette + .matches + .iter() + .any(|m| m.template == "/agents")); + + // Narrow to the top match and complete it. + app.chat.palette.selected = 0; + chat_press(&mut app, KeyCode::Tab); + assert!(app.chat.input.starts_with('/')); + assert_eq!(app.chat.cursor, app.chat.input.len()); + + // With the palette open, Up/Down move the selection, not history. + let before = app.chat.input.clone(); + chat_press(&mut app, KeyCode::Down); + assert_eq!(app.chat.input, before); + + // Deleting back past '/' closes the palette. + app.chat.input.clear(); + app.chat.cursor = 0; + chat_press(&mut app, KeyCode::Char('h')); + assert!(app.chat.palette.matches.is_empty()); + } + #[test] fn closing_chat_pane_undocks_and_switching_replaces_docked_chat() { let mut app = make_app(); diff --git a/src/ui.rs b/src/ui.rs index e101d45..a7dddfe 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1601,6 +1601,41 @@ fn draw_chat_in(f: &mut Frame<'_>, app: &App, popup: Rect, focused: bool) -> Cha .collect(); f.render_widget(Paragraph::new(input_lines), input_area); + // Slash-command completion popup: overlays the transcript just above the + // separator while the input starts with '/'. + if focused && !app.chat.palette.matches.is_empty() { + let count = (app.chat.palette.matches.len().min(8) as u16).min(transcript_h as u16); + if count > 0 { + let popup = Rect { + x: inner.x, + y: sep.y.saturating_sub(count), + width: inner.width, + height: count, + }; + f.render_widget(Clear, popup); + let lines: Vec> = app + .chat + .palette + .matches + .iter() + .take(count as usize) + .enumerate() + .map(|(index, entry)| { + let style = if index == app.chat.palette.selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default().fg(Color::White).bg(Color::DarkGray) + }; + Line::from(vec![ + Span::styled(format!(" {:<30}", entry.template), style), + Span::styled(entry.summary.clone(), style.add_modifier(Modifier::DIM)), + ]) + }) + .collect(); + f.render_widget(Paragraph::new(lines), popup); + } + } + ChatLayout { area: popup, transcript_area: transcript,