2026-07-11 13:16:10 +07:00
|
|
|
use crate::app::mode::ModeKind;
|
|
|
|
|
use crate::app::state::rest::AppStateRest;
|
|
|
|
|
use crate::app::state::types::Overlay;
|
|
|
|
|
use crate::dto::chat::message::ChatMessage;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
#[expect(dead_code)]
|
|
|
|
|
pub enum Action {
|
|
|
|
|
Quit,
|
|
|
|
|
ForceQuit,
|
|
|
|
|
SwitchMode(ModeKind),
|
|
|
|
|
SubmitInput(String),
|
|
|
|
|
InsertChar(char),
|
|
|
|
|
DeleteChar,
|
|
|
|
|
DeleteCharRight,
|
|
|
|
|
CursorLeft,
|
|
|
|
|
CursorRight,
|
|
|
|
|
HistoryUp,
|
|
|
|
|
HistoryDown,
|
|
|
|
|
ScrollUp,
|
|
|
|
|
ScrollDown,
|
|
|
|
|
OpenOverlay(Overlay),
|
|
|
|
|
CloseOverlay,
|
|
|
|
|
ToggleYoloArm,
|
|
|
|
|
ToolResult {
|
|
|
|
|
tool_call_id: String,
|
|
|
|
|
output: String,
|
|
|
|
|
is_error: bool,
|
|
|
|
|
},
|
|
|
|
|
StreamToken(String),
|
|
|
|
|
StreamDone,
|
|
|
|
|
StreamError(String),
|
|
|
|
|
SystemNote {
|
|
|
|
|
kind: String,
|
|
|
|
|
message: String,
|
|
|
|
|
},
|
|
|
|
|
RunCommand(String),
|
|
|
|
|
QuitConfirm,
|
|
|
|
|
Resize(u16, u16),
|
|
|
|
|
Tick,
|
2026-07-11 18:23:01 +07:00
|
|
|
LessonExport {
|
|
|
|
|
path: String,
|
|
|
|
|
},
|
|
|
|
|
LessonImport {
|
|
|
|
|
path: String,
|
|
|
|
|
},
|
2026-07-11 13:16:10 +07:00
|
|
|
RecordUsage {
|
|
|
|
|
tokens_in: u64,
|
|
|
|
|
tokens_out: u64,
|
|
|
|
|
duration_ms: u64,
|
|
|
|
|
},
|
|
|
|
|
RecordReviewTokens {
|
|
|
|
|
tokens: u64,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|
|
|
|
match action {
|
|
|
|
|
Action::Quit => {
|
|
|
|
|
state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ForceQuit => {
|
|
|
|
|
state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
Action::SwitchMode(mode) => {
|
|
|
|
|
state.misc.overlay = match mode {
|
|
|
|
|
ModeKind::Chat
|
|
|
|
|
| ModeKind::Agents
|
|
|
|
|
| ModeKind::Bash
|
|
|
|
|
| ModeKind::Workflow => Overlay::None,
|
|
|
|
|
ModeKind::Help => Overlay::Help,
|
|
|
|
|
ModeKind::Settings => Overlay::Settings,
|
|
|
|
|
ModeKind::SessionHub => Overlay::SessionHub,
|
|
|
|
|
ModeKind::QuitConfirm => Overlay::QuitConfirm,
|
|
|
|
|
ModeKind::Onboard => Overlay::Onboard,
|
|
|
|
|
ModeKind::OnboardProvider => Overlay::OnboardProvider,
|
|
|
|
|
ModeKind::Picker => Overlay::Picker,
|
|
|
|
|
ModeKind::KeyInput => Overlay::KeyInput,
|
|
|
|
|
ModeKind::Editor => Overlay::Editor,
|
|
|
|
|
ModeKind::Effort => Overlay::Effort,
|
|
|
|
|
ModeKind::Mcp => Overlay::Mcp,
|
|
|
|
|
ModeKind::Security => Overlay::Security,
|
|
|
|
|
ModeKind::Todo => Overlay::Todo,
|
|
|
|
|
ModeKind::Rewind => Overlay::Rewind,
|
|
|
|
|
ModeKind::Loading => Overlay::Loading,
|
|
|
|
|
};
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::SubmitInput(text) => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::user(text.clone()));
|
|
|
|
|
let api_key = state.settings.api_key.clone();
|
|
|
|
|
let model = state.settings.model.clone();
|
|
|
|
|
let msgs = rt.messages.clone();
|
|
|
|
|
let pending = state.pending_api_response.clone();
|
|
|
|
|
if let Some(key) = api_key {
|
|
|
|
|
if !key.is_empty() {
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let client = crate::service::openrouter::OpenRouterClient::new(key, model);
|
|
|
|
|
match client.chat(&msgs) {
|
|
|
|
|
Ok(response) => {
|
|
|
|
|
if let Ok(mut guard) = pending.lock() {
|
|
|
|
|
*guard = Some(response);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
if let Ok(mut guard) = pending.lock() {
|
|
|
|
|
*guard = Some(format!("Error: {}", e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::InsertChar(c) => {
|
|
|
|
|
state.input.insert(c);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::DeleteChar => {
|
|
|
|
|
state.input.delete_left();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::DeleteCharRight => {
|
|
|
|
|
state.input.delete_right();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::CursorLeft => {
|
|
|
|
|
state.input.char_left();
|
|
|
|
|
}
|
|
|
|
|
Action::CursorRight => {
|
|
|
|
|
state.input.char_right();
|
|
|
|
|
}
|
|
|
|
|
Action::HistoryUp => {
|
|
|
|
|
state.input.history_up();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::HistoryDown => {
|
|
|
|
|
state.input.history_down();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ScrollUp => {
|
|
|
|
|
let total = state.transcript_cache.messages.len();
|
|
|
|
|
state.scroll.scroll_up();
|
|
|
|
|
state.scroll.scroll_down(total);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ScrollDown => {
|
|
|
|
|
let total = state.transcript_cache.messages.len();
|
|
|
|
|
state.scroll.scroll_down(total);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::OpenOverlay(overlay) => {
|
|
|
|
|
state.misc.overlay = overlay;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::CloseOverlay => {
|
|
|
|
|
state.misc.overlay = Overlay::None;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ToggleYoloArm => {
|
|
|
|
|
state.misc.yolo_armed = !state.misc.yolo_armed;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::ToolResult {
|
|
|
|
|
tool_call_id,
|
|
|
|
|
output,
|
|
|
|
|
is_error,
|
|
|
|
|
} => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone()));
|
|
|
|
|
rt.tool_call_results.push(
|
|
|
|
|
crate::app::state::runtime::ToolCallResult {
|
|
|
|
|
tool_call_id,
|
|
|
|
|
tool_name: String::new(),
|
|
|
|
|
output,
|
|
|
|
|
is_error,
|
|
|
|
|
duration_ms: 0,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::StreamToken(token) => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
let found = rt.messages.iter_mut().rev().find(|m| {
|
|
|
|
|
matches!(m.role, crate::dto::chat::message::Role::Assistant)
|
|
|
|
|
});
|
|
|
|
|
if let Some(last) = found {
|
|
|
|
|
let current = last.content.take().unwrap_or_default();
|
|
|
|
|
last.content = Some(current + &token);
|
|
|
|
|
} else {
|
|
|
|
|
let mut msg = ChatMessage::assistant(None);
|
|
|
|
|
msg.content = Some(token);
|
|
|
|
|
rt.push_message(msg);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::StreamDone => {
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::StreamError(msg) => {
|
|
|
|
|
let toast = crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
msg,
|
|
|
|
|
);
|
|
|
|
|
state.push_toast(toast);
|
|
|
|
|
}
|
|
|
|
|
Action::SystemNote { kind: _kind, message } => {
|
|
|
|
|
let toast = crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Info,
|
|
|
|
|
message,
|
|
|
|
|
);
|
|
|
|
|
state.push_toast(toast);
|
|
|
|
|
}
|
|
|
|
|
Action::RunCommand(text) => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.push_message(ChatMessage::user(text));
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::QuitConfirm => {
|
|
|
|
|
state.misc.overlay = Overlay::QuitConfirm;
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::Resize(w, _h) => {
|
|
|
|
|
state.scroll.set_max_visible(w as usize);
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 18:23:01 +07:00
|
|
|
Action::LessonExport { path } => {
|
|
|
|
|
let dest = std::path::Path::new(&path);
|
|
|
|
|
if let Some(parent) = dest.parent() {
|
|
|
|
|
let _ = std::fs::create_dir_all(parent);
|
|
|
|
|
}
|
|
|
|
|
match crate::model::memory::export_lessons(&state.memory_dir, dest) {
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Success,
|
|
|
|
|
format!("lessons exported to {}", path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
format!("export failed: {}", e),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::LessonImport { path } => {
|
|
|
|
|
let src = std::path::Path::new(&path);
|
|
|
|
|
match crate::model::memory::import_lessons(&state.memory_dir, src) {
|
|
|
|
|
Ok(count) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Success,
|
|
|
|
|
format!("imported {} lessons from {}", count, path),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
state.push_toast(crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
format!("import failed: {}", e),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
Action::Tick => {
|
|
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
state.misc.drain_expired_toasts(now_ms);
|
|
|
|
|
let api_response = if let Ok(mut guard) = state.pending_api_response.lock() {
|
|
|
|
|
guard.take()
|
|
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
|
|
|
|
if let Some(response) = api_response {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
if response.starts_with("Error:") {
|
|
|
|
|
let toast = crate::app::state::types::Toast::new(
|
|
|
|
|
crate::app::state::types::ToastKind::Error,
|
|
|
|
|
response,
|
|
|
|
|
);
|
|
|
|
|
state.push_toast(toast);
|
|
|
|
|
} else {
|
|
|
|
|
rt.push_message(ChatMessage::assistant(Some(response)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Action::RecordUsage { tokens_in, tokens_out, duration_ms } => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.record_api_call(tokens_in, tokens_out, duration_ms);
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
Action::RecordReviewTokens { tokens } => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
|
|
|
|
rt.record_review_tokens(tokens);
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|