harness-app: - EngineHandle: non-blocking multi-turn API (prompt/abort/permission_reply/ list_sessions/session_messages/message_parts), persistent SQLite store, no auto-approve — TUI handles permission asks via real oneshot path - App refactored to wrap EngineHandle; headless run -p keeps auto-approve harness-tui: - Terminal guard (raw mode, alternate screen, panic hook, Drop restore) - Event loop: tokio::select! over crossterm events, bus events, 33ms render tick - AppState with MessageView/PartView (cached Vec<Line> field), ModalState - pulldown-cmark → ratatui markdown renderer (headings, bold, italic, code blocks, lists, blockquotes, links, manual word-wrap) - Layout: header, chat viewport, input (tui-textarea), status bar - Permission modal (y/a/n) wired to EngineHandle::permission_reply - Session picker (Ctrl+S) with resume - Abort (Esc), Ctrl+C×2 quit, slash commands (/new /model /agent /sessions) - 7 TestBackend snapshot tests (empty, messages, tool cards, modals)
263 lines
8.9 KiB
Rust
263 lines
8.9 KiB
Rust
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
|
|
use harness_app::EngineHandle;
|
|
use harness_core::permission::PermissionReply;
|
|
|
|
use crate::modal;
|
|
use crate::state::{AppState, ModalState};
|
|
|
|
/// Possible high-level actions produced by key input.
|
|
#[derive(Debug)]
|
|
pub enum InputAction {
|
|
None,
|
|
Submit { text: String },
|
|
Abort,
|
|
Quit,
|
|
LoadSessions,
|
|
NewSession,
|
|
SetModel(String),
|
|
SetAgent(String),
|
|
CloseModal,
|
|
ScrollUp(u16),
|
|
ScrollDown(u16),
|
|
PermissionReply(PermissionReply),
|
|
SessionPickerUp,
|
|
SessionPickerDown,
|
|
SessionPickerSelect,
|
|
}
|
|
|
|
/// Translate a crossterm event into an action and/or mutate `state` directly.
|
|
pub fn handle_event(event: Event, state: &mut AppState, _engine: &EngineHandle) -> InputAction {
|
|
match event {
|
|
Event::Key(key) => {
|
|
let action = handle_key(key, state);
|
|
// Any keypress can change the input buffer or cursor; force a redraw so typed
|
|
// characters appear immediately rather than only when some other event sets dirty.
|
|
state.dirty = true;
|
|
action
|
|
}
|
|
Event::Resize(_, _) => {
|
|
state.dirty = true;
|
|
InputAction::None
|
|
}
|
|
Event::Mouse(_) | Event::FocusGained | Event::FocusLost | Event::Paste(_) => {
|
|
InputAction::None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_key(key: KeyEvent, state: &mut AppState) -> InputAction {
|
|
match &state.modal {
|
|
ModalState::Permission { .. } => handle_permission_key(key, state),
|
|
ModalState::SessionPicker { .. } => handle_session_picker_key(key, state),
|
|
ModalState::None => handle_normal_key(key, state),
|
|
}
|
|
}
|
|
|
|
fn handle_permission_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
InputAction::PermissionReply(PermissionReply::Once)
|
|
}
|
|
KeyCode::Char('a') | KeyCode::Char('A') => {
|
|
InputAction::PermissionReply(PermissionReply::Always)
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') => {
|
|
InputAction::PermissionReply(PermissionReply::Reject)
|
|
}
|
|
// Esc rejects the pending request rather than merely hiding the modal — closing
|
|
// without a reply would leave the tool call blocked on its oneshot forever.
|
|
KeyCode::Esc => InputAction::PermissionReply(PermissionReply::Reject),
|
|
_ => InputAction::None,
|
|
}
|
|
}
|
|
|
|
fn handle_session_picker_key(key: KeyEvent, _state: &mut AppState) -> InputAction {
|
|
match key.code {
|
|
KeyCode::Up => InputAction::SessionPickerUp,
|
|
KeyCode::Down => InputAction::SessionPickerDown,
|
|
KeyCode::Enter => InputAction::SessionPickerSelect,
|
|
KeyCode::Esc => InputAction::CloseModal,
|
|
_ => InputAction::None,
|
|
}
|
|
}
|
|
|
|
fn handle_normal_key(key: KeyEvent, state: &mut AppState) -> InputAction {
|
|
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
|
|
// Reset the double-Ctrl+C guard on any key that isn't another Ctrl+C.
|
|
if !(matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')) && ctrl) {
|
|
state.ctrl_c_pressed = false;
|
|
}
|
|
|
|
match key.code {
|
|
KeyCode::Enter => {
|
|
let text = state.input.lines().join("\n");
|
|
state.input = tui_textarea::TextArea::default();
|
|
state
|
|
.input
|
|
.set_cursor_line_style(ratatui::style::Style::default());
|
|
if let Some(action) = parse_slash_command(&text) {
|
|
action
|
|
} else {
|
|
InputAction::Submit { text }
|
|
}
|
|
}
|
|
KeyCode::Char('c') if ctrl => {
|
|
if state.ctrl_c_pressed {
|
|
InputAction::Quit
|
|
} else {
|
|
state.ctrl_c_pressed = true;
|
|
state.dirty = true;
|
|
InputAction::None
|
|
}
|
|
}
|
|
KeyCode::Esc => {
|
|
if state.running {
|
|
InputAction::Abort
|
|
} else {
|
|
InputAction::None
|
|
}
|
|
}
|
|
KeyCode::Char('s') if ctrl => InputAction::LoadSessions,
|
|
KeyCode::Up => {
|
|
let (row, _) = state.input.cursor();
|
|
if row == 0 {
|
|
InputAction::ScrollUp(3)
|
|
} else {
|
|
state.input.input(key);
|
|
InputAction::None
|
|
}
|
|
}
|
|
KeyCode::Down => {
|
|
let (row, _) = state.input.cursor();
|
|
let last_line = state.input.lines().len().saturating_sub(1);
|
|
if row == last_line {
|
|
InputAction::ScrollDown(3)
|
|
} else {
|
|
state.input.input(key);
|
|
InputAction::None
|
|
}
|
|
}
|
|
_ => {
|
|
state.input.input(key);
|
|
InputAction::None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_slash_command(text: &str) -> Option<InputAction> {
|
|
let trimmed = text.trim();
|
|
if !trimmed.starts_with('/') {
|
|
return None;
|
|
}
|
|
let mut parts = trimmed.split_whitespace();
|
|
let cmd = parts.next()?;
|
|
let rest: String = parts.collect::<Vec<_>>().join(" ");
|
|
match cmd {
|
|
"/new" => Some(InputAction::NewSession),
|
|
"/model" if !rest.is_empty() => Some(InputAction::SetModel(rest)),
|
|
"/agent" if !rest.is_empty() => Some(InputAction::SetAgent(rest)),
|
|
"/sessions" => Some(InputAction::LoadSessions),
|
|
"/quit" => Some(InputAction::Quit),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Apply an action that needs async engine calls. Non-async decisions are applied inline.
|
|
pub async fn apply_action(action: InputAction, state: &mut AppState, engine: &EngineHandle) {
|
|
match action {
|
|
InputAction::Submit { text } => {
|
|
if let Some(session_id) = state.session_id.clone() {
|
|
if let Err(e) = engine.prompt(session_id, text, &state.model_ref).await {
|
|
tracing::error!(error = %e, "prompt failed");
|
|
}
|
|
}
|
|
}
|
|
InputAction::Abort => {
|
|
if let Some(session_id) = state.session_id.clone() {
|
|
engine.abort(&session_id);
|
|
}
|
|
}
|
|
InputAction::LoadSessions => match engine.list_sessions().await {
|
|
Ok(sessions) => modal::open_session_picker(state, sessions),
|
|
Err(e) => tracing::error!(error = %e, "failed to list sessions"),
|
|
},
|
|
InputAction::NewSession => match engine.new_session(&state.agent, &state.model_ref).await {
|
|
Ok(id) => {
|
|
state.session_id = Some(id);
|
|
state.messages.clear();
|
|
state.scroll_offset = 0;
|
|
state.dirty = true;
|
|
}
|
|
Err(e) => tracing::error!(error = %e, "failed to create session"),
|
|
},
|
|
InputAction::SetModel(model_ref) => {
|
|
state.model_ref = model_ref;
|
|
state.dirty = true;
|
|
}
|
|
InputAction::SetAgent(agent) => {
|
|
state.agent = agent;
|
|
state.dirty = true;
|
|
}
|
|
InputAction::Quit => state.quit = true,
|
|
InputAction::CloseModal => state.close_modal(),
|
|
InputAction::ScrollUp(n) => state.scroll_up(n),
|
|
InputAction::ScrollDown(n) => state.scroll_down(n),
|
|
InputAction::PermissionReply(reply) => {
|
|
modal::resolve_permission(state, engine, reply);
|
|
}
|
|
InputAction::SessionPickerUp => {
|
|
if let ModalState::SessionPicker {
|
|
selected,
|
|
sessions: _,
|
|
} = &mut state.modal
|
|
{
|
|
*selected = selected.saturating_sub(1);
|
|
}
|
|
state.dirty = true;
|
|
}
|
|
InputAction::SessionPickerDown => {
|
|
if let ModalState::SessionPicker { selected, sessions } = &mut state.modal {
|
|
if *selected + 1 < sessions.len() {
|
|
*selected += 1;
|
|
}
|
|
}
|
|
state.dirty = true;
|
|
}
|
|
InputAction::SessionPickerSelect => {
|
|
if let ModalState::SessionPicker { sessions, selected } = &state.modal {
|
|
if let Some(session) = sessions.get(*selected).cloned() {
|
|
if let Err(e) = modal::select_session(state, engine, session).await {
|
|
tracing::error!(error = %e, "failed to load session");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
InputAction::None => {}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::state::AppState;
|
|
use crossterm::event::KeyEvent;
|
|
|
|
#[tokio::test]
|
|
async fn typing_marks_frame_dirty_and_updates_input() {
|
|
let engine = EngineHandle::init_in_memory(std::env::temp_dir()).unwrap();
|
|
let mut state = AppState::new("anthropic/claude".into(), "orchestrator".into());
|
|
state.dirty = false;
|
|
|
|
let action = handle_event(
|
|
Event::Key(KeyEvent::from(KeyCode::Char('x'))),
|
|
&mut state,
|
|
&engine,
|
|
);
|
|
|
|
assert!(matches!(action, InputAction::None));
|
|
assert!(state.dirty, "a keystroke must request a redraw");
|
|
assert_eq!(state.input.lines().join("\n"), "x");
|
|
}
|
|
}
|