use std::collections::VecDeque;
use crate::mcp::{ElicitationAction, ElicitationResponse};
use crate::permissions::ApprovalOutcome;
use super::bridge::{
PendingApprovalRequest, PendingChildApproval, PendingElicitation, PendingOAuthDisplay,
};
use super::history::PromptHistory;
use super::key::{Key, KeyEvent};
use super::keymap::{Keymap, KeymapAction};
use super::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
Tool,
System,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptEntry {
pub role: Role,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VimMode {
#[default]
Insert,
Normal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputFocus {
#[default]
Composer,
HistorySearch,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HistorySearchState {
pub query: String,
pub selected: usize,
}
#[derive(Debug)]
pub enum Modal {
Approval(PendingApprovalRequest),
ChildApproval(PendingChildApproval),
Elicitation {
request: PendingElicitation,
answer: String,
},
OAuthDeviceCode(PendingOAuthDisplay),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatusLine {
pub model_label: String,
pub turn_active: bool,
pub notice: Option<String>,
}
#[derive(Debug)]
pub enum Action {
InsertChar(char),
Backspace,
DeleteForward,
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
Newline,
ClearComposerLine,
Submit(String),
ScrollUp,
ScrollDown,
ToggleTheme,
OpenHistorySearch,
HistorySearchType(char),
HistorySearchBackspace,
HistorySearchNext,
HistorySearchPrev,
HistorySearchConfirm,
HistorySearchCancel,
VimSetMode(VimMode),
VimMoveLeft,
VimMoveRight,
VimMoveHome,
VimMoveEnd,
VimDeleteChar,
VimDeleteLine,
RequestExternalEditor,
ExternalEditorResult(String),
ExternalEditorFailed(String),
PasteImage(String),
ShowApprovalModal(PendingApprovalRequest),
ResolveApproval(ApprovalOutcome),
ShowChildApprovalModal(PendingChildApproval),
ResolveChildApproval(ApprovalOutcome),
ShowElicitationModal(PendingElicitation),
ElicitationType(char),
ElicitationBackspace,
ResolveElicitationAccept,
ResolveElicitationDecline,
ResolveElicitationCancel,
ShowOAuthModal(PendingOAuthDisplay),
DismissOAuthModal,
AppendStreamingDelta(String),
FinalizeStreaming,
PushTranscript(TranscriptEntry),
SetModelLabel(String),
SetTurnActive(bool),
ArmQuit,
Quit,
Noop,
}
#[derive(Debug)]
pub struct TuiState {
pub input: String,
pub cursor: usize,
pub transcript: Vec<TranscriptEntry>,
pub streaming: Option<String>,
pub modal: Option<Modal>,
modal_queue: VecDeque<Modal>,
pub theme: Theme,
pub keymap: Keymap,
pub vim_enabled: bool,
pub vim_mode: VimMode,
pub history: PromptHistory,
pub history_search: Option<HistorySearchState>,
pub input_focus: InputFocus,
pub status: StatusLine,
pub scroll: usize,
pub should_quit: bool,
quit_armed: bool,
pub external_editor_requested: bool,
pub pending_images: Vec<String>,
pub last_submission: Option<String>,
}
impl TuiState {
pub fn new(theme: Theme, keymap: Keymap, vim_enabled: bool, history: PromptHistory) -> Self {
TuiState {
input: String::new(),
cursor: 0,
transcript: Vec::new(),
streaming: None,
modal: None,
modal_queue: VecDeque::new(),
theme,
keymap,
vim_enabled,
vim_mode: if vim_enabled {
VimMode::Normal
} else {
VimMode::Insert
},
history,
history_search: None,
input_focus: InputFocus::Composer,
status: StatusLine::default(),
scroll: 0,
should_quit: false,
quit_armed: false,
external_editor_requested: false,
pending_images: Vec::new(),
last_submission: None,
}
}
pub fn new_default() -> Self {
TuiState::new(
Theme::default(),
Keymap::default(),
false,
PromptHistory::new(),
)
}
pub fn handle_key(&self, key: KeyEvent) -> Vec<Action> {
if let Some(modal) = &self.modal {
return self.handle_key_in_modal(modal, key);
}
match self.input_focus {
InputFocus::HistorySearch => self.handle_key_in_history_search(key),
InputFocus::Composer => self.handle_key_in_composer(key),
}
}
fn handle_key_in_composer(&self, key: KeyEvent) -> Vec<Action> {
let km = &self.keymap;
if key == km.key_for(KeymapAction::Quit) {
return if self.input.is_empty() {
if self.quit_armed {
vec![Action::Quit]
} else {
vec![Action::ArmQuit]
}
} else {
vec![Action::MoveHome, Action::ClearComposerLine]
};
}
if key == km.key_for(KeymapAction::HistorySearch) {
return vec![Action::OpenHistorySearch];
}
if key == km.key_for(KeymapAction::ToggleTheme) {
return vec![Action::ToggleTheme];
}
if key == km.key_for(KeymapAction::ScrollUp) {
return vec![Action::ScrollUp];
}
if key == km.key_for(KeymapAction::ScrollDown) {
return vec![Action::ScrollDown];
}
if key == km.key_for(KeymapAction::ExternalEditor) {
return vec![Action::RequestExternalEditor];
}
if key == km.key_for(KeymapAction::Newline) {
return vec![Action::Newline];
}
if self.vim_enabled && self.vim_mode == VimMode::Normal {
return self.handle_key_vim_normal(key);
}
if key == km.key_for(KeymapAction::Submit) {
if self.input.is_empty() {
return vec![];
}
return vec![Action::Submit(self.input.clone())];
}
match key.key {
Key::Char(c) if !key.ctrl && !key.alt => vec![Action::InsertChar(c)],
Key::Backspace => vec![Action::Backspace],
Key::Delete => vec![Action::DeleteForward],
Key::Left => vec![Action::MoveLeft],
Key::Right => vec![Action::MoveRight],
Key::Home => vec![Action::MoveHome],
Key::End => vec![Action::MoveEnd],
Key::Escape if self.vim_enabled => vec![Action::VimSetMode(VimMode::Normal)],
_ => vec![],
}
}
fn handle_key_vim_normal(&self, key: KeyEvent) -> Vec<Action> {
if key.ctrl || key.alt {
return vec![];
}
match key.key {
Key::Char('i') => vec![Action::VimSetMode(VimMode::Insert)],
Key::Char('a') => vec![Action::VimMoveRight, Action::VimSetMode(VimMode::Insert)],
Key::Char('o') => vec![
Action::VimMoveEnd,
Action::Newline,
Action::VimSetMode(VimMode::Insert),
],
Key::Char('h') => vec![Action::VimMoveLeft],
Key::Char('l') => vec![Action::VimMoveRight],
Key::Char('0') => vec![Action::VimMoveHome],
Key::Char('$') => vec![Action::VimMoveEnd],
Key::Char('x') => vec![Action::VimDeleteChar],
Key::Char('d') => vec![Action::VimDeleteLine],
Key::Enter if self.input.is_empty() => vec![],
Key::Enter => vec![Action::Submit(self.input.clone())],
_ => vec![],
}
}
fn handle_key_in_history_search(&self, key: KeyEvent) -> Vec<Action> {
match key.key {
Key::Escape => vec![Action::HistorySearchCancel],
Key::Enter => vec![Action::HistorySearchConfirm],
Key::Up => vec![Action::HistorySearchPrev],
Key::Down => vec![Action::HistorySearchNext],
_ if key == self.keymap.key_for(KeymapAction::HistorySearch) => {
vec![Action::HistorySearchNext]
}
Key::Backspace => vec![Action::HistorySearchBackspace],
Key::Char(c) if !key.ctrl && !key.alt => vec![Action::HistorySearchType(c)],
_ => vec![],
}
}
fn handle_key_in_modal(&self, modal: &Modal, key: KeyEvent) -> Vec<Action> {
match modal {
Modal::Approval(_) if !key.ctrl && !key.alt => match key.key {
Key::Char('y') | Key::Char('a') => {
vec![Action::ResolveApproval(ApprovalOutcome::Allow)]
}
Key::Char('s') => vec![Action::ResolveApproval(ApprovalOutcome::AllowForSession)],
Key::Char('n') | Key::Char('d') | Key::Escape => {
vec![Action::ResolveApproval(ApprovalOutcome::Deny)]
}
_ => vec![],
},
Modal::Approval(_) => vec![],
Modal::ChildApproval(_) if !key.ctrl && !key.alt => match key.key {
Key::Char('y') | Key::Char('a') => {
vec![Action::ResolveChildApproval(ApprovalOutcome::Allow)]
}
Key::Char('s') => vec![Action::ResolveChildApproval(
ApprovalOutcome::AllowForSession,
)],
Key::Char('n') | Key::Char('d') | Key::Escape => {
vec![Action::ResolveChildApproval(ApprovalOutcome::Deny)]
}
_ => vec![],
},
Modal::ChildApproval(_) => vec![],
Modal::Elicitation { .. } => match key.key {
Key::Enter => vec![Action::ResolveElicitationAccept],
Key::Escape => vec![Action::ResolveElicitationCancel],
Key::F(2) => vec![Action::ResolveElicitationDecline],
Key::Backspace => vec![Action::ElicitationBackspace],
Key::Char(c) if !key.ctrl && !key.alt => vec![Action::ElicitationType(c)],
_ => vec![],
},
Modal::OAuthDeviceCode(_) => match key.key {
Key::Enter | Key::Escape => vec![Action::DismissOAuthModal],
_ => vec![],
},
}
}
pub fn apply(&mut self, action: Action) {
if !matches!(action, Action::ArmQuit | Action::Quit) {
if self.quit_armed {
self.status.notice = None;
}
self.quit_armed = false;
}
match action {
Action::InsertChar(c) => {
self.input.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
Action::Backspace => {
if self.cursor > 0 {
let mut idx = self.cursor - 1;
while !self.input.is_char_boundary(idx) {
idx -= 1;
}
self.input.remove(idx);
self.cursor = idx;
}
}
Action::DeleteForward => {
if self.cursor < self.input.len() {
self.input.remove(self.cursor);
}
}
Action::MoveLeft => {
if self.cursor > 0 {
let mut idx = self.cursor - 1;
while !self.input.is_char_boundary(idx) {
idx -= 1;
}
self.cursor = idx;
}
}
Action::MoveRight => {
if self.cursor < self.input.len() {
let mut idx = self.cursor + 1;
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
idx += 1;
}
self.cursor = idx;
}
}
Action::MoveHome => self.cursor = 0,
Action::MoveEnd => self.cursor = self.input.len(),
Action::Newline => {
self.input.insert(self.cursor, '\n');
self.cursor += 1;
}
Action::ClearComposerLine => {
self.input.clear();
self.cursor = 0;
}
Action::Submit(text) => {
self.history.push(text.clone());
self.transcript.push(TranscriptEntry {
role: Role::User,
text: text.clone(),
});
self.input.clear();
self.cursor = 0;
self.last_submission = Some(text);
}
Action::ScrollUp => self.scroll = self.scroll.saturating_add(1),
Action::ScrollDown => self.scroll = self.scroll.saturating_sub(1),
Action::ToggleTheme => self.theme = self.theme.toggled(),
Action::OpenHistorySearch => {
self.input_focus = InputFocus::HistorySearch;
self.history_search = Some(HistorySearchState::default());
}
Action::HistorySearchType(c) => {
if let Some(s) = &mut self.history_search {
s.query.push(c);
s.selected = 0;
}
}
Action::HistorySearchBackspace => {
if let Some(s) = &mut self.history_search {
s.query.pop();
s.selected = 0;
}
}
Action::HistorySearchNext => {
if let Some(s) = &mut self.history_search {
let n = self.history.search(&s.query).len();
if n > 0 {
s.selected = (s.selected + 1) % n;
}
}
}
Action::HistorySearchPrev => {
if let Some(s) = &mut self.history_search {
let n = self.history.search(&s.query).len();
if n > 0 {
s.selected = (s.selected + n - 1) % n;
}
}
}
Action::HistorySearchConfirm => {
if let Some(s) = self.history_search.take() {
if let Some(&hit) = self.history.search(&s.query).get(s.selected) {
self.input = hit.to_string();
self.cursor = self.input.len();
}
}
self.input_focus = InputFocus::Composer;
}
Action::HistorySearchCancel => {
self.history_search = None;
self.input_focus = InputFocus::Composer;
}
Action::VimSetMode(mode) => self.vim_mode = mode,
Action::VimMoveLeft => self.apply(Action::MoveLeft),
Action::VimMoveRight => self.apply(Action::MoveRight),
Action::VimMoveHome => self.apply(Action::MoveHome),
Action::VimMoveEnd => self.apply(Action::MoveEnd),
Action::VimDeleteChar => self.apply(Action::DeleteForward),
Action::VimDeleteLine => self.apply(Action::ClearComposerLine),
Action::RequestExternalEditor => self.external_editor_requested = true,
Action::ExternalEditorResult(text) => {
self.external_editor_requested = false;
self.input = text;
self.cursor = self.input.len();
}
Action::ExternalEditorFailed(message) => {
self.external_editor_requested = false;
self.transcript.push(TranscriptEntry {
role: Role::System,
text: format!("$EDITOR failed: {message}"),
});
}
Action::PasteImage(reference) => {
self.pending_images.push(reference.clone());
let token = format!("[image: {reference}]");
self.input.insert_str(self.cursor, &token);
self.cursor += token.len();
}
Action::ShowApprovalModal(req) => self.enqueue_or_show(Modal::Approval(req)),
Action::ResolveApproval(outcome) => {
if let Some(Modal::Approval(req)) =
self.take_modal_if(|m| matches!(m, Modal::Approval(_)))
{
let note = approval_note(&req.tool, req.subject.as_deref(), outcome);
let _ = req.reply_tx.send(outcome);
self.transcript.push(TranscriptEntry {
role: Role::System,
text: note,
});
}
self.dequeue_modal();
}
Action::ShowChildApprovalModal(req) => self.enqueue_or_show(Modal::ChildApproval(req)),
Action::ResolveChildApproval(outcome) => {
if let Some(Modal::ChildApproval(req)) =
self.take_modal_if(|m| matches!(m, Modal::ChildApproval(_)))
{
let note = format!(
"child `{}` {}",
req.child_agent_id,
approval_note(&req.tool, req.subject.as_deref(), outcome)
);
let _ = req.reply_tx.send(outcome);
self.transcript.push(TranscriptEntry {
role: Role::System,
text: note,
});
}
self.dequeue_modal();
}
Action::ShowElicitationModal(req) => self.enqueue_or_show(Modal::Elicitation {
request: req,
answer: String::new(),
}),
Action::ElicitationType(c) => {
if let Some(Modal::Elicitation { answer, .. }) = &mut self.modal {
answer.push(c);
}
}
Action::ElicitationBackspace => {
if let Some(Modal::Elicitation { answer, .. }) = &mut self.modal {
answer.pop();
}
}
Action::ResolveElicitationAccept => {
if let Some(Modal::Elicitation { request, answer }) =
self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
{
let content = elicitation_content(&request.requested_schema, &answer);
self.transcript.push(TranscriptEntry {
role: Role::System,
text: format!("elicitation answered: {}", mask_elicitation_answer(&answer)),
});
let _ = request.reply_tx.send(ElicitationResponse {
action: ElicitationAction::Accept,
content: Some(content),
});
}
self.dequeue_modal();
}
Action::ResolveElicitationDecline => {
if let Some(Modal::Elicitation { request, .. }) =
self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
{
self.transcript.push(TranscriptEntry {
role: Role::System,
text: "elicitation declined".to_string(),
});
let _ = request.reply_tx.send(ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
});
}
self.dequeue_modal();
}
Action::ResolveElicitationCancel => {
if let Some(Modal::Elicitation { request, .. }) =
self.take_modal_if(|m| matches!(m, Modal::Elicitation { .. }))
{
let _ = request.reply_tx.send(ElicitationResponse {
action: ElicitationAction::Cancel,
content: None,
});
}
self.dequeue_modal();
}
Action::ShowOAuthModal(display) => {
self.enqueue_or_show(Modal::OAuthDeviceCode(display))
}
Action::DismissOAuthModal => {
self.take_modal_if(|m| matches!(m, Modal::OAuthDeviceCode(_)));
self.dequeue_modal();
}
Action::AppendStreamingDelta(delta) => {
self.streaming
.get_or_insert_with(String::new)
.push_str(&delta);
}
Action::FinalizeStreaming => {
if let Some(text) = self.streaming.take() {
self.transcript.push(TranscriptEntry {
role: Role::Assistant,
text,
});
}
}
Action::PushTranscript(entry) => self.transcript.push(entry),
Action::SetModelLabel(label) => self.status.model_label = label,
Action::SetTurnActive(active) => self.status.turn_active = active,
Action::ArmQuit => {
self.quit_armed = true;
self.status.notice = Some("press Ctrl+C again to exit".to_string());
}
Action::Quit => self.should_quit = true,
Action::Noop => {}
}
}
pub fn on_key(&mut self, key: KeyEvent) {
for action in self.handle_key(key) {
self.apply(action);
}
}
pub fn take_submission(&mut self) -> Option<String> {
self.last_submission.take()
}
pub fn take_pending_images(&mut self) -> Vec<String> {
std::mem::take(&mut self.pending_images)
}
fn enqueue_or_show(&mut self, modal: Modal) {
if self.modal.is_none() {
self.modal = Some(modal);
} else {
self.modal_queue.push_back(modal);
}
}
fn dequeue_modal(&mut self) {
if self.modal.is_none() {
self.modal = self.modal_queue.pop_front();
}
}
fn take_modal_if(&mut self, pred: impl FnOnce(&Modal) -> bool) -> Option<Modal> {
if self.modal.as_ref().is_some_and(pred) {
self.modal.take()
} else {
None
}
}
pub fn fail_close_pending_modals(&mut self) {
self.modal = None;
self.modal_queue.clear();
}
}
fn approval_note(tool: &str, subject: Option<&str>, outcome: ApprovalOutcome) -> String {
let verdict = match outcome {
ApprovalOutcome::Deny => "denied",
ApprovalOutcome::Allow => "allowed (once)",
ApprovalOutcome::AllowForSession => "allowed (for session)",
};
match subject {
Some(s) => format!("approval: {tool} `{s}` — {verdict}"),
None => format!("approval: {tool} — {verdict}"),
}
}
fn elicitation_content(schema: &serde_json::Value, answer: &str) -> serde_json::Value {
if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
if props.len() == 1 {
if let Some(name) = props.keys().next() {
return serde_json::json!({ name: answer });
}
}
}
serde_json::json!({ "value": answer })
}
fn mask_elicitation_answer(answer: &str) -> &'static str {
if answer.is_empty() {
"(empty)"
} else {
"••••"
}
}