use vw_shared::question;
use vw_shared::todo::Todo;
use super::ui_message::{UiMessageId, UiStepState, UiTokenUsage, UiTurnTerminal};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum OverlayFocus {
#[default]
Prompt,
Overlay,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct OverlayState {
pub(crate) stack: Vec<UiOverlay>,
pub(crate) focus: OverlayFocus,
}
impl OverlayState {
pub(crate) fn push(&mut self, overlay: UiOverlay) {
self.stack.push(overlay);
self.focus = OverlayFocus::Overlay;
}
pub(crate) fn pop(&mut self) -> Option<UiOverlay> {
let overlay = self.stack.pop();
if self.stack.is_empty() {
self.focus = OverlayFocus::Prompt;
}
overlay
}
pub(crate) fn active(&self) -> Option<&UiOverlay> {
self.stack.last()
}
pub(crate) fn clear(&mut self) {
self.stack.clear();
self.focus = OverlayFocus::Prompt;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UiOverlayKind {
Confirm,
Search,
Question,
Todo,
Task,
CommandPalette,
Error,
Mcp,
Memory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum UiOverlay {
Confirm(UiConfirmOverlay),
Search(UiSearchOverlay),
Question(UiQuestionOverlay),
Todo(UiTodoOverlay),
Task(UiTaskOverlay),
CommandPalette(UiCommandPaletteOverlay),
Error(UiErrorOverlay),
Mcp(UiMcpOverlay),
Memory(UiMemoryOverlay),
}
impl UiOverlay {
pub(crate) fn kind(&self) -> UiOverlayKind {
match self {
Self::Confirm(_) => UiOverlayKind::Confirm,
Self::Search(_) => UiOverlayKind::Search,
Self::Question(_) => UiOverlayKind::Question,
Self::Todo(_) => UiOverlayKind::Todo,
Self::Task(_) => UiOverlayKind::Task,
Self::CommandPalette(_) => UiOverlayKind::CommandPalette,
Self::Error(_) => UiOverlayKind::Error,
Self::Mcp(_) => UiOverlayKind::Mcp,
Self::Memory(_) => UiOverlayKind::Memory,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiConfirmOverlay {
pub(crate) title: String,
pub(crate) body: String,
pub(crate) confirm_label: String,
pub(crate) cancel_label: String,
pub(crate) destructive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiSearchMatch {
pub(crate) message_id: Option<UiMessageId>,
pub(crate) start: usize,
pub(crate) end: usize,
pub(crate) preview: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct UiSearchOverlay {
pub(crate) query: String,
pub(crate) matches: Vec<UiSearchMatch>,
pub(crate) selected_index: Option<usize>,
pub(crate) case_sensitive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiQuestionOption {
pub(crate) label: String,
pub(crate) description: String,
pub(crate) preview: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiQuestionPrompt {
pub(crate) header: String,
pub(crate) question: String,
pub(crate) options: Vec<UiQuestionOption>,
pub(crate) multiple: bool,
pub(crate) allow_custom_input: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiQuestionToolContext {
pub(crate) message_id: String,
pub(crate) call_id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UiQuestionSurfaceKind {
Question,
ToolFallback,
PermissionRequest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiQuestionOverlay {
pub(crate) request_id: String,
pub(crate) session_id: String,
pub(crate) prompts: Vec<UiQuestionPrompt>,
pub(crate) answers: Vec<Vec<String>>,
pub(crate) tool: Option<UiQuestionToolContext>,
pub(crate) selected_index: usize,
}
impl UiQuestionOverlay {
pub(crate) fn from_request(request: &question::Request) -> Self {
let prompts = request
.questions
.iter()
.map(|info| UiQuestionPrompt {
header: info.header.clone(),
question: info.question.clone(),
options: info
.options
.iter()
.map(|option| UiQuestionOption {
label: option.label.clone(),
description: option.description.clone(),
preview: option.preview.clone(),
})
.collect(),
multiple: info.multiple.unwrap_or(false),
allow_custom_input: info.custom.unwrap_or(false),
})
.collect::<Vec<_>>();
Self {
request_id: request.id.clone(),
session_id: request.session_id.clone(),
answers: vec![Vec::new(); prompts.len()],
prompts,
tool: request.tool.as_ref().map(|tool| UiQuestionToolContext {
message_id: tool.message_id.clone(),
call_id: tool.call_id.clone(),
}),
selected_index: 0,
}
}
pub(crate) fn is_tool_backed(&self) -> bool {
self.tool.is_some()
}
pub(crate) fn surface_kind(&self) -> UiQuestionSurfaceKind {
if self.is_permission_request() {
UiQuestionSurfaceKind::PermissionRequest
} else if self.is_tool_backed() {
UiQuestionSurfaceKind::ToolFallback
} else {
UiQuestionSurfaceKind::Question
}
}
pub(crate) fn is_permission_request(&self) -> bool {
self.is_tool_backed()
&& self.prompts.iter().any(|prompt| {
contains_permission_marker(prompt.header.as_str())
|| contains_permission_marker(prompt.question.as_str())
|| prompt.options.iter().any(|option| {
option_looks_like_permission(option.label.as_str())
|| option_looks_like_permission(option.description.as_str())
})
})
}
pub(crate) fn modal_title(&self) -> &'static str {
match self.surface_kind() {
UiQuestionSurfaceKind::Question => "提问",
UiQuestionSurfaceKind::ToolFallback => "工具提问回退",
UiQuestionSurfaceKind::PermissionRequest => "权限请求",
}
}
pub(crate) fn request_label(&self) -> &'static str {
match self.surface_kind() {
UiQuestionSurfaceKind::Question => "提问",
UiQuestionSurfaceKind::ToolFallback => "工具提问",
UiQuestionSurfaceKind::PermissionRequest => "权限请求",
}
}
pub(crate) fn reply_error_title(&self) -> &'static str {
match self.surface_kind() {
UiQuestionSurfaceKind::Question => "提问回复失败",
UiQuestionSurfaceKind::ToolFallback => "工具提问回复失败",
UiQuestionSurfaceKind::PermissionRequest => "权限请求回复失败",
}
}
pub(crate) fn reject_error_title(&self) -> &'static str {
match self.surface_kind() {
UiQuestionSurfaceKind::Question => "提问拒绝失败",
UiQuestionSurfaceKind::ToolFallback => "工具提问拒绝失败",
UiQuestionSurfaceKind::PermissionRequest => "权限请求拒绝失败",
}
}
pub(crate) fn empty_submission_title(&self) -> &'static str {
match self.surface_kind() {
UiQuestionSurfaceKind::Question => "提交提问回复",
UiQuestionSurfaceKind::ToolFallback => "提交工具提问回复",
UiQuestionSurfaceKind::PermissionRequest => "提交权限请求回复",
}
}
pub(crate) fn empty_submission_message(&self) -> &'static str {
match self.surface_kind() {
UiQuestionSurfaceKind::Question => "请至少提供一个回答后再提交。",
UiQuestionSurfaceKind::ToolFallback => {
"请至少提供一个回答后再提交该工具回退问题,或按 Ctrl+R 明确拒绝。"
}
UiQuestionSurfaceKind::PermissionRequest => {
"请先选择一个授权选项后再提交,或按 Ctrl+R 明确拒绝。"
}
}
}
}
fn contains_permission_marker(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() {
return false;
}
["approval", "approve", "permission", "allow", "deny", "reject"]
.iter()
.any(|marker| normalized.contains(marker))
}
fn option_looks_like_permission(value: &str) -> bool {
let normalized = value.trim().to_ascii_lowercase();
if normalized.is_empty() {
return false;
}
["allow", "approve", "deny", "reject", "always"]
.iter()
.any(|marker| normalized.contains(marker))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiTodoItem {
pub(crate) id: String,
pub(crate) content: String,
pub(crate) status: String,
pub(crate) priority: String,
}
impl From<&Todo> for UiTodoItem {
fn from(value: &Todo) -> Self {
Self {
id: value.id.clone(),
content: value.content.clone(),
status: value.status.clone(),
priority: value.priority.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct UiTodoOverlay {
pub(crate) session_id: Option<String>,
pub(crate) items: Vec<UiTodoItem>,
pub(crate) selected_index: usize,
pub(crate) dirty: bool,
}
impl UiTodoOverlay {
pub(crate) fn from_todos(session_id: Option<&str>, todos: &[Todo]) -> Self {
Self {
session_id: session_id.map(ToOwned::to_owned),
items: todos.iter().map(UiTodoItem::from).collect(),
selected_index: 0,
dirty: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiTaskStepItem {
pub(crate) message_id: UiMessageId,
pub(crate) step_index: u32,
pub(crate) state: UiStepState,
pub(crate) started_ms: u64,
pub(crate) finished_ms: Option<u64>,
pub(crate) model: Option<String>,
pub(crate) finish_reason: Option<String>,
pub(crate) usage: UiTokenUsage,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct UiTaskOverlay {
pub(crate) session_id: Option<String>,
pub(crate) turn_terminal: UiTurnTerminal,
pub(crate) pending_questions: usize,
pub(crate) todo_count: usize,
pub(crate) sync_error: Option<String>,
pub(crate) steps: Vec<UiTaskStepItem>,
pub(crate) selected_index: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiPaletteItem {
pub(crate) id: String,
pub(crate) label: String,
pub(crate) detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct UiCommandPaletteOverlay {
pub(crate) query: String,
pub(crate) items: Vec<UiPaletteItem>,
pub(crate) selected_index: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiErrorOverlay {
pub(crate) title: String,
pub(crate) message: String,
pub(crate) recoverable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum McpServerTransport {
Stdio,
Sse,
Http,
}
impl McpServerTransport {
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Stdio => "stdio",
Self::Sse => "sse",
Self::Http => "http",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiMcpServerInfo {
pub(crate) name: String,
pub(crate) transport: McpServerTransport,
pub(crate) address: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct UiMcpOverlay {
pub(crate) servers: Vec<UiMcpServerInfo>,
pub(crate) selected_index: usize,
pub(crate) config_source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UiMemoryEntry {
pub(crate) scope: String,
pub(crate) filename: String,
pub(crate) path: String,
pub(crate) preview_lines: Vec<String>,
pub(crate) total_lines: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct UiMemoryOverlay {
pub(crate) entries: Vec<UiMemoryEntry>,
pub(crate) selected_index: usize,
}