返回 CodeWhale
facets.rs
根目录 / crates / command-contract / src / facets.rs
1 //! Independent, object-safe capability shapes for staged command migration.
2 //!
3 //! FEAT-014 publishes these interfaces without implementing them for the TUI
4 //! or changing an existing command. Later work adopts them inside
5 //! `codewhale-tui` one command group at a time. Only after every group uses
6 //! these shapes will groups move physically into a commands crate.
7
8 use std::path::{Path, PathBuf};
9
10 use codewhale_core::request::{Message, SystemPrompt};
11 use serde_json::Value;
12
13 use crate::types::{CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId};
14
15 /// Session identity, messages, queue operations, and token totals.
16 pub trait CommandSessionContext {
17 fn session_id(&self) -> Option<String>;
18 fn api_messages(&self) -> Vec<Message>;
19 fn add_message(&mut self, message: Message);
20 fn queued_message_count(&self) -> usize;
21 fn remove_queued_message(&mut self, index: usize) -> Result<(), String>;
22 fn total_tokens(&self) -> u64;
23 }
24
25 /// Model selection, provider identity, and fallback chain.
26 ///
27 /// Slice 4 dropped the `reasoning_effort()` facet: reasoning preference is
28 /// owned by `codewhale_tui::reasoning_preference::ReasoningEffort` and no
29 /// command group consumed the duplicated boundary enum.
30 pub trait CommandModelContext {
31 fn current_model(&self) -> String;
32 fn auto_model(&self) -> bool;
33 fn set_model_selection(&mut self, model: String, provider: Option<CommandProviderId>);
34 fn provider_identity(&self) -> Option<CommandProviderId>;
35 fn fallback_chain(&self) -> Vec<CommandProviderId>;
36 }
37
38 /// Cost display and accounting operations.
39 pub trait CommandCostContext {
40 fn display_currency(&self) -> CommandCurrency;
41 fn session_cost_for_currency(&self, currency: CommandCurrency) -> f64;
42 fn subagent_cost_for_currency(&self, currency: CommandCurrency) -> f64;
43 fn accrue_cost_estimate(&mut self, amount: f64, currency: CommandCurrency);
44 fn record_turn_cost(
45 &mut self,
46 amount: f64,
47 currency: CommandCurrency,
48 route_receipt: Option<String>,
49 );
50 }
51
52 /// Operating mode, approval posture, shell access, and policy lock.
53 pub trait CommandModePolicyContext {
54 fn mode(&self) -> CommandMode;
55 fn set_mode(&mut self, mode: CommandMode);
56 fn approval_mode(&self) -> CommandApprovalMode;
57 fn allow_shell(&self) -> bool;
58 fn set_shell_access(&mut self, allow: bool);
59 fn policy_locked(&self) -> bool;
60 }
61
62 /// Read access to the effective system prompt.
63 pub trait CommandSystemPromptContext {
64 fn system_prompt(&self) -> Option<SystemPrompt>;
65 }
66
67 /// Active skill identity and skill-cache refresh.
68 pub trait CommandSkillsContext {
69 fn active_skill(&self) -> Option<String>;
70 fn active_skill_provenance(&self) -> Option<String>;
71 fn refresh_skill_cache(&mut self);
72 }
73
74 /// Workspace path and a bounded serialized work-state snapshot.
75 pub trait CommandWorkspaceContext {
76 fn workspace(&self) -> PathBuf;
77 fn work_state_snapshot(&self) -> Result<Option<String>, String>;
78 /// Session-aware canonical operation digest. Returns the final user-facing
79 /// digest text or a safe explicit error; never a serialized snapshot.
80 /// No-active-work and temporary-unavailability semantics are preserved by
81 /// the host implementation (FEAT-018 D5).
82 fn operation_digest(&mut self) -> Result<String, String>;
83 }
84
85 /// Stable-key translation with named replacements (FEAT-018 D3).
86 ///
87 /// Message identity uses stable snake_case keys plus named replacements. The
88 /// TUI host maps those keys to the current catalog and preserves the existing
89 /// English fallback for intentionally incomplete locale packs. Unknown keys or
90 /// invalid replacement contracts fail safely and produce a command error; they
91 /// never panic and never display a raw lookup key.
92 pub trait CommandPresentationContext {
93 /// Resolve a stable message key with its named replacements.
94 fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String>;
95 }
96
97 /// Portable receipt for a successful atomic media attachment (FEAT-018 D4).
98 /// Carries only the information needed for the existing confirmation text.
99 #[derive(Debug, Clone, PartialEq, Eq)]
100 pub struct MediaAttachmentReceipt {
101 pub kind: String,
102 pub path: std::path::PathBuf,
103 }
104
105 /// Atomic composer/media capability (FEAT-018 D4).
106 ///
107 /// The host performs media validation and composer insertion as one atomic
108 /// operation. Rejected, missing, unsupported, corrupt, or oversized media
109 /// leaves composer state unchanged and returns a safe error. Only portable
110 /// success information crosses the boundary; composer markup, mutable input
111 /// text, decoder internals, and TUI types never do.
112 pub trait CommandMediaContext {
113 /// Validate and insert a resolved media path atomically.
114 fn attach_media(&mut self, resolved_path: &Path) -> Result<MediaAttachmentReceipt, String>;
115 }
116
117 // ---------------------------------------------------------------------------
118 // Project (FEAT-021 D1/D2/D3/D4)
119 // ---------------------------------------------------------------------------
120
121 /// Portable goal status for the project facet (FEAT-021 D1).
122 ///
123 /// Mirrors the four TUI-owned `tools::goal::GoalStatus` variants without
124 /// naming the TUI type. The adapter maps host state onto this enum; handlers
125 /// compare and render it directly.
126 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127 pub enum ProjectGoalStatus {
128 #[default]
129 Active,
130 Paused,
131 Complete,
132 Blocked,
133 }
134
135 /// Portable session-share projection (FEAT-021 D1).
136 ///
137 /// Carries only the emptiness/length and the model/mode labels the live
138 /// `/share` handler consumes. The session history itself, exporter I/O, and
139 /// all `App` state stay host-side.
140 #[derive(Debug, Clone, PartialEq, Eq)]
141 pub struct ProjectShareProjection {
142 /// Whether the session history is empty (drives the empty-share error).
143 pub history_is_empty: bool,
144 /// Session history length used in the export message and action.
145 pub history_len: usize,
146 /// Current model label.
147 pub model: String,
148 /// Current operating-mode label.
149 pub mode_label: String,
150 }
151
152 /// Portable goal projection (FEAT-021 D1).
153 ///
154 /// Carries the visible goal state, the effective pending-control view, and the
155 /// session-derived token fallback the live `/goal` handler consumes. Concrete
156 /// goal-service, session-manager, and `App` types never cross the boundary.
157 #[derive(Debug, Clone, PartialEq, Eq)]
158 pub struct ProjectGoalState {
159 /// Visible goal objective.
160 pub objective: Option<String>,
161 /// Visible goal status.
162 pub status: ProjectGoalStatus,
163 /// Pause reason label when the goal is paused (already rendered).
164 pub pause_reason: Option<String>,
165 /// Elapsed seconds from `started_at` when present (host-computed).
166 pub started_at_elapsed_seconds: Option<u64>,
167 /// Seconds of goal time used (stable budget/elapsed source).
168 pub time_used_seconds: u64,
169 /// Optional token budget.
170 pub token_budget: Option<u32>,
171 /// Tokens used by the goal engine.
172 pub tokens_used: u64,
173 /// Session conversation-token total (fallback when tokens_used == 0).
174 pub session_total_tokens: u32,
175 /// Goal continuation count.
176 pub continuation_count: u32,
177 /// Whether pending goal controls are queued (effective-state gate).
178 pub pending_controls: bool,
179 /// Last-known durable objective (session-derived effective source).
180 pub last_known_objective: Option<String>,
181 /// Last-known durable status (session-derived effective source).
182 pub last_known_status: Option<ProjectGoalStatus>,
183 /// Whether the conversation has API messages (bare `/goal` context gate).
184 pub conversation_present: bool,
185 /// Whether the host is currently loading (idle-hint gate).
186 pub is_loading: bool,
187 /// Whether the goal continuation loop is waiting (idle-hint gate).
188 pub goal_continuation_waiting: bool,
189 }
190
191 /// Host project data for the project command group (FEAT-021 D1).
192 ///
193 /// Exposes the typed, exact-minimum operations the live project handlers
194 /// consume: `/lsp` status/set state, `/share` session payload data, and
195 /// `/goal` goal state including the session-derived effective values.
196 /// `/init` host data flows through the existing `WORKSPACE` facet (D2), so
197 /// `/init` destructures exactly `WORKSPACE` (D4) and consumes no
198 /// project-facet method. All results are contract-owned portable values; implementation
199 /// errors cross as safe text. The TUI adapter is the only place that touches
200 /// `App`, `config::config`, the goal service, or the session manager.
201 pub trait CommandProjectContext {
202 /// `/lsp` status: whether LSP diagnostics are enabled.
203 fn lsp_enabled(&self) -> bool;
204 /// `/lsp` set: enable or disable LSP diagnostics.
205 fn lsp_set(&mut self, enabled: bool) -> Result<(), String>;
206 /// `/share` projection: session emptiness, length, model, and mode label.
207 fn share_projection(&self) -> ProjectShareProjection;
208 /// `/goal` projection: visible and effective goal state.
209 fn goal_state(&self) -> ProjectGoalState;
210 }
211
212 // ---------------------------------------------------------------------------
213 // Memory (FEAT-019 D1/D2/D8/D9)
214 // ---------------------------------------------------------------------------
215
216 /// Portable semantic hit for a native-memory search or get result.
217 ///
218 /// Carries only the typed location and text the handler consumes for
219 /// formatting; the TUI-owned `NativeMemoryHit` never crosses the boundary.
220 #[derive(Debug, Clone, PartialEq, Eq)]
221 pub struct MemoryHit {
222 pub source: PathBuf,
223 pub line_start: usize,
224 pub line_end: usize,
225 pub text: String,
226 }
227
228 /// Portable native-memory location summary (status operation).
229 #[derive(Debug, Clone, PartialEq, Eq)]
230 pub struct MemoryStatus {
231 pub root: PathBuf,
232 pub source: PathBuf,
233 pub index: PathBuf,
234 }
235
236 /// Portable result of a successful remember operation.
237 #[derive(Debug, Clone, PartialEq, Eq)]
238 pub struct MemoryRemembered {
239 pub source: PathBuf,
240 pub line_start: usize,
241 }
242
243 /// Portable import outcome: imported (with destination) or skipped.
244 #[derive(Debug, Clone, PartialEq, Eq)]
245 pub enum MemoryImportOutcome {
246 Imported { destination: PathBuf },
247 Skipped,
248 }
249
250 /// Portable get outcome: found hit or explicit not-found.
251 #[derive(Debug, Clone, PartialEq, Eq)]
252 pub enum MemoryGetOutcome {
253 Found(MemoryHit),
254 NotFound,
255 }
256
257 /// Portable export payload — the exported memory document itself, never a
258 /// preformatted command response.
259 #[derive(Debug, Clone, PartialEq, Eq)]
260 pub struct MemoryExport {
261 pub content: String,
262 }
263
264 /// Portable reindex entry count.
265 #[derive(Debug, Clone, PartialEq, Eq)]
266 pub struct MemoryReindex {
267 pub entry_count: usize,
268 }
269
270 /// Zero-field success value for delete operations (D2): the handler already
271 /// owns the selected scope and needs no additional success data.
272 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
273 pub struct MemoryDelete;
274
275 /// Typed remember target (D9): the handler resolves workspace identity through
276 /// the workspace facet and passes the resulting typed ID here.
277 #[derive(Debug, Clone, PartialEq, Eq)]
278 pub enum MemoryRememberTarget {
279 Global,
280 Workspace { workspace_id: String },
281 }
282
283 /// Typed delete scope for the non-workspace delete method (D8/D9).
284 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
285 pub enum MemoryDeleteScope {
286 /// Delete every memory entry (global and all workspace scopes).
287 All,
288 /// Delete only the global scope entries.
289 Global,
290 }
291
292 /// Host memory data for the memory command group (FEAT-019 D1).
293 ///
294 /// Exposes the resolved user-memory file path, the enablement flag, and one
295 /// typed method per exposed native-memory operation. All results are
296 /// contract-owned portable values; implementation errors cross as safe text.
297 /// Workspace-scoped operations take the borrowed workspace path as their first
298 /// argument (D8); non-workspace operations never receive workspace authority
299 /// and the facet never captures or retains workspace state internally.
300 pub trait CommandMemoryContext {
301 /// The resolved user-memory file path.
302 fn memory_path(&self) -> PathBuf;
303 /// Whether the `[memory] enabled` / `DEEPSEEK_MEMORY=on` flag is set.
304 fn memory_enabled(&self) -> bool;
305 /// Native-memory root, global source, and index paths.
306 fn status(&self) -> Result<MemoryStatus, String>;
307 /// The native-memory root path.
308 fn path(&self) -> Result<PathBuf, String>;
309 /// Workspace identity for the given workspace path.
310 fn workspace_id(&self, workspace: &Path) -> Result<String, String>;
311 /// Workspace-scoped search over the native-memory store.
312 fn search(&self, workspace: &Path, query: &str, limit: usize)
313 -> Result<Vec<MemoryHit>, String>;
314 /// Append a reviewed note to the typed global or workspace target.
315 fn remember(
316 &self,
317 target: MemoryRememberTarget,
318 note: &str,
319 ) -> Result<MemoryRemembered, String>;
320 /// Import legacy memory; distinguishes imported from skipped.
321 fn import(&self) -> Result<MemoryImportOutcome, String>;
322 /// Workspace-scoped get by entry id; not-found is a typed outcome.
323 fn get(&self, workspace: &Path, id: i64) -> Result<MemoryGetOutcome, String>;
324 /// Export the native-memory document content.
325 fn export(&self) -> Result<MemoryExport, String>;
326 /// Reindex the native-memory store; returns the indexed entry count.
327 fn reindex(&self) -> Result<MemoryReindex, String>;
328 /// Delete all or global scope; never receives workspace authority.
329 fn delete(&self, scope: MemoryDeleteScope) -> Result<MemoryDelete, String>;
330 /// Delete the given workspace scope; workspace path is the first argument.
331 fn delete_workspace(&self, workspace: &Path) -> Result<MemoryDelete, String>;
332 }
333
334 // ---------------------------------------------------------------------------
335 // Plugin (FEAT-020 D1/D2/D10/D11)
336 // ---------------------------------------------------------------------------
337
338 /// Portable plugin diagnostic level (FEAT-020 D2).
339 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
340 pub enum PluginDiagnosticLevel {
341 Warning,
342 Error,
343 }
344
345 /// Portable plugin diagnostic entry.
346 #[derive(Debug, Clone, PartialEq, Eq)]
347 pub struct PluginDiagnostic {
348 pub level: PluginDiagnosticLevel,
349 pub code: String,
350 pub message: String,
351 pub path: Option<PathBuf>,
352 }
353
354 /// Portable MCP transport classification for the capability review body.
355 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
356 pub enum PluginMcpTransport {
357 Stdio,
358 Http,
359 Invalid,
360 }
361
362 /// Portable MCP server detail for the capability review body (FEAT-020 D2).
363 ///
364 /// Carries only the semantic fields `render_mcp_inventory` consumes:
365 /// transport, command/url, argv, cwd, env provenance, timeouts, required,
366 /// enabled/disabled tool lists, and the enabled flag. Host `McpServerConfig`
367 /// never crosses the boundary.
368 #[derive(Debug, Clone, PartialEq, Eq)]
369 pub struct PluginMcpServerDetail {
370 pub name: String,
371 pub transport: PluginMcpTransport,
372 pub command: Option<String>,
373 pub argv: Vec<String>,
374 pub cwd: Option<PathBuf>,
375 pub env: Vec<(String, String)>,
376 pub url: Option<String>,
377 pub env_headers: Vec<(String, String)>,
378 pub bearer_token_env_var: Option<String>,
379 pub connect_timeout_secs: Option<u64>,
380 pub execute_timeout_secs: Option<u64>,
381 pub read_timeout_secs: Option<u64>,
382 pub required: bool,
383 pub enabled_tools: Vec<String>,
384 pub disabled_tools: Vec<String>,
385 pub enabled: bool,
386 }
387
388 /// Portable summary of one loaded plugin bundle (list output, FEAT-020 D2).
389 #[derive(Debug, Clone, PartialEq, Eq)]
390 pub struct PluginSummary {
391 pub name: String,
392 pub id: String,
393 pub state_label: String,
394 pub scope: String,
395 pub trust_status: String,
396 pub compatibility: String,
397 pub inventory: String,
398 pub active: bool,
399 pub trusted: bool,
400 pub enabled: bool,
401 }
402
403 /// Portable full bundle detail for show/review/validate rendering (FEAT-020 D2).
404 ///
405 /// Carries every semantic value the render helpers consume. The complete
406 /// `LoadedPlugin` never crosses the boundary; only branch-consumed fields are
407 /// projected here (D10).
408 #[derive(Debug, Clone, PartialEq, Eq)]
409 pub struct PluginDetail {
410 /// Inventory summary string (host-computed, e.g. `skills=1 mcp=0`).
411 pub inventory_summary: String,
412 pub name: String,
413 pub id: String,
414 pub version: String,
415 pub origin: String,
416 pub scope: String,
417 pub state_label: String,
418 pub trust_status: String,
419 pub compatibility: String,
420 pub content_hash: String,
421 pub capability_hash: String,
422 pub canonical_root: PathBuf,
423 pub active: bool,
424 pub trusted: bool,
425 pub enabled: bool,
426 pub unsupported_labels: Vec<String>,
427 pub supported_labels: Vec<String>,
428 pub skills: Vec<String>,
429 pub filesystem_roots: Vec<String>,
430 pub network_hosts: Vec<String>,
431 pub stdio_mcp_servers: usize,
432 pub lifecycle_mutation: bool,
433 pub mcp_servers: Vec<PluginMcpServerDetail>,
434 pub diagnostics: Vec<PluginDiagnostic>,
435 }
436
437 /// Portable outcome of a plugin mutation (FEAT-020 D2/D11).
438 #[derive(Debug, Clone, PartialEq, Eq)]
439 pub enum PluginMutationOutcome {
440 Installed,
441 Updated,
442 NoChange,
443 Uninstalled,
444 NeedsApproval(String),
445 NetworkDenied(String),
446 }
447
448 /// Portable mutation receipt returned synchronously by the facet (FEAT-020 D11).
449 #[derive(Debug, Clone, PartialEq, Eq)]
450 pub struct PluginMutationReceipt {
451 pub name: String,
452 pub path: Option<PathBuf>,
453 pub content_hash: Option<String>,
454 pub installed_content_hash: Option<String>,
455 pub outcome: PluginMutationOutcome,
456 }
457
458 /// Portable bundle export receipt (FEAT-020 D2).
459 #[derive(Debug, Clone, PartialEq, Eq)]
460 pub struct PluginExportReceipt {
461 pub exported_name: String,
462 pub target: PathBuf,
463 pub display_name: Option<String>,
464 pub wrote_mcp_json: bool,
465 pub files_copied: u64,
466 pub skills_normalized: bool,
467 }
468
469 /// Portable legacy executable-tool detail (FEAT-020 D2).
470 #[derive(Debug, Clone, PartialEq, Eq)]
471 pub struct PluginLegacyTool {
472 pub name: String,
473 pub description: String,
474 pub approval: String,
475 pub input_schema: Option<String>,
476 pub path: PathBuf,
477 }
478
479 /// Portable legacy-tool scan result: directory and discovered tools.
480 #[derive(Debug, Clone, PartialEq, Eq)]
481 pub struct PluginLegacyScan {
482 pub dir: PathBuf,
483 pub tools: Vec<PluginLegacyTool>,
484 }
485
486 /// Portable Kimi managed-plugin candidate (FEAT-020 D2).
487 #[derive(Debug, Clone, PartialEq, Eq)]
488 pub struct PluginManagedCandidate {
489 pub name: String,
490 pub version: String,
491 pub license: Option<String>,
492 pub canonical_path: PathBuf,
493 pub content_hash: String,
494 pub capability_hash: String,
495 pub inventory: String,
496 pub applicable: bool,
497 }
498
499 /// Portable Kimi managed-scan result (FEAT-020 D2).
500 #[derive(Debug, Clone, PartialEq, Eq)]
501 pub struct PluginManagedScan {
502 pub root: PathBuf,
503 pub candidates: Vec<PluginManagedCandidate>,
504 pub rejected: Vec<String>,
505 }
506
507 /// Portable marketplace candidate install plan (FEAT-020 D2).
508 #[derive(Debug, Clone, PartialEq, Eq)]
509 pub enum PluginMarketplaceInstallPlan {
510 Supported { spec: String, source_kind: String },
511 AlreadyPresent { selector: String, reason: String },
512 Unsupported { reason: String },
513 }
514
515 /// Portable marketplace candidate (FEAT-020 D2).
516 #[derive(Debug, Clone, PartialEq, Eq)]
517 pub struct PluginMarketplaceCandidate {
518 pub name: String,
519 pub display_name: Option<String>,
520 pub version: Option<String>,
521 pub tier: String,
522 pub compatibility: Option<String>,
523 pub install_plan: PluginMarketplaceInstallPlan,
524 pub description: Option<String>,
525 pub homepage: Option<String>,
526 pub repository: Option<String>,
527 pub author: Option<String>,
528 pub license: Option<String>,
529 pub keywords: Vec<String>,
530 pub when: Option<String>,
531 pub diagnostics: Vec<PluginDiagnostic>,
532 pub has_errors: bool,
533 }
534
535 /// Portable marketplace catalog (FEAT-020 D2).
536 #[derive(Debug, Clone, PartialEq, Eq)]
537 pub struct PluginMarketplaceCatalog {
538 pub id: String,
539 /// Source document path (for the `show` provenance line).
540 pub source_path: Option<String>,
541 pub display_name: Option<String>,
542 pub description: Option<String>,
543 pub format: String,
544 pub tier: String,
545 pub publisher: Option<String>,
546 pub total_candidates: usize,
547 pub warning_count: usize,
548 pub candidates: Vec<PluginMarketplaceCandidate>,
549 pub diagnostics: Vec<PluginDiagnostic>,
550 }
551
552 /// Portable marketplace add receipt (FEAT-020 D2).
553 #[derive(Debug, Clone, PartialEq, Eq)]
554 pub struct PluginMarketplaceAddReceipt {
555 pub name: String,
556 pub candidate_count: usize,
557 pub warning_count: usize,
558 pub catalog: PluginMarketplaceCatalog,
559 }
560
561 /// Portable marketplace state: stored catalogs plus an optional host-provided
562 /// built-in `official` catalog.
563 #[derive(Debug, Clone, PartialEq, Eq)]
564 pub struct PluginMarketplaceState {
565 /// Optional host-provided built-in catalog. Current main provides none;
566 /// retaining the option keeps the portable boundary future-compatible
567 /// without inventing a catalog in the handler.
568 pub official: Option<PluginMarketplaceCatalog>,
569 pub stored: Vec<PluginMarketplaceCatalog>,
570 }
571
572 /// Portable suggestion for the `/plugin suggest` recommendation output.
573 #[derive(Debug, Clone, PartialEq, Eq)]
574 pub struct PluginSuggestion {
575 pub name: String,
576 /// State label rendered beside the plugin name (active/not-reviewed/…).
577 pub state_label: String,
578 pub description: String,
579 pub why: Vec<String>,
580 /// The actionable next step rendered under the suggestion.
581 pub next_step: String,
582 }
583
584 /// Host plugin data for the plugin command group (FEAT-020 D1).
585 ///
586 /// One object-safe, synchronous facet exposing the exact-minimum typed
587 /// operations the live `/plugin` branch closure consumes. Registry reads and
588 /// mutations, async-bridged install/update/uninstall (returning synchronous
589 /// portable receipts), export, legacy executable-tool scan, Kimi managed
590 /// import, and marketplace operations are all represented. The handler never
591 /// names `crate::plugins`, `PluginRegistry`, `LoadedPlugin`, `Config`, or
592 /// another concrete host service; implementation errors cross as safe text.
593 ///
594 /// Post-mutation side effects (rediscovery, skill-cache refresh, active-skill
595 /// reset) happen host-side inside the facet implementation; the handler only
596 /// renders the returned receipt (D11).
597 pub trait CommandPluginContext {
598 /// Read-only: registry summaries for list output.
599 fn summaries(&self) -> Result<Vec<PluginSummary>, String>;
600 /// Read-only: full portable detail for show/review/validate.
601 fn detail(&self, selector: &str) -> Result<PluginDetail, String>;
602 /// Read-only: registry-level diagnostics.
603 fn registry_diagnostics(&self) -> Vec<PluginDiagnostic>;
604 /// Read-only: whether validation reports no errors.
605 fn validation_is_clean(&self) -> bool;
606 /// Read-only: registry length (used by list/reload empty branches).
607 fn len(&self) -> usize;
608 /// Mutation: rediscover the workspace registry and refresh the skill
609 /// cache; returns the new registry length for the reload message.
610 fn reload(&mut self) -> Result<usize, String>;
611 /// Read-only: whether the registry is empty.
612 fn is_empty(&self) -> bool;
613 /// Return the one-shot on-disk-change nudge, if the host detects one.
614 /// The host owns the mutable catalog-stamp state; handlers only render.
615 fn reload_nudge(&mut self) -> Option<String>;
616 /// Read-only: persistence store path for marketplace state.
617 fn state_path(&self) -> Option<PathBuf>;
618 /// Read-only: recommend installed bundles for a task without side effects.
619 fn suggest(&self, task: &str) -> Result<Vec<PluginSuggestion>, String>;
620 /// Mutation: trust a bundle by exact review token. Success means the
621 /// mutation was applied; the handler renders the action word from its own
622 /// dispatch arm and may re-read `detail` for post-mutation state.
623 fn trust(&mut self, selector: &str, token: &str) -> Result<(), String>;
624 /// Mutation: enable a bundle. Success means enabled; re-read `detail` for
625 /// the post-mutation compatibility note.
626 fn enable(&mut self, selector: &str) -> Result<(), String>;
627 /// Mutation: disable a bundle.
628 fn disable(&mut self, selector: &str) -> Result<(), String>;
629 /// Mutation: revoke trust.
630 fn revoke_trust(&mut self, selector: &str) -> Result<(), String>;
631 /// Async-bridged install; returns a synchronous portable receipt (D11).
632 fn install(
633 &mut self,
634 source: &str,
635 expected_content_hash: Option<&str>,
636 ) -> Result<PluginMutationReceipt, String>;
637 /// Async-bridged update; returns a synchronous portable receipt (D11).
638 fn update(&mut self, selector: &str) -> Result<PluginMutationReceipt, String>;
639 /// Async-bridged uninstall; returns a synchronous portable receipt (D11).
640 fn uninstall(&mut self, selector: &str) -> Result<PluginMutationReceipt, String>;
641 /// File-level removal of a just-installed bundle whose content hash
642 /// mismatched (rollback). Unlike [`Self::uninstall`] it does not resolve a
643 /// registry selector and triggers no rediscovery or skill-cache side
644 /// effects; the host adapter owns the `crate::plugins` call (D1).
645 fn uninstall_path(&mut self, name: &str, plugins_dir: &Path) -> Result<(), String>;
646 /// Read-only: export a loaded bundle to a target directory.
647 fn export(&self, selector: &str, target: &Path) -> Result<PluginExportReceipt, String>;
648 /// Read-only: scan legacy executable plugin tools.
649 fn legacy_scan(&self) -> Result<Option<PluginLegacyScan>, String>;
650 /// Read-only: Kimi managed-plugin directory scan.
651 fn managed_scan(&self, home_override: Option<&Path>) -> Result<PluginManagedScan, String>;
652 /// Mutation: install a Kimi managed candidate by exact content hash.
653 fn managed_install(
654 &mut self,
655 canonical_path: &Path,
656 expected_content_hash: &str,
657 ) -> Result<PluginMutationReceipt, String>;
658 /// Read-only: marketplace state (optional host catalog + stored catalogs).
659 fn marketplace_state(&self) -> Result<PluginMarketplaceState, String>;
660 /// Mutation: add a local catalog document to the marketplace store.
661 fn marketplace_add(
662 &mut self,
663 name: &str,
664 path: &Path,
665 ) -> Result<PluginMarketplaceAddReceipt, String>;
666 /// Mutation: remove a stored marketplace catalog.
667 fn marketplace_remove(&mut self, name: &str) -> Result<bool, String>;
668 /// Mutation: install a marketplace candidate through the reviewed installer.
669 fn marketplace_install(
670 &mut self,
671 catalog: &str,
672 candidate: &str,
673 ) -> Result<PluginMutationReceipt, String>;
674 }
675
676 // ---------------------------------------------------------------------------
677 // Skill group (FEAT-022 D1)
678 // ---------------------------------------------------------------------------
679
680 /// Source provenance of a discovered skill (native file vs reviewed plugin snapshot).
681 #[derive(Debug, Clone, PartialEq, Eq)]
682 pub enum SkillSourceKind {
683 Native,
684 Plugin {
685 plugin_name: String,
686 plugin_id: String,
687 },
688 }
689
690 /// Curated product tier for bundled (shipped) skills.
691 ///
692 /// The canonical name→tier classification stays in the TUI host
693 /// (`crate::skills::system::bundled_skill_tier`); the portable projection
694 /// carries the resolved tier so the handler can render the curated listing
695 /// without duplicating the canonical bundle list.
696 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
697 pub enum SkillBundledTier {
698 CoreAgentic,
699 FormatTooling,
700 }
701
702 impl SkillBundledTier {
703 /// Product-facing tier heading used by the `/skills` listing.
704 #[must_use]
705 pub fn heading(self) -> &'static str {
706 match self {
707 Self::CoreAgentic => "Core agentic",
708 Self::FormatTooling => "Format & tooling",
709 }
710 }
711 }
712
713 /// One discovered skill entry (portable).
714 ///
715 /// The body is intentionally excluded: activation and review receive body
716 /// text through their own delegates (`SkillActivationOutcome`/`ReviewOutcome`);
717 /// listing and inspect render name, description, source, and path only (D1
718 /// exact-minimum).
719 #[derive(Debug, Clone, PartialEq, Eq)]
720 pub struct SkillEntry {
721 pub name: String,
722 pub description: String,
723 pub source: SkillSourceKind,
724 /// Native skills carry their on-disk path (inspect output).
725 pub path: Option<String>,
726 /// Bundled catalog tier; `None` for user/compatible skills.
727 pub bundled_tier: Option<SkillBundledTier>,
728 }
729
730 /// Portable projection of the host skill registry (discovery, D1).
731 ///
732 /// Carries every value the `/skills` and `/skill` handlers render: workspace
733 /// and configured skills dir displays, discovery mode label, searched
734 /// directories, entries, warnings, and the enabled-skill total.
735 #[derive(Debug, Clone, PartialEq, Eq)]
736 pub struct SkillRegistryProjection {
737 pub workspace: String,
738 pub skills_dir: String,
739 pub mode_label: String,
740 pub dirs: Vec<String>,
741 pub entries: Vec<SkillEntry>,
742 pub warnings: Vec<String>,
743 pub total: usize,
744 }
745
746 /// Target scope for skill mutations (`/skill install|update|uninstall|trust`).
747 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
748 pub enum SkillTargetScope {
749 Project,
750 Global,
751 }
752
753 /// Portable mutation outcome mirroring the host receipt variants.
754 #[derive(Debug, Clone, PartialEq, Eq)]
755 pub enum SkillMutationOutcome {
756 Installed,
757 Updated,
758 NoChange,
759 Removed,
760 Trusted,
761 Imported,
762 AlreadyPresent,
763 NeedsApproval(String),
764 NetworkDenied(String),
765 }
766
767 /// Synchronous portable receipt for a skill mutation (FEAT-020 D11 mirror):
768 /// the host owns the async network bridge; the handler renders the receipt
769 /// byte-identically from these values.
770 #[derive(Debug, Clone, PartialEq, Eq)]
771 pub struct SkillMutationReceipt {
772 pub name: String,
773 pub safe_target_path: String,
774 pub outcome: SkillMutationOutcome,
775 }
776
777 /// One curated remote registry entry (`/skills --remote`).
778 #[derive(Debug, Clone, PartialEq, Eq)]
779 pub struct RemoteSkillEntry {
780 pub name: String,
781 pub description: Option<String>,
782 pub source: String,
783 }
784
785 /// Remote registry fetch outcome (`/skills --remote`, suggest source).
786 #[derive(Debug, Clone, PartialEq, Eq)]
787 pub enum RemoteRegistryOutcome {
788 Loaded { entries: Vec<RemoteSkillEntry> },
789 NeedsApproval(String),
790 Denied(String),
791 }
792
793 /// Remote recommendation for `/skills suggest <task>`.
794 #[derive(Debug, Clone, PartialEq, Eq)]
795 pub struct SkillRecommendation {
796 pub name: String,
797 pub description: Option<String>,
798 pub matched_terms: Vec<String>,
799 }
800
801 /// Per-skill outcome of `/skills sync`.
802 #[derive(Debug, Clone, PartialEq, Eq)]
803 pub enum SkillSyncEntry {
804 Downloaded { name: String, path: String },
805 Fresh { name: String },
806 Failed { name: String, reason: String },
807 Denied { name: String, host: String },
808 NeedsApproval { name: String, host: String },
809 }
810
811 /// Aggregate `/skills sync` outcome.
812 ///
813 /// Registry-level network-policy outcomes are carried as variants so the
814 /// portable handler composes the exact `needs_approval` / `denied` messages.
815 #[derive(Debug, Clone, PartialEq, Eq)]
816 pub enum SkillSyncOutcome {
817 Done {
818 total: usize,
819 downloaded: usize,
820 fresh: usize,
821 failed: usize,
822 entries: Vec<SkillSyncEntry>,
823 },
824 RegistryNeedsApproval(String),
825 RegistryDenied(String),
826 }
827
828 /// Successful skill activation data (host performs the side effects).
829 #[derive(Debug, Clone, PartialEq, Eq)]
830 pub struct SkillActivationOutcome {
831 pub name: String,
832 pub description: String,
833 }
834
835 /// Activation failures with the exact data the handler renders.
836 #[derive(Debug, Clone, PartialEq, Eq)]
837 pub enum SkillActivationError {
838 NotFound {
839 requested: String,
840 available: Vec<String>,
841 warnings: Vec<String>,
842 },
843 PluginRejected {
844 name: String,
845 reason: String,
846 },
847 }
848
849 /// `/review` outcome data (host performs the side effects).
850 ///
851 /// On success the baseline `/review` renders no message — it only emits the
852 /// `SendMessage` action — so `Ready` carries no payload (D1 exact-minimum).
853 /// Warnings are only rendered on the not-found path.
854 #[derive(Debug, Clone, PartialEq, Eq)]
855 pub enum ReviewOutcome {
856 Ready,
857 NotFound {
858 skills_dir: String,
859 global_dir: String,
860 warnings: Vec<String>,
861 },
862 }
863
864 /// One snapshot entry for `/restore` listings.
865 #[derive(Debug, Clone, PartialEq, Eq)]
866 pub struct SnapshotEntry {
867 pub id: String,
868 pub label: String,
869 pub timestamp: i64,
870 }
871
872 /// Host approval posture for the `/restore` trust gate (D4: no MODE_POLICY).
873 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
874 pub struct CommandApprovalState {
875 pub yolo: bool,
876 pub trust_mode: bool,
877 }
878
879 /// Host skill data for the skills command group (FEAT-022 D1).
880 ///
881 /// Exposes the typed, exact-minimum operations the live skills handlers
882 /// consume: discovery (`/skills`), activation (`/skill`), synchronous
883 /// mutation receipts (`/skill install|update|uninstall|trust`), remote
884 /// registry + sync (`/skills --remote|sync|suggest`), review (`/review`),
885 /// and snapshot list/restore plus approval state (`/restore`). The host
886 /// adapter is the only place that touches `App`, `crate::plugins`,
887 /// `SnapshotRepo`, `crate::skills` services, config/network policy, and the
888 /// async runtime bridge. The shared FEAT-015 `CommandSkillsContext` is never
889 /// widened; active-skill reads use that facet, mutations flow through the
890 /// delegates here (D2). All results are contract-owned portable values;
891 /// implementation errors cross as safe text. `/skill` declares this facet
892 /// plus `CommandSkillsContext` for the baseline cache-refresh policy;
893 /// `/skills`, `/review`, and `/restore` declare exactly this facet.
894 pub trait CommandSkillGroupContext {
895 /// `/skills` discovery projection (workspace, skills dir, scan mode,
896 /// searched directories, plugin-provided skills, warnings).
897 fn skill_registry_projection(&self) -> SkillRegistryProjection;
898 /// `/skill` activation: host lookup, plugin-authority verification, and
899 /// active-skill/history side effects. `SendMessage` task composition is
900 /// handler-side.
901 fn activate_skill(
902 &mut self,
903 name: &str,
904 ) -> Result<SkillActivationOutcome, SkillActivationError>;
905 /// `/skill install` — synchronous portable receipt; host owns network/async.
906 fn install_skill(
907 &mut self,
908 scope: Option<SkillTargetScope>,
909 spec: &str,
910 ) -> Result<SkillMutationReceipt, String>;
911 /// `/skill update` — synchronous portable receipt; host owns network/async.
912 fn update_skill(
913 &mut self,
914 scope: Option<SkillTargetScope>,
915 name: &str,
916 ) -> Result<SkillMutationReceipt, String>;
917 /// `/skill uninstall` — synchronous portable receipt.
918 fn uninstall_skill(
919 &mut self,
920 scope: Option<SkillTargetScope>,
921 name: &str,
922 ) -> Result<SkillMutationReceipt, String>;
923 /// `/skill trust` — synchronous portable receipt.
924 fn trust_skill(
925 &mut self,
926 scope: Option<SkillTargetScope>,
927 name: &str,
928 ) -> Result<SkillMutationReceipt, String>;
929 /// `/skills --remote` registry fetch (network policy host-side).
930 fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String>;
931 /// `/skills suggest <task>` — host fetch + recommendation computation.
932 fn recommend_skills(&mut self, task: &str) -> Result<Vec<SkillRecommendation>, String>;
933 /// `/skills sync` — host registry sync (async bridge host-side).
934 fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String>;
935 /// `/review` activation: host discovery + side effects (empty-target
936 /// validation and `SendMessage` composition are handler-side).
937 fn run_review(&mut self) -> Result<ReviewOutcome, String>;
938 /// `/restore` snapshot listing.
939 fn snapshot_list(&mut self, limit: usize) -> Result<Vec<SnapshotEntry>, String>;
940 /// `/restore <N>`: host restores by snapshot id; handler composes the
941 /// exact success message from its list entry.
942 fn restore_snapshot(&mut self, id: &str) -> Result<(), String>;
943 /// `/restore` trust gate posture (yolo / trust_mode).
944 fn approval_state(&self) -> CommandApprovalState;
945 }
946
947 // ---------------------------------------------------------------------------
948 // Session lifecycle capability (FEAT-023).
949 //
950 // One contract-owned facet for the seven host-dependent lifecycle commands;
951 // `/compact` and `/purge` stay pure. The shared `CommandSessionContext` above
952 // stays unchanged: it
953 // serves commands outside this slice and must not gain persistence,
954 // navigation, picker, or lifecycle mutation authority (D2). No concrete App,
955 // SessionManager, session-journal, picker, configuration, or view-stack type
956 // crosses this boundary; successful results are structured portable fields so
957 // the handlers retain exact message composition (D2/D5).
958 // ---------------------------------------------------------------------------
959
960 /// Portable synchronization fields a lifecycle handler maps into the
961 /// temporary `SyncSession` action payload. The conversation and prompt types
962 /// are `codewhale-core` request types shared by the contract and the TUI
963 /// (FEAT-037 will move shared outcome ownership; FEAT-023 keeps the bounded
964 /// reference only for `/fork` and `/new` transitions, D6).
965 #[derive(Clone, Debug, PartialEq)]
966 pub struct SessionSyncPayload {
967 pub session_id: Option<String>,
968 pub messages: Vec<Message>,
969 pub system_prompt: Option<SystemPrompt>,
970 pub model: String,
971 pub workspace: PathBuf,
972 pub mode: CommandMode,
973 }
974
975 /// `/branch` success projection (`session/branch.rs`). The handler composes
976 /// the exact success line from these deterministic fields.
977 #[derive(Clone, Debug, PartialEq)]
978 pub struct SessionBranchOutcome {
979 pub leaf_display: String,
980 pub journal_entries_before: usize,
981 pub sync: SessionSyncPayload,
982 }
983
984 /// `/fork` success projection for an active-conversation fork. The handler
985 /// composes `Forked session {parent} -> {fork}` from these required fields.
986 #[derive(Clone, Debug, PartialEq)]
987 pub struct SessionForkReceipt {
988 pub parent_label: String,
989 pub fork_label: String,
990 pub sync: SessionSyncPayload,
991 }
992
993 /// `/fork <session_id|prefix>` success projection. Explicit-source forks
994 /// always report their spawn depth, so the contract makes that field required
995 /// rather than permitting an invalid missing-depth state.
996 #[derive(Clone, Debug, PartialEq)]
997 pub struct SessionForkFromReceipt {
998 pub parent_label: String,
999 pub fork_label: String,
1000 pub spawn_depth: u64,
1001 pub sync: SessionSyncPayload,
1002 }
1003
1004 /// `/save` success projection. The host performs the full baseline sequence
1005 /// (snapshot, serialization, atomic write, metadata application, work-state
1006 /// publication); the handler renders `Session saved to {display_path} (ID:
1007 /// {truncated_id})`.
1008 #[derive(Clone, Debug, PartialEq)]
1009 pub struct SessionSaveReceipt {
1010 pub display_path: String,
1011 pub truncated_id: String,
1012 }
1013
1014 /// `/new` success projection. The handler renders
1015 /// `Started new session {truncated_id} (New Session). Previous sessions
1016 /// remain available via /resume.`
1017 #[derive(Clone, Debug, PartialEq)]
1018 pub struct SessionNewReceipt {
1019 pub truncated_id: String,
1020 pub sync: SessionSyncPayload,
1021 }
1022
1023 /// `/sessions archive|unarchive|restore` success projection. The handler
1024 /// renders `Archived session {id} ({title})` or `Restored session ...` from
1025 /// the verb it dispatched.
1026 #[derive(Clone, Debug, PartialEq)]
1027 pub struct SessionArchiveReceipt {
1028 pub truncated_id: String,
1029 pub title: String,
1030 }
1031
1032 /// `/tree` body projection. The body rendering source (journal tree and
1033 /// linear transcript) stays TUI-owned; the handler appends the exact
1034 /// guidance lines (D5).
1035 #[derive(Clone, Debug, PartialEq)]
1036 pub enum TreeBodyProjection {
1037 /// Journal render already includes the trailing newline before guidance.
1038 Journal {
1039 rendered: String,
1040 },
1041 /// Linear pre-journal render (the marker lines).
1042 Linear {
1043 rendered: String,
1044 },
1045 EmptySession,
1046 NoSession,
1047 }
1048
1049 /// Lifecycle authority for the session command slice (FEAT-023 D2).
1050 ///
1051 /// Operation-granular synchronous delegates over the exact minimum host work
1052 /// the nine commands consume. Delegates may return the explicit host-error
1053 /// text the baseline surfaces for a failing stage; successful results are
1054 /// structured portable fields so handlers retain byte-identical composition.
1055 pub trait CommandSessionLifecycleContext {
1056 /// Live transition gate. Handlers return their own blocked-error text
1057 /// before invoking any mutating delegate, matching the baseline ordering
1058 /// (`/branch`, `/fork`, `/load`, `/new`). `/fork picker` and `/tree`
1059 /// never consult it in the baseline, so their paths must not either.
1060 fn transition_blocked(&self) -> bool;
1061
1062 /// `/branch` with no argument: the current leaf when an active journaled
1063 /// session resolves, otherwise `None` (the baseline silently falls back
1064 /// to the usage message on this path).
1065 fn branch_current_leaf_hint(&self) -> Option<String>;
1066
1067 /// `/branch <entry_id>`: persist the leaf move and apply the branched
1068 /// transcript. Errors are the exact baseline message for the failing
1069 /// stage (no active session, directory open, load, persist, or branch
1070 /// failure).
1071 fn branch_to(&mut self, entry_id: &str) -> Result<SessionBranchOutcome, String>;
1072
1073 /// `/tree`: produce the journal/linear/empty/no-session projection.
1074 /// Errors are the exact baseline directory-open message.
1075 fn tree_body(&self) -> Result<TreeBodyProjection, String>;
1076
1077 /// `/save [path]`: the full baseline persistence sequence.
1078 fn save_session(&mut self, explicit_path: Option<String>)
1079 -> Result<SessionSaveReceipt, String>;
1080
1081 /// `/fork` (active conversation): the full baseline parent/child save and
1082 /// switch sequence.
1083 fn fork_active(&mut self) -> Result<SessionForkReceipt, String>;
1084
1085 /// `/fork <session_id|prefix>`: explicit-source fork.
1086 fn fork_from(&mut self, session_id_or_prefix: &str) -> Result<SessionForkFromReceipt, String>;
1087
1088 /// `/new [--force]`: fresh-session transition. The caller has already
1089 /// parsed the argument and applied the transition-blocked gate; blocker,
1090 /// busy-work-state, and success handling match the baseline.
1091 fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String>;
1092
1093 /// `/load <path>`: resolve the path (separator-bearing direct vs
1094 /// workspace-relative) and validate the saved-session shape without
1095 /// applying state or emitting a premature success receipt.
1096 fn load_session(&mut self, path: &str) -> Result<PathBuf, String>;
1097
1098 /// `/sessions` picker open with optional preselection (bare, `show`,
1099 /// `list`, `picker`, and `open <id>` forms). Picker construction and
1100 /// locale selection stay host-side.
1101 fn open_picker(&mut self, preselected: Option<String>);
1102
1103 /// `/sessions archive|unarchive|restore <id>`: durable lifecycle state
1104 /// update that also syncs the live cached metadata atomically.
1105 fn set_archived(
1106 &mut self,
1107 session_id: &str,
1108 archived: bool,
1109 ) -> Result<SessionArchiveReceipt, String>;
1110
1111 /// `/sessions prune <days>`: prune persisted sessions older than `days`
1112 /// days while protecting the active session; returns the number pruned.
1113 fn prune_sessions(&mut self, days: u64) -> Result<usize, String>;
1114 }
1115
1116 // ---------------------------------------------------------------------------
1117 // FEAT-024: session control slice (D2-D7).
1118 //
1119 // One independently optional session-control authority covering exactly the
1120 // host work the six control commands (`/relay`, `/rename`, `/resume`, `/rc`,
1121 // `/remote-env`, `/title`) consume. `CommandSessionContext` and
1122 // `CommandSessionLifecycleContext` are deliberately not widened: control
1123 // authority exists only on this facet, and every delegate is an atomic host
1124 // operation or a semantic projection so handlers keep byte-identical
1125 // composition. Portable values never expose TUI state beyond what the
1126 // baseline branches on.
1127 // ---------------------------------------------------------------------------
1128
1129 /// `/relay` semantic snapshot (D4). The handler composes the byte-identical
1130 /// instruction from these deterministic fields; `crate::prompts`,
1131 /// `crate::todo_snapshot`, goal/todo/plan machinery, Work-state objects, and
1132 /// locks stay host-side.
1133 #[derive(Clone, Debug, PartialEq)]
1134 pub struct RelayProjection {
1135 /// Authoritative compact-template text (`COMPACT_TEMPLATE`), echoed with
1136 /// a trailing trim by the handler exactly as today.
1137 pub compact_template: String,
1138 pub workspace: String,
1139 pub mode: String,
1140 pub model: String,
1141 pub goal_objective: Option<String>,
1142 pub goal_token_budget: Option<u32>,
1143 pub todos: TodoProjection,
1144 pub plan: PlanProjection,
1145 }
1146
1147 /// To-do state distinction for the relay snapshot. The rendered body (if any)
1148 /// is produced host-side from the authoritative graph-backed snapshot seam.
1149 #[derive(Clone, Debug, PartialEq)]
1150 pub enum TodoProjection {
1151 /// Rendered to-do body lines.
1152 Body(String),
1153 /// Work state could not be read (`To-do: unavailable because the list is
1154 /// busy.`).
1155 Unavailable,
1156 /// No Work state or no to-do body.
1157 Absent,
1158 }
1159
1160 /// Plan-state distinction for the relay snapshot. `Busy` reproduces the
1161 /// baseline `try_lock` failure branch; `Absent` reproduces an empty snapshot.
1162 ///
1163 /// `PlanSections` is intentionally not boxed: the command-crate boundary gate
1164 /// forbids boxed storage in the contract, and the section payload is only ever
1165 /// built once per `/relay` dispatch.
1166 #[derive(Clone, Debug, PartialEq)]
1167 #[allow(clippy::large_enum_variant)]
1168 pub enum PlanProjection {
1169 Sections(PlanSections),
1170 Busy,
1171 Absent,
1172 }
1173
1174 /// Semantic plan snapshot fields consumed by `/relay`. Values are the raw
1175 /// snapshot values; the handler applies the baseline trim/empty filtering and
1176 /// label composition so ordering and spacing stay byte-identical.
1177 #[derive(Clone, Debug, Default, PartialEq)]
1178 pub struct PlanSections {
1179 pub title: Option<String>,
1180 pub objective: Option<String>,
1181 pub context_summary: Option<String>,
1182 pub explanation: Option<String>,
1183 pub sources_used: Vec<String>,
1184 pub critical_files: Vec<String>,
1185 pub constraints: Vec<String>,
1186 pub recommended_approach: Option<String>,
1187 pub verification_plan: Option<String>,
1188 pub risks_and_unknowns: Option<String>,
1189 pub handoff_packet: Option<String>,
1190 pub items: Vec<PlanStep>,
1191 }
1192
1193 /// Portable plan-step status. The adapter maps the TUI plan status onto this
1194 /// semantic enum; the command handler remains the sole owner of the exact
1195 /// `pending`/`in_progress`/`completed` labels.
1196 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1197 pub enum PlanStepStatus {
1198 Pending,
1199 InProgress,
1200 Completed,
1201 }
1202
1203 /// One semantic plan checklist item.
1204 #[derive(Clone, Debug, PartialEq, Eq)]
1205 pub struct PlanStep {
1206 pub status: PlanStepStatus,
1207 pub text: String,
1208 }
1209
1210 /// `/resume` route resolution (D6). The host resolves argument shape and
1211 /// performs container imports atomically; the handler selects the exact
1212 /// baseline message/action per variant.
1213 #[derive(Clone, Debug, PartialEq)]
1214 pub enum ResumeSource {
1215 /// Argument resolves to a readable file (`raw` direct path or
1216 /// workspace-relative path); the handler calls `import_session_file`.
1217 File(PathBuf),
1218 /// Argument resolved through the session manager (id or prefix).
1219 Session {
1220 /// Durable session file when present; `None` reproduces the baseline
1221 /// non-file fallback message arm.
1222 load_path: Option<PathBuf>,
1223 truncated_id: String,
1224 title: String,
1225 },
1226 /// Argument parsed as a foreign session container, which was imported
1227 /// atomically by the resolver.
1228 Imported(ResumeImportReceipt),
1229 /// Argument matched neither a file, a session, nor a container.
1230 NotFound { raw: String, error: String },
1231 }
1232
1233 /// Portable `/resume` import receipt. The handler renders
1234 /// `Imported foreign session as {truncated_id} ({entry_count} entries, leaf
1235 /// {leaf_display})`, and carries `sync` so the engine adopts the imported
1236 /// conversation instead of staying on the previous one.
1237 #[derive(Clone, Debug, PartialEq)]
1238 pub struct ResumeImportReceipt {
1239 pub truncated_id: String,
1240 pub entry_count: usize,
1241 pub leaf_display: String,
1242 pub sync: SessionSyncPayload,
1243 }
1244
1245 /// `/rename` success receipt; the title is the sanitized persisted value so
1246 /// the handler echoes exactly what was written.
1247 #[derive(Clone, Debug, PartialEq)]
1248 pub struct SessionTitleReceipt {
1249 pub title: String,
1250 }
1251
1252 /// Bare `/title` status projection (`Window title: [{effective}]{source}`).
1253 #[derive(Clone, Debug, PartialEq)]
1254 pub struct TitleReport {
1255 /// Effective window-title prefix, or `unset`.
1256 pub effective: String,
1257 pub source: TitleSource,
1258 }
1259
1260 #[derive(Clone, Debug, PartialEq)]
1261 pub enum TitleSource {
1262 /// Session-level window title set.
1263 Session,
1264 /// Config-default title applies.
1265 ConfigDefault,
1266 /// Neither a session title nor a config default.
1267 None,
1268 }
1269
1270 /// `/rc link` structured link data.
1271 #[derive(Clone, Debug, PartialEq)]
1272 pub struct RemoteLink {
1273 pub url: String,
1274 pub computer_url: Option<String>,
1275 }
1276
1277 /// `/rc open` outcome. Browser launch stays synchronous and single-attempt;
1278 /// no deferred external-URL action is produced (D6).
1279 #[derive(Clone, Debug, PartialEq)]
1280 pub enum RemoteOpenOutcome {
1281 NoLink,
1282 Opened { url: String },
1283 LaunchFailed { url: String },
1284 }
1285
1286 /// `/rc start` wording input: the active-turn copy is used while a turn is
1287 /// loading or a dispatch is in flight.
1288 #[derive(Clone, Debug, PartialEq)]
1289 pub struct RemoteStartInfo {
1290 pub connecting: bool,
1291 }
1292
1293 /// `/remote-env open` hosted-work target. The URL is fully encoded host-side
1294 /// (the portable handler must not depend on `urlencoding`); repo/branch echo
1295 /// the raw values used by the baseline message replacements.
1296 #[derive(Clone, Debug, PartialEq)]
1297 pub struct HostedWorkTarget {
1298 pub url: String,
1299 pub repo: String,
1300 pub branch: String,
1301 }
1302
1303 /// Control authority for the session command slice (FEAT-024 D2/D5).
1304 ///
1305 /// Operation-granular synchronous delegates over the exact minimum host work
1306 /// the six control commands consume. Delegates reproduce the baseline
1307 /// check/mutation order (transition gate before resume I/O, save before
1308 /// publication, single-attempt browser launch) and return portable receipts/
1309 /// projections or the exact host-error text the baseline surfaces. No
1310 /// `SessionManager`, saved-session/container type, `SessionPickerView`,
1311 /// remote-control service, Git wrapper, configuration, model/history type,
1312 /// lock, or host callback crosses the facet.
1313 pub trait CommandSessionControlContext {
1314 /// Live transition gate consulted by `/resume` before any picker or I/O.
1315 fn transition_blocked(&self) -> bool;
1316
1317 /// `/relay`: authoritative semantic snapshot (workspace/mode/model/goal/
1318 /// to-do/plan/compact-template). Unavailable sources are represented as
1319 /// explicit states, never panics.
1320 fn relay_projection(&self) -> RelayProjection;
1321
1322 /// Bare `/resume`: push the existing picker without preselection.
1323 fn open_resume_picker(&mut self);
1324
1325 /// `/resume <raw>`: resolve direct-path, workspace-relative, session
1326 /// id/prefix, and inline-container routes in the established order; a
1327 /// recognized inline container is imported atomically here.
1328 fn resolve_resume_source(&mut self, raw: &str) -> Result<ResumeSource, String>;
1329
1330 /// `/resume <file>`: read, parse, persist, and apply a foreign session
1331 /// file (container or plain saved session). Errors are the exact baseline
1332 /// read/parse/import text.
1333 fn import_session_file(&mut self, path: PathBuf) -> Result<ResumeImportReceipt, String>;
1334
1335 /// Apply the authoritative session-title character policy before the
1336 /// portable `/rename` and `/title` handlers validate and compose output.
1337 fn sanitize_session_title(&self, raw_title: &str) -> String;
1338
1339 /// `/rename <title>`: recover first-snapshot state, sync live state,
1340 /// persist, and publish with baseline order. The handler already applied
1341 /// sanitization plus blank and 100-character validation.
1342 fn rename_session(&mut self, title: &str) -> Result<SessionTitleReceipt, String>;
1343
1344 /// Bare `/title`: effective prefix and its source.
1345 fn title_report(&self) -> TitleReport;
1346
1347 /// `/title <title>`: persist an already sanitized and validated window
1348 /// title with baseline save/publication/redraw semantics.
1349 fn set_window_title(&mut self, title: String) -> Result<(), String>;
1350
1351 /// `/title off|clear|none`: clear the session window title with the same
1352 /// baseline persistence/redraw semantics.
1353 fn clear_window_title(&mut self) -> Result<(), String>;
1354
1355 /// `/rc status`: current remote-control status line.
1356 fn remote_status(&self) -> String;
1357
1358 /// `/rc link`: live session link plus optional computer-management URL.
1359 fn remote_link(&self) -> Option<RemoteLink>;
1360
1361 /// `/rc open`: synchronous single browser attempt over the authoritative
1362 /// URL-opening helper; outcome carries the URL for exact message text.
1363 fn remote_browser_open(&self) -> RemoteOpenOutcome;
1364
1365 /// `/rc start`: whether the active-turn copy applies.
1366 fn remote_start_info(&self) -> RemoteStartInfo;
1367
1368 /// `/rc stop`: refusal reason while a remote turn/envelope is active.
1369 fn remote_stop_refusal(&self) -> Option<String>;
1370
1371 /// `/remote-env open`: validate the hosted-work Git target host-side and
1372 /// return the encoded URL plus raw repo/branch echoes; `None` reproduces
1373 /// the unavailable-target error. Credentials never appear in values or
1374 /// errors.
1375 fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget>;
1376 }
1377
1378 // ---------------------------------------------------------------------------
1379 // FEAT-025: session export slice (D1-D9).
1380 //
1381 // One independently optional session-export authority covering exactly the
1382 // host work `/export` (and its `/daochu` alias) consumes. The shared
1383 // `CommandSessionContext`, `CommandSessionLifecycleContext`, and
1384 // `CommandSessionControlContext` facets are deliberately not widened: export
1385 // authority exists only on this facet, and every delegate is an atomic host
1386 // operation or a semantic projection so the portable handler keeps
1387 // byte-identical composition. Hidden payloads are excluded while projections
1388 // are built (D9), so internal reasoning, reasoning signatures, and inline or
1389 // local image bytes never enter these DTOs. No `App`, clipboard handler,
1390 // snapshot repository, history cell, session manager, configuration, client,
1391 // filesystem handle, or host callback crosses this boundary (D1/D3/D5/D7).
1392 // ---------------------------------------------------------------------------
1393
1394 /// Portable conversation metadata for the export header (D3).
1395 ///
1396 /// Values that already have an authoritative host derivation keep it
1397 /// (session-label truncation, provider identity, model label, mode display,
1398 /// workspace basename, message count, clock); portable rendering adds only
1399 /// export formatting and sanitization (D10).
1400 #[derive(Clone, Debug, PartialEq, Eq)]
1401 pub struct ExportMetadata {
1402 /// Host-truncated session id, or the baseline `unsaved` fallback.
1403 pub session_label: String,
1404 pub provider: String,
1405 pub model: String,
1406 pub mode: String,
1407 /// Workspace directory basename, or the baseline `workspace` fallback.
1408 pub workspace_name: String,
1409 /// `api_messages.len()` when authoritative, otherwise `history.len()`.
1410 pub message_count: usize,
1411 pub exported_at_unix: i64,
1412 }
1413
1414 /// One tool-call caller projection (D3). Only the fields the baseline export
1415 /// renders cross the boundary.
1416 #[derive(Clone, Debug, PartialEq, Eq)]
1417 pub struct ToolCallerProjection {
1418 pub caller_type: String,
1419 pub tool_id: Option<String>,
1420 }
1421
1422 /// One projected content block (D3/D9).
1423 ///
1424 /// Visible text and structured content cross as portable data; internal
1425 /// reasoning bodies, reasoning signatures, and inline or local image payloads
1426 /// are replaced by typed omission markers at projection time and never cross.
1427 #[derive(Clone, Debug, PartialEq)]
1428 pub enum ExportBlock {
1429 /// Visible text block; portable rendering sanitizes it.
1430 Text {
1431 text: String,
1432 },
1433 /// External image reference (`http`/`https` only); portable rendering
1434 /// redacts credential-bearing URLs.
1435 ImageReference {
1436 url: String,
1437 },
1438 /// Inline or local image payload excluded at projection time (D9).
1439 ImageOmitted,
1440 /// Internal reasoning body and reasoning signature excluded (D9).
1441 InternalReasoning,
1442 ToolCall {
1443 id: String,
1444 name: String,
1445 caller: Option<ToolCallerProjection>,
1446 input: Value,
1447 },
1448 ToolResult {
1449 tool_use_id: String,
1450 content: String,
1451 is_error: bool,
1452 /// `Some` when the host message carried structured result blocks; the
1453 /// host has already applied the safe-result filter (D9).
1454 structured: Option<Value>,
1455 },
1456 ServerToolCall {
1457 id: String,
1458 name: String,
1459 input: Value,
1460 },
1461 ToolSearchResult {
1462 tool_use_id: String,
1463 content: Value,
1464 },
1465 CodeExecutionResult {
1466 tool_use_id: String,
1467 content: Value,
1468 },
1469 }
1470
1471 /// One projected authoritative message (D3).
1472 ///
1473 /// `prompt_snippet` is the host-computed `snapshot_label_prompt_snippet` of
1474 /// the first visible text block. The parser and snippet algorithm stay
1475 /// TUI-owned (D8), so correlation compares authoritative values instead of
1476 /// re-deriving them portably.
1477 ///
1478 /// `is_user_role` carries the host's exact `Role::User` comparison. `role` is
1479 /// the rendered wire string, and comparing it textually would also match a
1480 /// `Role::Unrecognized("user")`, which the baseline never treated as a user
1481 /// turn. The flag keeps restore-point correlation faithful to the baseline.
1482 #[derive(Clone, Debug, PartialEq)]
1483 pub struct ExportMessage {
1484 pub role: String,
1485 /// Exact `message.role == Role::User`, not a string comparison.
1486 pub is_user_role: bool,
1487 pub blocks: Vec<ExportBlock>,
1488 pub prompt_snippet: Option<String>,
1489 }
1490
1491 /// One projected visible-history fallback entry (D3).
1492 #[derive(Clone, Debug, PartialEq, Eq)]
1493 pub enum HistoryEntry {
1494 /// Visible host content that portable rendering must still sanitize.
1495 Sanitized { role: String, body: String },
1496 /// An already-final baseline marker line that must not be sanitized again.
1497 Literal { role: String, body: String },
1498 }
1499
1500 /// Transcript source precedence (D3): authoritative API messages when
1501 /// present, otherwise the sanitized visible-history fallback.
1502 #[derive(Clone, Debug, PartialEq)]
1503 pub enum TranscriptProjection {
1504 Authoritative(Vec<ExportMessage>),
1505 HistoryFallback(Vec<HistoryEntry>),
1506 }
1507
1508 /// One snapshot projected to semantic fields (D8).
1509 ///
1510 /// `kind`, `sequence`, and `prompt_snippet` are the host-parsed label fields;
1511 /// the raw `label` is kept only for the human-readable table column. No
1512 /// preformatted correlation line crosses the boundary.
1513 #[derive(Clone, Debug, PartialEq, Eq)]
1514 pub struct RestoreSnapshot {
1515 pub id: String,
1516 pub label: String,
1517 pub timestamp_unix: i64,
1518 pub kind: String,
1519 pub sequence: Option<u64>,
1520 pub prompt_snippet: Option<String>,
1521 }
1522
1523 /// Restore-point projection with distinct baseline states (D3/D8).
1524 ///
1525 /// `None` means no snapshot repository exists, `Unreadable` preserves the host
1526 /// failure reason, and `Recorded` distinguishes an existing-but-empty
1527 /// repository from one with snapshots by the vector length.
1528 #[derive(Clone, Debug, PartialEq, Eq)]
1529 pub enum RestorePointProjection {
1530 None,
1531 Unreadable { reason: String },
1532 Recorded { snapshots: Vec<RestoreSnapshot> },
1533 }
1534
1535 /// Full conversation projection (D3/D8/D9).
1536 #[derive(Clone, Debug, PartialEq)]
1537 pub struct ConversationExportProjection {
1538 pub metadata: ExportMetadata,
1539 pub transcript: TranscriptProjection,
1540 pub restore_points: RestorePointProjection,
1541 }
1542
1543 /// Turn-handoff projection (D2).
1544 ///
1545 /// `markdown` is the unmodified shared TUI renderer output and
1546 /// `workspace_path` is the value the portable handler replaces with `.` after
1547 /// sanitizing; the renderer itself is neither moved nor duplicated.
1548 #[derive(Clone, Debug, PartialEq, Eq)]
1549 pub struct TurnHandoffProjection {
1550 pub markdown: String,
1551 pub workspace_path: String,
1552 }
1553
1554 /// Session-export authority for the `/export` slice (FEAT-025 D1-D9).
1555 ///
1556 /// Operation-granular synchronous delegates over the exact minimum host work
1557 /// the command consumes. The portable handler parses the request first, renders
1558 /// the selected scope second, and then uses these delegates in baseline order:
1559 /// clipboard exports call terminal-paste detection, recovery write, and
1560 /// clipboard delivery exactly once each with the same Markdown; file exports
1561 /// resolve the destination before writing it. A recovery-write `None` never
1562 /// prevents the clipboard attempt, and a turn-only export never requests the
1563 /// conversation projection (D6/D7).
1564 pub trait CommandSessionExportContext {
1565 /// Conversation export projection: metadata, authoritative-or-fallback
1566 /// transcript, and restore-point state. Read-only; opens only an existing
1567 /// snapshot repository and never creates one (D8).
1568 fn conversation_projection(&self) -> ConversationExportProjection;
1569
1570 /// Turn-handoff projection: unmodified shared renderer Markdown plus the
1571 /// workspace path value (D2).
1572 fn turn_handoff_projection(&self) -> TurnHandoffProjection;
1573
1574 /// Whether clipboard delivery goes through the terminal-client (SSH/OSC 52
1575 /// via tmux) path (D6).
1576 fn clipboard_requires_terminal_paste(&self) -> bool;
1577
1578 /// Write the shared `last-copy.md` recovery file. `None` reproduces the
1579 /// baseline silent failure; recovery writing never falls through to an
1580 /// error (D5/D6).
1581 fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf>;
1582
1583 /// Attempt clipboard delivery. `Err` carries the raw host clipboard error
1584 /// text; the handler composes the exact failure wording (D6).
1585 fn write_clipboard(&self, markdown: &str) -> Result<(), String>;
1586
1587 /// Resolve a file destination exactly as the baseline does (trim, empty
1588 /// check, `..` rejection, workspace canonicalization and rebasing, filename
1589 /// requirement). Errors are returned unwrapped (D7).
1590 fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String>;
1591
1592 /// Write the rendered export to a resolved destination with the baseline
1593 /// protection checks. Errors are returned unwrapped; the handler wraps them
1594 /// in `Failed to export {label} to {path}: {err}` (D7).
1595 fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String>;
1596 }
1597
1597 lines RUST