mermaid_cli/domain/state.rs
1//! The single state shape for the whole application.
2//!
3//! `State` is the value the reducer operates on. Everything the UI
4//! shows — from the chat log to the input buffer to the "Thinking…"
5//! animation — is derived from fields in this struct. Mutation happens
6//! only inside `update(state, msg)`; no other code is allowed to hold
7//! a `&mut State`.
8//!
9//! The sub-state enums (`TurnState`, `UiMode`, `McpServerStatus`) are
10//! intentionally explicit sum types. A previous generation of this
11//! codebase used bools like `is_generating: bool`, `is_cancelling:
12//! bool`, `is_tool_call_pending: bool` — the invariants between those
13//! bools were load-bearing and enforced by convention. Expressing the
14//! same state as a single enum makes it impossible to be in two modes
15//! at once, and the reducer can pattern-match instead of guarding with
16//! if-chains.
17
18use std::collections::{HashMap, VecDeque};
19use std::path::PathBuf;
20use std::time::SystemTime;
21
22use chrono::{DateTime, Local};
23
24use crate::app::instructions::LoadedInstructions;
25use crate::app::{Config, McpServerConfig};
26use crate::models::ChatMessage;
27use crate::models::tool_call::ToolCall as ModelToolCall;
28use crate::models::{ProviderContinuation, ReasoningLevel, TokenUsage, TokenUsageSource};
29use crate::runtime::SafetyMode;
30use crate::session::ConversationHistory;
31
32use super::cmd::ChatRequest;
33use super::compaction::CompactionTrigger;
34use super::ids::{IdAllocator, ToolCallId, TurnId};
35use super::msg::Msg;
36use super::question::PendingQuestionSet;
37use super::runtime::{RuntimeState, ToolArtifact, ToolRunMetadata, ToolStatus};
38
39/// Root state. The reducer takes `State` by value, returns a new
40/// `State`, and emits any side-effects as a `Vec<Cmd>`. No `&mut` — a
41/// deliberate choice so tests can diff before/after without aliasing
42/// worries, and so replay ("compute the final State that this Msg log
43/// would produce") is a straight fold.
44#[derive(Debug, Clone)]
45pub struct State {
46 pub session: Session,
47 pub turn: TurnState,
48 pub ui: UiState,
49 pub mcp: McpState,
50 pub settings: Config,
51 pub instructions: Option<LoadedInstructions>,
52 /// Durable semantic memory snapshot (auto-derived index + entries),
53 /// refreshed per turn like `instructions`. Its index is injected into the
54 /// model prompt alongside project instructions.
55 pub memory: Option<crate::app::memory::LoadedMemory>,
56 /// Discovered SKILL.md playbooks (project/user/plugin) plus the rendered
57 /// index injected into the model prompt alongside instructions and memory.
58 /// Loaded once at startup — skills are authored artifacts, not live state.
59 pub skills: Option<crate::app::skills::LoadedSkills>,
60 /// Context strings injected by `before_tool_use` plugin hooks
61 /// (`additionalContext`), buffered until the next dispatched model
62 /// request consumes them (see `push_call_model`). Byte-capped; transient
63 /// (never persisted with the session).
64 pub pending_hook_context: Vec<String>,
65 /// One-line notices about the task checklist for the model's next
66 /// request: user `/todos` edits, vetoed completions, staleness nudges.
67 /// Same lifecycle as `pending_hook_context` (consumed by the next real
68 /// dispatch, transient, never persisted).
69 pub pending_task_notices: Vec<String>,
70 /// Current working directory. Captured once at startup; tools
71 /// receive it via `ExecContext::workdir` and spawned subprocesses
72 /// inherit it. Centralized here so tests can inject a fake cwd.
73 pub cwd: PathBuf,
74 /// System temp dir, captured once at startup (`std::env::temp_dir()`).
75 /// Pasted-image attachments build their scratch path from it; holding it
76 /// here keeps the reducer free of the env read it used to do inline (#54).
77 pub temp_dir: PathBuf,
78 pub ids: IdAllocatorBundle,
79 /// When `Some`, the next render should pop up a modal confirmation
80 /// (e.g. "are you sure you want to /clear?"). Cleared by the
81 /// reducer when the user answers.
82 pub confirm: Option<Confirmation>,
83 /// FIFO queue of tool actions awaiting the user's inline approval
84 /// (interactive `ask` mode + Auto-mode escalations). The front item is
85 /// rendered as a modal; answering it pops the item and emits
86 /// `Cmd::ResolveApproval`, which unblocks the parked tool task. Empty in
87 /// headless mode (no broker → the out-of-band `/approve` flow instead).
88 pub pending_approval: VecDeque<PendingApproval>,
89 /// FIFO queue of `ask_user_question` batches awaiting the user's answers.
90 /// The front item renders as a selectable modal; submitting pops it and
91 /// emits `Cmd::ResolveQuestion`, unblocking the parked tool task. Empty in
92 /// headless mode (no broker → the tool proceeds without asking).
93 pub pending_question: VecDeque<PendingQuestionSet>,
94 /// Runtime-only observability state: process registry, provider
95 /// capability snapshot, and lifecycle timeline. Not sent to the
96 /// model.
97 pub runtime: RuntimeState,
98 /// Quit flag. When set, the main loop drains pending effects and
99 /// exits. The reducer never panics on its own; it sets this instead.
100 pub should_exit: bool,
101 /// Prompt-backed slash commands contributed by enabled plugins
102 /// (`manifest.prompts`). Loaded once at startup by the run loop (like
103 /// `skills`); the reducer expands `/name args` into a normal
104 /// `Msg::SubmitPrompt`, so recordings replay without the plugin
105 /// installed. Sorted by name.
106 pub plugin_commands: Vec<PluginCommand>,
107 /// `mermaid run --output-schema`: set by the headless driver before the
108 /// dedicated formatting turn; `build_chat_request` copies it onto the
109 /// request (dropping all tools for that turn). Never set interactively.
110 pub output_schema: Option<serde_json::Value>,
111 /// Wall-clock for the current reducer step, injected as data (Cause 3).
112 /// The driver stamps this once per tick — `Local::now()` live, or the
113 /// recorded entry's `ts` on replay — *before* calling `update`. The
114 /// reducer and the `transition` helpers read `state.now` instead of
115 /// `Local::now()` / `SystemTime::now()`, so `update(State, Msg)` is a pure
116 /// function of its inputs: the same `(State, Msg)` always yields the same
117 /// `State`, and folding a recorded `Msg` log recomputes State exactly.
118 pub now: DateTime<Local>,
119}
120
121impl State {
122 /// Build a fresh state tied to a specific model + project dir.
123 ///
124 /// Pure given its inputs: `now` seeds the injected clock and derives the
125 /// initial conversation's id/title, so `--replay` reconstructs the same
126 /// starting state from a recorded header. (The one environment read left
127 /// is `env::temp_dir()` — stable within a machine, and only feeds paste
128 /// scratch paths.) Nothing here touches the filesystem or tokio.
129 pub fn new(settings: Config, cwd: PathBuf, model_id: String, now: DateTime<Local>) -> Self {
130 let project_path = cwd.display().to_string();
131 let conversation = ConversationHistory::new(project_path, model_id.clone(), now);
132 let initial_title = conversation.title.clone();
133 // F5: seed `mcp.servers` from the user's configured MCP
134 // servers with `Starting` status. Previously the map started
135 // empty, and `McpServerReady` handlers used `get_mut` —
136 // configured servers never populated, so their tools never
137 // reached `build_chat_request`'s outgoing tool list.
138 let mcp = {
139 let mut m = McpState::default();
140 for (name, cfg) in &settings.mcp_servers {
141 m.servers.insert(
142 name.clone(),
143 McpServerEntry {
144 config: cfg.clone(),
145 status: McpServerStatus::Starting,
146 tools: Vec::new(),
147 },
148 );
149 }
150 m
151 };
152 // F11: honor the per-model reasoning preference (persisted via
153 // `/reasoning high` while using a specific model). Falls back to
154 // the global default when no entry exists.
155 let reasoning = settings
156 .reasoning_per_model
157 .get(&model_id)
158 .copied()
159 .unwrap_or(settings.default_model.reasoning);
160 let runtime = RuntimeState::new(&model_id);
161 Self {
162 session: Session {
163 conversation,
164 model_id,
165 reasoning,
166 safety_mode: settings.safety.mode,
167 last_token_usage: None,
168 cumulative_token_usage: TokenUsageTotals::default(),
169 context_usage: None,
170 is_subagent: false,
171 agent_preamble: None,
172 plan: None,
173 // Materialized by the effect layer after startup dispatches
174 // `Cmd::EnsureScratchpad`; the pure constructor never touches
175 // the filesystem.
176 scratchpad: None,
177 },
178 turn: TurnState::Idle,
179 ui: UiState {
180 last_title_dispatched: Some(initial_title),
181 theme: settings.ui.theme,
182 ..UiState::default()
183 },
184 mcp,
185 settings,
186 instructions: None,
187 memory: None,
188 skills: None,
189 pending_hook_context: Vec::new(),
190 pending_task_notices: Vec::new(),
191 cwd,
192 temp_dir: std::env::temp_dir(),
193 ids: IdAllocatorBundle::default(),
194 confirm: None,
195 pending_approval: VecDeque::new(),
196 pending_question: VecDeque::new(),
197 runtime,
198 should_exit: false,
199 output_schema: None,
200 plugin_commands: Vec::new(),
201 // Seed the injected clock from the caller (live: startup wall
202 // clock; replay: the recorded header's ts). The driver overwrites
203 // this on every iteration (Cause 3); the reducer never reads the
204 // wall clock directly.
205 now,
206 }
207 }
208
209 /// Apply a `--continue` / `--sessions` seed: replace the fresh
210 /// conversation with the loaded history and re-dispatch the terminal
211 /// title once. Shared by the live driver and `--replay` so both
212 /// construct the same starting state by definition.
213 pub fn seed_conversation(&mut self, history: ConversationHistory) {
214 let title = history.title.clone();
215 // Restore the live meters + safety mode that ride on the saved file
216 // (see `Session::snapshot_conversation`). Sessions saved before these
217 // fields existed leave them at None/0, so keep the config-default
218 // safety mode (already set by `State::new`) when the file has none.
219 if let Some(mode) = history.safety_mode {
220 self.session.safety_mode = mode;
221 }
222 // Restore planning-in-progress (None for sessions saved before the
223 // field existed, and for sessions that weren't planning).
224 self.session.plan = history.plan.clone();
225 self.session.last_token_usage = history.last_token_usage;
226 self.session.cumulative_token_usage = history.cumulative_token_usage;
227 self.session.context_usage = history.context_usage.clone();
228 self.session.conversation = history;
229 // A session persisted mid-tool (an assistant `tool_use` with no committed
230 // result, or a result whose call was archived out) would otherwise resume
231 // with an orphan and 400 the first request. Repair pairing on the loaded
232 // prefix so both the transcript and the next request are valid.
233 crate::domain::compaction::normalize_history(self.session.conversation.messages_mut());
234 // Checklist retirement deliberately does NOT happen here. There is one
235 // retirement rule and it lives at natural run end
236 // (`handle_stream_done`), where the summary line absorbs the count so
237 // retirement reads as completion rather than data loss.
238 //
239 // Retiring again at seed time made a SECOND rule with different
240 // behavior: run end preserves the list when a run is cancelled or
241 // errors, but a seed-time clear discarded any all-done list on the
242 // next `--resume`/`--continue` — including one the user cancelled and
243 // came back to. The transcript is not a substitute for the checklist
244 // the next run resumes against.
245 // Continue global image numbering past the highest number already in the
246 // loaded transcript, so `[Image #16]` keeps referring to that same image
247 // across --resume/--continue. Sessions saved before image numbering (no
248 // `image_numbers`) yield max 0 → start at 1, the default. Shared live +
249 // --replay seed path, so both reconstruct an identical allocator.
250 let max_image = self
251 .session
252 .conversation
253 .messages()
254 .iter()
255 .filter_map(|m| m.image_numbers.as_ref())
256 .flatten()
257 .copied()
258 .max()
259 .unwrap_or(0);
260 self.ids.image = crate::domain::ids::IdAllocator::starting_at(max_image + 1);
261 self.ui.last_title_dispatched = Some(title);
262 }
263
264 /// True iff the reducer is currently mid-turn. UI uses this for
265 /// the "⏎ cancels generation" hint and for keybind routing.
266 pub fn is_busy(&self) -> bool {
267 !matches!(self.turn, TurnState::Idle)
268 }
269
270 /// The active `TurnId`, if any turn is in flight. The reducer
271 /// filters incoming effect messages by comparing their embedded
272 /// `TurnId` to this value — if the user cancelled and started a
273 /// new turn, stale results from the old turn are dropped cleanly.
274 pub fn current_turn_id(&self) -> Option<TurnId> {
275 self.turn.id()
276 }
277}
278
279/// Per-component token counts accumulated for UI display. Components
280/// are disjoint (mirrors `TokenUsage`); totals are derived, never
281/// stored. Providers report usage per API request; the session keeps
282/// both the last request and the cumulative API usage so the footer
283/// does not imply this is the current model context length.
284#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
285pub struct TokenUsageTotals {
286 pub prompt_tokens: usize,
287 pub completion_tokens: usize,
288 pub cached_input_tokens: usize,
289 pub cache_creation_input_tokens: usize,
290 pub reasoning_output_tokens: usize,
291}
292
293impl TokenUsageTotals {
294 pub fn from_usage(usage: &TokenUsage) -> Self {
295 Self {
296 prompt_tokens: usage.prompt_tokens,
297 completion_tokens: usage.completion_tokens,
298 cached_input_tokens: usage.cached_input_tokens,
299 cache_creation_input_tokens: usage.cache_creation_input_tokens,
300 reasoning_output_tokens: usage.reasoning_output_tokens,
301 }
302 }
303
304 pub fn add_assign(&mut self, other: Self) {
305 self.prompt_tokens = self.prompt_tokens.saturating_add(other.prompt_tokens);
306 self.completion_tokens = self
307 .completion_tokens
308 .saturating_add(other.completion_tokens);
309 self.cached_input_tokens = self
310 .cached_input_tokens
311 .saturating_add(other.cached_input_tokens);
312 self.cache_creation_input_tokens = self
313 .cache_creation_input_tokens
314 .saturating_add(other.cache_creation_input_tokens);
315 self.reasoning_output_tokens = self
316 .reasoning_output_tokens
317 .saturating_add(other.reasoning_output_tokens);
318 }
319
320 pub fn input_total_tokens(&self) -> usize {
321 self.prompt_tokens
322 .saturating_add(self.cached_input_tokens)
323 .saturating_add(self.cache_creation_input_tokens)
324 }
325
326 pub fn output_total_tokens(&self) -> usize {
327 self.completion_tokens
328 .saturating_add(self.reasoning_output_tokens)
329 }
330
331 pub fn total_tokens(&self) -> usize {
332 self.input_total_tokens()
333 .saturating_add(self.output_total_tokens())
334 }
335}
336
337/// Approximate request-context breakdown used before provider usage
338/// arrives. These numbers are diagnostic estimates, not billing facts.
339#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
340pub struct PromptTokenBreakdown {
341 pub system_tokens: usize,
342 pub instructions_tokens: usize,
343 pub message_tokens: usize,
344 pub tool_schema_tokens: usize,
345 pub image_count: usize,
346 pub message_count: usize,
347 pub tool_count: usize,
348}
349
350impl PromptTokenBreakdown {
351 pub fn total_tokens(&self) -> usize {
352 self.system_tokens
353 .saturating_add(self.instructions_tokens)
354 .saturating_add(self.message_tokens)
355 .saturating_add(self.tool_schema_tokens)
356 }
357}
358
359/// The model-visible context for the latest request. This is separate
360/// from cumulative session usage, which is an API/accounting total.
361#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
362pub struct ContextUsageSnapshot {
363 pub used_tokens: usize,
364 pub max_tokens: Option<usize>,
365 pub remaining_tokens: Option<usize>,
366 pub used_percent: Option<u8>,
367 pub source: TokenUsageSource,
368 pub prompt_tokens: usize,
369 pub cached_input_tokens: usize,
370 pub cache_creation_input_tokens: usize,
371 pub completion_tokens: usize,
372 pub reasoning_output_tokens: usize,
373 pub breakdown: Option<PromptTokenBreakdown>,
374}
375
376impl ContextUsageSnapshot {
377 pub fn from_usage(usage: &TokenUsage, max_tokens: Option<usize>) -> Self {
378 // input + output ≈ what the next request's prompt will occupy;
379 // derived from disjoint components so it means the same thing
380 // for every provider.
381 Self::new(
382 usage.total_tokens(),
383 max_tokens,
384 usage.source,
385 usage.prompt_tokens,
386 usage.cached_input_tokens,
387 usage.cache_creation_input_tokens,
388 usage.completion_tokens,
389 usage.reasoning_output_tokens,
390 None,
391 )
392 }
393
394 pub fn from_estimate(breakdown: PromptTokenBreakdown, max_tokens: Option<usize>) -> Self {
395 let used = breakdown.total_tokens();
396 Self::new(
397 used,
398 max_tokens,
399 TokenUsageSource::Estimate,
400 used,
401 0,
402 0,
403 0,
404 0,
405 Some(breakdown),
406 )
407 }
408
409 #[allow(clippy::too_many_arguments)]
410 fn new(
411 used_tokens: usize,
412 max_tokens: Option<usize>,
413 source: TokenUsageSource,
414 prompt_tokens: usize,
415 cached_input_tokens: usize,
416 cache_creation_input_tokens: usize,
417 completion_tokens: usize,
418 reasoning_output_tokens: usize,
419 breakdown: Option<PromptTokenBreakdown>,
420 ) -> Self {
421 let remaining_tokens = max_tokens.map(|max| max.saturating_sub(used_tokens));
422 let used_percent = max_tokens
423 .filter(|max| *max > 0)
424 .map(|max| ((used_tokens.saturating_mul(100)) / max).min(100) as u8);
425 Self {
426 used_tokens,
427 max_tokens,
428 remaining_tokens,
429 used_percent,
430 source,
431 prompt_tokens,
432 cached_input_tokens,
433 cache_creation_input_tokens,
434 completion_tokens,
435 reasoning_output_tokens,
436 breakdown,
437 }
438 }
439
440 pub fn is_estimate(&self) -> bool {
441 self.source == TokenUsageSource::Estimate
442 }
443
444 /// Return a copy with `extra` tokens folded into the running total. Used by
445 /// `/context` to add built-in tool-schema tokens that the reducer's
446 /// MCP-only request estimate can't see. Recomputes `remaining_tokens` and
447 /// `used_percent`; the breakdown is left untouched (the caller surfaces the
448 /// built-in figure on its own line so the MCP line stays accurate).
449 pub fn with_additional_tokens(mut self, extra: usize) -> Self {
450 if extra == 0 {
451 return self;
452 }
453 self.used_tokens = self.used_tokens.saturating_add(extra);
454 self.remaining_tokens = self
455 .max_tokens
456 .map(|max| max.saturating_sub(self.used_tokens));
457 self.used_percent = self
458 .max_tokens
459 .filter(|max| *max > 0)
460 .map(|max| ((self.used_tokens.saturating_mul(100)) / max).min(100) as u8);
461 self
462 }
463}
464
465pub fn estimate_context_usage_for_request(
466 request: &ChatRequest,
467 max_tokens: Option<usize>,
468) -> ContextUsageSnapshot {
469 let system_tokens = approx_tokens(&request.system_prompt);
470 let instructions_tokens = request
471 .instructions
472 .as_deref()
473 .map(approx_tokens)
474 .unwrap_or(0);
475 let message_tokens = request
476 .messages
477 .iter()
478 .map(|msg| {
479 let image_chars = msg
480 .images
481 .as_ref()
482 .map(|imgs| imgs.iter().map(|img| img.len()).sum::<usize>())
483 .unwrap_or(0);
484 // Include assistant tool-call name + arguments JSON, which the
485 // estimate previously ignored (see estimate_message_tokens).
486 let tool_call_chars = msg
487 .tool_calls
488 .as_ref()
489 .map(|calls| {
490 calls
491 .iter()
492 .map(|tc| {
493 tc.function.name.len()
494 + tc.function.arguments.to_string().len()
495 + tc.id.as_deref().map(str::len).unwrap_or(0)
496 })
497 .sum::<usize>()
498 })
499 .unwrap_or(0);
500 approx_tokens(&msg.content)
501 .saturating_add(approx_tokens(&format!(
502 "{:?}{}{}",
503 msg.role,
504 msg.tool_name.as_deref().unwrap_or(""),
505 msg.tool_call_id.as_deref().unwrap_or("")
506 )))
507 .saturating_add(image_chars.div_ceil(4))
508 .saturating_add(tool_call_chars.div_ceil(4))
509 })
510 .sum();
511 let tool_schema_tokens = estimate_tool_schema_tokens(&request.tools);
512 let image_count = request
513 .messages
514 .iter()
515 .filter_map(|msg| msg.images.as_ref())
516 .map(Vec::len)
517 .sum();
518 ContextUsageSnapshot::from_estimate(
519 PromptTokenBreakdown {
520 system_tokens,
521 instructions_tokens,
522 message_tokens,
523 tool_schema_tokens,
524 image_count,
525 message_count: request.messages.len(),
526 tool_count: request.tools.len(),
527 },
528 max_tokens,
529 )
530}
531
532fn approx_tokens(text: &str) -> usize {
533 text.len().div_ceil(4)
534}
535
536/// Estimate the token cost of a set of tool schemas as the model sees them
537/// (serialized OpenAI-style). Shared by `estimate_context_usage_for_request`
538/// and the effect runner so the reducer's `/context` preview can account for
539/// the built-in tool schemas that are only appended to the request during
540/// dispatch enrichment.
541pub fn estimate_tool_schema_tokens(tools: &[super::cmd::ToolDefinition]) -> usize {
542 let tool_schema: Vec<_> = tools.iter().map(|tool| tool.to_openai_json()).collect();
543 serde_json::to_string(&tool_schema)
544 .map(|s| approx_tokens(&s))
545 .unwrap_or(0)
546}
547
548/// The plan's DATA while `Session.safety_mode == SafetyMode::Plan` — never the
549/// fact of being in plan mode, which the mode value alone decides. Plan IS a
550/// safety mode (the strictest position in the Shift+Tab cycle), so there is no
551/// second flag and no remembered restore target here; the policy gate applies
552/// the plan carve-outs (the plan file itself, memory writes, known-safe builds)
553/// off the mode.
554///
555/// Serialized into `ConversationHistory` on every save (like `safety_mode`)
556/// so `--resume` restores planning-in-progress; sessions saved before this
557/// field existed deserialize to `None`.
558#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
559pub struct PlanState {
560 /// Absolute path of the plan file the model authors — the single path the
561 /// policy gate exempts from the read-only floor.
562 pub plan_path: std::path::PathBuf,
563 /// Model to restore when plan mode ends. `Some` only when `[plan] model`
564 /// swapped the session onto a plan-phase model at entry.
565 #[serde(default)]
566 pub prev_model_id: Option<String>,
567 /// Reasoning level to restore when plan mode ends. `Some` only when
568 /// `[plan] reasoning` overrode it at entry.
569 #[serde(default)]
570 pub prev_reasoning: Option<crate::models::ReasoningLevel>,
571}
572
573/// The mode-defining facts the model was last told about, snapshotted at
574/// each dispatch by the context-delta injector
575/// (`reducer::advertise_context_changes`): the reducer diffs live state
576/// against this and injects one persistent history marker per change, then
577/// re-stamps it. One un-bypassable announcement path for plan entry/exit,
578/// safety-mode flips, and model swaps — transitions themselves stay
579/// message-log-free (the codex snapshot+diff pattern).
580///
581/// Lives on `ConversationHistory` (persisted with the transcript) so a
582/// resumed session diffs against what THAT conversation's model last saw,
583/// and `/clear`/fresh forks start from `None` (= seed silently, announce
584/// nothing). Plan permissions stay out: a `/plan config` retune is already
585/// reflected live in the system prompt and never contradicts history.
586#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
587pub struct AdvertisedContext {
588 /// `Some(plan_path)` while the model has been told it is planning.
589 pub plan_path: Option<std::path::PathBuf>,
590 pub safety_mode: SafetyMode,
591 pub model_id: String,
592}
593
594impl AdvertisedContext {
595 /// The facts as they stand right now — the injector's diff input.
596 pub fn observe(session: &Session) -> Self {
597 Self {
598 plan_path: session.plan.as_ref().map(|p| p.plan_path.clone()),
599 safety_mode: session.safety_mode,
600 model_id: session.model_id.clone(),
601 }
602 }
603}
604
605/// Persistent conversational state that survives across turns.
606///
607/// "Session" here means the user-visible chat session, not the tokio
608/// runtime or the TCP connection to the provider. One chat = one
609/// `Session` = one on-disk `ConversationHistory` file.
610#[derive(Debug, Clone)]
611pub struct Session {
612 pub conversation: ConversationHistory,
613 pub model_id: String,
614 pub reasoning: ReasoningLevel,
615 /// Live safety mode for this session. Initialized from
616 /// `config.safety.mode`, then mutated in-session by `Shift+Tab` /
617 /// `/safety` (session-scoped — never written back to the config file).
618 /// The reducer threads this into `Cmd::ExecuteTool` so the policy gate
619 /// enforces the *current* mode, not the startup snapshot.
620 pub safety_mode: SafetyMode,
621 /// Token usage for the most recent completed provider request.
622 /// `None` means the provider did not report usage for that turn.
623 pub last_token_usage: Option<TokenUsageTotals>,
624 /// Prompt/completion/total API usage accumulated for this session.
625 pub cumulative_token_usage: TokenUsageTotals,
626 /// Latest model-visible context snapshot. This may be an estimate
627 /// while a request is in flight and is replaced by provider-reported
628 /// usage when available.
629 pub context_usage: Option<ContextUsageSnapshot>,
630 /// True when this session IS a subagent (a child reducer driven by
631 /// `SubagentTool`). `system_prompt_for_state` appends the subagent
632 /// contract (final message = the report returned to the parent) when
633 /// set. Never true for a user-facing session.
634 pub is_subagent: bool,
635 /// Agent-type system-prompt block (e.g. the Explore type's "read-only
636 /// reconnaissance" charter), appended after the subagent contract.
637 /// Only ever `Some` on subagent sessions.
638 pub agent_preamble: Option<String>,
639 /// `Some` while the session is in plan mode (see [`PlanState`]). Never
640 /// `Some` on subagent sessions — children explore, they don't plan.
641 pub plan: Option<PlanState>,
642 /// Per-session scratch directory, once the effect layer has materialized
643 /// it on disk (`Cmd::EnsureScratchpad` -> `Msg::ScratchpadReady`). `None`
644 /// until then, and reset whenever the conversation id changes (`/clear`,
645 /// `/load`, rewind fork) — the reducer re-emits `EnsureScratchpad` at
646 /// those points. The reducer stamps this onto `Cmd::ExecuteTool` so tools
647 /// see it via `ExecContext::scratchpad`. Runtime-only, never persisted.
648 pub scratchpad: Option<PathBuf>,
649}
650
651impl Session {
652 /// Clone the conversation with the live meters + safety mode overlaid, so
653 /// a saved file carries the full restorable state. These fields live on
654 /// `Session` (which is NOT serialized — only `conversation` is), so every
655 /// `Cmd::SaveConversation` snapshots them in and `seed_conversation`
656 /// hydrates them back on resume.
657 pub fn snapshot_conversation(&self) -> ConversationHistory {
658 let mut history = self.conversation.clone();
659 history.safety_mode = Some(self.safety_mode);
660 history.plan = self.plan.clone();
661 history.last_token_usage = self.last_token_usage;
662 history.cumulative_token_usage = self.cumulative_token_usage;
663 history.context_usage = self.context_usage.clone();
664 history
665 }
666
667 /// The committed message log. All messages visible in the chat
668 /// widget live here; partial in-flight content lives in
669 /// `TurnState::Generating`.
670 pub fn messages(&self) -> &[ChatMessage] {
671 self.conversation.messages()
672 }
673
674 /// Append a committed assistant/user/tool message. Mutation happens
675 /// through here so the reducer has one chokepoint to update the
676 /// conversation's `updated_at` and derived title.
677 ///
678 /// `now` is the reducer's injected clock (`state.now`). It stamps both
679 /// the message's commit timestamp and `updated_at` — the wall-clock
680 /// stamp `ChatMessage::new` put on the message at construction is
681 /// deliberately overwritten with the deterministic one, so `update()`
682 /// is a pure function and `--replay` recommits identical messages.
683 pub fn append(&mut self, mut msg: ChatMessage, now: DateTime<Local>) {
684 msg.timestamp = now;
685 self.conversation.add_messages(&[msg], now);
686 }
687}
688
689/// The turn state machine. Each variant carries its own `TurnId` so
690/// the reducer can cheaply check "is this effect result for the
691/// current turn?" without threading the ID through every match arm.
692///
693/// The `ExecutingTools::outcomes: Vec<Option<ToolOutcome>>` field is
694/// the architectural payoff: every slot starts `None`, flips to
695/// `Some(outcome)` as each tool finishes, and the transition to the
696/// follow-up `Generating` state requires `outcomes` to be fully
697/// populated. Statically impossible to "lose" a tool result.
698#[derive(Debug, Clone)]
699pub enum TurnState {
700 Idle,
701 Generating {
702 id: TurnId,
703 started: SystemTime,
704 partial_text: String,
705 partial_reasoning: String,
706 /// Running token estimate — updated by `StreamText` events.
707 tokens: usize,
708 /// Sub-phase for richer status display (see `GenPhase`).
709 phase: GenPhase,
710 /// Opaque provider state carried until the assistant message commits.
711 provider_continuation: Option<ProviderContinuation>,
712 /// Tool calls the model has streamed so far this turn.
713 /// `StreamToolCall` messages push here; `StreamDone` drains
714 /// the vec, allocates `PendingToolCall` entries, and
715 /// transitions to `ExecutingTools`. When the vec is empty at
716 /// stream end, the turn returns to `Idle`.
717 pending_tool_calls: Vec<ModelToolCall>,
718 /// True when this turn resumes a reply cut by the per-response
719 /// output cap (auto-continue). The commit stamps the resulting
720 /// message `ChatMessageKind::Continuation` so the transcript can
721 /// stitch it into the previous bubble. Survives an intervening
722 /// empty-retry or truncation-recovery compaction so a chain never
723 /// loses the marker mid-way.
724 continuation: bool,
725 },
726 ExecutingTools {
727 id: TurnId,
728 /// When tool execution started, so the status line can show elapsed
729 /// time (a long-running command — `npm run dev`, a slow build — would
730 /// otherwise look frozen at 0s).
731 started: SystemTime,
732 calls: Vec<PendingToolCall>,
733 outcomes: Vec<Option<ToolOutcome>>,
734 },
735 /// Summarizing history as a step of its own: a manual `/compact`
736 /// (`trigger: Manual`, ends the turn afterwards) or a truncation recovery
737 /// (`trigger: TruncationRecovery`, resumes the run afterwards). Pre-turn auto
738 /// compaction instead runs while `Generating` because it is preflight for the
739 /// same user turn. `trigger` is what the finished/failed handlers key off.
740 Compacting {
741 id: TurnId,
742 started: SystemTime,
743 trigger: CompactionTrigger,
744 /// True when the turn that led into this compaction was itself a
745 /// continuation (see `Generating::continuation`): a `TruncationRecovery`
746 /// resume must re-enter `Generating` with the flag intact or a
747 /// continuation chain interrupted by a genuine context-full compaction
748 /// would commit its remaining text unmarked.
749 resume_continuation: bool,
750 },
751 /// `CancelTurn` was dispatched. The reducer has already emitted a
752 /// `Cmd::CancelScope` — now we wait for the final `Cancelled` /
753 /// `StreamDone` that the effect runner sends back when the scope's
754 /// `JoinSet` drains. Only then do we transition to `Idle`.
755 ///
756 /// Stuck in `Cancelling` too long = effect runner has a bug. UI
757 /// surfaces a "cleanup taking a while…" hint after 2s.
758 Cancelling {
759 id: TurnId,
760 since: SystemTime,
761 },
762}
763
764impl TurnState {
765 pub fn id(&self) -> Option<TurnId> {
766 match self {
767 TurnState::Idle => None,
768 TurnState::Generating { id, .. }
769 | TurnState::ExecutingTools { id, .. }
770 | TurnState::Compacting { id, .. }
771 | TurnState::Cancelling { id, .. } => Some(*id),
772 }
773 }
774
775 /// True when a `Msg` tagged with the given `TurnId` should be
776 /// accepted. Events from prior turns return false — the reducer's
777 /// first line on every effect-result arm.
778 pub fn accepts(&self, event_turn: TurnId) -> bool {
779 self.id() == Some(event_turn)
780 }
781}
782
783/// Sub-phase of `Generating`. Informational — the reducer updates it
784/// as the provider's stream progresses so the UI can show a meaningful
785/// status ("Thinking…" vs "Sending…" vs "Streaming").
786#[derive(Debug, Clone, Copy, PartialEq, Eq)]
787pub enum GenPhase {
788 /// Request dispatched, awaiting first byte.
789 Sending,
790 /// First chunk was reasoning content — currently inside a
791 /// thinking/reasoning block.
792 Thinking,
793 /// Streaming assistant content (post-thinking, or no thinking at
794 /// all).
795 Streaming,
796}
797
798/// One pending tool call that the model has asked us to execute. Wraps
799/// the wire-format tool call with an internal ID + the original
800/// provider-native structure so the reducer never loses provenance.
801#[derive(Debug, Clone)]
802pub struct PendingToolCall {
803 pub call_id: ToolCallId,
804 /// The raw tool call as it appeared in the model's response.
805 /// Preserved verbatim so the follow-up tool-result message can
806 /// reference the right function name + id on the wire.
807 pub source: ModelToolCall,
808}
809
810/// Outcome of a single tool execution.
811///
812/// `model_content` is the text that goes back to the model in the
813/// follow-up tool message. Everything else is Mermaid-owned
814/// structure for rendering, replay, process tracking, and timeline
815/// inspection.
816#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
817pub struct ToolOutcome {
818 pub status: ToolStatus,
819 pub summary: String,
820 pub model_content: String,
821 pub error: Option<String>,
822 pub metadata: Box<ToolRunMetadata>,
823 pub artifacts: Vec<ToolArtifact>,
824 pub duration_secs: Option<f64>,
825}
826
827impl ToolOutcome {
828 pub fn success(
829 model_content: impl Into<String>,
830 summary: impl Into<String>,
831 duration_secs: f64,
832 ) -> Self {
833 let duration = Some(duration_secs);
834 let metadata = ToolRunMetadata {
835 duration_secs: duration,
836 ..ToolRunMetadata::default()
837 };
838 Self {
839 status: ToolStatus::Success,
840 summary: summary.into(),
841 model_content: model_content.into(),
842 error: None,
843 metadata: Box::new(metadata),
844 artifacts: Vec::new(),
845 duration_secs: duration,
846 }
847 }
848
849 pub fn error(error: impl Into<String>, duration_secs: f64) -> Self {
850 let error = error.into();
851 let duration = Some(duration_secs);
852 Self {
853 status: ToolStatus::Error,
854 summary: error.clone(),
855 model_content: format!("Error: {}", error),
856 error: Some(error),
857 metadata: Box::new(ToolRunMetadata {
858 duration_secs: duration,
859 ..ToolRunMetadata::default()
860 }),
861 artifacts: Vec::new(),
862 duration_secs: duration,
863 }
864 }
865
866 pub fn cancelled() -> Self {
867 Self {
868 status: ToolStatus::Cancelled,
869 summary: "[cancelled]".to_string(),
870 model_content: "[Tool call skipped: the user cancelled before execution]".to_string(),
871 error: None,
872 metadata: Box::new(ToolRunMetadata::default()),
873 artifacts: Vec::new(),
874 duration_secs: None,
875 }
876 }
877
878 pub fn with_metadata(mut self, mut metadata: ToolRunMetadata) -> Self {
879 metadata.duration_secs = self.duration_secs;
880 self.metadata = Box::new(metadata);
881 self
882 }
883
884 pub fn with_artifacts(mut self, artifacts: Vec<ToolArtifact>) -> Self {
885 self.artifacts = artifacts.clone();
886 self.metadata.artifacts = artifacts;
887 self
888 }
889
890 pub fn with_images(self, images: Vec<String>) -> Self {
891 self.with_artifacts(
892 images
893 .into_iter()
894 .map(|data| ToolArtifact::Image { data })
895 .collect(),
896 )
897 }
898
899 /// Override the status after construction. When transitioning to
900 /// `Error`, populate `error` from `model_content` (if not already set)
901 /// so the renderer — `action_display_for`, which falls back to
902 /// `error_message().unwrap_or("[cancelled]")` — surfaces the failure
903 /// instead of mislabeling it as a cancellation. The MCP proxy uses this
904 /// for `isError: true` results (#91): the model still sees the server's
905 /// content verbatim via `model_content`, but the outcome reads as an
906 /// error rather than a success.
907 pub fn with_status(mut self, status: ToolStatus) -> Self {
908 if status == ToolStatus::Error && self.error.is_none() {
909 self.error = Some(self.model_content.clone());
910 }
911 self.status = status;
912 self
913 }
914
915 pub fn was_cancelled(&self) -> bool {
916 self.status == ToolStatus::Cancelled
917 }
918
919 pub fn is_success(&self) -> bool {
920 self.status == ToolStatus::Success
921 }
922
923 pub fn output(&self) -> &str {
924 &self.model_content
925 }
926
927 pub fn error_message(&self) -> Option<&str> {
928 self.error.as_deref()
929 }
930
931 pub fn images(&self) -> Option<Vec<String>> {
932 let images: Vec<String> = self
933 .artifacts
934 .iter()
935 .filter_map(|artifact| match artifact {
936 ToolArtifact::Image { data } => Some(data.clone()),
937 _ => None,
938 })
939 .collect();
940 if images.is_empty() {
941 None
942 } else {
943 Some(images)
944 }
945 }
946
947 /// Convert to a textual representation suitable for embedding in
948 /// the follow-up `tool` role message. Cancellation produces a
949 /// placeholder so the model sees "this was skipped" rather than
950 /// the history becoming malformed.
951 pub fn as_tool_message_content(&self) -> String {
952 self.model_content.clone()
953 }
954}
955
956/// Live activity for one in-flight tool call (today: a subagent child).
957/// `activity` is a short stable label ("read_file…", "thinking"); `tokens`
958/// is the child's cumulative output-token estimate, throttled at the source.
959#[derive(Debug, Clone, Default, PartialEq, Eq)]
960pub struct LiveToolStatus {
961 pub activity: String,
962 pub tokens: usize,
963}
964
965/// One plugin-contributed slash command (a markdown prompt from an enabled
966/// plugin's `manifest.prompts`). Plain data — parsing/IO happens in
967/// `app::plugin_assets`; the reducer only expands and submits.
968#[derive(Debug, Clone, PartialEq, Eq)]
969pub struct PluginCommand {
970 /// Command name without the leading `/` (validated `[a-z0-9-]+`).
971 pub name: String,
972 /// One-line description for the palette and `/help`.
973 pub description: String,
974 /// The prompt body. `$ARGUMENTS` is replaced with the typed args;
975 /// without the token, non-empty args append as a final paragraph.
976 pub body: String,
977 /// Owning plugin name, shown as `(plugin:<name>)` in the palette.
978 pub plugin: String,
979}
980
981impl PluginCommand {
982 /// Expand the body with typed arguments: replace-all of `$ARGUMENTS`
983 /// when the token is present, else append the args as a new paragraph
984 /// when non-empty. Pure.
985 pub fn expand(&self, args: &str) -> String {
986 let args = args.trim();
987 if self.body.contains("$ARGUMENTS") {
988 return self.body.replace("$ARGUMENTS", args);
989 }
990 if args.is_empty() {
991 self.body.clone()
992 } else {
993 format!("{}\n\n{}", self.body, args)
994 }
995 }
996}
997
998/// All UI-only state. Things in `UiState` never affect what gets sent
999/// to the model — only what the user sees.
1000#[derive(Debug, Clone, Default)]
1001pub struct UiState {
1002 pub mode: UiMode,
1003 /// Active color theme. Seeded from `config.ui.theme` in `State::new`;
1004 /// `/theme` switches it live (and persists via `Cmd::PersistUiTheme`).
1005 /// The render layer memoizes the resolved `Theme` off this value.
1006 pub theme: crate::app::ThemeChoice,
1007 /// `NO_COLOR` was set (present and non-empty) at startup. Injected by the
1008 /// run loop after `State::new` — the reducer never reads the environment.
1009 /// While true the render layer draws `Theme::plain()` regardless of
1010 /// `theme`, and `/theme` notes that colors are disabled.
1011 pub no_color: bool,
1012 pub input_buffer: String,
1013 /// Byte position within `input_buffer`. The reducer normalizes to
1014 /// a UTF-8 char boundary on every mutation via
1015 /// `floor_char_boundary`, so widgets can slice safely.
1016 pub input_cursor: usize,
1017 /// Pending image pastes for the next user message. Each is mirrored by an
1018 /// inline `[Image #N]` token in `input_buffer`; the token is the source of
1019 /// truth at submit time (see `image_token` + `handle_submit_prompt`).
1020 pub attachments: Vec<Attachment>,
1021 /// In-flight `Cmd::ReadClipboard` reads (Ctrl+V) whose result
1022 /// (`Msg::ClipboardRead`) hasn't arrived yet. A counter, not a bool, so a
1023 /// burst of rapid Ctrl+V presses all drain before a held submit fires.
1024 /// Incremented where `Cmd::ReadClipboard` is pushed; decremented in
1025 /// `handle_clipboard_read`.
1026 pub clipboard_reads_pending: u32,
1027 /// Set when Enter is pressed while `clipboard_reads_pending > 0`: the submit
1028 /// is held until the read drains so a fast paste-then-Enter still includes
1029 /// the pasted image instead of racing past it. `handle_clipboard_read`
1030 /// re-runs the submit once the last pending read lands.
1031 pub submit_after_clipboard: bool,
1032 /// When `Some(i)`, the palette has a highlighted row. `None` =
1033 /// closed / not showing.
1034 pub palette_cursor: Option<usize>,
1035 /// Cached project file list for the @-mention picker (relative paths,
1036 /// dirs with a trailing `/`). `None` until the first walk completes;
1037 /// stale-while-revalidate — every picker OPEN refreshes it.
1038 pub project_files: Option<Vec<String>>,
1039 /// A `Cmd::ListProjectFiles` walk is in flight (dedupe: opening the
1040 /// picker again while loading must not spawn a second walk).
1041 pub project_files_loading: bool,
1042 /// Current fuzzy matches for the active @-token, best first (top 50).
1043 /// Recomputed in the reducer on every text mutation — not per-frame in
1044 /// render — because fuzzy-ranking 20k paths at 60 Hz would be wasteful.
1045 pub file_picker_matches: Vec<String>,
1046 /// Highlighted row in `file_picker_matches`. `None` = picker closed.
1047 pub file_picker_cursor: Option<usize>,
1048 /// The user Esc'd the picker for the CURRENT token; cleared on the next
1049 /// text mutation so typing reopens it.
1050 pub file_picker_dismissed: bool,
1051 /// Messages the user typed while a turn was in flight, FIFO. Mid-run
1052 /// steering drains the WHOLE queue at each tool boundary (committed as
1053 /// user messages before the follow-up model call); a message queued
1054 /// mid-stream with no later tool boundary drains one-at-a-time at turn
1055 /// end instead. Each entry carries the attachment ids that were present
1056 /// when the user submitted it, so delivery sends the images that
1057 /// belonged to *that* message.
1058 pub queued_messages: VecDeque<QueuedMessage>,
1059 /// Last terminal title dispatched via `Cmd::SetTerminalTitle`.
1060 /// Arms that change `session.conversation.title` consult this
1061 /// and emit a fresh `SetTerminalTitle` only on diff.
1062 pub last_title_dispatched: Option<String>,
1063 /// Follow-up `Msg`s the reducer has queued for re-entry. The
1064 /// outer `update()` drains this after each single-step call so
1065 /// a handler can emit a synthetic event (e.g. Enter-on-slash
1066 /// queuing `Msg::Slash(cmd)`) without self-invoking the
1067 /// reducer. Bounded drain depth guards against runaway loops.
1068 pub pending_msgs: VecDeque<Msg>,
1069 /// Live activity per in-flight tool call, keyed by the call id.
1070 /// Fed by `Msg::ToolProgress` (today: subagent activity — the child's
1071 /// current tool / coarse phase plus a throttled token count) and rendered
1072 /// by the agent panel + status line next to the tool label. Entries are
1073 /// removed on that call's `ToolFinished` and the map is cleared when the
1074 /// turn ends or cancels; call ids are session-unique, so a stale entry
1075 /// can never attach to a later call.
1076 pub live_tool_status: HashMap<ToolCallId, LiveToolStatus>,
1077 /// Up-arrow history navigation cursor into
1078 /// `session.conversation.input_history`. `None` = not
1079 /// navigating (input_buffer is whatever the user typed).
1080 /// `Some(i)` = currently displaying history entry at index `i`
1081 /// from the END (0 = newest).
1082 pub input_history_cursor: Option<usize>,
1083 /// Whatever the user had typed before hitting Up. Preserved so
1084 /// stepping past the newest history entry with Down restores
1085 /// the partial input unchanged. Cleared on any non-nav key.
1086 pub history_draft: String,
1087 /// Running accumulator for mouse-wheel scroll events (F13). The
1088 /// reducer adds the delta here on `Msg::MouseScroll`; the render
1089 /// layer compares against its last-seen snapshot and applies the
1090 /// diff to the chat pane's `ChatState`. This keeps the reducer
1091 /// pure — it doesn't touch render-layer state, it just publishes
1092 /// an intent. `i32` wraps at ~2 billion scrolls (never).
1093 pub mouse_scroll_accum: i32,
1094 /// Monotonic "jump to bottom" counter (keyboard `End`). Same
1095 /// publish-then-diff pattern as `mouse_scroll_accum`: the reducer bumps it,
1096 /// the render layer diffs it against its last-seen value and calls
1097 /// `ChatState::resume_auto_scroll` — keeping the reducer pure.
1098 pub scroll_to_bottom_seq: u32,
1099 /// Monotonic "repaint everything" counter. Same publish-then-diff pattern
1100 /// as `scroll_to_bottom_seq`: the reducer bumps it, the run loop diffs it
1101 /// against its last-seen value and calls `Terminal::clear()` before the
1102 /// next draw. Needed because ratatui diff-renders against its back buffer:
1103 /// bytes some OTHER process wrote to the tty (a child that opened
1104 /// `/dev/tty`, a stray `printf` from another terminal) are invisible to
1105 /// that buffer and would otherwise persist as ghost cells. Bumped when a
1106 /// shell command finishes and on Ctrl+L.
1107 pub full_redraw_seq: u32,
1108 /// Ctrl+C exit arming (press-twice-to-exit). `Some(deadline)` after a
1109 /// first Ctrl+C: a second press at or before the deadline exits; any
1110 /// other key disarms; past the deadline the next Ctrl+C re-arms. Expiry
1111 /// is lazy — compared against `state.now`, so the render hint vanishes on
1112 /// the next tick with no state change. Ctrl+D on empty input and `/quit`
1113 /// still exit immediately.
1114 pub exit_armed_until: Option<DateTime<Local>>,
1115 /// Double-Esc rewind arming. `Some(t)` after an idle Esc; a second Esc
1116 /// within `ESC_REWIND_WINDOW_MS` of `t` opens the rewind picker. Any
1117 /// other key disarms; expiry is lazy against `state.now` like
1118 /// `exit_armed_until` (the hint vanishes on the next tick). Busy Esc
1119 /// never arms — it stays the cancel gesture.
1120 pub esc_armed_at: Option<DateTime<Local>>,
1121 /// Whether the terminal window has LOST focus (from terminal focus
1122 /// reporting via `Msg::FocusChanged`). Defaults `false` (assume attended, so
1123 /// terminals without focus reporting never ding); the attention bell fires
1124 /// only while this is `true`.
1125 pub terminal_unfocused: bool,
1126 /// Whether committed reasoning/thinking blocks are expanded in
1127 /// the chat transcript. Hidden by default to keep the TUI focused
1128 /// on user-facing work while retaining provider-required history.
1129 pub show_reasoning: bool,
1130 /// Whether the task checklist under the status line is collapsed to its
1131 /// one-line form (Ctrl+T toggles). Named for the non-default state so
1132 /// `derive(Default)` yields expanded, session-scoped, never persisted.
1133 pub tasks_collapsed: bool,
1134 /// Ephemeral confirmation for an action the user just took by hand
1135 /// ("copied 42 chars to clipboard"): the text and the instant it stops
1136 /// being drawn. Expiry is lazy against `state.now` — the 60 Hz tick makes
1137 /// it vanish on its own, exactly like `esc_armed_at`.
1138 ///
1139 /// Deliberately NOT the transcript. A copy confirmation is feedback on a
1140 /// keystroke, not part of the conversation; parking it in the message log
1141 /// left a permanent "Copied N chars to clipboard" row above the input for
1142 /// the rest of the session. Anything worth KEEPING (an error, a config
1143 /// change) still goes through `Msg::TransientStatus` to the transcript.
1144 pub toast: Option<(String, DateTime<Local>)>,
1145}
1146
1147/// How long a [`UiState::toast`] stays on screen.
1148pub const TOAST_TTL: chrono::Duration = chrono::Duration::milliseconds(2000);
1149
1150impl UiState {
1151 /// The @-mention token under the cursor, when the picker may show:
1152 /// not user-dismissed, and not while the buffer is a slash command
1153 /// (the slash palette owns that surface).
1154 pub fn active_file_token(&self) -> Option<crate::domain::file_mention::AtToken> {
1155 if self.file_picker_dismissed || self.input_buffer.starts_with('/') {
1156 return None;
1157 }
1158 crate::domain::file_mention::active_at_token(&self.input_buffer, self.input_cursor)
1159 }
1160
1161 /// Whether the @-mention file picker is currently open.
1162 pub fn file_picker_open(&self) -> bool {
1163 self.active_file_token().is_some()
1164 }
1165}
1166
1167/// One selectable row in the `/model` picker.
1168///
1169/// Deliberately flat data, not a provider handle: discovery runs in the effect
1170/// layer and hands the reducer plain strings, so the picker renders (and
1171/// `--replay` reproduces) without touching the network.
1172#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1173pub struct ModelChoice {
1174 /// The full id `/model <id>` would take — `ollama/llama3.2`,
1175 /// `anthropic/claude-opus-4-5`.
1176 pub id: String,
1177 /// Group heading this row sits under: `Local (Ollama)`, `anthropic`, …
1178 pub group: String,
1179 /// Dim right-hand column: what the row is good for, or why it can't run.
1180 pub detail: String,
1181 /// Ready to use right now. `false` for an Ollama model that is known but
1182 /// not pulled — still selectable (selection triggers the pull), but marked
1183 /// so the list never implies it will answer instantly.
1184 pub ready: bool,
1185}
1186
1187/// Top-level UI mode. Like `TurnState` this is a sum type instead of a
1188/// zoo of independent bools. `EditingInput` is the default.
1189#[derive(Debug, Clone, PartialEq, Eq, Default)]
1190pub enum UiMode {
1191 #[default]
1192 EditingInput,
1193 /// `/load` — list of saved conversations visible. `candidates`
1194 /// holds what the effect handler returned; `cursor` is the
1195 /// highlighted row.
1196 ConversationList {
1197 candidates: Vec<ConversationSummary>,
1198 cursor: usize,
1199 },
1200 /// `/model` — list of available models visible.
1201 ModelList,
1202 /// `/model` with no argument: the interactive model picker. `candidates`
1203 /// is everything discovery found (local Ollama models plus each keyed
1204 /// remote provider's catalog); `query` narrows it as the user types, which
1205 /// is what keeps a provider returning 200 ids usable.
1206 ModelPicker {
1207 candidates: Vec<ModelChoice>,
1208 query: String,
1209 cursor: usize,
1210 /// Discovery is still in flight. Rendered as a "searching…" row rather
1211 /// than an empty list, so a slow provider doesn't look like "no models".
1212 loading: bool,
1213 },
1214 /// Double-Esc rewind: pick an earlier user message to fork the session
1215 /// at. Candidates are user-role Normal messages, newest first. Selecting
1216 /// one forks into a NEW session (original preserved, lineage stamped)
1217 /// with the composer pre-filled.
1218 /// The `/plan config` settings picker: per-category permission levels,
1219 /// model/reasoning overrides, approval behavior. `cursor` is the
1220 /// highlighted row.
1221 PlanConfig { cursor: usize },
1222 RewindPicker {
1223 candidates: Vec<RewindCandidate>,
1224 cursor: usize,
1225 },
1226}
1227
1228/// One rewind target: a user message's position in the conversation plus a
1229/// one-line excerpt for the picker row. Never rides in a `Msg` (the whole
1230/// flow is Key-driven), so no serde — record/replay work unchanged.
1231#[derive(Debug, Clone, PartialEq, Eq)]
1232pub struct RewindCandidate {
1233 /// Index into `conversation.messages` of the user message; the fork
1234 /// keeps `messages[..index]` and pre-fills the composer with this one.
1235 pub message_index: usize,
1236 /// First line of the message, clipped for the picker row.
1237 pub excerpt: String,
1238}
1239
1240/// Summary row for the conversation picker. Produced by
1241/// `Cmd::ListConversations` → `Msg::ConversationsListed`.
1242#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1243pub struct ConversationSummary {
1244 pub id: String,
1245 pub title: String,
1246 pub message_count: usize,
1247 pub updated_at: String,
1248}
1249
1250/// One pasted image, ready to send. Kept in the reducer state — not on
1251/// disk — because the image hasn't been confirmed for a message yet.
1252#[derive(Debug, Clone)]
1253pub struct Attachment {
1254 pub id: u64,
1255 /// Global, conversation-wide image number — the `N` shown in the inline
1256 /// `[Image #N]` token and, once sent, in the committed message. Stable for
1257 /// the life of the image; distinct from `id`, which only scopes attachment
1258 /// ownership within a submit.
1259 pub number: u64,
1260 pub base64_data: String,
1261 /// Temp file path (written by the effect runner when the paste
1262 /// event comes in, so the TUI can show a preview).
1263 pub temp_path: PathBuf,
1264 pub size_bytes: usize,
1265 pub format: String,
1266}
1267
1268/// A user message queued while a turn was in flight, with the attachment
1269/// ids that were present at submit time. Capturing the ids here (instead
1270/// of re-reading live `ui.attachments` at drain time) ensures the
1271/// auto-submit consumes the images the user attached to *this* message.
1272#[derive(Debug, Clone)]
1273pub struct QueuedMessage {
1274 pub text: String,
1275 pub attachment_ids: Vec<u64>,
1276}
1277
1278/// MCP server lifecycle state. Mutation is driven by `Msg::McpServer*`
1279/// events emitted from `effect::mcp` when a server starts, advertises
1280/// tools, or exits.
1281#[derive(Debug, Clone, Default)]
1282pub struct McpState {
1283 pub servers: HashMap<String, McpServerEntry>,
1284 /// Deferred MCP tools promoted to direct advertisement by a
1285 /// `tool_search` call this session (sanitized full names). A
1286 /// `BTreeSet` keeps the advertised tool order byte-stable across
1287 /// requests for prompt-cache warmth (#F68). Transient: cleared by
1288 /// conversation switch/`/clear` along with the rest of the session.
1289 pub promoted: std::collections::BTreeSet<String>,
1290}
1291
1292#[derive(Debug, Clone)]
1293pub struct McpServerEntry {
1294 pub config: McpServerConfig,
1295 pub status: McpServerStatus,
1296 /// Tools advertised by the server. Populated on the
1297 /// `McpServerReady` event; reducer exposes these to the model
1298 /// when building the tool list for the next request.
1299 pub tools: Vec<McpToolSpec>,
1300}
1301
1302#[derive(Debug, Clone, PartialEq, Eq)]
1303pub enum McpServerStatus {
1304 /// `initialize` request dispatched, not yet acknowledged.
1305 Starting,
1306 Ready,
1307 Errored {
1308 reason: String,
1309 },
1310 Stopped,
1311}
1312
1313/// Subset of the MCP `ToolDefinition` carried in reducer state. `name` is
1314/// the FULL sanitized advertised name (`mcp__<server>__<tool>`, provider-safe
1315/// charset and length — see `crate::mcp::sanitize`); `raw_name` is the bare
1316/// tool name exactly as the server advertised it, used for user-facing
1317/// display and for `enabled_tools`/`disabled_tools` filtering.
1318#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1319pub struct McpToolSpec {
1320 pub name: String,
1321 /// Bare tool name as the server advertised it (pre-sanitization).
1322 #[serde(default)]
1323 pub raw_name: String,
1324 pub description: String,
1325 pub input_schema: serde_json::Value,
1326 /// Server-advertised `annotations.readOnlyHint` (UNTRUSTED; absent ⇒
1327 /// false = write-shaped). Feeds the external-writes policy floor: it can
1328 /// only keep a read at its old permissiveness, never grant more than the
1329 /// safety mode gives.
1330 #[serde(default)]
1331 pub read_only_hint: bool,
1332}
1333
1334/// A pending user confirmation (modal). Examples: confirming `/clear`,
1335/// confirming overwrite of an existing file on `/save <name>`.
1336#[derive(Debug, Clone)]
1337pub struct Confirmation {
1338 pub prompt: String,
1339 pub accept_msg_token: ConfirmationTarget,
1340}
1341
1342/// What to do when the user confirms. The reducer translates
1343/// `Msg::ConfirmAccepted` into a secondary dispatch based on this.
1344#[derive(Debug, Clone)]
1345pub enum ConfirmationTarget {
1346 ClearConversation,
1347}
1348
1349/// One tool action awaiting inline approval. Built by the policy gate and
1350/// delivered via `Msg::ApprovalRequested`; rendered as a modal. The `prompt`
1351/// body is pre-formatted by the gate (command / path / summary, plus any
1352/// Auto-review reason) so the render layer stays dumb.
1353#[derive(Debug, Clone)]
1354pub struct PendingApproval {
1355 pub turn: TurnId,
1356 pub call_id: ToolCallId,
1357 pub tool: String,
1358 /// `RiskClass::as_str()` — shown on the title line.
1359 pub risk: String,
1360 pub kind: ApprovalKind,
1361 /// Pre-formatted body (the command/path being run + any classifier reason).
1362 pub prompt: String,
1363 /// What "don't ask again" (option 2) will allowlist, shown in the prompt.
1364 pub allowlist_scope: String,
1365 /// Highlighted option for arrow-key navigation: 0 = Yes, 1 = Yes-always,
1366 /// 2 = No. Number keys (1/2/3) still resolve directly regardless of this.
1367 pub selected_option: usize,
1368}
1369
1370/// The user's answer to an approval prompt.
1371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1372pub enum ApprovalChoice {
1373 Approve,
1374 ApproveAlways,
1375 Deny,
1376}
1377
1378/// Category of the gated action — drives the prompt's label. Mirrors the
1379/// runtime `ToolCategory` but lives in `domain` so the pure reducer needn't
1380/// depend on `providers`.
1381#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1382pub enum ApprovalKind {
1383 Shell,
1384 FileMutation,
1385 Web,
1386 Mcp,
1387 Subagent,
1388 ComputerUse,
1389 Classify,
1390}
1391
1392/// Severity carried on `Msg::CompactionFailed`. The compaction-failed handler
1393/// uses it to distinguish a benign no-op (`Info`, e.g. too little history to
1394/// compact) from a real failure worth surfacing.
1395#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1396pub enum StatusKind {
1397 Info,
1398 Warn,
1399 Error,
1400}
1401
1402/// All ID allocators for the session. Grouped so the reducer can
1403/// request any of them through a single `&mut state.ids`.
1404#[derive(Debug, Clone, Copy, Default)]
1405pub struct IdAllocatorBundle {
1406 pub turn: IdAllocator,
1407 pub tool_call: IdAllocator,
1408 /// Global, conversation-wide image counter. Every pasted image draws its
1409 /// stable `[Image #N]` display number from here, so the number stays with
1410 /// that image across the whole chat (and across `--resume`, which reseeds it
1411 /// past the highest persisted number in `seed_conversation`).
1412 pub image: IdAllocator,
1413}
1414
1415impl IdAllocatorBundle {
1416 pub fn fresh_turn(&mut self) -> TurnId {
1417 TurnId(self.turn.next())
1418 }
1419
1420 pub fn fresh_tool_call(&mut self) -> ToolCallId {
1421 ToolCallId(self.tool_call.next())
1422 }
1423
1424 pub fn fresh_image(&mut self) -> u64 {
1425 self.image.next()
1426 }
1427}
1428
1429#[cfg(test)]
1430mod tests {
1431 use super::*;
1432
1433 fn mock_state() -> State {
1434 State::new(
1435 Config::default(),
1436 PathBuf::from("/tmp/project"),
1437 "ollama/test".to_string(),
1438 chrono::Local::now(),
1439 )
1440 }
1441
1442 #[test]
1443 fn fresh_state_is_idle() {
1444 let s = mock_state();
1445 assert!(matches!(s.turn, TurnState::Idle));
1446 assert!(!s.is_busy());
1447 assert!(s.current_turn_id().is_none());
1448 }
1449
1450 fn state_for(settings: Config, model_id: &str) -> State {
1451 State::new(
1452 settings,
1453 PathBuf::from("/tmp/project"),
1454 model_id.to_string(),
1455 chrono::Local::now(),
1456 )
1457 }
1458
1459 /// The user's configured reasoning level must survive session bootstrap —
1460 /// without this it silently reverts to the enum default on every start.
1461 ///
1462 /// These assertions used to sit on `ModelConfig::from_app_config`, a
1463 /// byte-identical copy of this rule that nothing ever called. Deleting the
1464 /// duplicate left the live rule — right here in `State::new` — with no
1465 /// coverage at all, so the tests moved to it rather than going away.
1466 #[test]
1467 fn session_reasoning_comes_from_the_global_default() {
1468 let mut settings = Config::default();
1469 settings.default_model.reasoning = ReasoningLevel::High;
1470 let state = state_for(settings, "ollama/qwen3-coder:30b");
1471 assert_eq!(state.session.reasoning, ReasoningLevel::High);
1472 assert_eq!(state.session.model_id, "ollama/qwen3-coder:30b");
1473 }
1474
1475 #[test]
1476 fn session_reasoning_falls_back_to_the_enum_default_when_unset() {
1477 let state = state_for(Config::default(), "ollama/qwen3-coder:30b");
1478 assert_eq!(
1479 state.session.reasoning,
1480 Config::default().default_model.reasoning
1481 );
1482 }
1483
1484 /// A per-model preference beats the global default: `/reasoning high` on
1485 /// one model sticks for that model without touching any other.
1486 #[test]
1487 fn session_reasoning_prefers_the_per_model_entry() {
1488 let mut settings = Config::default();
1489 settings.default_model.reasoning = ReasoningLevel::Low;
1490 settings.reasoning_per_model.insert(
1491 "anthropic/claude-sonnet-4-6".to_string(),
1492 ReasoningLevel::High,
1493 );
1494
1495 let pinned = state_for(settings.clone(), "anthropic/claude-sonnet-4-6");
1496 assert_eq!(pinned.session.reasoning, ReasoningLevel::High);
1497
1498 let other = state_for(settings, "ollama/foo");
1499 assert_eq!(other.session.reasoning, ReasoningLevel::Low);
1500 }
1501
1502 #[test]
1503 fn snapshot_and_seed_round_trip_restores_meters_and_safety() {
1504 // Move the live session state away from its `State::new` defaults, then
1505 // snapshot it into a conversation and seed it back into a fresh state —
1506 // this is exactly the save→resume path.
1507 let mut src = mock_state();
1508 src.session.safety_mode = SafetyMode::FullAccess;
1509 src.session.cumulative_token_usage = TokenUsageTotals {
1510 prompt_tokens: 4321,
1511 ..Default::default()
1512 };
1513 src.session.last_token_usage = Some(TokenUsageTotals {
1514 prompt_tokens: 100,
1515 ..Default::default()
1516 });
1517 src.session.context_usage = Some(ContextUsageSnapshot::new(
1518 8000,
1519 Some(128_000),
1520 TokenUsageSource::Estimate,
1521 8000,
1522 0,
1523 0,
1524 0,
1525 0,
1526 None,
1527 ));
1528
1529 let snapshot = src.session.snapshot_conversation();
1530
1531 let mut restored = mock_state();
1532 assert_eq!(
1533 restored.session.safety_mode,
1534 SafetyMode::Ask,
1535 "config default"
1536 );
1537 assert_eq!(restored.session.cumulative_token_usage.total_tokens(), 0);
1538
1539 restored.seed_conversation(snapshot);
1540 assert_eq!(restored.session.safety_mode, SafetyMode::FullAccess);
1541 assert_eq!(restored.session.cumulative_token_usage.total_tokens(), 4321);
1542 assert_eq!(
1543 restored.session.last_token_usage.unwrap().total_tokens(),
1544 100
1545 );
1546 assert_eq!(restored.session.context_usage.unwrap().used_tokens, 8000);
1547 }
1548
1549 #[test]
1550 fn seed_from_pre_persistence_file_keeps_config_default_safety() {
1551 // A conversation saved before these fields existed has `safety_mode:
1552 // None`; seeding it must NOT clobber the config-default mode that
1553 // `State::new` already set.
1554 let history = ConversationHistory::new(
1555 "/tmp/p".to_string(),
1556 "ollama/test".to_string(),
1557 chrono::Local::now(),
1558 );
1559 assert_eq!(history.safety_mode, None);
1560 let mut restored = mock_state();
1561 restored.session.safety_mode = SafetyMode::Auto; // stand in for a config default
1562 restored.seed_conversation(history);
1563 assert_eq!(
1564 restored.session.safety_mode,
1565 SafetyMode::Auto,
1566 "a None saved mode must not override the config default"
1567 );
1568 }
1569
1570 #[test]
1571 fn turn_state_accepts_matches_id() {
1572 let s = TurnState::Generating {
1573 id: TurnId(7),
1574 started: SystemTime::now(),
1575 partial_text: String::new(),
1576 partial_reasoning: String::new(),
1577 tokens: 0,
1578 phase: GenPhase::Sending,
1579 provider_continuation: None,
1580 pending_tool_calls: Vec::new(),
1581 continuation: false,
1582 };
1583 assert!(s.accepts(TurnId(7)));
1584 assert!(!s.accepts(TurnId(6)));
1585 assert!(!s.accepts(TurnId(8)));
1586 }
1587
1588 #[test]
1589 fn idle_rejects_all_turn_ids() {
1590 let s = TurnState::Idle;
1591 assert!(!s.accepts(TurnId(1)));
1592 assert!(!s.accepts(TurnId(999)));
1593 }
1594
1595 #[test]
1596 fn fresh_id_allocators_monotonic() {
1597 let mut bundle = IdAllocatorBundle::default();
1598 assert_eq!(bundle.fresh_turn(), TurnId(1));
1599 assert_eq!(bundle.fresh_turn(), TurnId(2));
1600 assert_eq!(bundle.fresh_tool_call(), ToolCallId(1));
1601 // Cross-allocator independence — fresh turns don't consume
1602 // tool call IDs.
1603 }
1604
1605 #[test]
1606 fn tool_outcome_cancelled_content_is_placeholder() {
1607 let o = ToolOutcome::cancelled();
1608 assert!(o.was_cancelled());
1609 let content = o.as_tool_message_content();
1610 assert!(content.contains("cancelled"));
1611 }
1612
1613 #[test]
1614 fn tool_outcome_finished_returns_output_verbatim() {
1615 let o = ToolOutcome::success("hello world", "hello world", 0.1);
1616 assert_eq!(o.as_tool_message_content(), "hello world");
1617 assert!(!o.was_cancelled());
1618 }
1619
1620 #[test]
1621 fn session_append_records_message() {
1622 let mut s = mock_state();
1623 s.session.append(ChatMessage::user("hi"), s.now);
1624 assert_eq!(s.session.messages().len(), 1);
1625 assert_eq!(s.session.messages()[0].content, "hi");
1626 }
1627}