Add mode switching.

This commit is contained in:
Drew 2026-02-24 13:51:52 -08:00
parent 5d213b43d3
commit 5cb6647513
3 changed files with 394 additions and 86 deletions

View file

@ -37,6 +37,25 @@ 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(buffer)
/// Command --Esc/Enter--> Normal
#[derive(Debug, Clone, 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 `String` holds the partial command buffer
/// (without the leading `:`).
Command(String),
}
/// 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 +64,12 @@ 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,
/// 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,
}
impl AppState {
@ -53,6 +78,9 @@ impl AppState {
messages: Vec::new(),
input: String::new(),
scroll: 0,
mode: Mode::Insert,
pending_keys: Vec::new(),
viewport_height: 0,
}
}
}
@ -101,25 +129,126 @@ enum LoopControl {
/// 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?;
// 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.mode = Mode::Command(String::new());
}
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::Esc => {
state.mode = Mode::Normal;
None
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(LoopControl::Quit)
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
KeyCode::Char(c) => {
state.input.push(c);
None
}
@ -140,6 +269,46 @@ fn handle_key(key: Option<KeyEvent>, state: &mut AppState) -> Option<LoopControl
}
}
/// 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> {
// Extract the command buffer. We need ownership to mutate it.
let mut buf = match std::mem::replace(&mut state.mode, Mode::Normal) {
Mode::Command(b) => b,
_ => unreachable!(),
};
match key.code {
KeyCode::Esc => {
// Already set to Normal above.
}
KeyCode::Enter => {
// Command execution deferred to phase 2.3; just return to Normal.
}
KeyCode::Backspace => {
buf.pop();
if !buf.is_empty() {
state.mode = Mode::Command(buf);
}
// else stay in Normal (already set)
}
KeyCode::Char(c) => {
buf.push(c);
state.mode = Mode::Command(buf);
}
_ => {
state.mode = Mode::Command(buf);
}
}
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
@ -184,6 +353,7 @@ fn drain_ui_events(event_rx: &mut mpsc::Receiver<UIEvent>, state: &mut AppState)
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);
state.viewport_height = viewport_height;
let width = area.width.max(1) as usize;
let mut total_lines: u16 = 0;
@ -197,7 +367,18 @@ 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);
}
}
}
/// Render the full TUI into `frame`.
@ -457,4 +638,170 @@ 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(String::new()));
}
#[test]
fn command_esc_enters_normal() {
let mut state = AppState::new();
state.mode = Mode::Command("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("q".to_string());
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(String::new());
handle_key(make_key(KeyCode::Char('q')), &mut state);
assert_eq!(state.mode, Mode::Command("q".to_string()));
}
#[test]
fn command_backspace_empty_exits() {
let mut state = AppState::new();
state.mode = Mode::Command(String::new());
handle_key(make_key(KeyCode::Backspace), &mut state);
assert_eq!(state.mode, Mode::Normal);
}
#[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("q".to_string())] {
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);
}
}