use kodact_core::glyphs;
use std::collections::VecDeque;
use std::path::Path;
use std::time::{Duration, Instant};
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use kodact_core::commands::picker::{Choice, Pick, Picker};
use kodact_core::commands::screens::{FieldKind, Form, RowAction, Screen};
use kodact_engine::{EngineEvent, NoticeLevel, PermissionAnswer, PermissionPrompt};
use kodact_permissions::{PermissionMode, Suggestion};
use kodact_types::{ContentBlock, Delta, StreamEvent};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use serde_json::{json, Value};
use tokio::sync::oneshot;
use super::editor::Editor;
use super::text::{Markdown, Theme};
const RESULT_MARK: &str = " └ ";
const RESULT_INDENT: &str = " ";
#[derive(Debug, Clone, PartialEq)]
pub struct CommandInfo {
pub name: String,
pub args: String,
pub description: String,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StatusView {
pub model: String,
pub mode: String,
pub cwd: String,
pub cost: f64,
pub cost_unknown: bool,
pub context_pct: Option<u8>,
pub context_used: bool,
pub window: u64,
}
pub enum UiEvent {
Engine(EngineEvent),
Ask {
prompt: PermissionPrompt,
reply: oneshot::Sender<PermissionAnswer>,
},
Reply {
text: String,
is_error: bool,
},
Status(StatusView),
Commands(Vec<CommandInfo>),
Files(Vec<String>),
Theme(String),
Look(String),
Copy(String),
StatusLine(Option<String>),
Picker(Picker),
Screen(Screen),
Busy,
Idle,
Exit,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
Send(String),
Picker(String),
Interrupt,
SetMode(PermissionMode),
Exit,
}
#[derive(Debug, Clone)]
struct Question {
question: String,
options: Vec<(String, String)>,
multi: bool,
}
#[derive(Debug)]
enum DialogKind {
Permission {
always: Option<String>,
},
Plan,
Questions {
questions: Vec<Question>,
at: usize,
answers: serde_json::Map<String, Value>,
picked: Vec<bool>,
},
Picker {
picker: Picker,
filter: String,
},
Viewer {
screen: Screen,
cursor: usize,
top: usize,
back: Vec<usize>,
},
Form {
form: Form,
at: usize,
},
}
pub struct Dialog {
prompt: Option<PermissionPrompt>,
reply: Option<oneshot::Sender<PermissionAnswer>>,
kind: DialogKind,
selected: usize,
typing: Option<Editor>,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Search {
pub query: String,
pub hit: Option<usize>,
draft: String,
}
pub const KEYS: &[(&str, &str, Option<&str>)] = &[
("Enter", "Send (queued while a turn runs)", Some("submit")),
("Shift+Enter, Alt+Enter, Ctrl+J, \\ Enter", "New line", Some("newline")),
("Esc", "Interrupt the turn; close a menu; cancel a dialog", Some("cancel")),
("Esc Esc", "On an empty prompt: rewind (/rewind)", None),
("Ctrl+C", "Clear the input; interrupt; twice on an empty prompt: exit", Some("interrupt")),
("Ctrl+D", "Exit (empty prompt)", Some("exit")),
("Shift+Tab", "Cycle the permission mode: default, accept edits, plan", Some("cycleMode")),
("Up / Down", "Move between lines; earlier prompts", None),
("Ctrl+R", "Search earlier prompts", Some("historySearch")),
("Tab", "Complete a / command or an @ path", Some("complete")),
("@", "Mention a file (a menu of project paths)", None),
("!", "At the start: run a shell command", None),
("/", "At the start: a command (the menu lists them)", None),
("Ctrl+A / Ctrl+E, Home / End", "Start / end of line", None),
("Ctrl+W, Alt+Backspace", "Delete the word before the cursor", Some("deleteWord")),
("Ctrl+U / Ctrl+K", "Delete to the start / end of the line", None),
("Alt+B / Alt+F, Ctrl+Left / Ctrl+Right", "Word left / right", None),
("Ctrl+L", "Redraw the screen", Some("redraw")),
];
pub fn keys_text(keymap: &super::keys::Keymap) -> String {
let rows: Vec<(String, String)> = KEYS
.iter()
.map(|(k, what, action)| {
let keys = match action {
Some(a) if !keymap.is_default() => keymap.keys_for(a).join(", "),
_ => k.to_string(),
};
(keys, what.to_string())
})
.collect();
let w = rows.iter().map(|(k, _)| k.chars().count()).max().unwrap_or(0);
let mut lines: Vec<String> = rows.iter().map(|(k, what)| format!("{k:<w$} {what}")).collect();
if !keymap.is_default() {
lines.push(String::new());
lines.push(format!("Custom bindings from {}.", kodact_config::config_dir().join("keybindings.json").display()));
let unbound = keymap.unbound();
if !unbound.is_empty() {
lines.push(format!("Unbound: {}.", unbound.join(", ")));
}
}
lines.join("\n")
}
const TERMINAL_SETUP: &str = "Shift+Enter starts a new line when the terminal reports it as its own key. KodAct asks \
for this through the keyboard protocol that kitty, WezTerm, foot, Ghostty, Alacritty and iTerm2 (with \
\"Report keys using CSI u\" on) support.\n\nWhere it isn't available, these always start a new line: Alt+Enter \
(Option+Enter on macOS, with \"Use Option as Meta key\" on), Ctrl+J, or \\ then Enter.\n\nInside tmux, add \
`set -s extended-keys on` and `set -as terminal-features 'xterm*:extkeys'` to ~/.tmux.conf.";
pub fn terminal_setup_text(keyboard_protocol: bool) -> String {
let now = if keyboard_protocol {
"This terminal has the keyboard protocol on: Shift+Enter works."
} else {
"This terminal didn't turn the keyboard protocol on, so Shift+Enter may arrive as Enter."
};
format!("{now}\n\n{TERMINAL_SETUP}")
}
const FILE_MATCHES: usize = 50;
const DOUBLE_PRESS: Duration = Duration::from_millis(800);
pub struct App {
calls: std::collections::HashMap<String, (String, Value)>,
pub running: Vec<Running>,
pub theme: Theme,
pub editor: Editor,
pub status: StatusView,
pub busy: bool,
pub busy_since: Option<Instant>,
pub activity: String,
pub queued: VecDeque<String>,
pub pending: Vec<Line<'static>>,
pub live: String,
md: Markdown,
first_line: bool,
streamed: bool,
request_usage: kodact_types::Usage,
pub asked_at: Option<Instant>,
printed_blank: bool,
pub dialog: Option<Dialog>,
commands: Vec<CommandInfo>,
files: Vec<String>,
pub search: Option<Search>,
pub menu_selected: usize,
menu_dismissed: bool,
last_esc: Option<Instant>,
last_ctrl_c: Option<Instant>,
pub hint: Option<(String, Instant)>,
pub exit: bool,
pub dirty: bool,
color_ok: bool,
pub clipboard: Option<String>,
pub status_text: Option<String>,
pub viewer_page: std::cell::Cell<usize>,
pub keymap: super::keys::Keymap,
pub look: super::look::Look,
pub front: bool,
pub endpoint: String,
pub branch: String,
pub focus: bool,
held: Option<Dialog>,
}
const DIFF_LINES: usize = 16;
fn blank(l: &Line) -> bool {
l.spans.iter().all(|s| s.content.trim().is_empty())
}
fn check_mark(t: &Theme, line: &str) -> Option<(&'static str, Style)> {
[("ok ", t.success()), ("note", t.warning()), ("FAIL", t.error())]
.into_iter()
.find(|(m, _)| line.strip_prefix(m).is_some_and(|r| r.starts_with(' ')))
}
pub fn result_lines(
t: &Theme,
tool: &str,
text: &str,
is_error: bool,
structured: Option<&Value>,
) -> Vec<Line<'static>> {
let mark = || Span::styled(RESULT_MARK, t.hairline());
let one = |s: String, st: Style| vec![Line::from(vec![mark(), Span::styled(s, st)])];
let first_line = |text: &str| -> String {
text.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(160).collect()
};
if is_error {
let more = text.lines().filter(|l| !l.trim().is_empty()).count().saturating_sub(1);
let more = if more > 0 { format!(" (+{more} lines)") } else { String::new() };
return one(format!("{}{more}", first_line(text)), t.error());
}
match (tool, structured) {
("Read", Some(v)) if v["type"] == "text" => {
let f = &v["file"];
let n = f["numLines"].as_u64().unwrap_or(0);
let total = f["totalLines"].as_u64().unwrap_or(n);
let msg = if text.contains("is unchanged since you last read") {
"Unchanged since the last read".to_string()
} else if n < total {
format!("{n} of {total} lines")
} else {
format!("{n} {}", if n == 1 { "line" } else { "lines" })
};
one(msg, t.dim())
}
("Read", Some(v)) => one(format!("{} file", v["type"].as_str().unwrap_or("a")), t.dim()),
("Edit" | "MultiEdit" | "Write", Some(v)) if v.get("structuredPatch").is_some() => {
let hunks = v["structuredPatch"].as_array().cloned().unwrap_or_default();
let lines: Vec<String> = hunks
.iter()
.flat_map(|h| h["lines"].as_array().cloned().unwrap_or_default())
.filter_map(|l| l.as_str().map(str::to_string))
.collect();
let added = lines.iter().filter(|l| l.starts_with('+')).count();
let removed = lines.iter().filter(|l| l.starts_with('-')).count();
if tool == "Write" && hunks.is_empty() {
let n = v["content"].as_str().map(|c| c.lines().count()).unwrap_or(0);
return one(format!("{n} lines, a new file"), t.dim());
}
let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" });
let mut out = one(format!("{}, {}", plural(added, "addition"), plural(removed, "removal")), t.dim());
let mut shown = 0;
for h in &hunks {
let (mut old, mut new) = (h["oldStart"].as_u64().unwrap_or(1), h["newStart"].as_u64().unwrap_or(1));
for l in h["lines"].as_array().into_iter().flatten().filter_map(Value::as_str) {
let (sign, body) = l.split_at(l.chars().next().map(char::len_utf8).unwrap_or(0));
let (num, style) = match sign {
"-" => {
old += 1;
(old - 1, t.removed())
}
"+" => {
new += 1;
(new - 1, t.added())
}
_ => {
old += 1;
new += 1;
(new - 1, t.dim())
}
};
if sign != "-" && sign != "+" {
continue; }
if shown == DIFF_LINES {
break;
}
shown += 1;
out.push(Line::from(vec![
Span::raw(RESULT_INDENT),
Span::styled(format!("{num:>4} {sign} {body}"), style),
]));
}
}
let changed = added + removed;
if changed > shown {
out.push(Line::from(Span::styled(
format!("{RESULT_INDENT}… {} more changed lines", changed - shown),
t.dim(),
)));
}
out
}
("Bash", _) => {
let body: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
if body.is_empty() {
return one("(no output)".into(), t.dim());
}
let mut out = vec![];
for (i, l) in body.iter().take(3).enumerate() {
let lead = if i == 0 { mark() } else { Span::raw(RESULT_INDENT) };
let l: String = l.chars().take(200).collect();
out.push(Line::from(vec![lead, Span::styled(l, t.dim())]));
}
if body.len() > 3 {
out.push(Line::from(Span::styled(format!("{RESULT_INDENT}… +{} lines", body.len() - 3), t.dim())));
}
out
}
_ => {
let more = text.lines().count().saturating_sub(1);
let more = if more > 0 { format!(" (+{more} lines)") } else { String::new() };
one(format!("{}{more}", first_line(text)), t.dim())
}
}
}
#[derive(Debug, Clone)]
pub struct Running {
pub id: String,
pub header: Vec<Span<'static>>,
pub name: String,
pub input: Value,
pub since: Instant,
}
pub fn tool_verb(name: &str, done: bool) -> &'static str {
let (past, now) = match name {
"Bash" | "BashOutput" => ("Ran", "Running"),
"Read" | "NotebookRead" => ("Read", "Reading"),
"Edit" | "MultiEdit" | "NotebookEdit" => ("Edited", "Editing"),
"Write" => ("Wrote", "Writing"),
"Grep" => ("Searched", "Searching"),
"Glob" | "LS" => ("Listed", "Listing"),
"WebFetch" => ("Fetched", "Fetching"),
"WebSearch" => ("Searched the web", "Searching the web"),
"Task" => ("Delegated", "Delegating"),
"TodoWrite" => ("Updated the plan", "Updating the plan"),
"Skill" => ("Used skill", "Using skill"),
_ => ("Called", "Calling"),
};
if done {
past
} else {
now
}
}
pub fn tool_display_name(name: &str) -> String {
match name.strip_prefix("mcp__").and_then(|r| r.split_once("__")) {
Some((server, tool)) => format!("{server}.{tool}"),
None => name.to_string(),
}
}
pub fn tool_header(t: &Theme, name: &str, input: &Value, done: bool) -> Vec<Span<'static>> {
let arg = summarize(name, input);
let mut spans = vec![Span::styled(tool_verb(name, done).to_string(), t.bold())];
let builtin = tool_verb(name, true) != "Called";
if !builtin {
spans.push(Span::raw(" "));
spans.push(Span::styled(tool_display_name(name), t.info()));
}
if arg.is_empty() {
return spans;
}
spans.push(Span::raw(" "));
match name {
"Bash" => spans.push(Span::raw(arg)),
"Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" | "LS" | "Glob" | "Grep" | "WebFetch" => {
spans.push(Span::styled(arg, t.info()));
}
_ => spans.push(Span::styled(arg, t.dim())),
}
spans
}
pub fn summarize(tool: &str, input: &Value) -> String {
let key = match tool {
"Bash" => "command",
"Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => "file_path",
"Glob" | "Grep" => "pattern",
"LS" => "path",
"WebFetch" => "url",
"WebSearch" => "query",
"Task" => "description",
"Skill" => "skill",
_ => "",
};
let v = input.get(key).and_then(Value::as_str).map(str::to_string).or_else(|| match tool {
"TodoWrite" => input["todos"].as_array().map(|a| format!("{} todos", a.len())),
_ => input.as_object().and_then(|o| o.values().find_map(Value::as_str).map(str::to_string)),
});
let v = v.unwrap_or_default();
let one = v.lines().next().unwrap_or("");
if one.chars().count() > 100 {
format!("{}…", one.chars().take(100).collect::<String>())
} else {
one.to_string()
}
}
fn rule_text(s: &Suggestion) -> Option<String> {
match s {
Suggestion::AddRules { rules, .. } => {
Some(format!("Always allow {}", rules.iter().map(|r| r.to_rule_string()).collect::<Vec<_>>().join(", ")))
}
Suggestion::AddDirectories { directories, .. } => {
Some(format!("Allow {} for this session", directories.join(", ")))
}
Suggestion::SetMode { mode, .. } if mode == "acceptEdits" => {
Some("Allow all edits this session (shift+tab)".into())
}
Suggestion::SetMode { mode, .. } => Some(format!("Allow, and switch to {mode} mode")),
}
}
fn edit_preview(tool: &str, input: &Value) -> Vec<String> {
const MAX: usize = 8;
let side = |text: &str, sign: char| -> Vec<String> {
let lines: Vec<&str> = text.lines().collect();
let mut out: Vec<String> = lines.iter().take(MAX).map(|l| format!(" {sign} {l}")).collect();
if lines.len() > MAX {
out.push(format!(" … {} more lines", lines.len() - MAX));
}
out
};
match tool {
"Edit" => {
let mut v = side(input["old_string"].as_str().unwrap_or(""), '-');
v.extend(side(input["new_string"].as_str().unwrap_or(""), '+'));
v
}
"MultiEdit" => input["edits"]
.as_array()
.into_iter()
.flatten()
.take(3)
.flat_map(|e| {
let mut v = side(e["old_string"].as_str().unwrap_or(""), '-');
v.extend(side(e["new_string"].as_str().unwrap_or(""), '+'));
v
})
.collect(),
"Write" => side(input["content"].as_str().unwrap_or(""), '+'),
_ => vec![],
}
}
const MODES: [PermissionMode; 3] = [PermissionMode::Default, PermissionMode::AcceptEdits, PermissionMode::Plan];
impl App {
pub fn new(theme: Theme, history: Vec<String>) -> Self {
App {
calls: Default::default(),
running: vec![],
theme,
editor: Editor::with_history(history),
status: StatusView::default(),
busy: false,
busy_since: None,
activity: String::new(),
queued: VecDeque::new(),
pending: vec![],
live: String::new(),
md: Markdown::default(),
first_line: true,
streamed: false,
request_usage: Default::default(),
asked_at: None,
printed_blank: false,
dialog: None,
commands: vec![],
files: vec![],
search: None,
menu_selected: 0,
menu_dismissed: false,
last_esc: None,
last_ctrl_c: None,
hint: None,
exit: false,
dirty: true,
color_ok: theme.color,
clipboard: None,
status_text: None,
viewer_page: std::cell::Cell::new(10),
keymap: Default::default(),
look: Default::default(),
front: false,
endpoint: String::new(),
branch: String::new(),
focus: false,
held: None,
}
}
pub fn context_tokens(&self) -> u64 {
self.request_usage.context_tokens()
}
fn gap(&mut self) {
let last_blank = match self.pending.last() {
Some(l) => blank(l),
None => self.printed_blank,
};
if !last_blank {
self.pending.push(Line::default());
}
}
pub fn take_pending(&mut self) -> Vec<Line<'static>> {
if let Some(l) = self.pending.last() {
self.printed_blank = blank(l);
}
std::mem::take(&mut self.pending)
}
fn commit_text_line(&mut self, raw: &str) {
let t = self.theme;
let lines = self.md.push(raw, &t);
self.commit_answer_lines(lines);
}
fn answer_mark(&self) -> Span<'static> {
use super::look::Look;
match self.look {
Look::Frame | Look::Status | Look::Card => Span::styled(format!("{} ", glyphs::ANSWER), self.theme.dim()),
_ => Span::raw(" "),
}
}
fn clock() -> String {
chrono::Local::now().format("%H:%M").to_string()
}
fn commit_answer_lines(&mut self, lines: Vec<Line<'static>>) {
use super::look::Look;
let t = self.theme;
for line in lines {
let marker = if self.first_line { self.answer_mark() } else { Span::raw(" ") };
if self.first_line {
self.gap();
match self.look {
Look::Card => self.pending.push(Line::from(Span::styled(" kodact", t.accent()))),
Look::Ledger => self.pending.push(Line::from(vec![
Span::styled(format!(" {} ", Self::clock()), t.subtle()),
Span::styled("kodact", t.accent()),
])),
_ => {}
}
}
self.first_line = false;
let mut line = t.answer(line);
line.spans.insert(0, marker);
self.pending.push(line);
}
}
pub fn live_line(&self) -> Option<Line<'static>> {
if self.live.is_empty() {
return None;
}
let t = self.theme;
let mut line = t.answer(self.md.clone().line(&self.live, &t)?);
let marker = if self.first_line { self.answer_mark() } else { Span::raw(" ") };
line.spans.insert(0, marker);
Some(line)
}
fn flush_live(&mut self) {
if !self.live.is_empty() {
let rest = std::mem::take(&mut self.live);
self.commit_text_line(&rest);
}
let t = self.theme;
let held = self.md.finish(&t);
self.commit_answer_lines(held);
}
pub fn echo_prompt(&mut self, text: &str) {
use super::look::Look;
self.gap();
let t = self.theme;
match self.look {
Look::Glow => {
let st = if t.color {
Style::default().bg(super::palette::WARM_BG.color(t.depth, t.light))
} else {
t.user()
};
for l in text.lines() {
self.pending.push(Line::from(Span::styled(format!(" {l} "), st)));
}
}
Look::Card => {
self.pending.push(Line::from(Span::styled(format!(" you · {}", Self::clock()), t.subtle())));
for l in text.lines() {
self.pending.push(Line::from(format!(" {l}")));
}
}
Look::Ledger => {
for (i, l) in text.lines().enumerate() {
let lead = if i == 0 {
vec![
Span::styled(format!(" {} ", Self::clock()), t.subtle()),
Span::styled("you ", t.dim()),
]
} else {
vec![Span::raw(" ".repeat(16))]
};
let mut spans = lead;
spans.push(Span::raw(l.to_string()));
self.pending.push(Line::from(spans));
}
}
Look::Bare => {
for (i, l) in text.lines().enumerate() {
let mark = if i == 0 { "› " } else { " " };
self.pending
.push(Line::from(vec![Span::styled(mark, t.subtle()), Span::styled(l.to_string(), t.bold())]));
}
}
Look::Frame => {
let st =
if t.color { t.raised().fg(super::palette::PROMPT_FG.color(t.depth, t.light)) } else { t.user() };
let edge = if t.color { st.patch(t.mode(&self.status.mode)) } else { t.bold() };
let edge_char = if t.color { "▎ " } else { "│ " };
for l in text.lines() {
self.pending
.push(Line::from(vec![Span::styled(edge_char, edge), Span::styled(format!(" {l} "), st)]));
}
}
Look::Status => {
let st = t.user();
let mark_st = if t.color { st.patch(t.accent()) } else { st };
for (i, l) in text.lines().enumerate() {
let marker = if i == 0 { format!("{} ", glyphs::PROMPT) } else { " ".into() };
self.pending
.push(Line::from(vec![Span::styled(marker, mark_st), Span::styled(format!("{l} "), st)]));
}
}
}
}
fn reply_lines(&mut self, text: &str, is_error: bool) {
let checklist = text.lines().next().is_some_and(|l| check_mark(&self.theme, l).is_some());
let st = if is_error && !checklist { self.theme.error() } else { Style::default() };
self.gap();
for (i, l) in text.lines().enumerate() {
let marker = Span::styled(if i == 0 { RESULT_MARK } else { RESULT_INDENT }, self.theme.hairline());
let line = match check_mark(&self.theme, l) {
Some((mark, ms)) => {
Line::from(vec![marker, Span::styled(mark, ms), Span::raw(l[mark.len()..].to_string())])
}
None => Line::from(vec![marker, Span::styled(l.to_string(), st)]),
};
self.pending.push(line);
}
}
fn commit_tool(&mut self, r: Running, dot: Style, failed: bool) {
use super::look::Look;
self.gap();
let t = self.theme;
let line = match self.look {
l if l.plain_steps() => {
let mut text = tool_verb(&r.name, true).to_lowercase();
if tool_verb(&r.name, true) == "Called" {
text.push(' ');
text.push_str(&tool_display_name(&r.name));
}
let arg = summarize(&r.name, &r.input);
if !arg.is_empty() {
text.push(' ');
text.push_str(&arg);
}
let lead = if failed { Span::styled("✗ ", t.error()) } else { Span::raw(" ") };
Line::from(vec![lead, Span::styled(text, if failed { t.error() } else { t.subtle() })])
}
Look::Glow => {
let mut spans =
vec![if failed { Span::styled("✗ ", t.error()) } else { Span::styled("✓ ", t.accent()) }];
spans.extend(tool_header(&t, &r.name, &r.input, true));
Line::from(spans)
}
_ => {
let mut spans = vec![Span::styled(format!("{} ", glyphs::TOOL), dot)];
spans.extend(tool_header(&t, &r.name, &r.input, true));
Line::from(spans)
}
};
self.pending.push(line);
}
fn notice(&mut self, level: NoticeLevel, text: &str) {
let t = self.theme;
let (mark, mark_st, st) = match level {
NoticeLevel::Info => ("·", t.dim(), t.dim()),
NoticeLevel::Warning => ("▲", t.warning(), Style::default()),
NoticeLevel::Error => ("✕", t.error(), t.error()),
};
self.gap();
for (i, l) in text.lines().enumerate() {
let lead = if i == 0 { Span::styled(format!(" {mark} "), mark_st) } else { Span::raw(" ") };
self.pending.push(Line::from(vec![lead, Span::styled(l.to_string(), st)]));
}
}
pub fn on_event(&mut self, ev: UiEvent) {
self.dirty = true;
match ev {
UiEvent::Engine(e) => self.on_engine(e),
UiEvent::Ask { prompt, reply } => self.open_dialog(prompt, reply),
UiEvent::Reply { text, is_error } => {
if !text.trim().is_empty() {
self.reply_lines(&text, is_error);
}
}
UiEvent::Status(s) => self.status = s,
UiEvent::Commands(c) => self.commands = c,
UiEvent::Files(f) => self.files = f,
UiEvent::Theme(name) => {
let accent = self.theme.accent;
self.theme = Theme::named(&name, self.color_ok, self.theme.depth);
self.theme.accent = accent;
}
UiEvent::Look(name) => self.look = super::look::Look::named(&name).unwrap_or_default(),
UiEvent::Copy(text) => self.clipboard = Some(text),
UiEvent::StatusLine(text) => self.status_text = text,
UiEvent::Picker(picker) => self.open_picker(picker),
UiEvent::Screen(screen) => self.open_screen(screen),
UiEvent::Busy => {
if !self.busy {
self.busy = true;
self.busy_since = Some(Instant::now());
self.activity = "Running a scheduled task".into();
self.first_line = true;
}
}
UiEvent::Idle => {
self.flush_live();
for r in std::mem::take(&mut self.running) {
let dot = self.theme.dim();
self.commit_tool(r, dot, false);
}
self.busy = false;
self.busy_since = None;
self.activity.clear();
}
UiEvent::Exit => self.exit = true,
}
}
pub fn next_queued(&mut self) -> Option<String> {
if self.busy {
return None;
}
let text = self.queued.pop_front()?;
Some(self.start(text))
}
fn start(&mut self, text: String) -> String {
self.echo_prompt(&text);
self.busy = true;
self.busy_since = Some(Instant::now());
self.activity = "Working".into();
self.first_line = true;
text
}
fn meter(&mut self) {
let used = self.request_usage.context_tokens();
if used > 0 && self.status.window > 0 {
self.status.context_pct = Some((used * 100 / self.status.window).min(100) as u8);
self.status.context_used = true;
}
}
fn on_engine(&mut self, e: EngineEvent) {
match e {
EngineEvent::Stream { parent_tool_use_id: Some(_), .. }
| EngineEvent::Assistant { parent_tool_use_id: Some(_), .. }
| EngineEvent::User { parent_tool_use_id: Some(_), .. } => {}
EngineEvent::Stream { event, .. } => match event {
StreamEvent::MessageStart { message } => {
self.streamed = false;
self.request_usage = message.usage;
self.meter();
}
StreamEvent::MessageDelta { usage, .. } => {
let u = &mut self.request_usage;
if usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0 {
u.input_tokens = usage.input_tokens;
u.cache_read_input_tokens = usage.cache_read_input_tokens;
u.cache_creation_input_tokens = usage.cache_creation_input_tokens;
}
u.output_tokens = u.output_tokens.max(usage.output_tokens);
self.meter();
}
StreamEvent::ContentBlockStart { content_block: ContentBlock::Text { .. }, .. } => {
self.flush_live();
self.first_line = true;
self.md.reset();
self.activity = "Writing".into();
}
StreamEvent::ContentBlockDelta { delta: Delta::TextDelta { text }, .. } => {
self.streamed = true;
self.live.push_str(&text);
if self.first_line && !self.live.trim().is_empty() {
self.gap();
}
while let Some(i) = self.live.find('\n') {
let line: String = self.live[..i].to_string();
self.live.drain(..=i);
self.commit_text_line(&line);
}
}
StreamEvent::ContentBlockDelta { delta: Delta::ThinkingDelta { .. }, .. } => {
self.activity = "Thinking".into();
}
StreamEvent::ContentBlockStop { .. } | StreamEvent::MessageStop => self.flush_live(),
_ => {}
},
EngineEvent::Assistant { message, .. } => {
self.flush_live();
for b in &message.content {
match b {
ContentBlock::Text { text, .. } if !self.streamed && !text.trim().is_empty() => {
self.first_line = true;
self.md.reset();
for l in text.lines() {
self.commit_text_line(l);
}
}
ContentBlock::ToolUse { name, .. } if self.focus => self.activity = format!("Running {name}"),
ContentBlock::ToolUse { id, name, input, .. } => {
self.calls.insert(id.clone(), (name.clone(), input.clone()));
self.running.push(Running {
id: id.clone(),
header: tool_header(&self.theme, name, input, false),
name: name.clone(),
input: input.clone(),
since: Instant::now(),
});
self.activity = format!("Running {name}");
}
_ => {}
}
}
self.streamed = false;
}
EngineEvent::User { message, is_meta: false, tool_use_result, .. } => {
if self.focus {
self.activity = "Working".into();
return;
}
for b in &message.content {
if let ContentBlock::ToolResult { tool_use_id, content, is_error, .. } = b {
let tool = self.calls.remove(tool_use_id).map(|(n, _)| n).unwrap_or_default();
let failed = *is_error == Some(true);
if let Some(i) = self.running.iter().position(|r| r.id == *tool_use_id) {
let r = self.running.remove(i);
let dot = if failed { self.theme.error() } else { self.theme.success() };
self.commit_tool(r, dot, failed);
}
let lines = result_lines(
&self.theme,
&tool,
&content.to_text(),
*is_error == Some(true),
tool_use_result.as_ref(),
);
let lines = if self.look.plain_steps() {
lines
.into_iter()
.map(|mut l| {
if let Some(first) = l.spans.first_mut() {
if first.content == RESULT_MARK {
*first = Span::raw(RESULT_INDENT);
}
}
l
})
.collect()
} else {
lines
};
self.pending.extend(lines);
self.activity = "Working".into();
}
}
}
EngineEvent::PromptAccepted { message, .. } => {
let names: Vec<String> = message
.content
.iter()
.filter_map(ContentBlock::as_text)
.flat_map(kodact_agents::attach::attached_paths)
.map(|p| Path::new(&p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p))
.collect();
if !names.is_empty() {
let line = format!(" (attached: {})", names.join(", "));
self.pending.push(Line::from(Span::styled(line, self.theme.dim())));
}
}
EngineEvent::Notice { level, text } => self.notice(level, &text),
EngineEvent::System { subtype, data } => match subtype.as_str() {
"compact_boundary" => self.notice(NoticeLevel::Info, "Conversation compacted."),
"model_fallback" => self.notice(
NoticeLevel::Warning,
&format!(
"Switched to {} for this turn.",
data.get("to").and_then(Value::as_str).unwrap_or("another model")
),
),
_ => {}
},
EngineEvent::ToolProgress { tool_name, elapsed_secs, .. } => {
self.activity = format!("Running {tool_name} ({}s)", elapsed_secs as u64);
}
_ => {}
}
}
fn open_dialog(&mut self, prompt: PermissionPrompt, reply: oneshot::Sender<PermissionAnswer>) {
self.flush_live();
let kind = match prompt.tool_name.as_str() {
"AskUserQuestion" => {
let questions: Vec<Question> = prompt.input["questions"]
.as_array()
.into_iter()
.flatten()
.map(|q| Question {
question: q["question"].as_str().unwrap_or("").to_string(),
options: q["options"]
.as_array()
.into_iter()
.flatten()
.map(|o| {
(
o["label"].as_str().unwrap_or("").to_string(),
o["description"].as_str().unwrap_or("").to_string(),
)
})
.collect(),
multi: q["multiSelect"].as_bool().unwrap_or(false),
})
.collect();
let picked = vec![false; questions.first().map(|q| q.options.len()).unwrap_or(0)];
DialogKind::Questions { questions, at: 0, answers: serde_json::Map::new(), picked }
}
"ExitPlanMode" => {
self.gap();
self.pending.push(Line::from(Span::styled("Here is the plan:", self.theme.bold())));
let plan = prompt.input["plan"].as_str().unwrap_or("").to_string();
let mut md = Markdown::default();
let mut lines: Vec<Line<'static>> = plan.lines().flat_map(|l| md.push(l, &self.theme)).collect();
lines.extend(md.finish(&self.theme));
for mut line in lines {
line.spans.insert(0, Span::raw(" "));
self.pending.push(line);
}
DialogKind::Plan
}
_ => DialogKind::Permission { always: prompt.suggestions.first().and_then(rule_text) },
};
self.dialog = Some(Dialog { prompt: Some(prompt), reply: Some(reply), kind, selected: 0, typing: None });
self.asked_at = Some(Instant::now());
}
fn answer(&mut self, a: PermissionAnswer) {
self.asked_at = None;
if let Some(mut d) = self.dialog.take() {
if let Some(tx) = d.reply.take() {
let _ = tx.send(a);
}
}
self.dialog = self.held.take();
}
fn show(&mut self, d: Dialog) {
if self.dialog.as_ref().is_some_and(|open| open.reply.is_some()) {
self.held = Some(d);
} else {
self.dialog = Some(d);
}
}
pub fn dialog_options(&self) -> Vec<(String, String)> {
let Some(d) = &self.dialog else { return vec![] };
match &d.kind {
DialogKind::Permission { always } => {
let mut v = vec![("Allow once".to_string(), "y".to_string())];
if let Some(r) = always {
v.push((r.clone(), "a".to_string()));
}
v.push(("Deny, and tell KodAct what to do".to_string(), "n".to_string()));
v
}
DialogKind::Plan => vec![
("Yes, and accept edits without asking".into(), String::new()),
("Yes, and ask before each edit".into(), String::new()),
("No, keep planning".into(), "esc".into()),
],
DialogKind::Questions { questions, at, picked, .. } => {
let q = &questions[*at];
let mut v: Vec<(String, String)> = q
.options
.iter()
.enumerate()
.map(|(i, (l, desc))| {
let label =
if q.multi { format!("[{}] {l}", if picked[i] { "x" } else { " " }) } else { l.clone() };
(label, desc.clone())
})
.collect();
v.push(("Type an answer".into(), String::new()));
v
}
DialogKind::Viewer { .. } | DialogKind::Form { .. } => vec![],
DialogKind::Picker { .. } => self
.picker_rows()
.into_iter()
.map(|c| (if c.current { format!("{} ✔", c.label) } else { c.label.clone() }, c.detail.clone()))
.collect(),
}
}
pub fn waiting_for_answer(&self) -> bool {
self.dialog.as_ref().is_some_and(|d| {
matches!(d.kind, DialogKind::Permission { .. } | DialogKind::Plan | DialogKind::Questions { .. })
})
}
fn picker_rows(&self) -> Vec<&kodact_core::commands::picker::Choice> {
let Some(Dialog { kind: DialogKind::Picker { picker, filter }, .. }) = &self.dialog else { return vec![] };
let f = filter.to_lowercase();
picker
.choices
.iter()
.filter(|c| f.is_empty() || c.label.to_lowercase().contains(&f) || c.detail.to_lowercase().contains(&f))
.collect()
}
pub fn dialog_text(&self) -> (String, Vec<String>) {
let Some(d) = &self.dialog else { return (String::new(), vec![]) };
match &d.kind {
DialogKind::Picker { picker, filter } => {
let body = if filter.is_empty() {
vec!["Type to filter".to_string()]
} else {
vec![format!("Filter: {filter}")]
};
(picker.title.clone(), body)
}
DialogKind::Permission { .. } => {
let Some(p) = &d.prompt else { return (String::new(), vec![]) };
let arg = summarize(&p.tool_name, &p.input);
let mut body = vec![];
if p.tool_name == "Bash" {
body.extend(p.input["command"].as_str().unwrap_or("").lines().take(6).map(|l| format!(" {l}")));
if let Some(d) = p.input["description"].as_str().filter(|d| !d.trim().is_empty()) {
body.push(format!(" {d}"));
}
} else if tool_verb(&p.tool_name, true) == "Called" {
let fields = p.input.as_object().cloned().unwrap_or_default();
let pad = fields.keys().map(|k| k.chars().count()).max().unwrap_or(0).min(16);
for (k, v) in fields.iter().take(16) {
let v = match v {
Value::String(s) => s.clone(),
other => other.to_string(),
};
for (i, l) in v.lines().enumerate().take(12) {
let key = if i == 0 { k.as_str() } else { "" };
body.push(format!(" {key:<pad$} {l}"));
}
}
} else if !arg.is_empty() {
body.push(format!(" {arg}"));
}
body.extend(edit_preview(&p.tool_name, &p.input));
if !p.reason.is_empty() && p.reason != "this tool requires permission" {
body.push(p.reason.clone());
}
(format!("Approve {}?", tool_display_name(&p.tool_name)), body)
}
DialogKind::Plan => ("Go ahead with this plan?".into(), vec![]),
DialogKind::Viewer { screen, .. } => (screen.title.clone(), vec![]),
DialogKind::Form { form, .. } => (form.title.clone(), vec![]),
DialogKind::Questions { questions, at, .. } => {
let q = &questions[*at];
let n = questions.len();
let title = if n > 1 { format!("{} ({}/{n})", q.question, at + 1) } else { q.question.clone() };
let mut body = vec![];
if let Some(e) = &d.typing {
body.push(format!("> {}", e.text()));
}
(title, body)
}
}
}
pub fn dialog_selected(&self) -> usize {
self.dialog.as_ref().map(|d| d.selected).unwrap_or(0)
}
fn dialog_key(&mut self, key: KeyEvent) -> Vec<Action> {
if matches!(self.dialog.as_ref().map(|d| &d.kind), Some(DialogKind::Viewer { .. })) {
return self.viewer_key(key);
}
if matches!(self.dialog.as_ref().map(|d| &d.kind), Some(DialogKind::Form { .. })) {
return self.form_key(key);
}
let n = self.dialog_options().len();
let Some(d) = self.dialog.as_mut() else { return vec![] };
if let DialogKind::Picker { filter, .. } = &mut d.kind {
match key.code {
KeyCode::Up => d.selected = d.selected.checked_sub(1).unwrap_or(n.saturating_sub(1)),
KeyCode::Down | KeyCode::Tab => d.selected = (d.selected + 1) % n.max(1),
KeyCode::Enter if n > 0 => {
let i = d.selected.min(n - 1);
return self.choose(i);
}
KeyCode::Esc => self.dialog = None,
KeyCode::Backspace => {
filter.pop();
d.selected = 0;
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
filter.push(c);
d.selected = 0;
}
_ => {}
}
return vec![];
}
if let Some(e) = d.typing.as_mut() {
match key.code {
KeyCode::Enter => {
let text = e.text();
d.typing = None;
if !text.trim().is_empty() {
self.answer_question(text);
}
}
KeyCode::Esc => d.typing = None,
KeyCode::Backspace => e.backspace(),
KeyCode::Left => e.left(),
KeyCode::Right => e.right(),
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => e.insert(&c.to_string()),
_ => {}
}
return vec![];
}
if let DialogKind::Permission { always } = &d.kind {
let pick = match key.code {
KeyCode::Char('y') => Some(0),
KeyCode::Char('a') if always.is_some() => Some(1),
KeyCode::Char('n') => Some(n.saturating_sub(1)),
_ => None,
};
if let Some(i) = pick.filter(|_| !key.modifiers.contains(KeyModifiers::CONTROL)) {
d.selected = i;
return self.choose(i);
}
}
match key.code {
KeyCode::Up => d.selected = d.selected.checked_sub(1).unwrap_or(n.saturating_sub(1)),
KeyCode::Down | KeyCode::Tab => d.selected = (d.selected + 1) % n.max(1),
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' => {
let i = c as usize - '1' as usize;
if i < n {
d.selected = i;
return self.choose(i);
}
}
KeyCode::Char(' ') => {
if let DialogKind::Questions { questions, at, picked, .. } = &mut d.kind {
if questions[*at].multi && d.selected < picked.len() {
picked[d.selected] = !picked[d.selected];
}
}
}
KeyCode::Enter => {
let i = d.selected;
return self.choose(i);
}
KeyCode::Esc => return self.choose(n.saturating_sub(1)),
_ => {}
}
vec![]
}
fn choose(&mut self, i: usize) -> Vec<Action> {
let n = self.dialog_options().len();
let Some(d) = self.dialog.as_mut() else { return vec![] };
let allow = |updated_permissions| PermissionAnswer::Allow { updated_input: None, updated_permissions };
let stop = |message: &str| PermissionAnswer::Deny { message: message.to_string(), interrupt: true };
match &mut d.kind {
DialogKind::Picker { .. } => {
let Some(pick) = self.picker_rows().get(i).map(|c| c.pick.clone()) else { return vec![] };
self.dialog = None;
return match pick {
Pick::Run(text) => self.submit(text),
Pick::RunThenEdit { command, edit } => {
let a = self.submit(command);
self.editor.set(&edit);
a
}
Pick::Step(text) => vec![Action::Picker(text)],
Pick::Edit(text) => {
self.editor.set(&text);
vec![]
}
};
}
DialogKind::Viewer { .. } | DialogKind::Form { .. } => {}
DialogKind::Permission { always } => {
let always_row = always.is_some();
let suggestions = d.prompt.as_ref().map(|p| p.suggestions.clone()).unwrap_or_default();
let a = match (i, always_row) {
(0, _) => allow(vec![]),
(1, true) => allow(suggestions.iter().filter_map(|s| serde_json::to_value(s).ok()).collect()),
_ => stop(
"The user said no; nothing was run. Don't run it again or reach the same result another way \
(another command, shell or tool). Wait for them to say what to do instead.",
),
};
self.answer(a);
}
DialogKind::Plan => {
let a = match i {
0 => allow(vec![json!({"type": "setMode", "mode": "acceptEdits", "destination": "session"})]),
1 => allow(vec![]),
_ => stop("The user wants to keep planning. Wait for their feedback on the plan."),
};
self.answer(a);
}
DialogKind::Questions { questions, at, picked, .. } => {
if i + 1 == n {
if d.selected + 1 == n && d.typing.is_none() {
d.typing = Some(Editor::default());
}
return vec![];
}
let q = &questions[*at];
let answer = if q.multi {
let mut chosen: Vec<String> =
q.options.iter().zip(picked.iter()).filter(|(_, p)| **p).map(|(o, _)| o.0.clone()).collect();
if chosen.is_empty() {
chosen.push(q.options[i].0.clone());
}
chosen.join(", ")
} else {
q.options[i].0.clone()
};
self.answer_question(answer);
}
}
vec![]
}
fn answer_question(&mut self, answer: String) {
let Some(d) = self.dialog.as_mut() else { return };
let DialogKind::Questions { questions, at, answers, picked } = &mut d.kind else { return };
answers.insert(questions[*at].question.clone(), json!(answer));
if *at + 1 < questions.len() {
*at += 1;
*picked = vec![false; questions[*at].options.len()];
d.selected = 0;
return;
}
let mut input = d.prompt.as_ref().map(|p| p.input.clone()).unwrap_or_default();
input["answers"] = Value::Object(std::mem::take(answers));
self.answer(PermissionAnswer::Allow { updated_input: Some(input), updated_permissions: vec![] });
}
fn open_picker(&mut self, picker: Picker) {
self.flush_live();
let selected = picker.choices.iter().position(|c| c.current).unwrap_or(0);
self.show(Dialog {
prompt: None,
reply: None,
kind: DialogKind::Picker { picker, filter: String::new() },
selected,
typing: None,
});
}
pub fn open_screen(&mut self, screen: Screen) {
self.flush_live();
let kind = DialogKind::Viewer { screen, cursor: 0, top: 0, back: vec![] };
self.show(Dialog { prompt: None, reply: None, kind, selected: 0, typing: None });
}
pub fn viewer(&self) -> Option<(&Screen, usize, usize)> {
match &self.dialog.as_ref()?.kind {
DialogKind::Viewer { screen, cursor, top, .. } => Some((screen, *cursor, *top)),
_ => None,
}
}
pub fn form(&self) -> Option<(&Form, usize)> {
match &self.dialog.as_ref()?.kind {
DialogKind::Form { form, at } => Some((form, *at)),
_ => None,
}
}
fn viewer_key(&mut self, key: KeyEvent) -> Vec<Action> {
let page = self.viewer_page.get().max(1);
let Some(Dialog { kind: DialogKind::Viewer { screen, cursor, top, back }, .. }) = self.dialog.as_mut() else {
return vec![];
};
let last = screen.rows.len().saturating_sub(1);
if screen.rows.iter().all(|r| r.action.is_none()) {
let bottom = screen.rows.len().saturating_sub(page);
*top = match key.code {
KeyCode::Up => top.saturating_sub(1),
KeyCode::Down => (*top + 1).min(bottom),
KeyCode::PageUp => top.saturating_sub(page),
KeyCode::PageDown | KeyCode::Char(' ') => (*top + page).min(bottom),
KeyCode::Home => 0,
KeyCode::End => bottom,
KeyCode::Esc | KeyCode::Left | KeyCode::Char('q') | KeyCode::Enter => {
self.dialog = None;
return vec![];
}
_ => *top,
};
*cursor = *top;
return vec![];
}
match key.code {
KeyCode::Up => *cursor = cursor.saturating_sub(1),
KeyCode::Down => *cursor = (*cursor + 1).min(last),
KeyCode::PageUp => *cursor = cursor.saturating_sub(page),
KeyCode::PageDown | KeyCode::Char(' ') => *cursor = (*cursor + page).min(last),
KeyCode::Home => *cursor = 0,
KeyCode::End => *cursor = last,
KeyCode::Esc | KeyCode::Left | KeyCode::Char('q') => match back.pop() {
Some(from) if key.code != KeyCode::Char('q') => *cursor = from,
_ => {
self.dialog = None;
return vec![];
}
},
KeyCode::Enter | KeyCode::Right => match screen.rows.get(*cursor).and_then(|r| r.action.clone()) {
Some(RowAction::Jump(to)) => {
back.push(*cursor);
*cursor = to.min(last);
*top = *cursor;
}
Some(RowAction::Run(text)) if key.code == KeyCode::Enter => {
self.dialog = None;
return self.submit(text);
}
Some(RowAction::Confirm { question, command }) if key.code == KeyCode::Enter => {
let choices = vec![
Choice {
label: "Yes".into(),
detail: command.clone(),
pick: Pick::Run(command),
current: false,
},
Choice {
label: "No".into(),
detail: String::new(),
pick: Pick::Edit(String::new()),
current: false,
},
];
self.dialog = None;
self.open_picker(Picker { title: question, choices });
return vec![];
}
Some(RowAction::Form(form)) if key.code == KeyCode::Enter => {
self.dialog = Some(Dialog {
prompt: None,
reply: None,
kind: DialogKind::Form { form, at: 0 },
selected: 0,
typing: None,
});
return vec![];
}
_ => {}
},
_ => {}
}
if *cursor < *top {
*top = *cursor;
} else if *cursor >= *top + page {
*top = *cursor + 1 - page;
}
vec![]
}
fn form_key(&mut self, key: KeyEvent) -> Vec<Action> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let Some(Dialog { kind: DialogKind::Form { form, at }, .. }) = self.dialog.as_mut() else { return vec![] };
let n = form.fields.len();
match key.code {
KeyCode::Esc => self.dialog = None,
KeyCode::Enter => {
let text = form.command();
self.dialog = None;
return self.submit(text);
}
KeyCode::Tab | KeyCode::Down => *at = (*at + 1) % n.max(1),
KeyCode::BackTab | KeyCode::Up => *at = at.checked_sub(1).unwrap_or(n.saturating_sub(1)),
code => match form.fields.get_mut(*at).map(|f| &mut f.kind) {
Some(FieldKind::Text(t)) => match code {
KeyCode::Backspace => {
t.pop();
}
KeyCode::Char('u') if ctrl => t.clear(),
KeyCode::Char(c) if !ctrl => t.push(c),
_ => {}
},
Some(FieldKind::Choice { options, at: i }) => match code {
KeyCode::Left => *i = i.checked_sub(1).unwrap_or(options.len().saturating_sub(1)),
KeyCode::Right | KeyCode::Char(' ') => *i = (*i + 1) % options.len().max(1),
_ => {}
},
Some(FieldKind::Multi { options, picked, at: i }) => match code {
KeyCode::Left => *i = i.checked_sub(1).unwrap_or(options.len().saturating_sub(1)),
KeyCode::Right => *i = (*i + 1) % options.len().max(1),
KeyCode::Char(' ') if *i < picked.len() => picked[*i] = !picked[*i],
_ => {}
},
None => {}
},
}
vec![]
}
pub fn close_dialog_if_stale(&mut self) {
if self.dialog.as_ref().is_some_and(|d| d.reply.as_ref().is_some_and(|r| r.is_closed())) {
self.dialog = self.held.take();
self.dirty = true;
}
}
pub fn menu(&self) -> Vec<&CommandInfo> {
if self.menu_dismissed || self.dialog.is_some() {
return vec![];
}
let text = self.editor.text();
let Some(word) = text.strip_prefix('/') else { return vec![] };
if word.contains(char::is_whitespace) {
return vec![];
}
let word = word.to_lowercase();
let mut starts: Vec<&CommandInfo> = self.commands.iter().filter(|c| c.name.starts_with(&word)).collect();
let contains = self.commands.iter().filter(|c| !c.name.starts_with(&word) && c.name.contains(&word));
starts.extend(contains);
starts
}
pub fn file_menu(&self) -> Vec<&str> {
if self.menu_dismissed || self.dialog.is_some() || self.search.is_some() {
return vec![];
}
let (_, word) = self.editor.word_before_cursor();
let Some(q) = word.strip_prefix('@') else { return vec![] };
let q = q.to_lowercase();
let mut hits: Vec<(bool, usize, &str)> = self
.files
.iter()
.filter(|f| f.to_lowercase().contains(&q) && f.to_lowercase() != q)
.map(|f| {
let name = f.trim_end_matches('/').rsplit('/').next().unwrap_or(f).to_lowercase();
(!name.starts_with(&q), f.len(), f.as_str())
})
.collect();
hits.sort();
hits.into_iter().take(FILE_MATCHES).map(|(_, _, f)| f).collect()
}
fn complete_file(&mut self, path: &str) {
let (_, word) = self.editor.word_before_cursor();
let end = if path.ends_with('/') { "" } else { " " };
let mention =
if path.contains(char::is_whitespace) { format!("@\"{path}\"{end}") } else { format!("@{path}{end}") };
self.editor.replace_back(word.chars().count(), &mention);
}
fn find_history(&self, query: &str, from: Option<usize>) -> Option<usize> {
let h = self.editor.history();
let end = from.map(|f| f + 1).unwrap_or(h.len()).min(h.len());
(0..end).rev().find(|&i| h[i].contains(query))
}
fn search_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let mut s = self.search.take()?;
match key.code {
KeyCode::Char('r') if ctrl => {
let older = s.hit.and_then(|h| h.checked_sub(1)).and_then(|o| self.find_history(&s.query, Some(o)));
s.hit = older.or(s.hit);
}
KeyCode::Esc => {
self.editor.set(&s.draft);
return Some(vec![]);
}
KeyCode::Char('g') if ctrl => {
self.editor.set(&s.draft);
return Some(vec![]);
}
KeyCode::Backspace => {
s.query.pop();
s.hit = self.find_history(&s.query, None);
}
KeyCode::Char(c) if !ctrl => {
s.query.push(c);
s.hit = self.find_history(&s.query, None);
}
KeyCode::Enter => return Some(vec![]),
_ => return None,
}
match s.hit {
Some(h) => {
let text = self.editor.history()[h].clone();
self.editor.set(&text);
}
None => self.editor.set(&s.draft),
}
self.search = Some(s);
Some(vec![])
}
pub fn on_term_event(&mut self, ev: Event) -> Vec<Action> {
self.dirty = true;
match ev {
Event::Key(k) if k.kind != KeyEventKind::Release => match self.keymap.translate(k) {
Some(k) => self.on_key(k),
None => vec![],
},
Event::Paste(text) => {
if let Some(e) = self.dialog.as_mut().and_then(|d| d.typing.as_mut()) {
e.insert(&text);
} else if self.dialog.is_none() {
self.editor.insert(&text);
}
vec![]
}
_ => vec![],
}
}
fn set_hint(&mut self, text: &str) {
self.hint = Some((text.to_string(), Instant::now()));
}
pub fn on_key(&mut self, key: KeyEvent) -> Vec<Action> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let alt = key.modifiers.contains(KeyModifiers::ALT);
let now = Instant::now();
if ctrl && key.code == KeyCode::Char('c') {
self.search = None;
if self.dialog.is_some() {
self.answer(PermissionAnswer::Deny { message: "The user interrupted.".into(), interrupt: true });
return vec![Action::Interrupt];
}
if !self.editor.is_empty() {
self.editor.clear();
return vec![];
}
if self.busy {
return vec![Action::Interrupt];
}
if self.last_ctrl_c.is_some_and(|t| now.duration_since(t) < DOUBLE_PRESS) {
return vec![Action::Exit];
}
self.last_ctrl_c = Some(now);
self.set_hint("Press Ctrl-C again to exit");
return vec![];
}
if self.dialog.is_some() {
return self.dialog_key(key);
}
if self.search.is_some() {
if let Some(a) = self.search_key(key) {
return a;
}
}
if ctrl && key.code == KeyCode::Char('r') {
let draft = self.editor.text();
self.search = Some(Search { query: String::new(), hit: None, draft });
return vec![];
}
if ctrl && key.code == KeyCode::Char('d') && self.editor.is_empty() {
return if self.busy { vec![Action::Interrupt, Action::Exit] } else { vec![Action::Exit] };
}
let files = self.file_menu().len();
if files > 0 {
match key.code {
KeyCode::Up => {
self.menu_selected = self.menu_selected.checked_sub(1).unwrap_or(files - 1);
return vec![];
}
KeyCode::Down => {
self.menu_selected = (self.menu_selected + 1) % files;
return vec![];
}
KeyCode::Tab | KeyCode::Enter if !key.modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => {
let path = self.file_menu()[self.menu_selected.min(files - 1)].to_string();
self.complete_file(&path);
self.menu_selected = 0;
return vec![];
}
KeyCode::Esc => {
self.menu_dismissed = true;
return vec![];
}
_ => {}
}
}
if key.code == KeyCode::BackTab {
let cur = MODES.iter().position(|m| m.as_str() == self.status.mode).unwrap_or(0);
let next = MODES[(cur + 1) % MODES.len()];
self.status.mode = next.as_str().to_string();
return vec![Action::SetMode(next)];
}
if key.code == KeyCode::Char('?') && self.editor.is_empty() && !ctrl && !alt {
self.reply_lines(&keys_text(&self.keymap), false);
return vec![];
}
let menu_len = self.menu().len();
match key.code {
KeyCode::Esc => {
if menu_len > 0 {
self.menu_dismissed = true;
return vec![];
}
if self.busy {
return vec![Action::Interrupt];
}
if self.editor.is_empty() && self.last_esc.is_some_and(|t| now.duration_since(t) < DOUBLE_PRESS) {
self.last_esc = None;
return self.submit("/rewind".into());
}
self.last_esc = Some(now);
if !self.editor.is_empty() {
self.set_hint("Esc again on an empty prompt opens /rewind");
}
return vec![];
}
KeyCode::Enter if key.modifiers.intersects(KeyModifiers::SHIFT | KeyModifiers::ALT) => {
self.editor.insert("\n")
}
KeyCode::Char('j') if ctrl => self.editor.insert("\n"),
KeyCode::Enter => {
if menu_len > 0 {
let c = self.menu()[self.menu_selected.min(menu_len - 1)].clone();
let typed = self.editor.text();
if c.args.is_empty() || typed == format!("/{}", c.name) && c.args.starts_with('[') {
self.editor.set(&format!("/{}", c.name));
} else {
self.editor.set(&format!("/{} ", c.name));
return vec![];
}
}
let text = self.editor.text();
if let Some(stripped) = text.strip_suffix('\\') {
self.editor.set(&format!("{stripped}\n"));
return vec![];
}
if text.trim().is_empty() {
return vec![];
}
let text = self.editor.take();
return self.submit(text);
}
KeyCode::Tab if menu_len > 0 => {
let c = self.menu()[self.menu_selected.min(menu_len - 1)].clone();
self.editor.set(&format!("/{} ", c.name));
}
KeyCode::Up if menu_len > 0 => {
self.menu_selected = self.menu_selected.checked_sub(1).unwrap_or(menu_len - 1);
return vec![];
}
KeyCode::Down if menu_len > 0 => {
self.menu_selected = (self.menu_selected + 1) % menu_len;
return vec![];
}
KeyCode::Up => {
self.editor.up();
}
KeyCode::Down => {
self.editor.down();
}
KeyCode::Left if ctrl => self.editor.word_left(),
KeyCode::Right if ctrl => self.editor.word_right(),
KeyCode::Left => self.editor.left(),
KeyCode::Right => self.editor.right(),
KeyCode::Home => self.editor.home(),
KeyCode::End => self.editor.end(),
KeyCode::Backspace if alt || ctrl => self.editor.delete_word(),
KeyCode::Backspace => self.editor.backspace(),
KeyCode::Delete => self.editor.delete(),
KeyCode::Char('a') if ctrl => self.editor.home(),
KeyCode::Char('e') if ctrl => self.editor.end(),
KeyCode::Char('w') if ctrl => self.editor.delete_word(),
KeyCode::Char('u') if ctrl => self.editor.kill_to_start(),
KeyCode::Char('k') if ctrl => self.editor.kill_to_end(),
KeyCode::Char('b') if alt => self.editor.word_left(),
KeyCode::Char('f') if alt => self.editor.word_right(),
KeyCode::Char(c) if !ctrl => self.editor.insert(&c.to_string()),
_ => {}
}
self.menu_dismissed = false;
if self.menu().len() != menu_len || self.file_menu().len() != files {
self.menu_selected = 0;
}
vec![]
}
fn color_command(&mut self, arg: &str) -> (String, bool) {
let names: Vec<&str> = super::text::ACCENTS.iter().map(|(n, _)| *n).collect();
let list = format!("Colours: {}, default.", names.join(", "));
match arg {
"" => (format!("The accent colour marks prompts, answers and selections. {list} /color <name> sets it for this session."), false),
"default" | "reset" => {
self.theme.accent = None;
("Accent colour: KodAct's own.".into(), false)
}
name => match super::text::ACCENTS.iter().find(|(n, _)| *n == name) {
Some((n, c)) => {
self.theme.accent = Some(*c);
let note = if self.theme.color { "" } else { " (Colour is off, so it shows once colour is on.)" };
(format!("Accent colour: {n}, for this session.{note}"), false)
}
None => (format!("Unknown colour {name:?}. {list}"), true),
},
}
}
fn focus_command(&mut self, arg: &str) -> (String, bool) {
let on = match arg {
"" => !self.focus,
"on" => true,
"off" => false,
_ => return ("Usage: /focus [on|off]".into(), true),
};
self.focus = on;
let text = if on {
"Focus on: tool calls and their results stay out of the scrollback (the spinner still names the tool). /focus again turns it off."
} else {
"Focus off: tool calls show again."
};
(text.into(), false)
}
pub fn submit(&mut self, text: String) -> Vec<Action> {
self.front = false;
self.menu_selected = 0;
self.menu_dismissed = false;
let local = match text.trim() {
"/keybindings" => Some((keys_text(&self.keymap), false)),
"/terminal-setup" => Some((terminal_setup_text(super::keyboard_protocol()), false)),
t if t == "/color" || t.starts_with("/color ") => Some(self.color_command(t["/color".len()..].trim())),
t if t == "/focus" || t.starts_with("/focus ") => Some(self.focus_command(t["/focus".len()..].trim())),
_ => None,
};
if let Some((answer, is_error)) = local {
self.echo_prompt(&text);
self.reply_lines(&answer, is_error);
return vec![];
}
if self.busy {
if kodact_core::commands::immediate(&text, &kodact_core::commands::Catalog::default()) {
self.echo_prompt(&text);
return vec![Action::Send(text)];
}
self.queued.push_back(text);
return vec![];
}
vec![Action::Send(self.start(text))]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::text::plain;
use kodact_types::{ApiMessage, MessageContent, Role, StopReason, Usage};
fn app() -> App {
let mut a = App::new(Theme { color: false, light: false, accent: None, depth: Default::default() }, vec![]);
a.commands = vec![
CommandInfo { name: "clear".into(), args: "[name]".into(), description: "Start a new conversation".into() },
CommandInfo { name: "compact".into(), args: "[instructions]".into(), description: "Summarize".into() },
CommandInfo { name: "model".into(), args: "[model]".into(), description: "Switch models".into() },
CommandInfo { name: "status".into(), args: String::new(), description: "Show status".into() },
];
a
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ctrl(c: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL)
}
fn typed(a: &mut App, s: &str) {
for c in s.chars() {
a.on_key(key(KeyCode::Char(c)));
}
}
fn texts(lines: &[Line]) -> Vec<String> {
lines.iter().map(plain).collect()
}
fn delta(text: &str) -> UiEvent {
UiEvent::Engine(EngineEvent::Stream {
event: StreamEvent::ContentBlockDelta { index: 0, delta: Delta::TextDelta { text: text.into() } },
parent_tool_use_id: None,
})
}
#[test]
fn enter_sends_and_queues_while_busy() {
let mut a = app();
typed(&mut a, "hello");
assert_eq!(a.on_key(key(KeyCode::Enter)), vec![Action::Send("hello".into())]);
assert!(a.busy);
assert_eq!(texts(&a.take_pending()), ["", "│ hello "]);
typed(&mut a, "next");
assert!(a.on_key(key(KeyCode::Enter)).is_empty(), "queued while a turn runs");
assert_eq!(a.queued.len(), 1);
assert_eq!(a.next_queued(), None, "still busy");
a.on_event(UiEvent::Idle);
assert_eq!(a.next_queued().as_deref(), Some("next"));
a.on_event(UiEvent::Idle);
typed(&mut a, "one\\");
assert!(a.on_key(key(KeyCode::Enter)).is_empty());
typed(&mut a, "two");
a.on_key(ctrl('j'));
assert_eq!(a.editor.text(), "one\ntwo\n");
}
#[test]
fn a_failed_checklist_paints_only_its_failed_rows() {
let mut a = App::new(Theme { color: true, light: false, accent: None, depth: Default::default() }, vec![]);
a.on_event(UiEvent::Reply { text: "ok git found\nFAIL shell none".into(), is_error: true });
let lines = a.take_pending();
let (ok, fail) = (&lines[1], &lines[2]);
assert_eq!(ok.spans[1].style, a.theme.success());
assert_ne!(ok.spans[2].style, a.theme.error(), "the ok row's detail is plain");
assert_eq!(fail.spans[1].content, "FAIL");
assert_eq!(fail.spans[1].style, a.theme.error());
a.on_event(UiEvent::Reply { text: "Unknown command: /x".into(), is_error: true });
assert_eq!(a.take_pending().last().unwrap().spans[1].style, a.theme.error());
}
#[test]
fn notices_lead_with_a_mark_in_their_colour() {
let mut a = App::new(Theme { color: true, light: false, accent: None, depth: Default::default() }, vec![]);
a.on_event(UiEvent::Engine(EngineEvent::Notice {
level: NoticeLevel::Warning,
text: "careful\nsecond".into(),
}));
let lines = a.take_pending();
assert_eq!(texts(&lines), ["", " ▲ careful", " second"]);
assert_eq!(lines[1].spans[0].style, a.theme.warning());
assert_eq!(lines[1].spans[1].style, Style::default(), "the words stay readable");
a.on_event(UiEvent::Engine(EngineEvent::Notice { level: NoticeLevel::Error, text: "broke".into() }));
assert_eq!(texts(&a.take_pending()), ["", " ✕ broke"]);
}
#[test]
fn key_bindings_apply_and_show_in_the_key_table() {
let mut a = app();
let (k, w) = crate::tui::keys::Keymap::parse(r#"{"ctrl+s": "submit", "ctrl+r": "none"}"#);
assert!(w.is_empty(), "{w:?}");
a.keymap = k;
let term = |a: &mut App, code, m| a.on_term_event(Event::Key(KeyEvent::new(code, m)));
typed(&mut a, "hello");
assert_eq!(term(&mut a, KeyCode::Char('s'), KeyModifiers::CONTROL), vec![Action::Send("hello".into())]);
a.on_event(UiEvent::Idle);
term(&mut a, KeyCode::Char('r'), KeyModifiers::CONTROL);
assert!(a.search.is_none(), "Ctrl+R is unbound");
let table = keys_text(&a.keymap);
assert!(table.contains("enter, ctrl+s") && table.contains("Unbound: ctrl+r."), "{table}");
assert!(keys_text(&Default::default()).starts_with("Enter "), "the defaults read as before");
}
#[test]
fn color_and_focus_change_this_session_only() {
let mut a = App::new(Theme { color: true, light: false, accent: None, depth: Default::default() }, vec![]);
assert!(a.submit("/color green".into()).is_empty(), "answered by the UI");
assert_eq!(a.theme.accent, Some(ratatui::style::Color::Green));
assert_eq!(a.theme.accent().fg, Some(ratatui::style::Color::Green));
a.on_event(UiEvent::Theme("light".into()));
assert_eq!(a.theme.accent, Some(ratatui::style::Color::Green), "a theme change keeps it");
a.take_pending();
a.submit("/color mauve".into());
assert!(texts(&a.take_pending()).iter().any(|l| l.contains("Unknown colour \"mauve\"")));
a.submit("/color default".into());
assert_eq!(a.theme.accent, None);
a.submit("/focus".into());
assert!(a.focus);
a.take_pending();
a.on_event(UiEvent::Engine(EngineEvent::Assistant {
message: ApiMessage {
id: "m".into(),
kind: "message".into(),
role: Role::Assistant,
model: "m".into(),
content: vec![
ContentBlock::text("Looking."),
ContentBlock::ToolUse {
id: "t".into(),
name: "Bash".into(),
input: json!({"command": "ls"}),
cache_control: None,
provider: None,
},
],
stop_reason: Some(StopReason::ToolUse),
stop_sequence: None,
usage: Usage::default(),
},
uuid: "u".into(),
parent_tool_use_id: None,
}));
a.on_event(UiEvent::Engine(EngineEvent::User {
message: kodact_types::Message::user(vec![ContentBlock::tool_result("t".to_string(), "file.txt", false)]),
uuid: "u2".into(),
tool_use_result: None,
is_meta: false,
parent_tool_use_id: None,
}));
let lines = texts(&a.take_pending());
assert!(lines.iter().any(|l| l.contains("Looking.")), "{lines:?}");
assert!(!lines.iter().any(|l| l.contains("Bash") || l.contains("file.txt")), "{lines:?}");
assert_eq!(a.activity, "Working");
a.submit("/focus off".into());
assert!(!a.focus);
}
#[test]
fn a_screen_arriving_during_a_question_waits_for_its_answer() {
let mut a = app();
let (tx, mut rx) = oneshot::channel();
a.on_event(UiEvent::Ask {
prompt: PermissionPrompt {
tool_name: "Bash".into(),
tool_use_id: "t".into(),
input: json!({"command": "ls"}),
reason: String::new(),
suggestions: vec![],
blocked_path: None,
},
reply: tx,
});
let screen = kodact_core::commands::screens::Screen { title: "Context".into(), rows: vec![] };
a.on_event(UiEvent::Screen(screen));
assert_eq!(a.dialog_text().0, "Approve Bash?", "the question stays");
assert!(rx.try_recv().is_err(), "and isn't answered");
a.on_key(key(KeyCode::Char('1')));
assert!(matches!(rx.try_recv(), Ok(PermissionAnswer::Allow { .. })));
assert_eq!(a.viewer().map(|v| v.0.title.as_str()), Some("Context"), "then the screen shows");
}
#[test]
fn immediate_commands_are_sent_while_busy() {
let mut a = app();
typed(&mut a, "hello");
a.on_key(key(KeyCode::Enter));
a.take_pending();
typed(&mut a, "/usage");
assert_eq!(a.on_key(key(KeyCode::Enter)), vec![Action::Send("/usage".into())], "sent, not queued");
assert!(a.queued.is_empty() && a.busy, "the turn goes on");
assert_eq!(texts(&a.take_pending()), ["", "│ /usage "], "echoed");
typed(&mut a, "/compact");
assert!(a.on_key(key(KeyCode::Enter)).is_empty(), "not immediate: queued");
assert_eq!(a.queued.len(), 1);
}
#[test]
fn the_context_meter_follows_each_request() {
let mut a = app();
a.status.window = 1000;
let ev = |event| UiEvent::Engine(EngineEvent::Stream { event, parent_tool_use_id: None });
let start = ApiMessage {
id: "m".into(),
kind: "message".into(),
role: Role::Assistant,
model: "x".into(),
content: vec![],
stop_reason: None,
stop_sequence: None,
usage: Usage { input_tokens: 100, ..Default::default() },
};
a.on_event(ev(StreamEvent::MessageStart { message: start }));
assert_eq!(a.status.context_pct, Some(10));
let body: kodact_types::MessageDeltaBody = serde_json::from_str("{}").unwrap();
a.on_event(ev(StreamEvent::MessageDelta {
delta: body,
usage: Usage { output_tokens: 50, ..Default::default() },
}));
assert_eq!(a.status.context_pct, Some(15), "output counts too");
let mut b = app();
b.status.window = 1000;
let body: kodact_types::MessageDeltaBody = serde_json::from_str("{}").unwrap();
b.on_event(ev(StreamEvent::MessageDelta {
delta: body,
usage: Usage { input_tokens: 300, output_tokens: 20, ..Default::default() },
}));
assert_eq!(b.status.context_pct, Some(32));
}
#[test]
fn a_streaming_answer_is_set_apart_from_the_prompt_at_once() {
let mut a = app();
a.echo_prompt("hi");
a.take_pending();
a.on_event(UiEvent::Engine(EngineEvent::Stream {
event: StreamEvent::ContentBlockStart { index: 0, content_block: ContentBlock::text("") },
parent_tool_use_id: None,
}));
a.on_event(delta("I will walk"));
assert_eq!(texts(&a.take_pending()), [""], "the blank line goes out with the first words");
a.on_event(delta(" through it.\n"));
assert_eq!(texts(&a.take_pending()), ["● I will walk through it."], "and isn't added twice");
}
#[test]
fn streamed_text_moves_to_scrollback_line_by_line() {
let mut a = app();
a.on_event(UiEvent::Engine(EngineEvent::Stream {
event: StreamEvent::ContentBlockStart { index: 0, content_block: ContentBlock::text("") },
parent_tool_use_id: None,
}));
a.on_event(delta("Here is **the** plan:\n- step"));
assert_eq!(texts(&a.pending), ["", "● Here is the plan:"]);
assert_eq!(a.live, "- step");
a.on_event(delta(" one\n```sh\nls\n"));
a.on_event(UiEvent::Engine(EngineEvent::Stream {
event: StreamEvent::ContentBlockStop { index: 0 },
parent_tool_use_id: None,
}));
assert_eq!(
texts(&a.take_pending())[2..],
[" • step one", " ╭─ sh", " │ ls"],
"lists get bullets; code gets a gutter"
);
let msg = ApiMessage {
id: "m".into(),
kind: "message".into(),
role: Role::Assistant,
model: "x".into(),
content: vec![
ContentBlock::text("Here is the plan"),
ContentBlock::ToolUse {
id: "t1".into(),
name: "Bash".into(),
input: json!({"command": "cargo test"}),
cache_control: None,
provider: None,
},
],
stop_reason: Some(StopReason::ToolUse),
stop_sequence: None,
usage: Usage::default(),
};
a.on_event(UiEvent::Engine(EngineEvent::Assistant {
message: msg,
uuid: "u".into(),
parent_tool_use_id: None,
}));
assert!(texts(&a.take_pending()).is_empty());
assert_eq!(a.running.len(), 1);
assert_eq!(texts(&[Line::from(a.running[0].header.clone())]), ["Running cargo test"]);
a.on_event(UiEvent::Engine(EngineEvent::User {
message: kodact_types::Message::user(vec![ContentBlock::tool_result("t1", "ok\n2 passed", false)]),
uuid: "u2".into(),
tool_use_result: None,
is_meta: false,
parent_tool_use_id: None,
}));
assert_eq!(
texts(&a.take_pending()),
["", "● Ran cargo test", " └ ok", " 2 passed"],
"the header goes out with its result; a command shows its first lines"
);
assert!(a.running.is_empty());
let _ = MessageContent::Text(String::new());
}
#[test]
fn each_style_writes_the_conversation_its_own_way() {
use crate::tui::look::Look;
let prompt_for = |look: Look| {
let mut a = app();
a.look = look;
a.echo_prompt("fix the cart");
texts(&a.take_pending())
};
assert_eq!(prompt_for(Look::Frame).last().unwrap(), "│ fix the cart ", "the panel's echo");
assert_eq!(prompt_for(Look::Bare).last().unwrap(), "› fix the cart");
let card = prompt_for(Look::Card);
assert!(card[card.len() - 2].starts_with(" you · "), "{card:?}");
let ledger = prompt_for(Look::Ledger);
assert!(ledger.last().unwrap().ends_with("you fix the cart"), "{ledger:?}");
assert_eq!(prompt_for(Look::Glow).last().unwrap(), " fix the cart ");
let step_for = |look: Look| {
let mut a = app();
a.look = look;
let r = Running {
id: "t".into(),
header: vec![],
name: "Bash".into(),
input: json!({"command": "cargo test"}),
since: Instant::now(),
};
let dot = a.theme.success();
a.commit_tool(r, dot, false);
texts(&a.take_pending()).last().unwrap().clone()
};
assert_eq!(step_for(Look::Bare), " ran cargo test");
assert_eq!(step_for(Look::Ledger), " ran cargo test");
assert_eq!(step_for(Look::Glow), "✓ Ran cargo test");
assert_eq!(step_for(Look::Frame), "● Ran cargo test");
let mut a = app();
a.on_event(UiEvent::Look("ledger".into()));
assert_eq!(a.look, Look::Ledger);
a.on_event(UiEvent::Look("neon".into()));
assert_eq!(a.look, Look::Frame, "an unknown name falls back to the first style");
}
#[test]
fn results_say_what_happened() {
let t = Theme { color: false, light: false, accent: None, depth: Default::default() };
let read =
json!({"type": "text", "file": {"filePath": "/p/a.rs", "numLines": 40, "startLine": 1, "totalLines": 120}});
assert_eq!(texts(&result_lines(&t, "Read", " 1\tfn main", false, Some(&read))), [" └ 40 of 120 lines"]);
let edit = json!({"filePath": "/p/src/calc.py", "structuredPatch": [{
"oldStart": 1, "oldLines": 3, "newStart": 1, "newLines": 3,
"lines": [" def add(a, b):", "- return a - b", "+ return a + b", ""]
}]});
assert_eq!(
texts(&result_lines(&t, "Edit", "The file was updated", false, Some(&edit))),
[" └ 1 addition, 1 removal", " 2 - return a - b", " 2 + return a + b"]
);
let write = json!({"type": "create", "filePath": "/p/new.txt", "content": "a\nb\n", "structuredPatch": []});
assert_eq!(texts(&result_lines(&t, "Write", "x", false, Some(&write))), [" └ 2 lines, a new file"]);
assert_eq!(texts(&result_lines(&t, "Bash", "", false, None)), [" └ (no output)"]);
let long = (1..=6).map(|i| format!("line {i}")).collect::<Vec<_>>().join("\n");
assert_eq!(
texts(&result_lines(&t, "Bash", &long, false, None)),
[" └ line 1", " line 2", " line 3", " … +3 lines"]
);
assert_eq!(texts(&result_lines(&t, "Bash", "boom\nmore", true, None)), [" └ boom (+1 lines)"]);
}
#[test]
fn edit_permission_shows_the_change() {
let mut a = app();
let (tx, rx) = oneshot::channel();
std::mem::forget(rx);
a.on_event(UiEvent::Ask {
prompt: PermissionPrompt {
tool_name: "Edit".into(),
tool_use_id: "t".into(),
input: json!({"file_path": "src/calc.py", "old_string": "return a - b", "new_string": "return a + b"}),
reason: "this tool requires permission".into(),
suggestions: vec![],
blocked_path: None,
},
reply: tx,
});
let (title, body) = a.dialog_text();
assert_eq!(title, "Approve Edit?");
assert_eq!(body, [" src/calc.py", " - return a - b", " + return a + b"], "no generic reason line");
assert!(a.waiting_for_answer());
}
#[test]
fn slash_menu_filters_completes_and_runs() {
let mut a = app();
typed(&mut a, "/c");
let names: Vec<&str> = a.menu().iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, ["clear", "compact"]);
a.on_key(key(KeyCode::Down));
a.on_key(key(KeyCode::Tab));
assert_eq!(a.editor.text(), "/compact ");
assert!(a.menu().is_empty(), "the menu closes after the name");
a.editor.clear();
typed(&mut a, "/stat");
assert_eq!(a.on_key(key(KeyCode::Enter)), vec![Action::Send("/status".into())]);
a.on_event(UiEvent::Idle);
typed(&mut a, "/mo");
assert!(a.on_key(key(KeyCode::Enter)).is_empty(), "a command with arguments waits for them");
assert_eq!(a.editor.text(), "/model ");
a.editor.clear();
typed(&mut a, "/zz");
assert!(a.menu().is_empty());
typed(&mut a, "\u{8}");
a.editor.clear();
typed(&mut a, "/cl");
a.on_key(key(KeyCode::Esc));
assert!(a.menu().is_empty(), "Esc hides the menu");
}
#[test]
fn esc_ctrl_c_and_shift_tab() {
let mut a = app();
a.status.mode = "default".into();
assert_eq!(
a.on_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)),
vec![Action::SetMode(PermissionMode::AcceptEdits)]
);
assert_eq!(a.status.mode, "acceptEdits");
typed(&mut a, "x");
a.on_key(ctrl('c'));
assert!(a.editor.is_empty(), "Ctrl-C clears first");
assert!(a.on_key(ctrl('c')).is_empty());
assert_eq!(a.on_key(ctrl('c')), vec![Action::Exit], "twice exits");
a.busy = true;
assert_eq!(a.on_key(key(KeyCode::Esc)), vec![Action::Interrupt]);
a.busy = false;
a.on_key(key(KeyCode::Esc));
assert_eq!(a.on_key(key(KeyCode::Esc)), vec![Action::Send("/rewind".into())]);
assert_eq!(a.on_key(ctrl('d')), vec![Action::Interrupt, Action::Exit]);
}
fn prompt(tool: &str, input: Value) -> PermissionPrompt {
PermissionPrompt {
tool_name: tool.into(),
tool_use_id: "t".into(),
input,
reason: String::new(),
suggestions: vec![],
blocked_path: None,
}
}
#[test]
fn dialogs_answer_permissions_questions_and_plans() {
let mut a = app();
let (tx, mut rx) = oneshot::channel();
let mut p = prompt("Bash", json!({"command": "npm test"}));
p.suggestions = vec![Suggestion::AddRules {
rules: vec![serde_json::from_value(json!({"toolName": "Bash", "ruleContent": "npm test"})).unwrap()],
behavior: kodact_permissions::Behavior::Allow,
destination: "localSettings".into(),
}];
a.on_event(UiEvent::Ask { prompt: p, reply: tx });
assert_eq!(a.dialog_text().0, "Approve Bash?");
assert_eq!(a.dialog_options()[1].0, "Always allow Bash(npm test)");
a.on_key(key(KeyCode::Char('2')));
match rx.try_recv().unwrap() {
PermissionAnswer::Allow { updated_permissions, .. } => assert_eq!(updated_permissions.len(), 1),
other => panic!("{other:?}"),
}
assert!(a.dialog.is_none());
let (tx, mut rx) = oneshot::channel();
a.on_event(UiEvent::Ask { prompt: prompt("Write", json!({"file_path": "a.txt"})), reply: tx });
a.on_key(key(KeyCode::Esc));
assert!(matches!(rx.try_recv().unwrap(), PermissionAnswer::Deny { interrupt: true, .. }));
let (tx, mut rx) = oneshot::channel();
let input = json!({"issue": "ACME-103", "body": "Fixed.\nTests pass."});
a.on_event(UiEvent::Ask { prompt: prompt("mcp__jira__add_comment", input.clone()), reply: tx });
let (title, body) = a.dialog_text();
assert_eq!(title, "Approve jira.add_comment?");
assert!(body.iter().any(|l| l.trim_start().starts_with("issue") && l.ends_with("ACME-103")), "{body:?}");
assert!(body.iter().any(|l| l.trim() == "Tests pass."), "every line of every argument: {body:?}");
a.on_key(key(KeyCode::Char('n')));
assert!(matches!(rx.try_recv().unwrap(), PermissionAnswer::Deny { .. }));
let (tx, mut rx) = oneshot::channel();
a.on_event(UiEvent::Ask { prompt: prompt("mcp__jira__add_comment", input.clone()), reply: tx });
a.on_key(key(KeyCode::Char('y')));
assert!(matches!(rx.try_recv().unwrap(), PermissionAnswer::Allow { .. }));
let header = |done| texts(&[Line::from(tool_header(&a.theme, "mcp__jira__add_comment", &input, done))]);
assert!(header(true)[0].starts_with("Called jira.add_comment "), "{:?}", header(true));
assert!(header(false)[0].starts_with("Calling jira.add_comment "));
let (tx, mut rx) = oneshot::channel();
let q = json!({"questions": [
{"question": "Which db?", "options": [{"label": "sqlite"}, {"label": "postgres"}]},
{"question": "Name?", "options": [{"label": "app"}]}
]});
a.on_event(UiEvent::Ask { prompt: prompt("AskUserQuestion", q), reply: tx });
a.on_key(key(KeyCode::Down));
a.on_key(key(KeyCode::Enter));
assert_eq!(a.dialog_text().0, "Name? (2/2)");
a.on_key(key(KeyCode::Char('2')));
typed(&mut a, "kodact");
a.on_key(key(KeyCode::Enter));
match rx.try_recv().unwrap() {
PermissionAnswer::Allow { updated_input: Some(v), .. } => {
assert_eq!(v["answers"], json!({"Which db?": "postgres", "Name?": "kodact"}))
}
other => panic!("{other:?}"),
}
let (tx, mut rx) = oneshot::channel();
a.take_pending();
a.on_event(UiEvent::Ask { prompt: prompt("ExitPlanMode", json!({"plan": "1. Edit a\n2. Test"})), reply: tx });
assert_eq!(texts(&a.take_pending())[1..], ["Here is the plan:", " 1. Edit a", " 2. Test"]);
a.on_key(key(KeyCode::Enter));
match rx.try_recv().unwrap() {
PermissionAnswer::Allow { updated_permissions, .. } => {
assert_eq!(updated_permissions[0]["mode"], "acceptEdits")
}
other => panic!("{other:?}"),
}
}
fn picker() -> Picker {
use kodact_core::commands::picker::Choice;
let c = |label: &str, pick: Pick, current: bool| Choice {
label: label.into(),
detail: String::new(),
pick,
current,
};
Picker {
title: "Pick".into(),
choices: vec![
c("Opus", Pick::Run("/model opus".into()), false),
c("Haiku", Pick::Run("/model haiku".into()), true),
c("Step", Pick::Step("/rewind 1".into()), false),
c("Back", Pick::RunThenEdit { command: "/rewind 1 both".into(), edit: "old prompt".into() }, false),
c("Add", Pick::Edit("/permissions add allow ".into()), false),
],
}
}
#[test]
fn pickers_filter_and_run_command_text() {
let mut a = app();
a.on_event(UiEvent::Picker(picker()));
assert_eq!(a.dialog_selected(), 1, "the current choice is selected");
assert_eq!(a.dialog_options()[1].0, "Haiku ✔");
typed(&mut a, "op");
assert_eq!(a.dialog_options().len(), 1);
assert_eq!(a.dialog_text().1, ["Filter: op"]);
assert_eq!(a.on_key(key(KeyCode::Enter)), vec![Action::Send("/model opus".into())]);
assert!(a.dialog.is_none());
a.on_event(UiEvent::Idle);
a.on_event(UiEvent::Picker(picker()));
typed(&mut a, "step");
assert_eq!(a.on_key(key(KeyCode::Enter)), vec![Action::Picker("/rewind 1".into())]);
a.on_event(UiEvent::Picker(picker()));
typed(&mut a, "back");
assert_eq!(a.on_key(key(KeyCode::Enter)), vec![Action::Send("/rewind 1 both".into())]);
assert_eq!(a.editor.text(), "old prompt", "the rewound prompt is back in the input box");
a.on_event(UiEvent::Idle);
a.editor.clear();
a.on_event(UiEvent::Picker(picker()));
typed(&mut a, "add");
assert!(a.on_key(key(KeyCode::Enter)).is_empty());
assert_eq!(a.editor.text(), "/permissions add allow ");
a.on_event(UiEvent::Picker(picker()));
a.on_key(key(KeyCode::Esc));
assert!(a.dialog.is_none(), "Esc closes a picker");
}
#[test]
fn ctrl_r_searches_history() {
let mut a = App::new(
Theme { color: false, light: false, accent: None, depth: Default::default() },
vec!["cargo test".into(), "git status".into(), "cargo build".into()],
);
typed(&mut a, "draft");
a.on_key(ctrl('r'));
typed(&mut a, "cargo");
assert_eq!(a.editor.text(), "cargo build", "the newest match first");
a.on_key(ctrl('r'));
assert_eq!(a.editor.text(), "cargo test", "Ctrl+R again: older");
a.on_key(ctrl('r'));
assert_eq!(a.editor.text(), "cargo test", "no older match: it stays");
a.on_key(key(KeyCode::Esc));
assert_eq!(a.editor.text(), "draft", "Esc goes back to what was typed");
assert!(a.search.is_none());
a.on_key(ctrl('r'));
typed(&mut a, "stat");
assert!(a.on_key(key(KeyCode::Enter)).is_empty(), "Enter takes the match, it doesn't send");
assert_eq!(a.editor.text(), "git status");
assert!(a.search.is_none());
a.editor.clear();
a.on_key(ctrl('r'));
typed(&mut a, "build");
a.on_key(key(KeyCode::End));
typed(&mut a, " --release");
assert_eq!(a.editor.text(), "cargo build --release");
}
#[test]
fn at_completes_paths() {
let mut a = app();
a.on_event(UiEvent::Files(vec![
"src/".into(),
"src/main.rs".into(),
"docs/main-notes.md".into(),
"Cargo.toml".into(),
"my notes.txt".into(),
]));
typed(&mut a, "explain @mai");
assert_eq!(a.file_menu(), ["src/main.rs", "docs/main-notes.md"], "name matches, shorter first");
a.on_key(key(KeyCode::Down));
assert!(a.on_key(key(KeyCode::Enter)).is_empty(), "Enter completes instead of sending");
assert_eq!(a.editor.text(), "explain @docs/main-notes.md ");
assert!(a.file_menu().is_empty());
typed(&mut a, "and @CARGO");
a.on_key(key(KeyCode::Tab));
assert_eq!(a.editor.text(), "explain @docs/main-notes.md and @Cargo.toml ");
typed(&mut a, "@src");
a.on_key(key(KeyCode::Tab));
assert!(a.editor.text().ends_with("@src/"), "a directory keeps the cursor in its path: {}", a.editor.text());
assert_eq!(a.file_menu(), ["src/main.rs"], "and its files are offered next");
a.on_key(key(KeyCode::Esc));
assert!(a.file_menu().is_empty(), "Esc hides it");
assert_eq!(
a.on_key(key(KeyCode::Enter)),
vec![Action::Send("explain @docs/main-notes.md and @Cargo.toml @src/".into())]
);
a.busy = false;
typed(&mut a, "@my");
a.on_key(key(KeyCode::Tab));
assert_eq!(a.editor.text(), "@\"my notes.txt\" ", "spaces are quoted");
}
#[test]
fn attached_files_show_under_the_prompt() {
let mut a = app();
let reminder = kodact_agents::attach::reminder(&[kodact_agents::attach::Attachment {
path: "/w/src/a.rs".into(),
kind: kodact_agents::attach::Kind::File,
text: "fn a() {}".into(),
}])
.unwrap();
a.on_event(UiEvent::Engine(EngineEvent::PromptAccepted {
message: kodact_types::Message::user(vec![
ContentBlock::text("explain @src/a.rs"),
ContentBlock::text(reminder),
]),
uuid: "u".into(),
}));
assert_eq!(texts(&a.pending), [" (attached: a.rs)"]);
}
#[test]
fn question_mark_themes_and_clipboard() {
let mut a = App::new(Theme { color: true, light: false, accent: None, depth: Default::default() }, vec![]);
a.on_key(key(KeyCode::Char('?')));
let shown = texts(&a.take_pending()).join("\n");
assert!(shown.contains("Shift+Tab") && shown.contains("Ctrl+R"), "{shown}");
assert!(a.editor.is_empty(), "? on an empty prompt isn't typed");
typed(&mut a, "why?");
assert_eq!(a.editor.text(), "why?");
a.on_event(UiEvent::Theme("light".into()));
assert_eq!(a.theme, Theme { color: true, light: true, accent: None, depth: Default::default() });
a.on_event(UiEvent::Theme("none".into()));
assert!(!a.theme.color);
a.on_event(UiEvent::Theme("dark".into()));
assert_eq!(a.theme, Theme { color: true, light: false, accent: None, depth: Default::default() });
let mut b = App::new(Theme { color: false, light: false, accent: None, depth: Default::default() }, vec![]);
b.on_event(UiEvent::Theme("light".into()));
assert!(!b.theme.color);
a.on_event(UiEvent::Copy("text".into()));
assert_eq!(a.clipboard.as_deref(), Some("text"));
a.on_event(UiEvent::StatusLine(Some("custom".into())));
assert_eq!(a.status_text.as_deref(), Some("custom"));
}
#[test]
fn ui_commands_answer_at_once_while_busy() {
let mut a = app();
typed(&mut a, "long task");
a.on_key(key(KeyCode::Enter));
a.take_pending();
typed(&mut a, "/keybindings");
assert!(a.on_key(key(KeyCode::Enter)).is_empty());
assert!(a.queued.is_empty(), "not queued behind the turn");
assert!(texts(&a.take_pending()).iter().any(|l| l.contains("Shift+Tab")));
assert!(terminal_setup_text(false).contains("Alt+Enter") && terminal_setup_text(true).contains("works"));
}
}