use std::collections::hash_map::DefaultHasher;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::future::Future;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::Duration;
use anyhow::{Result, anyhow};
use crossterm::ExecutableCommand;
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use serde_json::json;
use vw_agent::provider::provider;
use vw_gateway_client::GatewayChatStreamRequest;
use vw_gateway_client::vw_api_types::id::SessionId;
use vw_shared::session::ui_types::{
ChatMessage, ChatRole, ChatSession, ChatSessionMeta, TokenUsage,
};
use vw_shared::todo::Todo;
use super::controller::{
TuiController, TuiControllerCommand, TuiOverlayCommand, build_prompt_submission,
};
use super::input::{
TuiSlashCommandInvocation, TuiSlashCommandOutcome, execute_slash_command, parse_slash_command,
};
use super::model::{
McpServerTransport, PromptSubmission, QueuedPromptCommand, QueuedPromptCommandKind,
UiConfirmOverlay, UiErrorOverlay, UiMcpOverlay, UiMcpServerInfo, UiMemoryEntry,
UiMemoryOverlay, UiMessage, UiMessageBase, UiMessageId, UiOverlay, UiQuestionOverlay,
UiSystemMessage, UiSystemMessageLevel, UiTaskOverlay, UiTaskStepItem, UiTodoOverlay,
};
use super::render::{TuiRenderFeedback, TuiRenderer};
use super::runtime::gateway::GatewayUiRuntime;
use super::runtime::gateway::normalize_optional_str_ref;
use super::runtime::stream_adapter::{UiRuntimeEvent, UiRuntimeTerminalEvent};
use super::state::{
TuiAction, TuiModelCatalogEntry, TuiState, TuiStickyPromptSummary, TuiUnseenRangeSummary,
TuiVisibleTranscriptWindow, TuiWindowSummary, apply_runtime_event, reduce_tui_state,
select_visible_grouped_transcript_window,
};
use crate::app::agent::config::Config;
use crate::app::agent::session::processor as legacy_processor;
use crate::cli::processor::{
SessionProcessorComparableResult, SessionProcessorComparableTerminal,
run_session_processor_comparable_for_cli,
};
use crate::cli::session::{build_project_info, collect_git_workspace_status};
use crate::cli::setup::CliSetup;
#[cfg(unix)]
use std::fs::File;
#[cfg(unix)]
type CliBackendWriter = File;
#[cfg(not(unix))]
type CliBackendWriter = std::io::Stdout;
type TuiTerminal = Terminal<CrosstermBackend<CliBackendWriter>>;
const BUSY_HOST_POLL_RATE: Duration = Duration::from_millis(60);
const CANCEL_REQUESTED_STATUS: &str = "已请求取消;当前输出会在收到下一次运行时事件后停止。";
const QUESTION_CUSTOM_ANSWER_PREFIX: &str = "__custom__:";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TuiRunMode {
Standard,
Shadow,
}
impl TuiRunMode {
fn badge_label(self) -> &'static str {
match self {
Self::Standard => "TUI v2",
Self::Shadow => "TUI v2 shadow",
}
}
fn shadow_compare_enabled(self) -> bool {
matches!(self, Self::Shadow)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TodoSessionAccessAction {
OpenPanel,
Refresh,
Save,
}
impl TodoSessionAccessAction {
fn overlay_title(self) -> &'static str {
match self {
Self::OpenPanel => "待办面板不可用",
Self::Refresh => "待办刷新失败",
Self::Save => "待办保存失败",
}
}
fn action_label(self) -> &'static str {
match self {
Self::OpenPanel => "打开待办面板",
Self::Refresh => "刷新当前会话的待办列表",
Self::Save => "保存当前会话的待办列表",
}
}
}
pub(crate) fn todo_session_unavailable_overlay(action: TodoSessionAccessAction) -> UiErrorOverlay {
UiErrorOverlay {
title: action.overlay_title().to_string(),
message: format!(
"当前 TUI 宿主还没有绑定活动会话,因此无法{}。\n请先新建或恢复一个会话,再重试。",
action.action_label()
),
recoverable: true,
}
}
pub(crate) fn is_session_unavailable_error(message: &str) -> bool {
let normalized = message.trim().to_ascii_lowercase();
if normalized.is_empty() {
return false;
}
[
"session id is required",
"session is required",
"non-empty session id",
"missing session",
"session unavailable",
"session not found",
"no active session",
]
.iter()
.any(|marker| normalized.contains(marker))
}
fn build_mcp_overlay(workspace_root: Option<&std::path::Path>) -> UiMcpOverlay {
let candidates: Vec<(std::path::PathBuf, &str)> = {
let mut v = Vec::new();
if let Some(root) = workspace_root {
v.push((root.join(".vwacprc.json"), "project"));
}
if let Some(home) = dirs_home() {
v.push((home.join(".vibewindow").join("acp").join("config.json"), "global"));
}
v
};
for (path, label) in &candidates {
if !path.exists() {
continue;
}
if let Ok(content) = std::fs::read_to_string(path)
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(servers) = json.get("mcpServers")
{
let servers_info = parse_mcp_servers_json(servers);
return UiMcpOverlay {
servers: servers_info,
selected_index: 0,
config_source: format!("{} ({})", path.display(), label),
};
}
}
UiMcpOverlay {
servers: Vec::new(),
selected_index: 0,
config_source: "未找到 MCP 配置文件".to_string(),
}
}
fn dirs_home() -> Option<std::path::PathBuf> {
std::env::var_os("HOME").map(std::path::PathBuf::from)
}
fn parse_mcp_servers_json(value: &serde_json::Value) -> Vec<UiMcpServerInfo> {
match value {
serde_json::Value::Object(map) => map
.iter()
.map(|(name, server)| mcp_server_entry_to_info(name.clone(), server))
.collect(),
serde_json::Value::Array(arr) => arr
.iter()
.map(|server| {
let name =
server.get("name").and_then(|v| v.as_str()).unwrap_or("unknown").to_string();
mcp_server_entry_to_info(name, server)
})
.collect(),
_ => Vec::new(),
}
}
fn mcp_server_entry_to_info(name: String, server: &serde_json::Value) -> UiMcpServerInfo {
let transport_type = server.get("type").and_then(|v| v.as_str()).unwrap_or("stdio");
let (transport, address) = match transport_type {
"http" => {
let url = server.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
(McpServerTransport::Http, url)
}
"sse" => {
let url = server.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
(McpServerTransport::Sse, url)
}
_ => {
let command = server.get("command").and_then(|v| v.as_str()).unwrap_or("").to_string();
(McpServerTransport::Stdio, command)
}
};
UiMcpServerInfo { name, transport, address }
}
const MEMORY_PREVIEW_MAX_LINES: usize = 30;
fn build_memory_overlay(workspace_root: Option<&std::path::Path>) -> UiMemoryOverlay {
let mut entries: Vec<UiMemoryEntry> = Vec::new();
if let Some(root) = workspace_root {
let agents_path = root.join("AGENTS.md");
if agents_path.exists()
&& let Some(entry) = read_memory_file(&agents_path, "project")
{
entries.push(entry);
}
let project_memory_dir = root.join(".vibewindow").join("memory");
collect_memory_dir_entries(&project_memory_dir, "project", &mut entries);
}
if let Some(home) = dirs_home() {
let global_memory_dir = home.join(".vibewindow").join("memory");
collect_memory_dir_entries(&global_memory_dir, "global", &mut entries);
}
UiMemoryOverlay { entries, selected_index: 0 }
}
fn read_memory_file(path: &std::path::Path, scope: &str) -> Option<UiMemoryEntry> {
let content = std::fs::read_to_string(path).ok()?;
let all_lines: Vec<&str> = content.lines().collect();
let total_lines = all_lines.len();
let preview_lines: Vec<String> =
all_lines.iter().take(MEMORY_PREVIEW_MAX_LINES).map(|l| l.to_string()).collect();
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown").to_string();
Some(UiMemoryEntry {
scope: scope.to_string(),
filename,
path: path.display().to_string(),
preview_lines,
total_lines,
})
}
fn collect_memory_dir_entries(
dir: &std::path::Path,
scope: &str,
entries: &mut Vec<UiMemoryEntry>,
) {
let Ok(read_dir) = std::fs::read_dir(dir) else {
return;
};
let mut paths: Vec<std::path::PathBuf> = read_dir
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
.collect();
paths.sort();
for path in paths {
if let Some(entry) = read_memory_file(&path, scope) {
entries.push(entry);
}
}
}
fn build_exit_confirm_body(state: &TuiState) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("确认离开 tui_v2 并返回 shell 吗?".to_string());
lines.push(String::new());
if let Some(session_id) = &state.session.session_id {
let short_id = if session_id.len() > 20 {
format!("{}…", &session_id[..20])
} else {
session_id.clone()
};
lines.push(format!(" 会话 ID : {short_id}"));
}
let msg_count = state
.messages
.iter()
.filter(|m| {
use super::model::UiMessage;
matches!(m, UiMessage::User(_) | UiMessage::Assistant(_))
})
.count();
if msg_count > 0 {
lines.push(format!(" 消息数 : {msg_count} 条"));
}
if let Some(model) = &state.status.model_name
&& !model.trim().is_empty()
{
lines.push(format!(" 当前模型 : {model}"));
}
if state.prompt.is_busy() {
lines.push(String::new());
lines.push(" ⚠ 当前有请求正在进行,退出将中止本次输出。".to_string());
}
lines.push(String::new());
lines.push("Enter 确认退出 Esc 取消".to_string());
lines.join("\n")
}
fn is_permission_surface_error(message: &str) -> bool {
let normalized = message.trim().to_ascii_lowercase();
if normalized.is_empty() {
return false;
}
[
"requires approval",
"require approval",
"approval required",
"permission request",
"approval prompt",
"non-cli approval",
]
.iter()
.any(|marker| normalized.contains(marker))
}
fn is_permission_event_type(event_type: Option<&str>) -> bool {
event_type
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_ascii_lowercase)
.is_some_and(|value| value.contains("permission") || value.contains("approval"))
}
pub(crate) fn runtime_event_fallback_overlay(event: &UiRuntimeEvent) -> Option<UiErrorOverlay> {
match event {
UiRuntimeEvent::Terminal(UiRuntimeTerminalEvent::TimedOut { message, .. }) => {
Some(UiErrorOverlay {
title: "输出超时".to_string(),
message: format!("当前轮次在 gateway 返回稳定终态前已超时。\n{}", message.trim()),
recoverable: true,
})
}
UiRuntimeEvent::Terminal(UiRuntimeTerminalEvent::Error(message)) => {
Some(runtime_terminal_error_overlay(message.as_str()))
}
UiRuntimeEvent::Unknown { event_type } => {
Some(runtime_unknown_event_overlay(event_type.as_deref()))
}
UiRuntimeEvent::Delta(_)
| UiRuntimeEvent::StepStart { .. }
| UiRuntimeEvent::StepFinish { .. }
| UiRuntimeEvent::TaskStateChanged { .. }
| UiRuntimeEvent::SessionMetadataChanged { .. }
| UiRuntimeEvent::UsageUpdated { .. }
| UiRuntimeEvent::Terminal(
UiRuntimeTerminalEvent::Done { .. } | UiRuntimeTerminalEvent::Cancelled { .. },
) => None,
}
}
fn runtime_unknown_event_overlay(event_type: Option<&str>) -> UiErrorOverlay {
let event_type =
event_type.map(str::trim).filter(|value| !value.is_empty()).unwrap_or("unknown");
if is_permission_event_type(Some(event_type)) {
return UiErrorOverlay {
title: "权限事件回退".to_string(),
message: format!(
"gateway 发出了一条权限或授权事件,但 tui_v2 无法将它解码到专用权限界面。请按 F2 打开待处理请求;如果没有出现,尝试升级协议后重试。\n事件类型: {}",
event_type
),
recoverable: true,
};
}
UiErrorOverlay {
title: "运行时事件回退".to_string(),
message: format!(
"gateway 发出了一条当前不支持或无法解码的运行时事件。会话内容仍可查看,但本轮可能不完整。\n事件类型: {}",
event_type
),
recoverable: true,
}
}
fn runtime_terminal_error_overlay(message: &str) -> UiErrorOverlay {
let normalized = message.trim();
if is_session_unavailable_error(normalized) {
return UiErrorOverlay {
title: "会话不可用".to_string(),
message: format!(
"当前运行时已经失去可用的会话绑定,因此本轮无法继续。\n{}",
if normalized.is_empty() {
"请先新建或恢复一个会话,再重试。"
} else {
normalized
}
),
recoverable: true,
};
}
if is_permission_surface_error(normalized) {
return UiErrorOverlay {
title: "权限请求失败".to_string(),
message: format!(
"当前轮次触发了授权或权限边界,但 tui_v2 无法将它解码到专用权限界面。请按 F2 打开待处理请求;如果没有出现,先恢复会话后重试。\n{}",
if normalized.is_empty() {
"授权或权限请求处理失败"
} else {
normalized
}
),
recoverable: true,
};
}
UiErrorOverlay {
title: "输出失败".to_string(),
message: format!(
"当前轮次因运行时或网络错误而结束。\n{}",
if normalized.is_empty() { "gateway 输出失败" } else { normalized }
),
recoverable: true,
}
}
fn latest_session_preview(previews: &[ChatSessionMeta]) -> Option<&ChatSessionMeta> {
previews.iter().max_by(|left, right| {
left.updated_ms
.cmp(&right.updated_ms)
.then(left.message_count.cmp(&right.message_count))
.then(left.call_count.cmp(&right.call_count))
.then(left.id.cmp(&right.id))
})
}
fn snapshot_from_preview(preview: &ChatSessionMeta) -> ChatSession {
ChatSession {
id: preview.id.clone(),
title: preview.title.clone(),
messages: Vec::new(),
message_ids: Vec::new(),
calls: Vec::new(),
steps: Vec::new(),
created_ms: preview.updated_ms,
updated_ms: preview.updated_ms,
}
}
fn empty_session_snapshot(session_id: &str) -> ChatSession {
ChatSession {
id: session_id.to_string(),
title: String::new(),
messages: Vec::new(),
message_ids: Vec::new(),
calls: Vec::new(),
steps: Vec::new(),
created_ms: 0,
updated_ms: 0,
}
}
pub(crate) fn select_restore_session_id(
explicit_session_id: Option<&str>,
previews: &[ChatSessionMeta],
) -> Option<String> {
normalize_optional_str_ref(explicit_session_id)
.map(ToOwned::to_owned)
.or_else(|| latest_session_preview(previews).map(|preview| preview.id.clone()))
}
pub(crate) struct TuiApp {
run_mode: TuiRunMode,
runtime: GatewayUiRuntime,
state: TuiState,
controller: TuiController,
renderer: TuiRenderer,
deferred_prompt_draft: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TuiAppCommandOutcome {
Continue,
Quit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionRestoreOutcome {
Snapshot,
MetadataOnly,
Missing,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TuiDeltaRedrawSnapshot {
message_count: usize,
window_summary: TuiWindowSummary,
sticky_prompt: Option<TuiStickyPromptSummary>,
unseen_range: Option<TuiUnseenRangeSummary>,
visible_content_hash: u64,
}
impl TuiDeltaRedrawSnapshot {
pub(crate) fn capture(state: &TuiState) -> Self {
let visible_window = select_visible_grouped_transcript_window(state);
Self {
message_count: state.messages.len(),
window_summary: visible_window.window_summary(),
sticky_prompt: visible_window.sticky_prompt().cloned(),
unseen_range: visible_window.unseen_range(),
visible_content_hash: hash_visible_window_messages(state, &visible_window),
}
}
fn contains_tail(&self) -> bool {
self.message_count > 0 && self.window_summary.covered_message_end >= self.message_count
}
}
fn hash_visible_window_messages(
state: &TuiState,
visible_window: &TuiVisibleTranscriptWindow<'_>,
) -> u64 {
let mut hasher = DefaultHasher::new();
visible_window.covered_message_start.hash(&mut hasher);
visible_window.covered_message_end.hash(&mut hasher);
for message in state.messages.iter().skip(visible_window.covered_message_start).take(
visible_window.covered_message_end.saturating_sub(visible_window.covered_message_start),
) {
format!("{message:?}").hash(&mut hasher);
}
hasher.finish()
}
pub(crate) fn should_redraw_after_runtime_delta(
before: &TuiDeltaRedrawSnapshot,
after: &TuiState,
) -> bool {
let after_snapshot = TuiDeltaRedrawSnapshot::capture(after);
if before.window_summary.follows_tail() || after_snapshot.window_summary.follows_tail() {
return true;
}
if before.message_count != after_snapshot.message_count {
return true;
}
if before.window_summary != after_snapshot.window_summary
|| before.sticky_prompt != after_snapshot.sticky_prompt
|| before.unseen_range != after_snapshot.unseen_range
|| before.visible_content_hash != after_snapshot.visible_content_hash
{
return true;
}
before.contains_tail() || after_snapshot.contains_tail() || tail_message_visible(after)
}
fn tail_message_visible(state: &TuiState) -> bool {
if state.messages.is_empty() {
return false;
}
let viewport_messages = state.scroll.viewport_messages.max(1);
let last_message = state.messages.len().saturating_sub(1);
last_message >= state.scroll.top_message
&& last_message < state.scroll.top_message.saturating_add(viewport_messages)
}
pub(crate) fn dequeue_queued_prompt_command(
state: &mut TuiState,
deferred_prompt_draft: &mut Option<String>,
) -> Option<TuiControllerCommand> {
if state.prompt.is_busy() {
return None;
}
loop {
let Some(command) = state.prompt.pop_queued_command() else {
restore_deferred_prompt_draft(state, deferred_prompt_draft);
return None;
};
capture_deferred_prompt_draft(state, deferred_prompt_draft);
if let Some(next_command) = queued_command_to_controller_command(state, command) {
return Some(next_command);
}
}
}
pub(crate) fn restore_deferred_prompt_draft(
state: &mut TuiState,
deferred_prompt_draft: &mut Option<String>,
) {
let Some(draft) = deferred_prompt_draft.take() else {
return;
};
if state.prompt.is_busy() {
*deferred_prompt_draft = Some(draft);
return;
}
if state.prompt.value.is_empty() {
reduce_tui_state(state, TuiAction::PromptValueSet(draft));
}
}
fn capture_deferred_prompt_draft(state: &mut TuiState, deferred_prompt_draft: &mut Option<String>) {
if state.prompt.value.is_empty() {
return;
}
*deferred_prompt_draft = Some(state.prompt.value.clone());
reduce_tui_state(state, TuiAction::PromptValueSet(String::new()));
}
fn queued_command_to_controller_command(
state: &TuiState,
command: QueuedPromptCommand,
) -> Option<TuiControllerCommand> {
match command.kind {
QueuedPromptCommandKind::Submit => build_prompt_submission(state, command.raw.as_str())
.map(TuiControllerCommand::SubmitPrompt),
QueuedPromptCommandKind::SlashCommand => Some(TuiControllerCommand::ExecuteSlashCommand(
parse_queued_slash_command(command.raw.as_str()),
)),
}
}
fn parse_queued_slash_command(raw: &str) -> TuiSlashCommandInvocation {
parse_slash_command(raw).unwrap_or(TuiSlashCommandInvocation {
raw: raw.trim().to_string(),
token: raw.trim().trim_start_matches('/').to_string(),
argument: None,
kind: None,
})
}
pub(crate) fn question_overlay_submission_answers(overlay: &UiQuestionOverlay) -> Vec<Vec<String>> {
overlay
.answers
.iter()
.map(|answers| {
answers
.iter()
.map(|answer| {
answer
.strip_prefix(QUESTION_CUSTOM_ANSWER_PREFIX)
.unwrap_or(answer.as_str())
.to_string()
})
.collect::<Vec<_>>()
})
.collect()
}
pub(crate) fn todo_overlay_items_as_shared_todos(overlay: &UiTodoOverlay) -> Vec<Todo> {
overlay
.items
.iter()
.map(|item| Todo {
content: item.content.clone(),
status: item.status.clone(),
priority: item.priority.clone(),
id: item.id.clone(),
})
.collect()
}
impl TuiApp {
pub(crate) fn bootstrap(
config: &Config,
setup: &CliSetup,
run_mode: TuiRunMode,
) -> Result<Self> {
let runtime = GatewayUiRuntime::for_workspace(config).map_err(|err| anyhow!(err))?;
let preflight =
runtime.ensure_local_gateway_ready_blocking().map_err(|err| anyhow!(err))?;
let mut app = Self {
run_mode,
runtime,
state: TuiState::default(),
controller: TuiController::default(),
renderer: TuiRenderer::default(),
deferred_prompt_draft: None,
};
app.bootstrap_session_state_blocking(setup);
if preflight.started_gateway() {
app.push_app_system_message(
format!(
"Local gateway auto-started at {} before entering tui_v2.",
app.runtime.endpoint().describe()
),
UiSystemMessageLevel::Success,
);
}
app.refresh_project_context_blocking();
app.refresh_task_state_blocking();
Ok(app)
}
pub(crate) fn state(&self) -> &TuiState {
&self.state
}
pub(crate) fn run(mut self) -> Result<()> {
let mut terminal = TuiTerminalLifecycle::enter()?;
let mut feedback = self.draw(terminal.terminal_mut())?;
self.controller.sync_layout(&mut self.state, &feedback);
loop {
if let Some(command) =
dequeue_queued_prompt_command(&mut self.state, &mut self.deferred_prompt_draft)
{
if matches!(
self.handle_command(terminal.terminal_mut(), command)?,
TuiAppCommandOutcome::Quit
) {
break;
}
feedback = self.draw(terminal.terminal_mut())?;
self.controller.sync_layout(&mut self.state, &feedback);
continue;
}
let event = self.controller.next_event()?;
let command = self.controller.handle_event(&mut self.state, event);
if matches!(
self.handle_command(terminal.terminal_mut(), command)?,
TuiAppCommandOutcome::Quit
) {
break;
}
feedback = self.draw(terminal.terminal_mut())?;
self.controller.sync_layout(&mut self.state, &feedback);
}
Ok(())
}
fn handle_command(
&mut self,
terminal: &mut TuiTerminal,
command: TuiControllerCommand,
) -> Result<TuiAppCommandOutcome> {
match command {
TuiControllerCommand::Continue | TuiControllerCommand::CancelActiveSubmission => {
Ok(TuiAppCommandOutcome::Continue)
}
TuiControllerCommand::Quit => Ok(TuiAppCommandOutcome::Quit),
TuiControllerCommand::Overlay(command) => self.handle_overlay_command(command),
TuiControllerCommand::ExecuteSlashCommand(invocation) => {
Ok(match execute_slash_command(&mut self.state, &invocation) {
TuiSlashCommandOutcome::Quit => TuiAppCommandOutcome::Quit,
TuiSlashCommandOutcome::Continue => TuiAppCommandOutcome::Continue,
TuiSlashCommandOutcome::Resume { session_id } => {
self.restore_session_from_command(session_id.as_deref());
TuiAppCommandOutcome::Continue
}
})
}
TuiControllerCommand::SubmitPrompt(submission) => {
self.submit_prompt(terminal, submission)
}
}
}
fn handle_overlay_command(
&mut self,
command: TuiOverlayCommand,
) -> Result<TuiAppCommandOutcome> {
match command {
TuiOverlayCommand::OpenSearchOverlay => {
self.open_search_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::OpenPendingQuestions => {
if !self.state.prompt.is_busy() {
self.refresh_task_state_blocking();
}
self.open_pending_question_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::OpenTodoPanel => {
if !self.state.prompt.is_busy() {
self.refresh_task_state_blocking();
}
self.open_todo_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::OpenTaskPanel => {
if !self.state.prompt.is_busy() {
self.refresh_task_state_blocking();
}
self.open_task_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::OpenMcpPanel => {
self.open_mcp_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::OpenMemoryPanel => {
self.open_memory_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::ConfirmExit => {
self.open_exit_confirm_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::ConfirmAccepted(overlay) => {
let confirm_label = overlay.confirm_label.trim().to_ascii_lowercase();
if confirm_label == "exit" || confirm_label == "退出" {
return Ok(TuiAppCommandOutcome::Quit);
}
if confirm_label == "clear" || confirm_label == "清空" {
self.state.clear_messages();
self.sync_session_metadata_blocking();
self.persist_session_snapshot_blocking();
self.push_app_system_message(
"当前会话内容已清空",
UiSystemMessageLevel::Success,
);
self.refresh_task_state_blocking();
return Ok(TuiAppCommandOutcome::Continue);
}
reduce_tui_state(&mut self.state, TuiAction::OverlayPopped);
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::QuestionSubmitted(overlay) => {
let answers = question_overlay_submission_answers(&overlay);
if answers.iter().all(Vec::is_empty) {
self.push_overlay_error(
overlay.empty_submission_title(),
overlay.empty_submission_message(),
);
return Ok(TuiAppCommandOutcome::Continue);
}
match self.runtime.question_reply_blocking(overlay.request_id.as_str(), answers) {
Ok(()) => {
reduce_tui_state(&mut self.state, TuiAction::OverlayPopped);
self.refresh_task_state_blocking();
self.push_app_system_message(
format!(
"{} {} 已提交回答",
overlay.request_label(),
overlay.request_id
),
UiSystemMessageLevel::Success,
);
}
Err(err) => {
self.push_overlay_error(
overlay.reply_error_title(),
format!("问题回复失败: {err}"),
);
}
}
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::QuestionRejected(overlay) => {
match self.runtime.question_reject_blocking(overlay.request_id.as_str()) {
Ok(()) => {
reduce_tui_state(&mut self.state, TuiAction::OverlayPopped);
self.refresh_task_state_blocking();
self.push_app_system_message(
format!("{} {} 已拒绝", overlay.request_label(), overlay.request_id),
UiSystemMessageLevel::Warning,
);
}
Err(err) => {
self.push_overlay_error(
overlay.reject_error_title(),
format!("问题拒绝失败: {err}"),
);
}
}
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::TodoRefresh(_) => {
if self.current_session_id().is_none() {
self.push_error_overlay(todo_session_unavailable_overlay(
TodoSessionAccessAction::Refresh,
));
return Ok(TuiAppCommandOutcome::Continue);
}
if !self.state.prompt.is_busy() {
self.refresh_task_state_blocking();
}
self.reload_active_todo_overlay();
Ok(TuiAppCommandOutcome::Continue)
}
TuiOverlayCommand::TodoSave(overlay) => {
if self.current_session_id().is_none() {
self.push_error_overlay(todo_session_unavailable_overlay(
TodoSessionAccessAction::Save,
));
return Ok(TuiAppCommandOutcome::Continue);
}
let todos = todo_overlay_items_as_shared_todos(&overlay);
match self
.runtime
.session_todo_update_blocking(overlay.session_id.as_deref(), &todos)
{
Ok(()) => {
self.refresh_task_state_blocking();
self.reload_active_todo_overlay();
self.push_app_system_message(
"待办面板已保存",
UiSystemMessageLevel::Success,
);
}
Err(err) => {
self.push_overlay_error("待办保存失败", format!("待办更新失败: {err}"));
}
}
Ok(TuiAppCommandOutcome::Continue)
}
}
}
fn refresh_task_state_blocking(&mut self) {
let question_session_id = self
.state
.session
.session_id
.clone()
.or_else(|| self.runtime.session_id().map(ToOwned::to_owned));
let todo_session_id = self
.state
.session
.session_id
.clone()
.or_else(|| self.runtime.session_id().map(ToOwned::to_owned));
let mut errors = Vec::new();
if let Some(session_id) = question_session_id.as_deref() {
match self.runtime.question_list_for_session_blocking(Some(session_id)) {
Ok(requests) => {
let overlays =
requests.iter().map(UiQuestionOverlay::from_request).collect::<Vec<_>>();
reduce_tui_state(&mut self.state, TuiAction::QuestionsReplaced(overlays));
}
Err(err) => {
reduce_tui_state(&mut self.state, TuiAction::QuestionsReplaced(Vec::new()));
errors.push(format!("问题同步失败: {err}"));
}
}
} else {
reduce_tui_state(&mut self.state, TuiAction::QuestionsReplaced(Vec::new()));
}
if let Some(session_id) = todo_session_id.as_deref() {
match self.runtime.session_todo_get_blocking(Some(session_id)) {
Ok(todos) => {
let overlay = UiTodoOverlay::from_todos(Some(session_id), &todos);
reduce_tui_state(
&mut self.state,
TuiAction::TodoOverlayReplaced(Some(overlay)),
);
}
Err(err) => {
reduce_tui_state(&mut self.state, TuiAction::TodoOverlayReplaced(None));
errors.push(format!("待办同步失败: {err}"));
}
}
} else {
reduce_tui_state(&mut self.state, TuiAction::TodoOverlayReplaced(None));
}
reduce_tui_state(
&mut self.state,
TuiAction::TaskSyncErrorSet((!errors.is_empty()).then(|| errors.join("; "))),
);
self.reload_active_todo_overlay();
self.reload_active_task_overlay();
}
fn refresh_project_context_blocking(&mut self) {
let workspace_root = self.runtime.directory().to_path_buf();
let project_info = build_project_info(&workspace_root);
let git_status = collect_git_workspace_status(&workspace_root);
reduce_tui_state(&mut self.state, TuiAction::ProjectWorkspaceRootSet(Some(workspace_root)));
reduce_tui_state(&mut self.state, TuiAction::ProjectInfoSet(project_info));
reduce_tui_state(&mut self.state, TuiAction::ProjectGitStatusSet(git_status));
}
fn open_search_overlay(&mut self) {
reduce_tui_state(&mut self.state, TuiAction::SearchQuerySet(String::new()));
}
fn open_pending_question_overlay(&mut self) {
if let Some(error) = self.state.tasks.sync_error.clone() {
self.push_overlay_error(
"问题面板不可用",
format!("由于任务同步失败,当前无法读取待处理问题列表。\n{error}"),
);
return;
}
let Some(question) = self.state.tasks.pending_questions.first().cloned() else {
self.push_overlay_error("问题面板", "当前会话没有待处理问题。");
return;
};
reduce_tui_state(&mut self.state, TuiAction::OverlayPushed(UiOverlay::Question(question)));
}
fn open_todo_overlay(&mut self) {
if self.current_session_id().is_none() {
self.push_error_overlay(todo_session_unavailable_overlay(
TodoSessionAccessAction::OpenPanel,
));
return;
}
if let Some(error) = self.state.tasks.sync_error.clone() {
let overlay = if is_session_unavailable_error(error.as_str()) {
todo_session_unavailable_overlay(TodoSessionAccessAction::OpenPanel)
} else {
UiErrorOverlay {
title: "待办面板不可用".to_string(),
message: format!("由于任务同步失败,当前无法打开待办面板。\n{error}"),
recoverable: true,
}
};
self.push_error_overlay(overlay);
return;
}
let Some(todo_overlay) = self.state.tasks.todo_overlay.clone() else {
self.push_overlay_error("待办面板", "当前会话没有可用的待办列表。");
return;
};
reduce_tui_state(&mut self.state, TuiAction::OverlayPushed(UiOverlay::Todo(todo_overlay)));
}
fn open_task_overlay(&mut self) {
let task_overlay = self.build_task_overlay();
reduce_tui_state(&mut self.state, TuiAction::OverlayPushed(UiOverlay::Task(task_overlay)));
}
fn open_mcp_overlay(&mut self) {
let overlay = build_mcp_overlay(self.state.project.workspace_root.as_deref());
reduce_tui_state(&mut self.state, TuiAction::OverlayPushed(UiOverlay::Mcp(overlay)));
}
fn open_memory_overlay(&mut self) {
let overlay = build_memory_overlay(self.state.project.workspace_root.as_deref());
reduce_tui_state(&mut self.state, TuiAction::OverlayPushed(UiOverlay::Memory(overlay)));
}
fn open_exit_confirm_overlay(&mut self) {
if self
.state
.overlays
.active()
.is_some_and(|o| matches!(o, UiOverlay::Confirm(c) if c.confirm_label == "退出"))
{
return;
}
let body = build_exit_confirm_body(&self.state);
reduce_tui_state(
&mut self.state,
TuiAction::OverlayPushed(UiOverlay::Confirm(UiConfirmOverlay {
title: "退出 TUI".to_string(),
body,
confirm_label: "退出".to_string(),
cancel_label: "继续留在这里".to_string(),
destructive: false,
})),
);
}
fn build_task_overlay(&self) -> UiTaskOverlay {
let steps = self
.state
.messages
.iter()
.filter_map(|message| match message {
UiMessage::Step(step) => Some(UiTaskStepItem {
message_id: step.base.id.clone(),
step_index: step.step_index,
state: step.state.clone(),
started_ms: step.started_ms,
finished_ms: step.finished_ms,
model: step.model.clone(),
finish_reason: step.finish_reason.clone(),
usage: step.usage.clone(),
}),
_ => None,
})
.collect::<Vec<_>>();
UiTaskOverlay {
session_id: self.state.session.session_id.clone(),
turn_terminal: self.state.status.turn_terminal.clone(),
pending_questions: self.state.tasks.pending_questions.len(),
todo_count: self
.state
.tasks
.todo_overlay
.as_ref()
.map(|overlay| overlay.items.len())
.unwrap_or_default(),
sync_error: self.state.tasks.sync_error.clone(),
selected_index: steps.len().saturating_sub(1),
steps,
}
}
fn reload_active_todo_overlay(&mut self) {
if !matches!(self.state.overlays.active(), Some(UiOverlay::Todo(_))) {
return;
}
let has_session = self.current_session_id().is_some();
if let Some(error) = self.state.tasks.sync_error.clone() {
let overlay = if !has_session || is_session_unavailable_error(error.as_str()) {
todo_session_unavailable_overlay(TodoSessionAccessAction::OpenPanel)
} else {
UiErrorOverlay {
title: "待办面板不可用".to_string(),
message: format!("由于任务同步失败,当前无法打开待办面板。\n{error}"),
recoverable: true,
}
};
if let Some(active_overlay) = self.state.overlays.stack.last_mut() {
*active_overlay = UiOverlay::Error(overlay);
}
return;
}
let Some(todo_overlay) = self.state.tasks.todo_overlay.clone() else {
let overlay = if has_session {
UiErrorOverlay {
title: "待办面板".to_string(),
message: "当前会话没有可用的待办列表。".to_string(),
recoverable: true,
}
} else {
todo_session_unavailable_overlay(TodoSessionAccessAction::OpenPanel)
};
if let Some(active_overlay) = self.state.overlays.stack.last_mut() {
*active_overlay = UiOverlay::Error(overlay);
}
return;
};
if let Some(UiOverlay::Todo(active_overlay)) = self.state.overlays.stack.last_mut() {
*active_overlay = todo_overlay;
}
}
fn reload_active_task_overlay(&mut self) {
let next_overlay = self.build_task_overlay();
if let Some(UiOverlay::Task(active_overlay)) = self.state.overlays.stack.last_mut() {
let selected_index =
active_overlay.selected_index.min(next_overlay.steps.len().saturating_sub(1));
*active_overlay = next_overlay;
active_overlay.selected_index = selected_index;
}
}
fn current_session_id(&self) -> Option<&str> {
self.state.session.session_id.as_deref().or_else(|| self.runtime.session_id())
}
fn push_error_overlay(&mut self, overlay: UiErrorOverlay) {
if matches!(self.state.overlays.active(), Some(UiOverlay::Error(active)) if active == &overlay)
{
return;
}
reduce_tui_state(&mut self.state, TuiAction::OverlayPushed(UiOverlay::Error(overlay)));
}
fn push_overlay_error(&mut self, title: impl Into<String>, message: impl Into<String>) {
self.push_error_overlay(UiErrorOverlay {
title: title.into(),
message: message.into(),
recoverable: true,
});
}
fn push_app_system_message(&mut self, text: impl Into<String>, level: UiSystemMessageLevel) {
let message_index = self.state.messages.len();
reduce_tui_state(
&mut self.state,
TuiAction::MessagePushed(UiMessage::System(UiSystemMessage {
base: UiMessageBase::new(UiMessageId::local(format!(
"ui-app-system-{}",
message_index
))),
text: text.into(),
level,
})),
);
}
fn submit_prompt(
&mut self,
terminal: &mut TuiTerminal,
submission: PromptSubmission,
) -> Result<TuiAppCommandOutcome> {
let shadow_request = self
.run_mode
.shadow_compare_enabled()
.then(|| legacy_shadow_request_from_state(&self.state, &submission))
.flatten();
reduce_tui_state(&mut self.state, TuiAction::PromptSubmissionStarted(submission.clone()));
let feedback = self.draw(terminal)?;
self.controller.sync_layout(&mut self.state, &feedback);
let request = gateway_stream_request_from_state(&self.state, &submission);
let runtime = self.runtime.clone();
let (sender, receiver) = mpsc::channel();
let cancel_requested = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel_requested);
let mut gateway_shadow_output = String::new();
let mut gateway_shadow_step_finishes = 0usize;
let mut gateway_shadow_terminal = None::<UiRuntimeTerminalEvent>;
let worker = thread::spawn(move || {
let mut saw_terminal = false;
let terminal_event = runtime.stream_chat_blocking(&request, |event| {
if matches!(event, UiRuntimeEvent::Terminal(_)) {
saw_terminal = true;
}
if sender.send(event).is_err() {
return false;
}
!worker_cancel.load(Ordering::Relaxed)
});
if !saw_terminal {
let _ = sender.send(UiRuntimeEvent::Terminal(terminal_event));
}
});
let mut quit_after_submission = false;
let mut saw_terminal = false;
let mut worker_disconnected = false;
while !saw_terminal && !worker_disconnected {
loop {
match receiver.try_recv() {
Ok(event) => {
match &event {
UiRuntimeEvent::Delta(delta) => {
gateway_shadow_output.push_str(delta);
}
UiRuntimeEvent::StepFinish { .. } => {
gateway_shadow_step_finishes =
gateway_shadow_step_finishes.saturating_add(1);
}
UiRuntimeEvent::Terminal(terminal_event) => {
gateway_shadow_terminal = Some(terminal_event.clone());
}
UiRuntimeEvent::TaskStateChanged { .. }
| UiRuntimeEvent::SessionMetadataChanged { .. }
| UiRuntimeEvent::UsageUpdated { .. }
| UiRuntimeEvent::StepStart { .. }
| UiRuntimeEvent::Unknown { .. } => {}
}
saw_terminal = self.apply_runtime_event_and_draw(terminal, event)?;
if saw_terminal {
break;
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
worker_disconnected = true;
break;
}
}
}
if saw_terminal || worker_disconnected {
break;
}
let event = self.controller.next_event_with_timeout(BUSY_HOST_POLL_RATE)?;
let command = self.controller.handle_event(&mut self.state, event);
match command {
TuiControllerCommand::Continue
| TuiControllerCommand::SubmitPrompt(_)
| TuiControllerCommand::ExecuteSlashCommand(_) => {}
TuiControllerCommand::CancelActiveSubmission => {
self.request_submission_cancel(cancel_requested.as_ref());
}
TuiControllerCommand::Quit => {
quit_after_submission = true;
self.request_submission_cancel(cancel_requested.as_ref());
}
TuiControllerCommand::Overlay(command) => {
if matches!(self.handle_overlay_command(command)?, TuiAppCommandOutcome::Quit) {
quit_after_submission = true;
self.request_submission_cancel(cancel_requested.as_ref());
}
}
}
let feedback = self.draw(terminal)?;
self.controller.sync_layout(&mut self.state, &feedback);
}
worker.join().map_err(|_| anyhow!("tui_v2 stream worker panicked"))?;
if worker_disconnected && !saw_terminal {
return Err(anyhow!("tui_v2 stream worker exited before sending a terminal event"));
}
let gateway_shadow_result = gateway_shadow_terminal.as_ref().map(|terminal_event| {
SessionProcessorComparableResult {
output: gateway_shadow_output,
usage: comparable_usage_from_runtime_terminal(terminal_event),
step_finishes: gateway_shadow_step_finishes,
terminal: comparable_terminal_from_runtime_terminal(terminal_event),
}
});
self.refresh_task_state_blocking();
self.refresh_project_context_blocking();
self.run_shadow_compare_blocking(shadow_request, gateway_shadow_result);
self.persist_session_snapshot_blocking();
if quit_after_submission {
Ok(TuiAppCommandOutcome::Quit)
} else {
Ok(TuiAppCommandOutcome::Continue)
}
}
fn apply_runtime_event_and_draw(
&mut self,
terminal: &mut TuiTerminal,
event: UiRuntimeEvent,
) -> Result<bool> {
let should_refresh_task_state = matches!(
&event,
UiRuntimeEvent::TaskStateChanged { session_id }
if self.runtime_event_targets_current_session(session_id.as_deref())
);
let should_refresh_session_metadata = matches!(
&event,
UiRuntimeEvent::SessionMetadataChanged { session_id, .. }
if self.runtime_event_targets_current_session(session_id.as_deref())
);
let delta_snapshot = matches!(event, UiRuntimeEvent::Delta(_))
.then(|| TuiDeltaRedrawSnapshot::capture(&self.state));
let saw_terminal = matches!(event, UiRuntimeEvent::Terminal(_));
let fallback_overlay = runtime_event_fallback_overlay(&event);
apply_runtime_event(&mut self.state, event);
if should_refresh_session_metadata {
self.sync_session_metadata_blocking();
}
if should_refresh_task_state {
self.refresh_task_state_blocking();
}
self.reload_active_task_overlay();
if let Some(overlay) = fallback_overlay {
self.push_error_overlay(overlay);
}
if let Some(delta_snapshot) = delta_snapshot.as_ref()
&& !should_redraw_after_runtime_delta(delta_snapshot, &self.state)
{
return Ok(saw_terminal);
}
let feedback = self.draw(terminal)?;
self.controller.sync_layout(&mut self.state, &feedback);
Ok(saw_terminal)
}
fn runtime_event_targets_current_session(&self, session_id: Option<&str>) -> bool {
match normalize_optional_str_ref(session_id) {
Some(session_id) => self.current_session_id() == Some(session_id),
None => true,
}
}
fn request_submission_cancel(&mut self, cancel_requested: &AtomicBool) {
if cancel_requested.swap(true, Ordering::Relaxed) {
return;
}
reduce_tui_state(
&mut self.state,
TuiAction::StatusErrorSet(Some(CANCEL_REQUESTED_STATUS.to_string())),
);
}
fn draw(&mut self, terminal: &mut TuiTerminal) -> Result<TuiRenderFeedback> {
let mut feedback = TuiRenderFeedback::default();
let endpoint_label = self.runtime.endpoint().describe();
let spinner_frame = self.controller.spinner_frame();
terminal.draw(|frame| {
feedback = self.renderer.render_frame(
frame,
&self.state,
self.run_mode.badge_label(),
endpoint_label.as_str(),
spinner_frame,
);
})?;
Ok(feedback)
}
fn bootstrap_session_state_blocking(&mut self, setup: &CliSetup) {
reduce_tui_state(
&mut self.state,
TuiAction::ModelCatalogReplaced(load_model_catalog_blocking()),
);
let restored = self.restore_session_snapshot_blocking();
if !restored {
self.ensure_session_binding_blocking();
}
self.sync_session_metadata_blocking();
if self.state.session.title.trim().is_empty() {
let title = self
.runtime
.title()
.map(ToOwned::to_owned)
.unwrap_or_else(|| default_session_title(self.runtime.directory()));
reduce_tui_state(&mut self.state, TuiAction::SessionTitleSet(title));
}
if self.state.session.scope.is_none() {
let scope = self.resolve_session_scope_blocking();
reduce_tui_state(&mut self.state, TuiAction::SessionScopeSet(scope));
}
let active_model = normalize_optional_str_ref(self.state.status.model_name.as_deref())
.map(ToOwned::to_owned)
.or_else(|| {
normalize_optional_str_ref(Some(setup.model_name.as_str())).map(ToOwned::to_owned)
});
let active_provider =
normalize_optional_str_ref(self.state.status.provider_name.as_deref())
.map(ToOwned::to_owned)
.or_else(|| active_model.as_deref().and_then(provider_name_from_model))
.or_else(|| {
normalize_optional_str_ref(Some(setup.provider_name.as_str()))
.map(ToOwned::to_owned)
});
reduce_tui_state(&mut self.state, TuiAction::StatusProviderSet(active_provider));
reduce_tui_state(&mut self.state, TuiAction::StatusModelSet(active_model));
if self.state.messages.is_empty() {
reduce_tui_state(
&mut self.state,
TuiAction::MessagePushed(UiMessage::System(UiSystemMessage {
base: bootstrap_message_base(),
text: bootstrap_system_message(self.run_mode),
level: UiSystemMessageLevel::Info,
})),
);
}
}
fn run_shadow_compare_blocking(
&mut self,
request: Option<legacy_processor::Request>,
gateway_result: Option<SessionProcessorComparableResult>,
) {
if !self.run_mode.shadow_compare_enabled() {
return;
}
let Some(request) = request else {
self.push_app_system_message(
"Shadow compare skipped because no stable session binding was available for the current turn. Re-run with --tui-mode legacy to fall back.",
UiSystemMessageLevel::Warning,
);
return;
};
let Some(gateway_result) = gateway_result else {
self.push_app_system_message(
"Shadow compare skipped because the gateway turn did not produce a comparable terminal result. Re-run with --tui-mode legacy to fall back.",
UiSystemMessageLevel::Warning,
);
return;
};
match run_legacy_shadow_compare_blocking(request) {
Ok(legacy_result) => match compare_shadow_results(&gateway_result, &legacy_result) {
Ok(()) => {
self.push_app_system_message(
shadow_compare_success_message(&gateway_result),
UiSystemMessageLevel::Success,
);
}
Err(diff) => {
self.push_app_system_message(
format!(
"Shadow compare diverged from legacy: {diff}. Re-run with --tui-mode legacy to fall back."
),
UiSystemMessageLevel::Warning,
);
}
},
Err(err) => {
self.push_app_system_message(
format!(
"Shadow compare failed before legacy replay completed: {err}. Re-run with --tui-mode legacy to fall back."
),
UiSystemMessageLevel::Warning,
);
}
}
}
fn restore_session_snapshot_blocking(&mut self) -> bool {
let previews = match self.runtime.session_ui_previews_blocking() {
Ok(previews) => previews,
Err(err) => {
reduce_tui_state(
&mut self.state,
TuiAction::StatusErrorSet(Some(format!("session preview sync failed: {err}"))),
);
Vec::new()
}
};
let Some(session_id) = select_restore_session_id(self.runtime.session_id(), &previews)
else {
return false;
};
matches!(
self.restore_session_snapshot_by_id_blocking(&previews, session_id.as_str(), true),
SessionRestoreOutcome::Snapshot
)
}
fn restore_session_snapshot_by_id_blocking(
&mut self,
previews: &[ChatSessionMeta],
session_id: &str,
allow_empty_binding: bool,
) -> SessionRestoreOutcome {
let scope = self.resolve_session_scope_blocking();
let path = self.runtime.session_path_blocking(Some(session_id)).ok().flatten();
let snapshot = match self.runtime.session_ui_load_any_blocking(Some(session_id)) {
Ok(snapshot) => snapshot,
Err(err) => {
reduce_tui_state(
&mut self.state,
TuiAction::StatusErrorSet(Some(format!("session restore failed: {err}"))),
);
None
}
};
let Some(snapshot) = snapshot else {
let preview = previews.iter().find(|preview| preview.id == session_id);
let Some(snapshot) = preview
.map(snapshot_from_preview)
.or_else(|| allow_empty_binding.then(|| empty_session_snapshot(session_id)))
else {
return SessionRestoreOutcome::Missing;
};
reduce_tui_state(
&mut self.state,
TuiAction::ReplaceFromSnapshot { snapshot, scope, path },
);
return SessionRestoreOutcome::MetadataOnly;
};
reduce_tui_state(&mut self.state, TuiAction::ReplaceFromSnapshot { snapshot, scope, path });
SessionRestoreOutcome::Snapshot
}
fn restore_session_from_command(&mut self, session_id: Option<&str>) {
if self.state.session.session_id.is_some() {
self.persist_session_snapshot_blocking();
}
let previews = match self.runtime.session_ui_previews_blocking() {
Ok(previews) => previews,
Err(err) => {
self.push_overlay_error("恢复失败", format!("会话预览同步失败: {err}"));
return;
}
};
let Some(target_session_id) = select_restore_session_id(session_id, &previews) else {
self.push_overlay_error("恢复失败", "当前没有可供 tui_v2 恢复的会话快照。");
return;
};
match self.restore_session_snapshot_by_id_blocking(
&previews,
target_session_id.as_str(),
false,
) {
SessionRestoreOutcome::Snapshot => {
self.finish_session_restore();
self.push_app_system_message(
format!("已恢复会话 {}", target_session_id),
UiSystemMessageLevel::Success,
);
}
SessionRestoreOutcome::MetadataOnly => {
self.finish_session_restore();
self.push_app_system_message(
format!("会话 {} 还没有持久化快照;本次仅恢复了会话绑定。", target_session_id),
UiSystemMessageLevel::Warning,
);
}
SessionRestoreOutcome::Missing => {
self.push_overlay_error(
"恢复失败",
format!("没有找到 {} 对应的会话快照或预览。", target_session_id),
);
}
}
}
fn finish_session_restore(&mut self) {
self.sync_session_metadata_blocking();
if normalize_optional_str_ref(self.state.status.provider_name.as_deref()).is_none()
&& let Some(provider_name) =
self.state.status.model_name.as_deref().and_then(provider_name_from_model)
{
reduce_tui_state(&mut self.state, TuiAction::StatusProviderSet(Some(provider_name)));
}
self.refresh_task_state_blocking();
self.refresh_project_context_blocking();
reduce_tui_state(&mut self.state, TuiAction::StatusErrorSet(None));
self.state.refresh_session_preview();
}
fn sync_runtime_session_seed_from_state(&mut self) {
let title =
(!self.state.session.title.trim().is_empty()).then(|| self.state.session.title.clone());
self.runtime.bind_session_seed(
self.state.session.session_id.clone(),
self.state.session.scope.clone(),
title,
);
}
fn ensure_session_binding_blocking(&mut self) {
if self.state.session.session_id.is_some() {
self.sync_runtime_session_seed_from_state();
return;
}
if let Some(session_id) = self.runtime.session_id() {
self.state.session.session_id = Some(session_id.to_string());
self.state.refresh_session_preview();
self.sync_runtime_session_seed_from_state();
return;
}
let title = self
.runtime
.title()
.map(ToOwned::to_owned)
.unwrap_or_else(|| default_session_title(self.runtime.directory()));
match self.runtime.session_create_blocking(Some(title.as_str())) {
Ok(created) => {
self.state.session.session_id = Some(created.id);
self.state.session.title = created.title;
self.state.refresh_session_preview();
self.sync_runtime_session_seed_from_state();
}
Err(err) => {
reduce_tui_state(
&mut self.state,
TuiAction::StatusErrorSet(Some(format!("session create failed: {err}"))),
);
}
}
}
fn resolve_session_scope_blocking(&mut self) -> Option<String> {
if let Some(scope) = self.runtime.scope().map(ToOwned::to_owned) {
return Some(scope);
}
match self.runtime.session_scope_get_blocking() {
Ok(scope) => scope,
Err(err) => {
reduce_tui_state(
&mut self.state,
TuiAction::StatusErrorSet(Some(format!("session scope sync failed: {err}"))),
);
None
}
}
}
fn sync_session_metadata_blocking(&mut self) {
let scope = self.resolve_session_scope_blocking();
reduce_tui_state(&mut self.state, TuiAction::SessionScopeSet(scope));
let Some(session_id) = self.state.session.session_id.clone() else {
return;
};
if let Ok(Some(meta)) =
self.runtime.session_preview_meta_blocking(Some(session_id.as_str()))
{
if !meta.title.trim().is_empty() {
reduce_tui_state(&mut self.state, TuiAction::SessionTitleSet(meta.title.clone()));
}
reduce_tui_state(&mut self.state, TuiAction::SessionUpdatedMsSet(meta.updated_ms));
reduce_tui_state(&mut self.state, TuiAction::SessionPreviewSet(Some(meta.into())));
}
match self.runtime.session_path_blocking(Some(session_id.as_str())) {
Ok(Some(path)) => {
reduce_tui_state(&mut self.state, TuiAction::SessionPathSet(Some(path)));
}
Ok(None) => {
reduce_tui_state(&mut self.state, TuiAction::SessionPathSet(None));
}
Err(_) => {}
}
self.sync_runtime_session_seed_from_state();
}
fn persist_session_snapshot_blocking(&mut self) {
self.ensure_session_binding_blocking();
let Some(session_id) = self.state.session.session_id.clone() else {
return;
};
let mut snapshot = self.state.to_chat_session();
if snapshot.id.trim().is_empty() {
snapshot.id = session_id;
}
if snapshot.title.trim().is_empty() {
snapshot.title = self.state.session.title.clone();
}
match self.runtime.session_ui_save_blocking(&snapshot) {
Ok(()) => {
self.sync_session_metadata_blocking();
}
Err(err) => {
reduce_tui_state(
&mut self.state,
TuiAction::StatusErrorSet(Some(format!("session save failed: {err}"))),
);
}
}
}
}
pub(crate) fn run_tui_v2(config: &Config, setup: &CliSetup, run_mode: TuiRunMode) -> Result<()> {
TuiApp::bootstrap(config, setup, run_mode)?.run()
}
fn default_session_title(directory: &Path) -> String {
directory
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.trim().is_empty())
.map(|name| format!("TUI v2 {}", name.trim()))
.unwrap_or_else(|| "TUI v2 会话".to_string())
}
fn bootstrap_message_base() -> UiMessageBase {
UiMessageBase::new(UiMessageId::local("ui-bootstrap-system"))
}
fn bootstrap_system_message(run_mode: TuiRunMode) -> String {
match run_mode {
TuiRunMode::Standard => "S7-2b 宿主已启动。直接在输入区键入内容并按 Enter,即可发起真实的 gateway 轮次;按 F1 查看帮助,按 F2/F3/F4 打开问题、待办和任务面板;如需切换宿主,可重新运行 --tui-mode legacy 或 --tui-mode v2-shadow。".to_string(),
TuiRunMode::Shadow => "S7-2b 影子宿主已启动。每一轮都会先走 gateway,再回放 legacy processor 做对比;如需回退,可重新运行 --tui-mode legacy。".to_string(),
}
}
fn provider_name_from_model(model_name: &str) -> Option<String> {
normalize_optional_str_ref(Some(model_name))
.and_then(|model_name| model_name.split_once('/').map(|(provider_name, _)| provider_name))
.map(ToOwned::to_owned)
}
fn load_model_catalog_blocking() -> Vec<TuiModelCatalogEntry> {
let mut entries = block_on_sync(provider::list())
.into_values()
.flat_map(|provider_info| {
let provider_id = provider_info.id;
let provider_name =
normalize_catalog_text(provider_info.name.as_str(), provider_id.as_str());
let mut models = provider_info.models.into_values().collect::<Vec<_>>();
models.sort_by(|left, right| {
left.name.cmp(&right.name).then_with(|| left.id.cmp(&right.id))
});
models.into_iter().map(move |model| TuiModelCatalogEntry {
provider_id: provider_id.clone(),
provider_name: provider_name.clone(),
model_id: model.id.clone(),
model_name: normalize_catalog_text(model.name.as_str(), model.id.as_str()),
})
})
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left.provider_name
.cmp(&right.provider_name)
.then_with(|| left.provider_id.cmp(&right.provider_id))
.then_with(|| left.model_name.cmp(&right.model_name))
.then_with(|| left.model_id.cmp(&right.model_id))
});
entries.dedup_by(|left, right| {
left.provider_id == right.provider_id && left.model_id == right.model_id
});
entries
}
fn normalize_catalog_text(value: &str, fallback: &str) -> String {
let value = value.trim();
if value.is_empty() { fallback.to_string() } else { value.to_string() }
}
fn block_on_sync<F, T>(future: F) -> T
where
F: Future<Output = T>,
{
match tokio::runtime::Handle::try_current() {
Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)),
Err(_) => {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tui_v2 model catalog runtime should initialize");
runtime.block_on(future)
}
}
}
fn gateway_stream_request_from_state(
state: &TuiState,
submission: &PromptSubmission,
) -> GatewayChatStreamRequest {
let snapshot = state.to_chat_session();
GatewayChatStreamRequest {
session_id: normalize_optional_str_ref(submission.session_id.as_deref())
.map(SessionId::from),
messages: snapshot.messages.iter().map(gateway_message_from_chat_message).collect(),
system: None,
model: submission.model.clone(),
agent: None,
allowed_tools: None,
acp_agent: None,
acp_allowed_tools: None,
options: None,
}
}
fn gateway_message_from_chat_message(message: &ChatMessage) -> serde_json::Value {
json!({
"role": gateway_role(message.role),
"content": message.content,
})
}
fn legacy_shadow_request_from_state(
state: &TuiState,
submission: &PromptSubmission,
) -> Option<legacy_processor::Request> {
let session_id = normalize_optional_str_ref(submission.session_id.as_deref())?.to_string();
let root = normalize_optional_str_ref(submission.root.as_deref())
.map(ToOwned::to_owned)
.or_else(|| state.project.workspace_root.as_ref().map(|path| path.display().to_string()));
let snapshot = state.to_chat_session();
Some(legacy_processor::Request {
stream: submission.stream_id.unwrap_or_default(),
session: session_id,
query: submission.text.clone(),
root,
model: submission.model.clone(),
options: json!({}),
approval: None,
channel_name: None,
non_cli_approval_context: None,
assistant_message_id: None,
history: snapshot.messages,
persist_app_session_artifacts: false,
})
}
fn comparable_usage_from_runtime_terminal(terminal: &UiRuntimeTerminalEvent) -> TokenUsage {
match terminal {
UiRuntimeTerminalEvent::Done { usage, .. }
| UiRuntimeTerminalEvent::Cancelled { usage, .. }
| UiRuntimeTerminalEvent::TimedOut { usage, .. } => usage
.as_ref()
.map(|usage| TokenUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
cached_tokens: usage.cached_tokens,
reasoning_tokens: usage.reasoning_tokens,
})
.unwrap_or_default(),
UiRuntimeTerminalEvent::Error(_) => TokenUsage::default(),
}
}
fn comparable_terminal_from_runtime_terminal(
terminal: &UiRuntimeTerminalEvent,
) -> SessionProcessorComparableTerminal {
match terminal {
UiRuntimeTerminalEvent::Done { finish_reason, message_id, parent_message_id, .. } => {
SessionProcessorComparableTerminal::Done {
finish_reason: finish_reason.clone(),
message_id: message_id.clone(),
parent_message_id: parent_message_id.clone(),
}
}
UiRuntimeTerminalEvent::Cancelled { reason, message_id, parent_message_id, .. } => {
SessionProcessorComparableTerminal::Cancelled {
reason: reason.clone(),
message_id: message_id.clone(),
parent_message_id: parent_message_id.clone(),
}
}
UiRuntimeTerminalEvent::TimedOut { message, message_id, parent_message_id, .. } => {
SessionProcessorComparableTerminal::TimedOut {
message: message.clone(),
message_id: message_id.clone(),
parent_message_id: parent_message_id.clone(),
}
}
UiRuntimeTerminalEvent::Error(message) => {
SessionProcessorComparableTerminal::Error(message.clone())
}
}
}
fn run_legacy_shadow_compare_blocking(
request: legacy_processor::Request,
) -> Result<SessionProcessorComparableResult> {
thread::spawn(move || -> Result<SessionProcessorComparableResult> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|err| anyhow!("shadow compare runtime init failed: {err}"))?;
runtime.block_on(run_session_processor_comparable_for_cli(request, None))
})
.join()
.map_err(|_| anyhow!("legacy shadow compare worker panicked"))?
}
pub(crate) fn compare_shadow_results(
gateway: &SessionProcessorComparableResult,
legacy: &SessionProcessorComparableResult,
) -> std::result::Result<(), String> {
let mut diffs = Vec::new();
if comparable_terminal_kind(gateway.terminal()) != comparable_terminal_kind(legacy.terminal()) {
diffs.push(format!(
"terminal {} != {}",
comparable_terminal_kind(gateway.terminal()),
comparable_terminal_kind(legacy.terminal())
));
}
if normalized_compare_output(gateway.output.as_str())
!= normalized_compare_output(legacy.output.as_str())
{
diffs.push(format!(
"output {} != {}",
compare_output_summary(gateway.output.as_str()),
compare_output_summary(legacy.output.as_str())
));
}
if gateway.usage != legacy.usage {
diffs.push(format!(
"usage {} != {}",
usage_summary(&gateway.usage),
usage_summary(&legacy.usage)
));
}
if gateway.step_finishes != legacy.step_finishes {
diffs.push(format!("steps {} != {}", gateway.step_finishes, legacy.step_finishes));
}
if diffs.is_empty() { Ok(()) } else { Err(diffs.join("; ")) }
}
fn comparable_terminal_kind(terminal: &SessionProcessorComparableTerminal) -> &'static str {
match terminal {
SessionProcessorComparableTerminal::Done { .. } => "done",
SessionProcessorComparableTerminal::Cancelled { .. } => "cancelled",
SessionProcessorComparableTerminal::TimedOut { .. } => "timeout",
SessionProcessorComparableTerminal::Error(_) => "error",
}
}
fn normalized_compare_output(output: &str) -> &str {
output.trim_end_matches(['\n', '\r'])
}
fn compare_output_summary(output: &str) -> String {
let normalized = normalized_compare_output(output);
let preview = normalized.chars().take(24).collect::<String>();
if preview.is_empty() {
"0 chars".to_string()
} else if normalized.chars().count() > 24 {
format!("{} chars ({preview}...)", normalized.chars().count())
} else {
format!("{} chars ({preview})", normalized.chars().count())
}
}
fn usage_summary(usage: &TokenUsage) -> String {
format!(
"in={} out={} cached={} reasoning={}",
usage.input_tokens, usage.output_tokens, usage.cached_tokens, usage.reasoning_tokens
)
}
fn shadow_compare_success_message(result: &SessionProcessorComparableResult) -> String {
format!(
"Shadow compare matched legacy on terminal/output/usage/steps (terminal={} steps={} output={}).",
comparable_terminal_kind(result.terminal()),
result.step_finishes,
compare_output_summary(result.output.as_str())
)
}
trait ComparableTerminalAccess {
fn terminal(&self) -> &SessionProcessorComparableTerminal;
}
impl ComparableTerminalAccess for SessionProcessorComparableResult {
fn terminal(&self) -> &SessionProcessorComparableTerminal {
&self.terminal
}
}
fn gateway_role(role: ChatRole) -> &'static str {
match role {
ChatRole::User => "user",
ChatRole::Assistant => "assistant",
ChatRole::System => "system",
ChatRole::Tool => "tool",
}
}
struct TuiTerminalLifecycle {
terminal: TuiTerminal,
}
impl TuiTerminalLifecycle {
fn enter() -> Result<Self> {
enable_raw_mode()?;
#[cfg(unix)]
let mut screen: CliBackendWriter =
OpenOptions::new().read(true).write(true).open("/dev/tty")?;
#[cfg(not(unix))]
let mut screen: CliBackendWriter = std::io::stdout();
screen.execute(EnterAlternateScreen)?;
screen.execute(EnableMouseCapture)?;
let _ = screen.execute(PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES,
));
let backend = CrosstermBackend::new(screen);
let mut terminal = Terminal::new(backend)?;
terminal.hide_cursor()?;
Ok(Self { terminal })
}
fn terminal_mut(&mut self) -> &mut TuiTerminal {
&mut self.terminal
}
}
impl Drop for TuiTerminalLifecycle {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = self.terminal.backend_mut().execute(PopKeyboardEnhancementFlags);
let _ = self.terminal.backend_mut().execute(DisableMouseCapture);
let _ = self.terminal.backend_mut().execute(LeaveAlternateScreen);
let _ = self.terminal.show_cursor();
}
}