返回 CodeWhale
types.rs
根目录 / crates / tui / src / tui / app / types.rs
1 //! Plain data types shared across the TUI: modes, effort/collapse/display
2 //! enums, the public `TuiOptions` construction bag, queued-message records,
3 //! and the action enums drained by the event loop.
4 //!
5 //! Everything here is pure data (plus parsing/labeling helpers that need no
6 //! `App` state). All items are re-exported from `app.rs` so existing
7 //! `crate::tui::app::X` paths are unchanged.
8
9 use super::*;
10
11 /// What an interactive setting selection actually did.
12 ///
13 /// The three cases are genuinely different to the user, and the boolean this
14 /// replaced conflated the last two: a refused selection and an accepted one
15 /// that only wrote the startup default both returned `false`, so every caller
16 /// reported "already in that mode" and showed no receipt for the write.
17 ///
18 /// Only [`Self::Changed`] means live session state moved — that is the case
19 /// that must still emit an `AppAction` so the engine is resynchronized.
20 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 pub enum SettingSelection {
22 /// Live state moved, and the startup default was persisted.
23 Changed,
24 /// Live state already matched, and the startup default was persisted. This
25 /// is the normal shape after a session restore, where the live value and
26 /// the startup default legitimately disagree.
27 PersistedSame,
28 /// Refused by the turn lock (#2982). Nothing was written anywhere.
29 Refused,
30 }
31
32 impl SettingSelection {
33 /// Whether live state moved — i.e. whether the engine needs resyncing.
34 #[must_use]
35 pub fn changed_live_state(self) -> bool {
36 matches!(self, Self::Changed)
37 }
38
39 /// Whether the selection was accepted at all (either case that persisted).
40 #[must_use]
41 #[cfg(test)]
42 pub fn accepted(self) -> bool {
43 !matches!(self, Self::Refused)
44 }
45 }
46
47 /// Supported application modes for the TUI.
48 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49 pub enum AppMode {
50 Agent,
51 #[allow(dead_code)]
52 Auto,
53 /// Legacy compatibility alias; resolves to [`Self::Agent`] + bypass approvals.
54 Yolo,
55 Plan,
56 Operate,
57 }
58
59 /// Reasoning-effort tier, mirrored across DeepSeek and Codex effort pickers.
60 ///
61 /// The config file accepts every supported string value for forward-compat with
62 /// providers that expose the full spectrum; DeepSeek currently collapses
63 /// `Low`/`Medium` → `high`. OpenAI Codex normalizes inherited DeepSeek-only
64 /// `Off` to `Low` and displays/sends `Max` as `xhigh` at the provider
65 /// boundary. The default keyboard cycler walks the three DeepSeek-distinct
66 /// tiers: `Off` → `High` → `Max` → `Off`; provider-aware callers should use
67 /// [`ReasoningEffort::cycle_next_for_provider`]. Auto routing has no concrete
68 /// provider yet, so [`ReasoningEffort::cycle_next_for_auto_model`] retains the
69 /// full provider-neutral preference vocabulary until dispatch.
70 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
71 pub enum ReasoningEffort {
72 Off,
73 Minimal,
74 Low,
75 Medium,
76 High,
77 XHigh,
78 Ultra,
79 Auto,
80 #[default]
81 Max,
82 }
83
84 /// Provider-effective reasoning state used by durable receipts and visible
85 /// requested-to-effective labels.
86 ///
87 /// Some routes, notably first-party GLM-5-Turbo, support a thinking toggle but
88 /// publish no effort tiers. Keeping that state distinct prevents a requested
89 /// `max` from being displayed or persisted as an effective `max` claim.
90 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
91 pub(crate) enum EffectiveReasoningEffort {
92 Tier(ReasoningEffort),
93 ThinkingEnabledGranularityUnavailable,
94 Unavailable,
95 }
96
97 /// Exact provider/model route whose prompt can be inspected or replayed.
98 ///
99 /// Auto-model sessions keep `model == "auto"` as the user's selection, so
100 /// cache operations must carry the last concrete route separately. The base
101 /// URL is absent after restoring an older session because saved Auto receipts
102 /// intentionally do not persist raw endpoints.
103 #[derive(Debug, Clone, PartialEq, Eq)]
104 pub(crate) struct CacheReplayTarget {
105 pub(crate) provider: ApiProvider,
106 pub(crate) provider_identity: String,
107 /// Additive exact provider id used by persisted-route resolution.
108 /// `None` is meaningful for the legacy root-level `custom` route.
109 pub(crate) provider_id: Option<String>,
110 pub(crate) model: String,
111 pub(crate) base_url: Option<String>,
112 }
113
114 impl EffectiveReasoningEffort {
115 /// Reconstruct a safe request tier for cache replay and inspection.
116 ///
117 /// Routes with an enabled-but-untiered receipt collapse every non-Off
118 /// request to the same wire toggle, so High is the canonical value that
119 /// keeps reasoning enabled without claiming a granular effective tier.
120 #[must_use]
121 pub(crate) const fn request_tier_for_replay(self) -> Option<ReasoningEffort> {
122 match self {
123 Self::Tier(tier) => Some(tier),
124 Self::ThinkingEnabledGranularityUnavailable => Some(ReasoningEffort::High),
125 Self::Unavailable => None,
126 }
127 }
128 }
129
130 impl From<EffectiveReasoningEffort> for crate::work_graph::ReasoningEffortTier {
131 fn from(value: EffectiveReasoningEffort) -> Self {
132 match value {
133 EffectiveReasoningEffort::Tier(tier) => tier.into(),
134 EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable => {
135 Self::ThinkingEnabledGranularityUnavailable
136 }
137 EffectiveReasoningEffort::Unavailable => Self::Unavailable,
138 }
139 }
140 }
141
142 impl From<crate::work_graph::ReasoningEffortTier> for EffectiveReasoningEffort {
143 fn from(value: crate::work_graph::ReasoningEffortTier) -> Self {
144 use crate::work_graph::ReasoningEffortTier as Tier;
145 match value {
146 Tier::Off => Self::Tier(ReasoningEffort::Off),
147 Tier::Low => Self::Tier(ReasoningEffort::Low),
148 Tier::Medium => Self::Tier(ReasoningEffort::Medium),
149 Tier::High => Self::Tier(ReasoningEffort::High),
150 Tier::Auto => Self::Tier(ReasoningEffort::Auto),
151 Tier::Max => Self::Tier(ReasoningEffort::Max),
152 Tier::ThinkingEnabledGranularityUnavailable => {
153 Self::ThinkingEnabledGranularityUnavailable
154 }
155 Tier::Unavailable => Self::Unavailable,
156 }
157 }
158 }
159
160 impl From<ReasoningEffort> for crate::work_graph::ReasoningEffortTier {
161 fn from(value: ReasoningEffort) -> Self {
162 match value {
163 ReasoningEffort::Off => Self::Off,
164 ReasoningEffort::Minimal => Self::Low,
165 ReasoningEffort::Low => Self::Low,
166 ReasoningEffort::Medium => Self::Medium,
167 ReasoningEffort::High => Self::High,
168 ReasoningEffort::XHigh => Self::Max,
169 ReasoningEffort::Ultra => Self::Max,
170 ReasoningEffort::Auto => Self::Auto,
171 ReasoningEffort::Max => Self::Max,
172 }
173 }
174 }
175
176 impl ReasoningEffort {
177 /// Parse an operator-supplied effort value.
178 ///
179 /// This is deliberately the one canonical spelling table for every
180 /// human-facing route. Callers that read an old persisted config may use
181 /// [`Self::from_setting`] for its compatibility fallback, but a new CLI,
182 /// settings, or tool input must reject an unknown value instead of quietly
183 /// turning it into `max`.
184 pub fn parse_strict(value: &str) -> Result<Self, String> {
185 let trimmed = value.trim();
186 match trimmed.to_ascii_lowercase().as_str() {
187 "off" | "disabled" | "none" | "false" => Ok(Self::Off),
188 "low" | "minimum" | "minimal" | "light" => Ok(Self::Low),
189 "medium" | "mid" => Ok(Self::Medium),
190 "high" => Ok(Self::High),
191 "auto" | "automatic" => Ok(Self::Auto),
192 "max" | "maximum" | "xhigh" | "ultra" | "ultracode" => Ok(Self::Max),
193 _ => Err(format!(
194 "Unrecognized reasoning effort {trimmed:?}. Expected: auto, off, low, medium, high, or max."
195 )),
196 }
197 }
198
199 /// Parse a persisted config-file string into an effort tier. Unknown
200 /// legacy values fall back to the default (`Max`) so an old malformed
201 /// settings file never prevents startup. New user input should use
202 /// [`Self::parse_strict`] instead.
203 #[must_use]
204 pub fn from_setting(value: &str) -> Self {
205 Self::parse_strict(value).unwrap_or_default()
206 }
207
208 #[must_use]
209 pub fn from_setting_for_provider(value: &str, provider: ApiProvider) -> Self {
210 Self::from_setting(value).normalize_for_provider(provider)
211 }
212
213 /// Canonical lowercase label used for config storage and UI hints.
214 #[must_use]
215 pub fn as_setting(self) -> &'static str {
216 match self {
217 Self::Off => "off",
218 Self::Minimal => "minimal",
219 Self::Low => "low",
220 Self::Medium => "medium",
221 Self::High => "high",
222 Self::XHigh => "xhigh",
223 Self::Ultra => "ultra",
224 Self::Auto => "auto",
225 Self::Max => "max",
226 }
227 }
228
229 /// Short label for the header chip.
230 #[must_use]
231 pub fn short_label(self) -> &'static str {
232 match self {
233 Self::Off => "off",
234 Self::Minimal => "minimal",
235 Self::Low => "low",
236 Self::Medium => "med",
237 Self::High => "high",
238 Self::XHigh => "xhigh",
239 Self::Ultra => "ultra",
240 Self::Auto => "auto",
241 Self::Max => "max",
242 }
243 }
244
245 /// Provider-facing label for user-visible surfaces.
246 #[must_use]
247 pub fn display_label_for_provider(self, provider: ApiProvider) -> &'static str {
248 match (provider, self.normalize_for_provider(provider)) {
249 (ApiProvider::OpenaiCodex, Self::Minimal) => "low",
250 (ApiProvider::OpenaiCodex, Self::Low) => "low",
251 (ApiProvider::OpenaiCodex, Self::Medium) => "medium",
252 (ApiProvider::OpenaiCodex, Self::High) => "high",
253 (ApiProvider::OpenaiCodex, Self::XHigh | Self::Ultra | Self::Max) => "xhigh",
254 (_, effort) => effort.short_label(),
255 }
256 }
257
258 /// Value forwarded to the engine/client. `None` means "provider default"
259 /// (for `Off` we still emit `"off"` so the client can inject
260 /// `thinking = {"type": "disabled"}`).
261 #[must_use]
262 pub fn api_value(self) -> Option<&'static str> {
263 Some(self.as_setting())
264 }
265
266 #[must_use]
267 pub fn normalize_for_provider(self, provider: ApiProvider) -> Self {
268 if provider != ApiProvider::OpenaiCodex {
269 return self;
270 }
271 match self {
272 Self::Off => Self::Low,
273 Self::Auto => Self::Medium,
274 other => other,
275 }
276 }
277
278 /// Resolve an effort against the exact provider route that will receive
279 /// the request. Both K3 routes are always-thinking, so `off` becomes the
280 /// lowest supported tier. The Kimi Code membership route otherwise keeps
281 /// its low/high/max mapping; direct Moonshot K3 additionally maps `medium`
282 /// to `high`. First-party DeepSeek routes keep `low` (the wire documents
283 /// low/high/max) while rounding `medium` up to `high`. Generic Moonshot
284 /// and every other non-Codex route retain the historic high coercion.
285 /// This intentionally does not change [`Self::normalize_for_provider`],
286 /// whose generic wire semantics are used by older callers that do not yet
287 /// have a route receipt.
288 #[must_use]
289 pub fn normalize_for_route(
290 self,
291 provider: ApiProvider,
292 base_url: &str,
293 wire_model: &str,
294 ) -> Self {
295 let normalized = self.normalize_for_provider(provider);
296 if crate::config::is_exact_kimi_code_k3_route(provider, base_url, wire_model) {
297 return match normalized {
298 Self::Off => Self::Low,
299 other => other,
300 };
301 }
302 if crate::config::is_exact_direct_moonshot_k3_route(provider, base_url, wire_model) {
303 return match normalized {
304 Self::Off => Self::Low,
305 Self::Medium => Self::High,
306 other => other,
307 };
308 }
309 if provider == ApiProvider::OpenaiCodex {
310 return normalized;
311 }
312 // First-party DeepSeek routes document `reasoning_effort` low/high/max
313 // on the wire (no medium), so `low` is a real, cheaper tier there and
314 // must reach the wire as low; `medium` rounds up to high because the
315 // dialect has no such value (#52).
316 if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
317 return match normalized {
318 Self::Low => Self::Low,
319 Self::Medium => Self::High,
320 other => other,
321 };
322 }
323 match normalized {
324 Self::Low | Self::Medium => Self::High,
325 other => other,
326 }
327 }
328
329 #[must_use]
330 pub fn api_value_for_provider(self, provider: ApiProvider) -> Option<&'static str> {
331 if provider != ApiProvider::OpenaiCodex {
332 return self.api_value();
333 }
334 Some(match self.normalize_for_provider(provider) {
335 Self::Minimal => "low",
336 Self::Low => "low",
337 Self::Medium => "medium",
338 Self::High => "high",
339 Self::XHigh => "xhigh",
340 Self::Ultra => "xhigh",
341 Self::Max => "xhigh",
342 Self::Off => "low",
343 Self::Auto => "medium",
344 })
345 }
346
347 /// Provider-facing value after exact-route normalization.
348 #[must_use]
349 pub fn api_value_for_route(
350 self,
351 provider: ApiProvider,
352 base_url: &str,
353 wire_model: &str,
354 ) -> Option<&'static str> {
355 self.normalize_for_route(provider, base_url, wire_model)
356 .api_value_for_provider(provider)
357 }
358
359 #[must_use]
360 pub fn as_setting_for_provider(self, provider: ApiProvider) -> &'static str {
361 self.api_value_for_provider(provider)
362 .unwrap_or_else(|| self.as_setting())
363 }
364
365 /// Persist the canonical setting after exact-route normalization.
366 #[must_use]
367 pub fn as_setting_for_route(
368 self,
369 provider: ApiProvider,
370 base_url: &str,
371 wire_model: &str,
372 ) -> &'static str {
373 self.normalize_for_route(provider, base_url, wire_model)
374 .as_setting_for_provider(provider)
375 }
376
377 /// Cycle through the three behaviorally distinct tiers.
378 #[must_use]
379 pub fn cycle_next(self) -> Self {
380 match self {
381 Self::Off => Self::High,
382 Self::Auto => Self::Off,
383 Self::Minimal | Self::Low | Self::Medium | Self::High | Self::XHigh | Self::Ultra => {
384 Self::Max
385 }
386 Self::Max => Self::Off,
387 }
388 }
389
390 #[must_use]
391 pub fn cycle_next_for_provider(self, provider: ApiProvider) -> Self {
392 if provider != ApiProvider::OpenaiCodex {
393 return self.cycle_next();
394 }
395 match self.normalize_for_provider(provider) {
396 Self::Minimal => Self::Low,
397 Self::Low => Self::Medium,
398 Self::Medium => Self::High,
399 Self::High => Self::Max,
400 Self::XHigh => Self::Low,
401 Self::Ultra => Self::Low,
402 Self::Max => Self::Low,
403 Self::Off | Self::Auto => Self::Low,
404 }
405 }
406
407 /// Cycle the unresolved auto-model preference without applying any
408 /// provider's normalization rules prematurely.
409 #[must_use]
410 pub fn cycle_next_for_auto_model(self) -> Self {
411 match self {
412 Self::Auto => Self::Off,
413 Self::Off => Self::Minimal,
414 Self::Minimal => Self::Low,
415 Self::Low => Self::Medium,
416 Self::Medium => Self::High,
417 Self::High => Self::XHigh,
418 Self::XHigh => Self::Ultra,
419 Self::Ultra => Self::Max,
420 Self::Max => Self::Auto,
421 }
422 }
423 }
424
425 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
426 pub enum ComposerDensity {
427 Compact,
428 Comfortable,
429 Spacious,
430 }
431
432 impl ComposerDensity {
433 #[must_use]
434 pub fn from_setting(value: &str) -> Self {
435 match value.trim().to_ascii_lowercase().as_str() {
436 "compact" | "tight" => Self::Compact,
437 "spacious" | "loose" => Self::Spacious,
438 _ => Self::Comfortable,
439 }
440 }
441 }
442
443 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
444 pub enum TranscriptSpacing {
445 Compact,
446 Comfortable,
447 Spacious,
448 }
449
450 impl TranscriptSpacing {
451 #[must_use]
452 pub fn from_setting(value: &str) -> Self {
453 match value.trim().to_ascii_lowercase().as_str() {
454 "compact" | "tight" => Self::Compact,
455 "spacious" | "loose" => Self::Spacious,
456 _ => Self::Comfortable,
457 }
458 }
459 }
460
461 /// Controls how dense tool-call runs are collapsed in the transcript.
462 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
463 pub enum ToolCollapseMode {
464 /// Collapse qualifying tool runs by default.
465 ///
466 /// Collapsed success cells keep the tool-name + arg/command summary as the
467 /// single intent line (#3256 decision): that is already the model-visible
468 /// call summary, so a second "intent" source is not required.
469 Compact,
470 /// Never collapse tool runs automatically.
471 Expanded,
472 /// Collapse only when calm mode is active.
473 Calm,
474 }
475
476 impl ToolCollapseMode {
477 #[must_use]
478 pub fn from_setting(value: &str) -> Self {
479 match value.trim().to_ascii_lowercase().as_str() {
480 "expanded" | "off" | "none" => Self::Expanded,
481 "calm" | "calm-mode" | "calm_only" | "calm-only" => Self::Calm,
482 // `collapsed`/`collapse` are issue #3256's preferred names for the
483 // default; treat them like the canonical `compact`.
484 _ => Self::Compact,
485 }
486 }
487
488 #[must_use]
489 pub fn as_setting(self) -> &'static str {
490 match self {
491 Self::Compact => "compact",
492 Self::Expanded => "expanded",
493 Self::Calm => "calm",
494 }
495 }
496
497 #[must_use]
498 pub fn is_active(self, calm_mode: bool) -> bool {
499 match self {
500 Self::Compact => true,
501 Self::Expanded => false,
502 Self::Calm => calm_mode,
503 }
504 }
505 }
506
507 impl AppMode {
508 /// Productive keyboard cycle: Plan -> Act -> Operate -> Plan.
509 ///
510 /// `Auto` remains an internal variant while the real implementation is
511 /// redesigned; do not expose it through user-facing mode selection (#3733).
512 /// `Yolo` is kept for parse/back-compat only and is not in the Tab cycle.
513 /// Operate joins the visible cycle because ordinary messages can now
514 /// coordinate background workers without requiring a Workflow definition.
515 pub const CYCLE: [Self; 3] = [Self::Plan, Self::Agent, Self::Operate];
516
517 #[must_use]
518 pub fn parse(value: &str) -> Option<Self> {
519 match value.trim().to_ascii_lowercase().as_str() {
520 "agent" | "act" | "auto" | "1" => Some(Self::Agent),
521 "plan" | "2" => Some(Self::Plan),
522 "operate" | "operation" | "ops" | "3" => Some(Self::Operate),
523 // Invisible one-way permission shorthand only — never a visible mode.
524 "yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" => {
525 Some(Self::Yolo)
526 }
527 _ => None,
528 }
529 }
530
531 #[must_use]
532 pub fn from_setting(value: &str) -> Self {
533 // Unreleased Multitask never shipped; normalize leftover settings to Operate.
534 match value.trim().to_ascii_lowercase().as_str() {
535 "multitask" | "multi" | "5" => Self::Operate,
536 other => Self::parse(other).unwrap_or(Self::Agent),
537 }
538 }
539
540 #[must_use]
541 pub fn as_setting(self) -> &'static str {
542 match self {
543 Self::Agent => "agent",
544 Self::Auto => "agent",
545 // Write current permission vocabulary, not the legacy YOLO label.
546 Self::Yolo => "agent",
547 Self::Plan => "plan",
548 Self::Operate => "operate",
549 }
550 }
551
552 /// Short label used in the UI footer.
553 pub fn label(self) -> &'static str {
554 match self {
555 AppMode::Agent => "ACT",
556 AppMode::Auto => "ACT",
557 AppMode::Yolo => "ACT",
558 AppMode::Plan => "PLAN",
559 AppMode::Operate => "OPERATE",
560 }
561 }
562
563 #[must_use]
564 pub fn display_name(self) -> &'static str {
565 match self {
566 AppMode::Agent => "Act",
567 AppMode::Auto => "Act",
568 AppMode::Yolo => "Act",
569 AppMode::Plan => "Plan",
570 AppMode::Operate => "Operate",
571 }
572 }
573
574 #[must_use]
575 pub fn number(self) -> char {
576 match self {
577 AppMode::Agent | AppMode::Auto | AppMode::Yolo => '1',
578 AppMode::Plan => '2',
579 AppMode::Operate => '3',
580 }
581 }
582
583 #[must_use]
584 pub fn uses_agent_baseline(self) -> bool {
585 matches!(self, Self::Agent | Self::Auto | Self::Operate)
586 }
587
588 /// Operate gets a higher parallel launch floor so background fan-out is
589 /// not throttled to a single slot when config is low.
590 #[must_use]
591 pub fn mode_delegation_launch_floor(self) -> usize {
592 match self {
593 Self::Operate => 4,
594 _ => 1,
595 }
596 }
597
598 /// Localized short name for the mode picker (user-facing surface only).
599 #[must_use]
600 pub fn display_name_localized(self, locale: Locale) -> Cow<'static, str> {
601 tr(
602 locale,
603 match self {
604 AppMode::Agent | AppMode::Auto | AppMode::Yolo => MessageId::AppModeAgent,
605 AppMode::Plan => MessageId::AppModePlan,
606 AppMode::Operate => MessageId::AppModeOperate,
607 },
608 )
609 }
610
611 /// Localized one-line hint for the mode picker (user-facing surface only).
612 #[must_use]
613 pub fn picker_hint_localized(self, locale: Locale) -> Cow<'static, str> {
614 tr(
615 locale,
616 match self {
617 AppMode::Agent | AppMode::Auto | AppMode::Yolo => MessageId::AppModeAgentHint,
618 AppMode::Plan => MessageId::AppModePlanHint,
619 AppMode::Operate => MessageId::AppModeOperateHint,
620 },
621 )
622 }
623
624 #[allow(dead_code)]
625 /// Description shown in help or onboarding text.
626 pub fn description(self) -> &'static str {
627 match self {
628 AppMode::Agent | AppMode::Auto => {
629 "Act mode - direct work in the current session with tools"
630 }
631 AppMode::Yolo => "Act mode with Full Access (legacy compatibility setting)",
632 AppMode::Plan => "Plan mode - research and design before implementing",
633 AppMode::Operate => "Operate mode - send tasks while Fleet workers run in parallel",
634 }
635 }
636
637 #[must_use]
638 pub fn next(self) -> Self {
639 let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else {
640 return Self::Agent;
641 };
642 Self::CYCLE[(index + 1) % Self::CYCLE.len()]
643 }
644
645 #[must_use]
646 pub fn previous(self) -> Self {
647 let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else {
648 return Self::Agent;
649 };
650 Self::CYCLE[(index + Self::CYCLE.len() - 1) % Self::CYCLE.len()]
651 }
652 }
653
654 /// Configuration required to bootstrap the TUI.
655 #[derive(Clone)]
656 #[allow(clippy::struct_excessive_bools)]
657 pub struct TuiOptions {
658 pub model: String,
659 pub workspace: PathBuf,
660 pub config_path: Option<PathBuf>,
661 pub config_profile: Option<String>,
662 pub allow_shell: bool,
663 /// Use the alternate screen buffer (fullscreen TUI).
664 pub use_alt_screen: bool,
665 /// Capture mouse input for internal scrolling/selection.
666 pub use_mouse_capture: bool,
667 /// Enable terminal bracketed-paste mode (OSC `?2004h` / `?2004l`). Defaults
668 /// on; settable via `bracketed_paste = false` in `settings.toml` for the
669 /// rare terminal that mishandles it.
670 pub use_bracketed_paste: bool,
671 /// Maximum number of concurrent sub-agents.
672 pub max_subagents: usize,
673 #[allow(dead_code)]
674 pub skills_dir: PathBuf,
675 #[allow(dead_code)]
676 pub memory_path: PathBuf,
677 #[allow(dead_code)]
678 pub notes_path: PathBuf,
679 #[allow(dead_code)]
680 pub mcp_config_path: PathBuf,
681 #[allow(dead_code)]
682 pub use_memory: bool,
683 /// Start in agent mode (defaults to agent; --yolo starts in YOLO)
684 pub start_in_agent_mode: bool,
685 /// Skip onboarding screens
686 pub skip_onboarding: bool,
687 /// Auto-approve tool executions (yolo mode)
688 pub yolo: bool,
689 /// Resume a previous session by ID
690 pub resume_session_id: Option<String>,
691 /// Pre-populate the composer with this text when the TUI starts.
692 /// Used by `deepseek pr <N>` (#451) to drop the model into a
693 /// session with the PR context already typed — the user can edit
694 /// before sending or hit Enter to fire as-is.
695 pub initial_input: Option<InitialInput>,
696 /// One-line receipt to show once at startup.
697 ///
698 /// Auto-resume uses this to say what it did — reattached, or fell back to
699 /// a fresh transcript because the candidate was missing, unreadable, or
700 /// recorded against a different workspace (#2934). Silence is the correct
701 /// value when nothing happened worth reporting.
702 pub startup_notice: Option<String>,
703 }
704
705 #[derive(Debug, Clone, PartialEq, Eq)]
706 pub enum InitialInput {
707 /// Pre-populate the composer and wait for the user to press Enter.
708 ///
709 /// Used by `codewhale pr <N>` (#451) to drop the model into a session
710 /// with the PR context already typed so the user can edit before sending.
711 Prefill(String),
712 /// Pre-populate the composer, submit it once startup is ready, then keep
713 /// the interactive session open for follow-up messages (#2370).
714 Submit(String),
715 /// Begin account-owned web remote control after the TUI is initialized.
716 RemoteControl,
717 }
718
719 // === Sub-state structs for App field organization (#377) ===
720
721 /// Vim modal editing mode for the composer input area.
722 ///
723 /// Enabled via `[composer] mode = "vim"` in `settings.toml`. When the
724 /// composer vim mode is active the user starts in `Normal` mode and presses
725 /// `i`, `a`, or `o` to enter `Insert` mode. `Esc` from `Insert` returns to
726 /// `Normal`. Standard vim motions (`h`/`j`/`k`/`l`, `w`/`b`, `0`/`$`, `x`,
727 /// `dd`) work in `Normal` mode. `Visual` is reserved for future selection
728 /// support and currently behaves like `Normal`.
729 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
730 pub enum VimMode {
731 /// Normal / command mode — motions and operators, no text insertion.
732 #[default]
733 Normal,
734 /// Insert mode — characters are appended at the cursor as typed.
735 Insert,
736 /// Visual mode — reserved for future selection support.
737 Visual,
738 }
739
740 impl VimMode {}
741
742 /// Message queued while the engine is busy.
743 #[derive(Debug, Clone, PartialEq, Eq)]
744 pub struct QueuedMessage {
745 pub display: String,
746 pub skill_instruction: Option<String>,
747 pub skill_provenance: Option<crate::plugins::types::PluginAuthority>,
748 }
749
750 /// How a freshly-typed user input should be sent.
751 ///
752 /// Picked by [`App::decide_composer_submit`] when the user submits a
753 /// non-empty composer.
754 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
755 pub enum SubmitDisposition {
756 /// Engine idle and online: send immediately.
757 Immediate,
758 /// Park on `queued_messages` (offline, or engine busy — #382).
759 Queue,
760 /// Amend the active turn immediately (#382).
761 Steer,
762 /// Park on `queued_messages` for dispatch after TurnComplete.
763 /// Legacy path; #382 unified busy states under `Queue`.
764 #[allow(dead_code)]
765 QueueFollowUp,
766 }
767
768 /// Enter-shaped gestures understood by the composer state machine.
769 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
770 pub enum ComposerSubmitChord {
771 Enter,
772 CtrlEnter,
773 }
774
775 /// The complete result of resolving a submit gesture against composer state.
776 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
777 pub enum ComposerSubmitAction {
778 Submit(SubmitDisposition),
779 /// Promote the oldest already-queued message into the active turn.
780 SendQueuedNow,
781 Noop,
782 }
783
784 /// Detailed tool payload attached to a history cell.
785 #[derive(Debug, Clone)]
786 pub struct ToolDetailRecord {
787 pub tool_id: String,
788 pub tool_name: String,
789 pub input: Value,
790 pub output: Option<String>,
791 }
792
793 /// Lightweight task view for sidebar rendering.
794 #[derive(Debug, Clone, PartialEq, Eq)]
795 pub struct TaskPanelEntry {
796 pub id: String,
797 pub status: String,
798 pub prompt_summary: String,
799 pub duration_ms: Option<u64>,
800 pub kind: TaskPanelEntryKind,
801 pub stale: bool,
802 pub elapsed_since_output_ms: Option<u64>,
803 pub owner_agent_id: Option<String>,
804 pub owner_agent_name: Option<String>,
805 /// #2889: structured current activity for the Work panel.
806 pub current_tool: Option<String>,
807 pub role: Option<String>,
808 pub files_touched: u32,
809 }
810
811 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
812 pub enum TaskPanelEntryKind {
813 Background,
814 }
815
816 impl QueuedMessage {
817 pub fn new(display: String, skill_instruction: Option<String>) -> Self {
818 Self {
819 display,
820 skill_instruction,
821 skill_provenance: None,
822 }
823 }
824
825 #[must_use]
826 pub fn with_skill_provenance(
827 mut self,
828 provenance: Option<crate::plugins::types::PluginAuthority>,
829 ) -> Self {
830 self.skill_provenance = provenance;
831 self
832 }
833
834 #[allow(dead_code)] // Tests and queue helpers use the display-only form; send path resolves @mentions.
835 pub fn content(&self) -> String {
836 if let Some(skill_instruction) = self.skill_instruction.as_ref() {
837 format!(
838 "{skill_instruction}\n\n---\n\nUser request: {}",
839 self.display
840 )
841 } else {
842 self.display.clone()
843 }
844 }
845 }
846
847 // === Actions ===
848
849 /// Actions emitted by the UI event loop.
850 #[derive(Debug, Clone, PartialEq)]
851 pub enum AppAction {
852 Quit,
853 #[allow(dead_code)] // For explicit /load command
854 LoadSession(PathBuf),
855 RemoteControl(crate::remote_control::RemoteControlAction),
856 SyncSession {
857 session_id: Option<String>,
858 messages: Vec<Message>,
859 system_prompt: Option<SystemPrompt>,
860 model: String,
861 workspace: PathBuf,
862 mode: AppMode,
863 },
864 OpenConfigEditor(ConfigUiMode),
865 OpenConfigView,
866 /// Open the native git worktree manager.
867 OpenWorktreeManager,
868 /// Open the `/model` two-pane picker (Pro/Flash + Off/High/Max).
869 OpenModelPicker,
870 /// Open the `/provider` picker modal — DeepSeek / NVIDIA NIM / OpenRouter
871 /// / Novita with inline API-key prompt for un-configured providers (#52).
872 OpenProviderPicker,
873 /// Open the `/provider` picker in setup/catalog mode, optionally focused on
874 /// a built-in provider that needs credentials before first use.
875 OpenProviderSetup {
876 provider: Option<ApiProvider>,
877 },
878 /// Run the xAI/Grok device-code flow with the TUI temporarily suspended.
879 StartXaiDeviceLogin,
880 /// Open the `/mode` picker modal for Act / Plan / Operate.
881 OpenModePicker,
882 /// Refresh the engine prompt after the UI operating mode changes.
883 ModeChanged(AppMode),
884 /// Synchronize a saved top-level approval policy into the live Config,
885 /// then refresh the engine prompt from the App's updated permission mode.
886 ApprovalPolicyPersisted {
887 policy: Option<String>,
888 },
889 /// Reload the active user permission rules after `/permissions` safely
890 /// removes one from the sibling `permissions.toml`.
891 PermissionRulesChanged,
892 /// Rebuild the engine's Skill/MCP catalogue from the App's newly replaced
893 /// immutable plugin snapshot after trust, enable, revoke, or reload.
894 PluginRegistryChanged,
895 /// Open the `/statusline` multi-select picker for footer items.
896 OpenStatusPicker,
897 /// Open the `/feedback` picker for GitHub issue/security destinations.
898 OpenFeedbackPicker,
899 /// Open the `/theme` picker modal with live preview of every preset.
900 OpenThemePicker,
901 /// Open the `/skills` manager — audit inventory + owned mutations.
902 OpenSkillsManager,
903 /// Open `/fleet` — the saved named-Fleet list (the primary Fleet surface).
904 OpenFleetList,
905 /// Open the `/fleet` roster — the saved-party view of the agent team.
906 OpenFleetRoster,
907 /// Open the `/fleet` profile authoring wizard.
908 OpenFleetSetup,
909 /// Open the `/hotbar` setup wizard.
910 OpenHotbarSetup,
911 /// Open the constitution-first `/setup` wizard shell.
912 OpenSetupWizard,
913 /// Open the constitution-first `/setup` wizard at a specific step.
914 OpenSetupWizardAt {
915 step: codewhale_config::SetupStep,
916 },
917 /// Record that the bundled/default constitution should be used.
918 UseBundledConstitution,
919 /// Open the exact effective base-prompt preview for the next turn (#3928).
920 ///
921 /// Handled where the session config lives, so the preview is built by the
922 /// same function the dispatch path uses. Human-only: it issues no provider
923 /// request and expands no tool catalog.
924 PreviewEffectiveBasePrompt,
925 /// Disable the Hotbar: persist `hotbar = []` and clear the live slots.
926 DisableHotbar,
927 /// Restore the default recommended Hotbar slots: remove the `hotbar` key so
928 /// the resolver falls back to the built-in defaults.
929 RestoreHotbarDefaults,
930 /// Open an external URL in the system browser.
931 OpenExternalUrl {
932 url: String,
933 label: String,
934 },
935 /// Send a message to the AI (normal chat mode).
936 SendMessage(String),
937 /// Cancel a running sub-agent through the engine manager.
938 CancelSubAgent {
939 agent_id: String,
940 },
941 /// Update the runtime goal status (`/goal pause|resume|clear|…`) without
942 /// dispatching a model turn. The UI layer translates this into
943 /// `Op::SetGoalStatus`.
944 SetGoalStatus {
945 status: crate::tools::goal::GoalStatus,
946 clear: bool,
947 },
948 ListSubAgents,
949 /// Ask the engine to describe the exact next outbound request
950 /// (`/preview-request`, #1004). The engine is the authority: only it can
951 /// rebuild the current tool catalog, MCP state, gates, and resolved route.
952 PreviewOutboundRequest {
953 /// Render the manifest as JSON instead of the human-readable table.
954 json: bool,
955 /// Render the exact base prompt only. Never includes runtime/system layers.
956 base_prompt_only: bool,
957 /// Optional text used only to resolve `auto` reasoning/routing. Never
958 /// added to the conversation and never sent to a provider.
959 hypothetical_prompt: Option<String>,
960 },
961 /// Show bounded read-only text without copying it into transcript history.
962 OpenTextPager {
963 title: String,
964 content: String,
965 },
966 FetchModels,
967 /// Force a Models.dev live-catalog refresh into ProviderLake (#4187).
968 RefreshModelsDevCatalog,
969 CacheWarmup,
970 /// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
971 /// restarting the process. The runtime rebuilds its API client from
972 /// the updated config. `model` overrides the post-switch model
973 /// (already normalized but not yet provider-prefixed).
974 SwitchProvider {
975 provider: ApiProvider,
976 model: Option<String>,
977 },
978 /// Switch provider+model through the same apply path as a `/model` route
979 /// row. Used by Hotbar route slots so dispatch does not hand-mutate config.
980 SwitchModelRoute {
981 provider: ApiProvider,
982 model: String,
983 },
984 UpdateCompaction(CompactionConfig),
985 UpdateStreamChunkTimeout(u64),
986 UpdateSubagentRuntimeConfig {
987 enabled: bool,
988 max_subagents: usize,
989 launch_concurrency: usize,
990 max_spawn_depth: u32,
991 api_timeout_secs: u64,
992 heartbeat_timeout_secs: u64,
993 },
994 /// Enable or disable the background advisor watcher for this session (#3982).
995 SetAdvisorEnabled {
996 enabled: bool,
997 },
998 /// Open the live transcript overlay through a terminal-safe command path.
999 OpenLiveTranscript,
1000 /// Open the whole-turn inspector (Ctrl+Alt+O, /turn inspect).
1001 OpenTurnInspector,
1002 OpenContextInspector,
1003 CompactContext {
1004 /// Optional user focus from `/compact <focus>`, forwarded into the
1005 /// successor-brief summary prompt.
1006 focus: Option<String>,
1007 },
1008 PurgeContext,
1009 TaskAdd {
1010 prompt: String,
1011 },
1012 TaskList,
1013 TaskShow {
1014 id: String,
1015 },
1016 TaskCancel {
1017 id: String,
1018 },
1019 Automation(AutomationAction),
1020 ShellJob(ShellJobAction),
1021 Mcp(McpUiAction),
1022 /// Switch to a different config profile without restarting.
1023 SwitchProfile {
1024 /// Profile name to load.
1025 profile: String,
1026 },
1027 /// Switch the workspace used by tools, hooks, tasks, and session metadata.
1028 SwitchWorkspace {
1029 workspace: PathBuf,
1030 },
1031 /// Record from the microphone and route the transcription into the
1032 /// composer (or auto-send it). Emitted by `/voice` and the voice hotbar
1033 /// action; handled in the UI event loop where the live `Config` supplies
1034 /// provider credentials.
1035 VoiceCapture,
1036 /// Export and share the current session as a web URL.
1037 ShareSession {
1038 history_len: usize,
1039 model: String,
1040 mode: String,
1041 },
1042 }
1043
1044 #[derive(Debug, Clone, PartialEq, Eq)]
1045 pub enum AutomationAction {
1046 List,
1047 Show(String),
1048 Pause(String),
1049 Resume(String),
1050 Delete {
1051 id: String,
1052 confirmation: Option<String>,
1053 },
1054 Run(String),
1055 }
1056
1057 #[derive(Debug, Clone, PartialEq, Eq)]
1058 pub enum ShellJobAction {
1059 List,
1060 Show {
1061 id: String,
1062 },
1063 Poll {
1064 id: String,
1065 wait: bool,
1066 },
1067 SendStdin {
1068 id: String,
1069 input: String,
1070 close: bool,
1071 },
1072 Cancel {
1073 id: String,
1074 },
1075 CancelAll,
1076 }
1077
1078 #[derive(Debug, Clone, PartialEq, Eq)]
1079 pub enum McpUiAction {
1080 Show,
1081 Init {
1082 force: bool,
1083 },
1084 AddStdio {
1085 name: String,
1086 command: String,
1087 args: Vec<String>,
1088 },
1089 AddHttp {
1090 name: String,
1091 url: String,
1092 transport: Option<String>,
1093 },
1094 Enable {
1095 name: String,
1096 },
1097 Disable {
1098 name: String,
1099 },
1100 Remove {
1101 name: String,
1102 },
1103 Login {
1104 name: String,
1105 scopes: Vec<String>,
1106 },
1107 Logout {
1108 name: String,
1109 },
1110 /// List consent-gated external MCP import candidates with provenance.
1111 ImportList,
1112 /// Approve importing one discovered external server into user mcp.json.
1113 ImportApprove {
1114 name: String,
1115 },
1116 /// Decline an external candidate (durable until source content changes).
1117 ImportDecline {
1118 name: String,
1119 },
1120 Validate,
1121 Reload,
1122 }
1123
1123 lines RUST