Add modal editing to the agent TUI. (#2)
Adds a status line indicating which mode the user is in. Adds a "normal" mode with keyboard shortcuts (including a chorded shortcut 'gg'). Adds a command mode with several basic commands that can be entered into an overlay. Chores: - Cleans up design/claude/plan.md to avoid confusing claude. - Adds some TODOs based on claude feedback.` Reviewed-on: #2 Co-authored-by: Drew Galbraith <drew@tiramisu.one> Co-committed-by: Drew Galbraith <drew@tiramisu.one>
This commit is contained in:
parent
5d213b43d3
commit
3fd448d431
9 changed files with 725 additions and 101 deletions
|
|
@ -61,7 +61,7 @@ Events use parent IDs forming a tree (not a flat list). This enables future bran
|
|||
|
||||
- Unit tests go in the same file as the code (`#[cfg(test)] mod tests`)
|
||||
- Integration tests go in `tests/`
|
||||
- TUI widget tests use `ratatui::backend::TestBackend` + `insta` snapshots
|
||||
- TUI widget tests use `ratatui::backend::TestBackend`
|
||||
- Provider tests replay recorded SSE fixtures from `tests/fixtures/`
|
||||
- Sandbox tests use `tempdir` and skip Landlock-specific assertions if kernel < 5.13
|
||||
- Run `cargo test` before every commit
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@
|
|||
- **`sandbox`:** Landlock policy construction, path validation logic (without applying kernel rules)
|
||||
- **`core`:** Conversation tree operations (insert, query by parent, turn computation, token totals), orchestrator state machine transitions against mock `StreamEvent` sequences
|
||||
- **`session`:** JSONL serialization roundtrips, parent ID chain reconstruction
|
||||
- **`tui`:** Widget rendering via Ratatui `TestBackend`, snapshot tests with `insta` crate for layout/mode indicator/token display
|
||||
- **`tui`:** Widget rendering via Ratatui `TestBackend`
|
||||
|
||||
### Integration Tests — Component Boundaries
|
||||
- **Core ↔ Provider:** Mock `ModelProvider` replaying recorded API sessions (full SSE streams with tool use). Tests the complete orchestration loop deterministically without network.
|
||||
|
|
@ -78,11 +78,6 @@
|
|||
- **Recorded session replay:** Capture real Claude API HTTP request/response pairs, replay deterministically. Exercises full stack (core + channel + mock TUI) without cost or network dependency. Primary E2E test strategy.
|
||||
- **Live API tests:** Small suite behind feature flag / env var. Verifies real API integration. Run manually before releases, not in CI.
|
||||
|
||||
### Snapshot Testing
|
||||
- `insta` crate for TUI visual regression testing from Phase 2 onward
|
||||
- Capture rendered `TestBackend` buffers as string snapshots
|
||||
- Catches layout, mode indicator, and token display regressions
|
||||
|
||||
### Benchmarking — SWE-bench
|
||||
- **Target:** SWE-bench Verified (500 curated problems) as primary benchmark
|
||||
- **Secondary:** SWE-bench Pro for testing planning mode on longer-horizon tasks
|
||||
|
|
@ -93,7 +88,6 @@
|
|||
|
||||
### Test Sequencing
|
||||
- Phase 1: Unit tests for SSE parser, event types, message serialization
|
||||
- Phase 2: Snapshot tests for TUI with `insta`
|
||||
- Phase 4: Recorded session replay infrastructure (core loop complex enough to warrant it)
|
||||
- Phase 6-7: Headless mode + first SWE-bench Verified run
|
||||
|
||||
|
|
|
|||
5
IDEAS.md
5
IDEAS.md
|
|
@ -42,6 +42,9 @@ Notes based on ideas I've had.
|
|||
- JSONL `SessionWriter` with `Event` structure
|
||||
- Parent IDs, timestamps, token usage per event
|
||||
- Predictable file location with session IDs
|
||||
- Ability to resume prior session.
|
||||
- **Done when:** Session files are coherent, parseable, with token counts per turn
|
||||
|
||||
|
||||
## Which-key like help
|
||||
- Show command chording in normal mode.
|
||||
- Help window slides up from bottom of screen.
|
||||
|
|
|
|||
75
PLAN.md
75
PLAN.md
|
|
@ -1,77 +1,9 @@
|
|||
# Implementation Plan
|
||||
|
||||
## Phase 1: Minimal Conversation Loop
|
||||
|
||||
**Done when:** Multi-turn streaming conversation with Claude works in terminal
|
||||
|
||||
### 1.1 Project Scaffolding
|
||||
- `Cargo.toml` with initial dependencies:
|
||||
- `ratatui`, `crossterm` — TUI
|
||||
- `tokio` (full features) — async runtime
|
||||
- `serde`, `serde_json` — serialization
|
||||
- `thiserror` — error types
|
||||
- `tracing`, `tracing-subscriber` — structured logging
|
||||
- `reqwest` (with `stream` feature) — HTTP client for SSE
|
||||
- `futures` — stream combinators
|
||||
- Establish `src/{app,tui,core,provider}/mod.rs` stubs
|
||||
- `cargo build` passes; `cargo clippy -- -D warnings` passes on empty stubs
|
||||
|
||||
### 1.2 Shared Types (`src/core/types.rs`)
|
||||
- `StreamEvent` enum: `TextDelta(String)`, `InputTokens(u32)`, `OutputTokens(u32)`, `Done`, `Error(String)`
|
||||
- `UserAction` enum (TUI → core channel): `SendMessage(String)`, `Quit`
|
||||
- `UIEvent` enum (core → TUI channel): `StreamDelta(String)`, `TurnComplete`, `Error(String)`
|
||||
- `ConversationMessage` struct: `role: Role`, `content: String`
|
||||
- All types derive `Debug`; all public types have doc comments
|
||||
|
||||
### 1.3 Provider: `ModelProvider` Trait + Claude SSE (`src/provider/`)
|
||||
- `ModelProvider` trait: `async fn stream(&self, messages: &[ConversationMessage]) -> impl Stream<Item = StreamEvent>`
|
||||
- `ClaudeProvider` struct: API key from env, `reqwest` HTTP client
|
||||
- Serialize messages to Anthropic Messages API JSON format
|
||||
- Parse SSE byte stream → `StreamEvent` (handle `content_block_delta`, `message_delta` for tokens, `message_stop`)
|
||||
- Unit tests: SSE parsing from hardcoded byte fixtures in `#[cfg(test)]`
|
||||
|
||||
### 1.4 Core: Conversation State + Orchestrator Loop (`src/core/`)
|
||||
- `ConversationHistory`: `Vec<ConversationMessage>` with `push` and `messages()` (flat list, no tree yet)
|
||||
- `Orchestrator` struct holding history, provider, channel senders/receivers
|
||||
- Orchestrator loop:
|
||||
1. Await `UserAction` from TUI channel
|
||||
2. On `SendMessage`: append user message, call `provider.stream()`
|
||||
3. Forward each `StreamEvent` as `UIEvent` to TUI
|
||||
4. Accumulate deltas into assistant message; append to history on `Done`
|
||||
5. On `Quit`: break loop
|
||||
|
||||
### 1.5 TUI: Layout + Input + Streaming Display (`src/tui/`)
|
||||
- `AppState` struct: `messages: Vec<(Role, String)>`, `input: String`, `scroll: u16`
|
||||
- Ratatui layout: full-height `Paragraph` output area (scrollable) + single-line `Paragraph` input
|
||||
- Insert mode only — printable chars append to `input`, Enter sends `UserAction::SendMessage`, Backspace deletes
|
||||
- On `UIEvent::StreamDelta`: append to last assistant message in `messages`, re-render
|
||||
- On `UIEvent::TurnComplete`: finalize assistant message
|
||||
- Crossterm raw mode enter/exit; restore terminal on panic or clean exit
|
||||
|
||||
### 1.6 App Wiring + Entry Point (`src/app/`, `src/main.rs`)
|
||||
- `main.rs`: parse `--project-dir <path>` CLI arg
|
||||
- Initialize `tracing_subscriber` (log to file, not stdout — avoids TUI interference)
|
||||
- Create `tokio::sync::mpsc` channel pair for `UserAction` and `UIEvent`
|
||||
- Spawn `Orchestrator::run()` as a tokio task
|
||||
- Run TUI event loop on main thread (Ratatui requires main thread for crossterm)
|
||||
- On `UserAction::Quit` or Ctrl-C: signal orchestrator shutdown, restore terminal, exit cleanly
|
||||
|
||||
### 1.7 Phase 1 Unit Tests
|
||||
- Provider: SSE byte fixture → correct `StreamEvent` sequence
|
||||
- Provider: `ConversationMessage` vec → correct Anthropic API JSON shape
|
||||
- Core: `ConversationHistory` push/read roundtrip
|
||||
- Core: Orchestrator state transitions against mock `StreamEvent` sequence (no real API)
|
||||
|
||||
## Phase 2: Vim Modes and Navigation
|
||||
- Normal, Insert, Command modes with visual indicator
|
||||
- `j`/`k` scroll in Normal mode
|
||||
- `:quit`, `:clear` commands
|
||||
- **Done when:** Fluid mode switching and scrolling feels vim-native
|
||||
|
||||
## Phase 3: Tool Execution
|
||||
- `Tool` trait, `ToolRegistry`, core tools (`read_file`, `write_file`, `shell_exec`)
|
||||
- Tool definitions in API requests, parse tool-use responses
|
||||
- Approval gate: core → TUI pending event → user approve/deny → result back
|
||||
- Approval gate: core -> TUI pending event -> user approve/deny -> result back
|
||||
- Working directory confinement + path validation (no Landlock yet)
|
||||
- **Done when:** Claude can read, modify files, and run commands with user approval
|
||||
|
||||
|
|
@ -81,8 +13,3 @@
|
|||
- `:net on/off` toggle, state in status bar
|
||||
- Graceful degradation on older kernels
|
||||
- **Done when:** Writes outside project dir fail; network toggle works
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
4
TODO.md
4
TODO.md
|
|
@ -2,3 +2,7 @@
|
|||
|
||||
- Move keyboard/event reads in the TUI to a separate thread or async/io loop
|
||||
- Keep UI and orchestrator in sync (i.e. messages display out of order if you queue up many.)
|
||||
- `update_scroll` auto-follows in Insert mode, yanking viewport to bottom on mode switch. Only auto-follow when new content arrives (in `drain_ui_events`), not every frame.
|
||||
- `G` sets scroll to `u16::MAX` and relies on `update_scroll` clamping. Compute actual max_scroll inline, or document the contract that `update_scroll` must always run before render.
|
||||
- `:clear` clears TUI state immediately but sends `ClearHistory` to orchestrator async. A mid-stream response can ghost back in after clear. Need synchronization (e.g. clear on `TurnComplete`, or have orchestrator ack the clear).
|
||||
- Command overlay width: `(out.width / 2).max(80)` makes overlay full-bleed on 80-col terminals. Consider `.max(40)` or a different minimum.
|
||||
|
|
|
|||
|
|
@ -34,6 +34,11 @@ impl ConversationHistory {
|
|||
pub fn messages(&self) -> &[ConversationMessage] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// Remove all messages from the history.
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConversationHistory {
|
||||
|
|
@ -73,6 +78,17 @@ mod tests {
|
|||
assert_eq!(msgs[1].content, "hi there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_empties_history() {
|
||||
let mut history = ConversationHistory::new();
|
||||
history.push(ConversationMessage {
|
||||
role: Role::User,
|
||||
content: "hello".to_string(),
|
||||
});
|
||||
history.clear();
|
||||
assert!(history.messages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_preserves_insertion_order() {
|
||||
let mut history = ConversationHistory::new();
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ impl<P: ModelProvider> Orchestrator<P> {
|
|||
while let Some(action) = self.action_rx.recv().await {
|
||||
match action {
|
||||
UserAction::Quit => break,
|
||||
UserAction::ClearHistory => {
|
||||
self.history.clear();
|
||||
}
|
||||
|
||||
UserAction::SendMessage(text) => {
|
||||
// Push the user message before snapshotting, so providers
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ pub enum UserAction {
|
|||
SendMessage(String),
|
||||
/// The user has requested to quit.
|
||||
Quit,
|
||||
/// The user has requested to clear conversation history.
|
||||
ClearHistory,
|
||||
}
|
||||
|
||||
/// An event sent from the core orchestrator to the TUI.
|
||||
|
|
|
|||
711
src/tui/mod.rs
711
src/tui/mod.rs
|
|
@ -20,9 +20,9 @@ use crossterm::terminal::{
|
|||
use futures::StreamExt;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Paragraph, Wrap};
|
||||
use ratatui::widgets::{Block, Clear, Paragraph, Wrap};
|
||||
use ratatui::{Frame, Terminal};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
|
@ -37,6 +37,24 @@ pub enum TuiError {
|
|||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Vim-style editing mode for the TUI.
|
||||
///
|
||||
/// The TUI starts in Insert mode so first-time users can type immediately.
|
||||
/// Mode transitions:
|
||||
/// Insert --Esc--> Normal
|
||||
/// Normal --i--> Insert
|
||||
/// Normal --:--> Command
|
||||
/// Command --Esc/Enter--> Normal
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Navigation and command entry. Keys are interpreted as commands, not text.
|
||||
Normal,
|
||||
/// Text input. Printable keys append to the input buffer.
|
||||
Insert,
|
||||
/// Ex-style command entry. The command buffer lives in `AppState::command_buffer`.
|
||||
Command,
|
||||
}
|
||||
|
||||
/// The UI-layer view of a conversation: rendered messages and the current input buffer.
|
||||
pub struct AppState {
|
||||
/// All conversation turns rendered as (role, content) pairs.
|
||||
|
|
@ -45,6 +63,16 @@ pub struct AppState {
|
|||
pub input: String,
|
||||
/// Vertical scroll offset for the output pane (lines from top).
|
||||
pub scroll: u16,
|
||||
/// Current vim-style editing mode.
|
||||
pub mode: Mode,
|
||||
/// Partial command text in Command mode (without the leading `:`).
|
||||
pub command_buffer: String,
|
||||
/// Buffered keystrokes for multi-key chord resolution in Normal mode (e.g. `gg`).
|
||||
pub pending_keys: Vec<char>,
|
||||
/// Last-known viewport height (output pane lines). Updated each frame by `update_scroll`.
|
||||
pub viewport_height: u16,
|
||||
/// Transient error message shown in the status bar, cleared on next keypress.
|
||||
pub status_error: Option<String>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -53,6 +81,11 @@ impl AppState {
|
|||
messages: Vec::new(),
|
||||
input: String::new(),
|
||||
scroll: 0,
|
||||
mode: Mode::Insert,
|
||||
command_buffer: String::new(),
|
||||
pending_keys: Vec::new(),
|
||||
viewport_height: 0,
|
||||
status_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,27 +130,133 @@ enum LoopControl {
|
|||
SendMessage(String),
|
||||
/// The user pressed Ctrl+C or Ctrl+D; exit the event loop.
|
||||
Quit,
|
||||
/// The user ran `:clear`; wipe the conversation.
|
||||
ClearHistory,
|
||||
}
|
||||
|
||||
/// Map a key event to a [`LoopControl`] signal, mutating `state` as a side-effect.
|
||||
///
|
||||
/// Returns `None` when the key is consumed with no further loop-level action needed.
|
||||
/// Ctrl+C / Ctrl+D quit from any mode. All other keys are dispatched to the
|
||||
/// handler for the current [`Mode`].
|
||||
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
||||
let key = key?;
|
||||
// Clear any transient status error on the next keypress.
|
||||
state.status_error = None;
|
||||
// Ctrl+C quits from any mode.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
|
||||
return Some(LoopControl::Quit);
|
||||
}
|
||||
// Ctrl+D quits only from Insert/Command mode. In Normal mode it scrolls.
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& key.code == KeyCode::Char('d')
|
||||
&& !matches!(state.mode, Mode::Normal)
|
||||
{
|
||||
return Some(LoopControl::Quit);
|
||||
}
|
||||
match &state.mode {
|
||||
Mode::Normal => handle_normal_key(key, state),
|
||||
Mode::Insert => handle_insert_key(key, state),
|
||||
Mode::Command => handle_command_key(key, state),
|
||||
}
|
||||
}
|
||||
|
||||
enum ChordResult {
|
||||
/// The chord was fully matched; the returned closure applies the effect.
|
||||
Executed,
|
||||
/// The keys so far are a valid prefix of at least one chord.
|
||||
Prefix,
|
||||
/// The keys don't match any chord or prefix.
|
||||
NoMatch,
|
||||
}
|
||||
|
||||
/// Check `keys` against known multi-key chords in Normal mode.
|
||||
///
|
||||
/// Currently only `gg` (scroll to top) is defined. Returns the match status
|
||||
/// without mutating state -- the caller applies effects for `Executed`.
|
||||
fn resolve_chord(keys: &[char]) -> ChordResult {
|
||||
match keys {
|
||||
[.., 'g', 'g'] => ChordResult::Executed,
|
||||
[.., 'g'] => ChordResult::Prefix,
|
||||
_ => ChordResult::NoMatch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a keypress in Normal mode.
|
||||
///
|
||||
/// | Key | Effect |
|
||||
/// |-----|-------------------------------------------|
|
||||
/// | `i` | Switch to Insert mode |
|
||||
/// | `:` | Switch to Command mode (empty buffer) |
|
||||
/// | `j` | Scroll down one line |
|
||||
/// | `k` | Scroll up one line |
|
||||
/// | `G` | Scroll to bottom |
|
||||
/// | `g` | Begin chord; `gg` scrolls to top |
|
||||
/// | Ctrl+d | Scroll down half a page |
|
||||
/// | Ctrl+u | Scroll up half a page |
|
||||
fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
||||
let is_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Char('d') if is_ctrl => {
|
||||
let half = (state.viewport_height / 2).max(1);
|
||||
state.scroll = state.scroll.saturating_add(half);
|
||||
state.pending_keys.clear();
|
||||
}
|
||||
KeyCode::Char('u') if is_ctrl => {
|
||||
let half = (state.viewport_height / 2).max(1);
|
||||
state.scroll = state.scroll.saturating_sub(half);
|
||||
state.pending_keys.clear();
|
||||
}
|
||||
KeyCode::Char('i') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.mode = Mode::Insert;
|
||||
}
|
||||
KeyCode::Char(':') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.command_buffer.clear();
|
||||
state.mode = Mode::Command;
|
||||
}
|
||||
KeyCode::Char('j') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.scroll = state.scroll.saturating_add(1);
|
||||
}
|
||||
KeyCode::Char('k') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.scroll = state.scroll.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Char('G') if !is_ctrl && state.pending_keys.is_empty() => {
|
||||
state.scroll = u16::MAX;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
state.pending_keys.push(c);
|
||||
match resolve_chord(&state.pending_keys) {
|
||||
ChordResult::Executed => {
|
||||
// Apply the chord effect. Currently only `gg`.
|
||||
state.scroll = 0;
|
||||
state.pending_keys.clear();
|
||||
}
|
||||
ChordResult::NoMatch => {
|
||||
state.pending_keys.clear();
|
||||
}
|
||||
ChordResult::Prefix => {} // wait for more keys
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
state.pending_keys.clear();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handle a keypress in Insert mode (the default text-entry mode).
|
||||
///
|
||||
/// | Key | Effect |
|
||||
/// |------------------|-------------------------------------------------|
|
||||
/// | Printable (no CTRL) | `state.input.push(c)` |
|
||||
/// | Backspace | `state.input.pop()` |
|
||||
/// | Esc | Switch to Normal mode |
|
||||
/// | Printable char | Append to input buffer |
|
||||
/// | Backspace | Pop last char from input buffer |
|
||||
/// | Enter (non-empty)| Take input, push User message, return `SendMessage` |
|
||||
/// | Enter (empty) | No-op |
|
||||
/// | Ctrl+C / Ctrl+D | Return `Quit` |
|
||||
fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl> {
|
||||
let key = key?;
|
||||
fn handle_insert_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(LoopControl::Quit)
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
Some(LoopControl::Quit)
|
||||
KeyCode::Esc => {
|
||||
state.mode = Mode::Normal;
|
||||
None
|
||||
}
|
||||
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
state.input.push(c);
|
||||
|
|
@ -140,6 +279,56 @@ fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl
|
|||
}
|
||||
}
|
||||
|
||||
/// Execute a parsed command string and return the appropriate control signal.
|
||||
///
|
||||
/// | Command | Effect |
|
||||
/// |---------------|-------------------------------------------------|
|
||||
/// | `quit` / `q` | Return `LoopControl::Quit` |
|
||||
/// | `clear` | Clear messages and scroll, return `ClearHistory` |
|
||||
/// | anything else | Set `status_error` on state |
|
||||
fn execute_command(buf: &str, state: &mut AppState) -> Option<LoopControl> {
|
||||
match buf.trim() {
|
||||
"quit" | "q" => Some(LoopControl::Quit),
|
||||
"clear" => {
|
||||
state.messages.clear();
|
||||
state.scroll = 0;
|
||||
Some(LoopControl::ClearHistory)
|
||||
}
|
||||
other => {
|
||||
state.status_error = Some(format!("Unknown command: {other}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a keypress in Command mode (ex-style `:` command entry).
|
||||
///
|
||||
/// | Key | Effect |
|
||||
/// |-----------|-----------------------------------------------------|
|
||||
/// | Esc | Abandon command, return to Normal |
|
||||
/// | Enter | Accept command (execution deferred to 2.3), Normal |
|
||||
/// | Backspace | Pop from buffer; if empty, return to Normal |
|
||||
/// | Char(c) | Append to command buffer |
|
||||
fn handle_command_key(key: KeyEvent, state: &mut AppState) -> Option<LoopControl> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
state.mode = Mode::Normal;
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
state.mode = Mode::Normal;
|
||||
return execute_command(&state.command_buffer.clone(), state);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
state.command_buffer.pop();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
state.command_buffer.push(c);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Drain all pending [`UIEvent`]s from `event_rx` and apply them to `state`.
|
||||
///
|
||||
/// This is non-blocking: it processes all currently-available events and returns
|
||||
|
|
@ -182,8 +371,9 @@ fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState)
|
|||
/// - `ceil(chars / width).max(1)` lines per newline-separated content line
|
||||
/// - 1 blank separator line
|
||||
fn update_scroll(state: &mut AppState, area: Rect) {
|
||||
// 3 = height of the input pane (border top + content + border bottom)
|
||||
let viewport_height = area.height.saturating_sub(3);
|
||||
// 4 = input pane (3: border top + content + border bottom) + status bar (1)
|
||||
let viewport_height = area.height.saturating_sub(4);
|
||||
state.viewport_height = viewport_height;
|
||||
let width = area.width.max(1) as usize;
|
||||
|
||||
let mut total_lines: u16 = 0;
|
||||
|
|
@ -197,7 +387,32 @@ fn update_scroll(state: &mut AppState, area: Rect) {
|
|||
total_lines = total_lines.saturating_add(1); // blank separator
|
||||
}
|
||||
|
||||
state.scroll = total_lines.saturating_sub(viewport_height);
|
||||
let max_scroll = total_lines.saturating_sub(viewport_height);
|
||||
|
||||
match state.mode {
|
||||
// In Insert mode, auto-follow the bottom of the conversation.
|
||||
Mode::Insert => {
|
||||
state.scroll = max_scroll;
|
||||
}
|
||||
// In Normal/Command mode, the user controls scroll -- just clamp to bounds.
|
||||
_ => {
|
||||
state.scroll = state.scroll.min(max_scroll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the overlay rectangle for the command palette, centered on `output_area`.
|
||||
fn command_overlay_rect(output_area: Rect) -> Rect {
|
||||
let overlay_w = (output_area.width / 2).max(80).min(output_area.width);
|
||||
let overlay_h: u16 = 3; // border + content + border
|
||||
let overlay_x = output_area.x + (output_area.width.saturating_sub(overlay_w)) / 2;
|
||||
let overlay_y = output_area.y + 2;
|
||||
Rect {
|
||||
x: overlay_x,
|
||||
y: overlay_y,
|
||||
width: overlay_w,
|
||||
height: overlay_h.min(output_area.height),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full TUI into `frame`.
|
||||
|
|
@ -211,11 +426,19 @@ fn update_scroll(state: &mut AppState, area: Rect) {
|
|||
/// | Input | Length(3)
|
||||
/// | > _ |
|
||||
/// +--------------------------------+
|
||||
/// | NORMAL tokens: -- | Length(1)
|
||||
/// +--------------------------------+
|
||||
/// ```
|
||||
///
|
||||
/// Role headers are coloured: `"You:"` in cyan, `"Assistant:"` in green.
|
||||
/// In Command mode, a one-line overlay appears at row 1 of the output pane.
|
||||
fn render(frame: &mut Frame, state: &AppState) {
|
||||
let chunks = Layout::vertical([Constraint::Fill(1), Constraint::Length(3)]).split(frame.area());
|
||||
let chunks = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(frame.area());
|
||||
|
||||
// --- Output pane ---
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
|
@ -236,10 +459,94 @@ fn render(frame: &mut Frame, state: &AppState) {
|
|||
.scroll((state.scroll, 0));
|
||||
frame.render_widget(output, chunks[0]);
|
||||
|
||||
// --- Command overlay (floating box centered on output pane) ---
|
||||
if state.mode == Mode::Command {
|
||||
let overlay_area = command_overlay_rect(chunks[0]);
|
||||
// Clear the area behind the overlay so it appears floating.
|
||||
frame.render_widget(Clear, overlay_area);
|
||||
let overlay = Paragraph::new(format!(":{}", state.command_buffer)).block(
|
||||
Block::bordered()
|
||||
.border_style(Style::default().fg(Color::Yellow))
|
||||
.title("Command"),
|
||||
);
|
||||
frame.render_widget(overlay, overlay_area);
|
||||
}
|
||||
|
||||
// --- Input pane ---
|
||||
let (input_title, input_style) = match state.mode {
|
||||
Mode::Normal | Mode::Command => (
|
||||
"Input (normal)",
|
||||
Style::default()
|
||||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
Mode::Insert => ("Input", Style::default()),
|
||||
};
|
||||
let input_text = format!("> {}", state.input);
|
||||
let input_widget = Paragraph::new(input_text).block(Block::bordered().title("Input"));
|
||||
let input_widget = Paragraph::new(input_text)
|
||||
.style(input_style)
|
||||
.block(Block::bordered().title(input_title));
|
||||
frame.render_widget(input_widget, chunks[1]);
|
||||
|
||||
// --- Cursor positioning ---
|
||||
match state.mode {
|
||||
Mode::Insert => {
|
||||
// Cursor at end of input text: border(1) + "> " (2) + input len
|
||||
let cursor_x = chunks[1].x + 1 + 2 + state.input.len() as u16;
|
||||
let cursor_y = chunks[1].y + 1; // inside the border
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
Mode::Command => {
|
||||
// Cursor in the floating overlay
|
||||
let overlay = command_overlay_rect(chunks[0]);
|
||||
// border(1) + ":" (1) + buf len
|
||||
let cursor_x = overlay.x + 1 + 1 + state.command_buffer.len() as u16;
|
||||
let cursor_y = overlay.y + 1; // inside the border
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
Mode::Normal => {} // no cursor
|
||||
}
|
||||
|
||||
// --- Status bar ---
|
||||
let (mode_label, mode_style) = match state.mode {
|
||||
Mode::Normal => (
|
||||
" NORMAL ",
|
||||
Style::default().bg(Color::Blue).fg(Color::White),
|
||||
),
|
||||
Mode::Insert => (
|
||||
" INSERT ",
|
||||
Style::default().bg(Color::Green).fg(Color::White),
|
||||
),
|
||||
Mode::Command => (
|
||||
" COMMAND ",
|
||||
Style::default().bg(Color::Yellow).fg(Color::Black),
|
||||
),
|
||||
};
|
||||
|
||||
let right_text = if let Some(ref err) = state.status_error {
|
||||
err.clone()
|
||||
} else {
|
||||
"tokens: --".to_string()
|
||||
};
|
||||
|
||||
let right_style = if state.status_error.is_some() {
|
||||
Style::default().fg(Color::Red)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let bar_width = chunks[2].width as usize;
|
||||
let left_len = mode_label.len();
|
||||
let right_len = right_text.len();
|
||||
let pad = bar_width.saturating_sub(left_len + right_len);
|
||||
|
||||
let status_line = Line::from(vec![
|
||||
Span::styled(mode_label, mode_style),
|
||||
Span::raw(" ".repeat(pad)),
|
||||
Span::styled(right_text, right_style),
|
||||
]);
|
||||
let status_widget = Paragraph::new(status_line);
|
||||
frame.render_widget(status_widget, chunks[2]);
|
||||
}
|
||||
|
||||
/// Run the TUI event loop.
|
||||
|
|
@ -301,6 +608,9 @@ pub async fn run(
|
|||
let _ = action_tx.send(UserAction::Quit).await;
|
||||
break;
|
||||
}
|
||||
Some(LoopControl::ClearHistory) => {
|
||||
let _ = action_tx.send(UserAction::ClearHistory).await;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -371,6 +681,13 @@ mod tests {
|
|||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_ctrl_char_is_noop() {
|
||||
let mut state = AppState::new();
|
||||
handle_key(ctrl_key('a'), &mut state);
|
||||
assert_eq!(state.input, "", "Ctrl+A should not insert 'a'");
|
||||
}
|
||||
|
||||
// --- drain_ui_events tests ---
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -457,4 +774,362 @@ mod tests {
|
|||
update_scroll(&mut state, area);
|
||||
assert!(state.scroll > 0, "expected scroll > 0 with 50 messages");
|
||||
}
|
||||
|
||||
// --- mode tests ---
|
||||
|
||||
#[test]
|
||||
fn mode_starts_insert() {
|
||||
assert_eq!(AppState::new().mode, Mode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_esc_enters_normal() {
|
||||
let mut state = AppState::new();
|
||||
handle_key(make_key(KeyCode::Esc), &mut state);
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_i_enters_insert() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
handle_key(make_key(KeyCode::Char('i')), &mut state);
|
||||
assert_eq!(state.mode, Mode::Insert);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_colon_enters_command() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
handle_key(make_key(KeyCode::Char(':')), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_esc_enters_normal() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "q".to_string();
|
||||
handle_key(make_key(KeyCode::Esc), &mut state);
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_enter_enters_normal() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
handle_key(make_key(KeyCode::Enter), &mut state);
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_chars_append_to_buffer() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
handle_key(make_key(KeyCode::Char('q')), &mut state);
|
||||
assert_eq!(state.mode, Mode::Command);
|
||||
assert_eq!(state.command_buffer, "q");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_backspace_empty_stays_in_command() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "ab".to_string();
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.command_buffer, "a");
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.command_buffer, "");
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.command_buffer, "");
|
||||
handle_key(make_key(KeyCode::Backspace), &mut state);
|
||||
assert_eq!(state.command_buffer, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_j_increments_scroll() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
handle_key(make_key(KeyCode::Char('j')), &mut state);
|
||||
assert_eq!(state.scroll, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_k_decrements_scroll() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
state.scroll = 5;
|
||||
handle_key(make_key(KeyCode::Char('k')), &mut state);
|
||||
assert_eq!(state.scroll, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_k_clamps_at_zero() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
handle_key(make_key(KeyCode::Char('k')), &mut state);
|
||||
assert_eq!(state.scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_big_g_scrolls_bottom() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
handle_key(make_key(KeyCode::Char('G')), &mut state);
|
||||
assert_eq!(state.scroll, u16::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_gg_scrolls_top() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
state.scroll = 50;
|
||||
handle_key(make_key(KeyCode::Char('g')), &mut state);
|
||||
assert_eq!(state.pending_keys, vec!['g']); // prefix, waiting
|
||||
handle_key(make_key(KeyCode::Char('g')), &mut state);
|
||||
assert_eq!(state.scroll, 0);
|
||||
assert!(state.pending_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_g_then_other_clears_pending() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
state.scroll = 50;
|
||||
handle_key(make_key(KeyCode::Char('g')), &mut state);
|
||||
handle_key(make_key(KeyCode::Char('x')), &mut state);
|
||||
assert!(state.pending_keys.is_empty());
|
||||
assert_eq!(state.scroll, 50); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_c_quits_from_any_mode() {
|
||||
for mode in [Mode::Normal, Mode::Insert, Mode::Command] {
|
||||
let mut state = AppState::new();
|
||||
state.mode = mode;
|
||||
let result = handle_key(ctrl_key('c'), &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_d_quits_from_insert() {
|
||||
let mut state = AppState::new();
|
||||
let result = handle_key(ctrl_key('d'), &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_d_scrolls_in_normal() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
state.viewport_height = 20;
|
||||
let result = handle_key(ctrl_key('d'), &mut state);
|
||||
assert!(result.is_none());
|
||||
assert_eq!(state.scroll, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_u_scrolls_up_in_normal() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
state.viewport_height = 20;
|
||||
state.scroll = 15;
|
||||
let result = handle_key(ctrl_key('u'), &mut state);
|
||||
assert!(result.is_none());
|
||||
assert_eq!(state.scroll, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_u_clamps_at_zero() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
state.viewport_height = 20;
|
||||
state.scroll = 3;
|
||||
handle_key(ctrl_key('u'), &mut state);
|
||||
assert_eq!(state.scroll, 0);
|
||||
}
|
||||
|
||||
// --- execute_command tests ---
|
||||
|
||||
#[test]
|
||||
fn command_quit_returns_quit() {
|
||||
let mut state = AppState::new();
|
||||
let result = execute_command("quit", &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_q_returns_quit() {
|
||||
let mut state = AppState::new();
|
||||
let result = execute_command("q", &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_clear_empties_messages() {
|
||||
let mut state = AppState::new();
|
||||
state.messages.push((Role::User, "hi".to_string()));
|
||||
state.messages.push((Role::Assistant, "hello".to_string()));
|
||||
state.scroll = 10;
|
||||
let result = execute_command("clear", &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::ClearHistory)));
|
||||
assert!(state.messages.is_empty());
|
||||
assert_eq!(state.scroll, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_unknown_sets_status_error() {
|
||||
let mut state = AppState::new();
|
||||
let result = execute_command("foo", &mut state);
|
||||
assert!(result.is_none());
|
||||
assert_eq!(state.status_error.as_deref(), Some("Unknown command: foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_error_cleared_on_next_keypress() {
|
||||
let mut state = AppState::new();
|
||||
state.status_error = Some("some error".to_string());
|
||||
handle_key(make_key(KeyCode::Char('h')), &mut state);
|
||||
assert!(state.status_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_enter_executes_quit() {
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "q".to_string();
|
||||
let result = handle_key(make_key(KeyCode::Enter), &mut state);
|
||||
assert!(matches!(result, Some(LoopControl::Quit)));
|
||||
assert_eq!(state.mode, Mode::Normal);
|
||||
}
|
||||
|
||||
// --- render snapshot tests ---
|
||||
|
||||
#[test]
|
||||
fn render_status_bar_normal_mode() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(all_text.contains("NORMAL"), "expected 'NORMAL' in buffer");
|
||||
assert!(
|
||||
all_text.contains("tokens: --"),
|
||||
"expected 'tokens: --' in buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_status_bar_insert_mode() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let state = AppState::new(); // starts in Insert
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(all_text.contains("INSERT"), "expected 'INSERT' in buffer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_status_bar_command_mode() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(all_text.contains("COMMAND"), "expected 'COMMAND' in buffer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_command_overlay_visible_in_command_mode() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Command;
|
||||
state.command_buffer = "quit".to_string();
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
all_text.contains(":quit"),
|
||||
"expected ':quit' overlay in buffer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_no_overlay_in_normal_mode() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
// Row 1 should not have a ":" prefix from the overlay
|
||||
let row1: String = (0..80)
|
||||
.map(|x| buf.cell((x, 1)).unwrap().symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
!row1.starts_with(':'),
|
||||
"overlay should not appear in Normal mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_input_pane_dimmed_in_normal() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.mode = Mode::Normal;
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
all_text.contains("Input (normal)"),
|
||||
"expected 'Input (normal)' title in Normal mode"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_status_bar_shows_error() {
|
||||
let backend = TestBackend::new(80, 24);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut state = AppState::new();
|
||||
state.status_error = Some("Unknown command: foo".to_string());
|
||||
terminal.draw(|frame| render(frame, &state)).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let all_text: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|c| c.symbol().to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
all_text.contains("Unknown command: foo"),
|
||||
"expected error in status bar"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue