返回 CodeWhale
contract.rs
根目录 / crates / tui / src / commands / contract.rs
1 //! FEAT-015 TUI command-boundary surface.
2 //!
3 //! This module holds the TUI-owned pieces of the staged command migration:
4 //! the pending-frontier projection (D4), the capability facet adapters
5 //! (D1), boundary-value and localization-key mappings (D3/D8), the envelope
6 //! construction helper (D1), and the seam helpers (D7-D9). It is deliberately
7 //! the only new TUI module for the migration surface; the production
8 //! registry/dispatch stay in `traits.rs` / `mod.rs`.
9 //!
10 //! FEAT-015 does NOT migrate any production command. The adapters below wrap
11 //! App-owned state behind the FEAT-014 contract shapes so later FEATs
12 //! (FEAT-018+) can adopt them one group at a time. Handlers only ever see
13 //! `&mut dyn` facets — concrete `App` is never exposed through an envelope.
14 //!
15 //! ## Authoritative host-proxy design (D1)
16 //!
17 //! `CommandContexts` has twenty-two independently optional facet slots, all
18 //! constructed here. The diagnostics adapter joins the host bundle in FEAT-029. Important behavior (mode transitions, model
19 //! invalidation, cost accounting, skill refresh) is authoritative on `App`. The adapters therefore share a
20 //! synchronous TUI-owned host proxy. Each trait call borrows `App` only for the
21 //! duration of that call and delegates to the real operation; handlers still
22 //! receive only portable facets and can never name concrete TUI state.
23 //!
24 //! ## Dead-code note
25 //!
26 //! FEAT-015 intentionally wires no production contextual command. Some bridge
27 //! helpers remain production-dead until the first slice migrates (FEAT-018+),
28 //! so this transitional module keeps a bounded dead-code allow.
29
30 use std::cell::RefCell;
31 use std::path::{Path, PathBuf};
32 use std::rc::Rc;
33
34 mod debug_diagnostics;
35 pub(in crate::commands) mod debug_operations;
36 use debug_operations::DebugOperationsAdapter;
37 mod diagnostics_messages;
38 #[cfg(test)]
39 pub(crate) use debug_diagnostics::CostComponents as DebugCostComponents;
40 use debug_diagnostics::DebugDiagnosticsAdapter;
41 #[cfg(test)]
42 pub(crate) use debug_diagnostics::warmup_key as project_debug_warmup_key;
43
44 use codewhale_command_contract::facets::{
45 CommandApprovalState, CommandCostContext, CommandMediaContext, CommandMemoryContext,
46 CommandModePolicyContext, CommandModelContext, CommandPluginContext,
47 CommandPresentationContext, CommandProjectContext, CommandSessionContext,
48 CommandSessionControlContext, CommandSessionLifecycleContext, CommandSkillGroupContext,
49 CommandSkillsContext, CommandSystemPromptContext, CommandWorkspaceContext, HostedWorkTarget,
50 MediaAttachmentReceipt, MemoryDelete, MemoryDeleteScope, MemoryExport, MemoryGetOutcome,
51 MemoryHit, MemoryImportOutcome, MemoryReindex, MemoryRememberTarget, MemoryRemembered,
52 MemoryStatus, PlanProjection, PlanSections, PlanStep, PlanStepStatus, PluginDetail,
53 PluginDiagnostic, PluginDiagnosticLevel, PluginExportReceipt, PluginLegacyScan,
54 PluginLegacyTool, PluginManagedCandidate, PluginManagedScan, PluginMarketplaceAddReceipt,
55 PluginMarketplaceCandidate, PluginMarketplaceCatalog, PluginMarketplaceInstallPlan,
56 PluginMarketplaceState, PluginMcpServerDetail, PluginMcpTransport, PluginMutationOutcome,
57 PluginMutationReceipt, PluginSuggestion, PluginSummary, ProjectGoalState, ProjectGoalStatus,
58 RelayProjection, RemoteLink, RemoteOpenOutcome, RemoteRegistryOutcome, RemoteSkillEntry,
59 RemoteStartInfo, ResumeImportReceipt, ResumeSource, ReviewOutcome, SessionArchiveReceipt,
60 SessionBranchOutcome, SessionForkFromReceipt, SessionForkReceipt, SessionNewReceipt,
61 SessionSaveReceipt, SessionSyncPayload, SessionTitleReceipt, SkillActivationError,
62 SkillActivationOutcome, SkillBundledTier, SkillEntry, SkillMutationOutcome,
63 SkillMutationReceipt, SkillRecommendation, SkillRegistryProjection, SkillSourceKind,
64 SkillSyncEntry, SkillSyncOutcome, SkillTargetScope, SnapshotEntry, TitleReport, TitleSource,
65 TodoProjection, TreeBodyProjection,
66 };
67 use codewhale_command_contract::facets::{
68 CommandSessionExportContext, ConversationExportProjection, ExportBlock, ExportMessage,
69 ExportMetadata, HistoryEntry, RestorePointProjection, RestoreSnapshot, ToolCallerProjection,
70 TranscriptProjection, TurnHandoffProjection,
71 };
72 #[cfg(test)]
73 use codewhale_command_contract::handler::ContextParts;
74 use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts};
75 use codewhale_command_contract::types::{
76 CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId,
77 };
78 use codewhale_config::AppMode;
79 use codewhale_core::request::{ContentBlock, Message, SystemPrompt};
80 use codewhale_execpolicy::ApprovalMode;
81
82 use crate::commands::groups::plugins::plugin_network_policy;
83
84 use crate::dependencies::ExternalTool as _;
85 use crate::network_policy::NetworkPolicy;
86 use crate::pricing::CostCurrency;
87 use crate::tui::app::App;
88 use crate::tui::history::HistoryCell;
89 use codewhale_localization::{MessageId, tr};
90
91 // ---------------------------------------------------------------------------
92 // Pending frontier projection (D4)
93 // ---------------------------------------------------------------------------
94
95 /// Sorted, unique frontier of command groups that still use concrete-`App`
96 /// handlers. This is the TUI-visible projection of the checked-in migration
97 /// topology (`scripts/command-migration-topology.json`); the CI gate performs
98 /// the authoritative bidirectional source scan against that artifact.
99 ///
100 /// Not referenced by production dispatch code — the fail-closed Python gate
101 /// (`scripts/check-command-migration-manifest.py`) reads this exact
102 /// declaration by source regex and the Rust frontier tests assert it.
103 #[cfg_attr(not(test), expect(dead_code))]
104 pub(crate) const PENDING_GROUPS: &[&str] = &["config", "core"];
105
106 // ---------------------------------------------------------------------------
107 // Boundary-value mappings (D8)
108 // ---------------------------------------------------------------------------
109
110 /// Map the TUI operating mode onto the portable command boundary value.
111 pub(crate) fn to_command_mode(mode: AppMode) -> CommandMode {
112 match mode {
113 AppMode::Agent => CommandMode::Agent,
114 AppMode::Plan => CommandMode::Plan,
115 AppMode::Operate => CommandMode::Operate,
116 }
117 }
118
119 pub(crate) fn from_command_mode(mode: CommandMode) -> AppMode {
120 match mode {
121 CommandMode::Agent => AppMode::Agent,
122 CommandMode::Plan => AppMode::Plan,
123 CommandMode::Operate => AppMode::Operate,
124 }
125 }
126
127 /// Map the TUI approval posture onto the portable command boundary value.
128 pub(crate) fn to_command_approval(mode: ApprovalMode) -> CommandApprovalMode {
129 match mode {
130 ApprovalMode::Auto => CommandApprovalMode::Auto,
131 ApprovalMode::Bypass => CommandApprovalMode::Bypass,
132 ApprovalMode::Suggest => CommandApprovalMode::Suggest,
133 ApprovalMode::Never => CommandApprovalMode::Never,
134 }
135 }
136
137 /// Map the TUI cost-display currency onto the portable command boundary value.
138 pub(crate) fn to_command_currency(currency: CostCurrency) -> CommandCurrency {
139 match currency {
140 CostCurrency::Usd => CommandCurrency::Usd,
141 CostCurrency::Cny => CommandCurrency::Cny,
142 }
143 }
144
145 fn from_command_currency(currency: CommandCurrency) -> CostCurrency {
146 match currency {
147 CommandCurrency::Usd => CostCurrency::Usd,
148 CommandCurrency::Cny => CostCurrency::Cny,
149 }
150 }
151
152 /// Stable provider identity text at the command boundary.
153 ///
154 /// The TUI persists either the canonical `ProviderKind::as_str()` spelling or —
155 /// for named custom providers — the exact configured identity text. This
156 /// function never leaks URLs, credentials, or filesystem paths.
157 pub(crate) fn to_provider_id(identity: &str) -> CommandProviderId {
158 CommandProviderId(identity.to_string())
159 }
160
161 /// Bridge a portable metadata description key onto the TUI localization id.
162 ///
163 /// The key convention (D3) is mechanical: the contract key equals the
164 /// snake_case of the [`MessageId`] variant name. The match table is the
165 /// authoritative bridge; unknown keys fail deterministically.
166 pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
167 Some(match key {
168 "cmd_advisor_description" => MessageId::CmdAdvisorDescription,
169 "cmd_agent_description" => MessageId::CmdAgentDescription,
170 "cmd_anchor_description" => MessageId::CmdAnchorDescription,
171 "cmd_attach_description" => MessageId::CmdAttachDescription,
172 "cmd_auto_description" => MessageId::CmdAutoDescription,
173 "cmd_auth_description" => MessageId::CmdAuthDescription,
174 "cmd_automation_description" => MessageId::CmdAutomationDescription,
175 "cmd_balance_description" => MessageId::CmdBalanceDescription,
176 "cmd_branch_description" => MessageId::CmdBranchDescription,
177 "cmd_cache_description" => MessageId::CmdCacheDescription,
178 "cmd_change_description" => MessageId::CmdChangeDescription,
179 "cmd_clear_description" => MessageId::CmdClearDescription,
180 "cmd_compact_description" => MessageId::CmdCompactDescription,
181 "cmd_config_description" => MessageId::CmdConfigDescription,
182 "cmd_constitution_description" => MessageId::CmdConstitutionDescription,
183 "cmd_context_description" => MessageId::CmdContextDescription,
184 "cmd_cost_description" => MessageId::CmdCostDescription,
185 "cmd_diff_description" => MessageId::CmdDiffDescription,
186 "cmd_edit_description" => MessageId::CmdEditDescription,
187 "cmd_effort_description" => MessageId::CmdEffortDescription,
188 "cmd_exit_description" => MessageId::CmdExitDescription,
189 "cmd_export_description" => MessageId::CmdExportDescription,
190 "cmd_feedback_description" => MessageId::CmdFeedbackDescription,
191 "cmd_fleet_description" => MessageId::CmdFleetDescription,
192 "cmd_fork_description" => MessageId::CmdForkDescription,
193 "cmd_goal_description" => MessageId::CmdGoalDescription,
194 "cmd_help_description" => MessageId::CmdHelpDescription,
195 "cmd_hf_description" => MessageId::CmdHfDescription,
196 "cmd_home_description" => MessageId::CmdHomeDescription,
197 "cmd_hooks_description" => MessageId::CmdHooksDescription,
198 "cmd_hotbar_description" => MessageId::CmdHotbarDescription,
199 "cmd_init_description" => MessageId::CmdInitDescription,
200 "cmd_jobs_description" => MessageId::CmdJobsDescription,
201 "cmd_dispatch_description" => MessageId::CmdDispatchDescription,
202 "cmd_lane_description" => MessageId::CmdLaneDescription,
203 "cmd_links_description" => MessageId::CmdLinksDescription,
204 "cmd_load_description" => MessageId::CmdLoadDescription,
205 "cmd_logout_description" => MessageId::CmdLogoutDescription,
206 "cmd_lsp_description" => MessageId::CmdLspDescription,
207 "cmd_mcp_description" => MessageId::CmdMcpDescription,
208 "cmd_memory_description" => MessageId::CmdMemoryDescription,
209 "cmd_mode_description" => MessageId::CmdModeDescription,
210 "cmd_model_db_description" => MessageId::CmdModelDbDescription,
211 "cmd_model_description" => MessageId::CmdModelDescription,
212 "cmd_models_description" => MessageId::CmdModelsDescription,
213 "cmd_network_description" => MessageId::CmdNetworkDescription,
214 "cmd_new_description" => MessageId::CmdNewDescription,
215 "cmd_note_description" => MessageId::CmdNoteDescription,
216 "cmd_permissions_description" => MessageId::CmdPermissionsDescription,
217 "cmd_pin_description" => MessageId::CmdPinDescription,
218 "cmd_plugin_description" => MessageId::CmdPluginDescription,
219 "cmd_plugin_detail_description" => MessageId::CmdPluginDetailDescription,
220 "cmd_preview_request_description" => MessageId::CmdPreviewRequestDescription,
221 "cmd_profile_description" => MessageId::CmdProfileDescription,
222 "cmd_provider_description" => MessageId::CmdProviderDescription,
223 "cmd_purge_description" => MessageId::CmdPurgeDescription,
224 "cmd_queue_description" => MessageId::CmdQueueDescription,
225 "cmd_relay_description" => MessageId::CmdRelayDescription,
226 "cmd_remote_control_description" => MessageId::CmdRemoteControlDescription,
227 "cmd_remote_env_description" => MessageId::CmdRemoteEnvDescription,
228 "cmd_rename_description" => MessageId::CmdRenameDescription,
229 "cmd_restore_description" => MessageId::CmdRestoreDescription,
230 "cmd_resume_description" => MessageId::CmdResumeDescription,
231 "cmd_receipts_description" => MessageId::CmdReceiptsDescription,
232 "cmd_retry_description" => MessageId::CmdRetryDescription,
233 "cmd_review_description" => MessageId::CmdReviewDescription,
234 "cmd_rlm_description" => MessageId::CmdRlmDescription,
235 "cmd_save_description" => MessageId::CmdSaveDescription,
236 "cmd_sessions_description" => MessageId::CmdSessionsDescription,
237 "cmd_settings_description" => MessageId::CmdSettingsDescription,
238 "cmd_setup_description" => MessageId::CmdSetupDescription,
239 "cmd_share_description" => MessageId::CmdShareDescription,
240 "cmd_sidebar_description" => MessageId::CmdSidebarDescription,
241 "cmd_skill_description" => MessageId::CmdSkillDescription,
242 "cmd_skills_description" => MessageId::CmdSkillsDescription,
243 "cmd_stash_description" => MessageId::CmdStashDescription,
244 "cmd_status_description" => MessageId::CmdStatusDescription,
245 "cmd_statusline_description" => MessageId::CmdStatuslineDescription,
246 "cmd_structcopy_description" => MessageId::CmdStructcopyDescription,
247 "cmd_subagents_description" => MessageId::CmdSubagentsDescription,
248 "cmd_system_description" => MessageId::CmdSystemDescription,
249 "cmd_task_description" => MessageId::CmdTaskDescription,
250 "cmd_theme_description" => MessageId::CmdThemeDescription,
251 "cmd_title_description" => MessageId::CmdTitleDescription,
252 "cmd_tokens_description" => MessageId::CmdTokensDescription,
253 "cmd_tools_description" => MessageId::CmdToolsDescription,
254 "cmd_translate_description" => MessageId::CmdTranslateDescription,
255 "cmd_tree_description" => MessageId::CmdTreeDescription,
256 "cmd_trust_description" => MessageId::CmdTrustDescription,
257 "cmd_turn_inspect_description" => MessageId::CmdTurnInspectDescription,
258 "cmd_undo_description" => MessageId::CmdUndoDescription,
259 "cmd_update_description" => MessageId::CmdUpdateDescription,
260 "cmd_verbose_description" => MessageId::CmdVerboseDescription,
261 "cmd_voice_control_description" => MessageId::CmdVoiceControlDescription,
262 "cmd_voice_description" => MessageId::CmdVoiceDescription,
263 "cmd_voice_send_description" => MessageId::CmdVoiceSendDescription,
264 "cmd_workflow_description" => MessageId::CmdWorkflowDescription,
265 "cmd_workflows_description" => MessageId::CmdWorkflowsDescription,
266 "cmd_workspace_description" => MessageId::CmdWorkspaceDescription,
267 _ => return None,
268 })
269 }
270
271 // ---------------------------------------------------------------------------
272 // Capability facet adapters (D1)
273 // ---------------------------------------------------------------------------
274
275 /// Shared TUI host hidden behind the portable command facets.
276 ///
277 /// The envelope has twenty-two optional facet slots; authoritative mutation methods live on `App`. Each adapter therefore owns
278 /// an `Rc` clone of this synchronous host proxy. Trait calls borrow `App` only
279 /// for the duration of one method, delegate to the real TUI authority, and
280 /// return owned values. Command handlers never receive or name `App`.
281 struct CommandHost<'a> {
282 app: RefCell<&'a mut App>,
283 config: Option<&'a crate::config::Config>,
284 }
285
286 type SharedCommandHost<'a> = Rc<CommandHost<'a>>;
287
288 #[path = "session_structcopy_host.rs"]
289 pub(in crate::commands) mod structcopy_host;
290 use structcopy_host::SessionStructcopyAdapter;
291
292 // ---------------------------------------------------------------------------
293 // Session lifecycle adapter (FEAT-023 D4)
294 //
295 // Sole host owner of concrete lifecycle machinery for the nine lifecycle
296 // commands: App reads/mutations, SessionManager, saved-session creation,
297 // journal load/branching, filesystem persistence, work-state snapshots/
298 // publication, picker/view-stack construction, archive/prune, and the core
299 // `reset_conversation_state` call for `/new`. Every delegate reproduces the
300 // baseline check/mutation order exactly (blocked transitions fail before I/O,
301 // branching never rewrites journal history, publication failures retain their
302 // post-save semantics, archive state updates atomically) and returns portable
303 // receipts or the exact host-error text the baseline surfaces. The lifecycle
304 // bodies no longer live in `groups/session/session.rs`; adapter regressions and
305 // portable-handler tests preserve their host and presentation contracts.
306 // ---------------------------------------------------------------------------
307 pub(crate) struct SessionLifecycleAdapter<'a> {
308 host: SharedCommandHost<'a>,
309 }
310
311 impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> {
312 fn transition_blocked(&self) -> bool {
313 self.host.app.borrow().session_transition_blocked()
314 }
315
316 fn branch_current_leaf_hint(&self) -> Option<String> {
317 let app = self.host.app.borrow();
318 let session_id = app.current_session_id.as_deref()?;
319 let manager = crate::session_manager::SessionManager::default_location().ok()?;
320 let mut session = manager.load_session(session_id).ok()?;
321 session.ensure_journal();
322 session.journal.as_ref()?.leaf_id.clone()
323 }
324
325 fn branch_to(&mut self, entry_id: &str) -> Result<SessionBranchOutcome, String> {
326 let mut app = self.host.app.borrow_mut();
327 let session_id = match app.current_session_id.clone() {
328 Some(id) => id,
329 None => {
330 return Err(
331 "No active session to branch. Resume or create a session first.".to_string(),
332 );
333 }
334 };
335 let manager = match crate::session_manager::SessionManager::default_location() {
336 Ok(m) => m,
337 Err(e) => return Err(format!("could not open sessions directory: {e}")),
338 };
339 crate::tui::persistence_actor::flush_before_transition()?;
340 let mut session = match manager.load_session(&session_id) {
341 Ok(s) => s,
342 Err(e) => return Err(format!("could not load session {session_id}: {e}")),
343 };
344 session.ensure_journal();
345 let journal_len_before = session
346 .journal
347 .as_ref()
348 .map(|j| j.entries.len())
349 .unwrap_or(0);
350 // An entry a bounded save archived (#6842) is restored, with any
351 // ancestors the journal no longer holds, before branching to it.
352 if let Err(e) = manager.restore_archived_journal_chain(&mut session, entry_id) {
353 return Err(format!(
354 "branch failed: could not restore {entry_id} from the session's journal archive: {e}"
355 ));
356 }
357 match session.journal_branch_to(entry_id) {
358 Ok(()) => {
359 if let Err(e) = manager.save_session(&session) {
360 return Err(format!(
361 "branch could not be persisted; active conversation unchanged: {e}"
362 ));
363 }
364 app.restore_api_messages(session.messages.clone(), &session);
365 let leaf_display = session
366 .leaf_id
367 .clone()
368 .unwrap_or_else(|| "(none)".to_string());
369 app.clear_history();
370 app.session_artifacts = session.artifacts.clone();
371 app.session_context_references = session.context_references.clone();
372 app.extend_history(
373 session
374 .messages
375 .iter()
376 .flat_map(crate::tui::history::history_cells_from_message),
377 );
378 app.scroll_to_bottom();
379 Ok(SessionBranchOutcome {
380 leaf_display,
381 journal_entries_before: journal_len_before,
382 sync: SessionSyncPayload {
383 session_id: Some(session_id),
384 messages: session.messages,
385 system_prompt: app.system_prompt.clone(),
386 model: app.model.clone(),
387 workspace: app.workspace.clone(),
388 mode: to_command_mode(app.mode),
389 },
390 })
391 }
392 Err(e) => Err(format!(
393 "branch failed: {e}. Use `/tree` to see valid entry ids."
394 )),
395 }
396 }
397
398 fn tree_body(&self) -> Result<TreeBodyProjection, String> {
399 let app = self.host.app.borrow();
400 let manager = match crate::session_manager::SessionManager::default_location() {
401 Ok(m) => m,
402 Err(e) => return Err(format!("could not open sessions directory: {e}")),
403 };
404 if let Some(session_id) = app.current_session_id.clone() {
405 if let Ok(mut session) = manager.load_session(&session_id) {
406 session.ensure_journal();
407 if let Some(journal) = session.journal.as_ref() {
408 let mut rendered = crate::session_tree::render_tree(journal);
409 match manager.load_journal_archive(&session_id) {
410 Ok(archived) if !archived.is_empty() => {
411 rendered.push_str(&format!(
412 "{} older off-branch entries are archived in {}/{}.jsonl \
413 (not drawn); `/branch <id>` restores one.\n",
414 archived.len(),
415 crate::session_manager::JOURNAL_ARCHIVE_DIR,
416 session_id,
417 ));
418 }
419 Ok(_) => {}
420 Err(e) => {
421 rendered.push_str(&format!("journal archive could not be read: {e}\n"))
422 }
423 }
424 return Ok(TreeBodyProjection::Journal { rendered });
425 }
426 }
427 if app.api_messages.is_empty() {
428 return Ok(TreeBodyProjection::EmptySession);
429 }
430 let mut rendered =
431 String::from("Active branch (linear — journal will be created on save):\n");
432 for (i, msg) in app.api_messages.iter().enumerate() {
433 let snippet: String = msg
434 .content
435 .iter()
436 .filter_map(|b| match b {
437 codewhale_models::ContentBlock::Text { text, .. } => Some(text.as_str()),
438 _ => None,
439 })
440 .collect::<Vec<_>>()
441 .join(" ");
442 let short: String = snippet.chars().take(60).collect();
443 let marker = if i + 1 == app.api_messages.len() {
444 "*"
445 } else {
446 "●"
447 };
448 rendered.push_str(&format!(" {marker} [{i}] {}: {short}\n", msg.role));
449 }
450 Ok(TreeBodyProjection::Linear { rendered })
451 } else {
452 Ok(TreeBodyProjection::NoSession)
453 }
454 }
455
456 fn save_session(
457 &mut self,
458 explicit_path: Option<String>,
459 ) -> Result<SessionSaveReceipt, String> {
460 let mut app = self.host.app.borrow_mut();
461 let explicit_save_path = explicit_path.map(PathBuf::from);
462
463 // Explicit save must report contended Work state instead of falling
464 // back to the last automatic snapshot. Reuse the canonical snapshot
465 // builder after that preflight so stable IDs also retain lifecycle,
466 // title, provider, and window metadata.
467 app.work_state_snapshot()
468 .map_err(|error| format!("Failed to snapshot Work state: {error}"))?;
469 let manager = crate::session_manager::SessionManager::default_location()
470 .map_err(|error| format!("could not open sessions directory: {error}"))?;
471 let mut session = crate::tui::ui::build_session_snapshot(&mut app, &manager)?;
472 // Snapshots are journal-only (#6214 T3); this path serializes
473 // directly instead of through `save_session`, so rehydrate the
474 // `messages` projection first — otherwise the file loses history.
475 session.make_storage_compatible();
476 let queue_transition =
477 crate::tui::ui::prepare_offline_queue_transition(&app, &session.metadata.id)?;
478 // C01-09: an explicit path may replace a saved session (a re-save),
479 // never an arbitrary file. Whatever exists there must read as one.
480 if let Some(path) = explicit_save_path.as_deref()
481 && path.symlink_metadata().is_ok()
482 && crate::session_manager::SessionManager::load_session_metadata(path).is_err()
483 {
484 return Err(format!(
485 "Refusing to overwrite {}: it is not a saved Codewhale session. Choose a new path, or move that file first.",
486 path.display()
487 ));
488 }
489 let save_path = explicit_save_path.unwrap_or_else(|| {
490 let dir = crate::session_manager::default_sessions_dir()
491 .unwrap_or_else(|_| app.workspace.clone());
492 dir.join(format!("{}.json", session.metadata.id))
493 });
494
495 let sessions_dir = save_path
496 .parent()
497 .filter(|p| !p.as_os_str().is_empty())
498 .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf);
499
500 match std::fs::create_dir_all(&sessions_dir) {
501 Ok(()) => {
502 let json = match serde_json::to_string_pretty(&session) {
503 Ok(j) => j,
504 Err(e) => return Err(format!("Failed to serialize session: {e}")),
505 };
506 match crate::utils::write_atomic(&save_path, json.as_bytes()) {
507 Ok(()) => {
508 crate::tui::ui::install_offline_queue_transition(
509 &mut app,
510 queue_transition,
511 );
512 app.current_session_id = Some(session.metadata.id.clone());
513 app.current_session_metadata = Some(session.metadata.clone());
514 app.session_title = Some(session.metadata.title.clone());
515 if let Err(err) = app.publish_pending_work_state() {
516 return Err(format!(
517 "Session saved, but Work views were not published: {err}"
518 ));
519 }
520 Ok(SessionSaveReceipt {
521 display_path: save_path.display().to_string(),
522 truncated_id: crate::session_manager::truncate_id(&session.metadata.id)
523 .to_string(),
524 })
525 }
526 Err(e) => Err(format!("Failed to save session: {e}")),
527 }
528 }
529 Err(e) => Err(format!("Failed to create directory: {e}")),
530 }
531 }
532
533 fn fork_active(&mut self) -> Result<SessionForkReceipt, String> {
534 let mut app = self.host.app.borrow_mut();
535 if app.api_messages.is_empty() {
536 return Err("Nothing to fork. Send or load a message first.".to_string());
537 }
538
539 let manager = match crate::session_manager::SessionManager::default_location() {
540 Ok(manager) => manager,
541 Err(err) => {
542 return Err(format!("could not open sessions directory: {err}"));
543 }
544 };
545
546 let mut parent = crate::tui::ui::build_session_snapshot(&mut app, &manager)?;
547 parent.make_storage_compatible();
548 if let Err(err) = manager.save_session(&parent) {
549 return Err(format!("Failed to save parent session: {err}"));
550 }
551
552 let mut forked = parent.clone();
553 forked.metadata.id = uuid::Uuid::new_v4().to_string();
554 forked.metadata.created_at = chrono::Utc::now();
555 forked.metadata.updated_at = forked.metadata.created_at;
556 forked.metadata.archived = false;
557 forked.metadata.runtime_store = None;
558 forked.approval_receipts.clear();
559 forked.window_title = None;
560 forked.metadata.spawn_depth = parent.metadata.spawn_depth.saturating_add(1);
561 forked.metadata.mark_forked_from(&parent.metadata);
562 if let Some(journal) = forked.journal.as_mut() {
563 journal.spawn_depth = forked.metadata.spawn_depth;
564 }
565 let queue_transition =
566 crate::tui::ui::prepare_offline_queue_transition(&app, &forked.metadata.id)?;
567
568 if let Err(err) = manager.save_session(&forked) {
569 return Err(format!("Failed to save forked session: {err}"));
570 }
571 if let Err(err) = app.publish_pending_work_state() {
572 return Err(format!(
573 "Sessions saved, but Work views were not published: {err}"
574 ));
575 }
576
577 crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
578 app.current_session_id = Some(forked.metadata.id.clone());
579 app.current_session_metadata = Some(forked.metadata.clone());
580 app.restore_api_messages(forked.messages.clone(), &forked);
581 app.session_title = Some(forked.metadata.title.clone());
582 // A fork starts as its own session: no inherited tab/window title.
583 app.window_title = None;
584 let fork_id = forked.metadata.id.clone();
585 let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string();
586 let fork_label = crate::session_manager::truncate_id(&fork_id).to_string();
587 let mode = to_command_mode(app.mode);
588 Ok(SessionForkReceipt {
589 parent_label,
590 fork_label,
591 sync: SessionSyncPayload {
592 session_id: Some(fork_id),
593 messages: app.api_messages.as_ref().clone(),
594 system_prompt: app.system_prompt.clone(),
595 model: app.model.clone(),
596 workspace: app.workspace.clone(),
597 mode,
598 },
599 })
600 }
601
602 fn fork_from(&mut self, session_id_or_prefix: &str) -> Result<SessionForkFromReceipt, String> {
603 let mut app = self.host.app.borrow_mut();
604 let manager = match crate::session_manager::SessionManager::default_location() {
605 Ok(m) => m,
606 Err(err) => {
607 return Err(format!("could not open sessions directory: {err}"));
608 }
609 };
610 let source = manager
611 .load_session(session_id_or_prefix)
612 .or_else(|_| manager.load_session_by_prefix(session_id_or_prefix));
613 let mut source_session = match source {
614 Ok(s) => s,
615 Err(e) => {
616 return Err(format!(
617 "could not load session '{}': {e}",
618 session_id_or_prefix
619 ));
620 }
621 };
622 source_session.ensure_journal();
623 let journal = source_session.journal.clone().unwrap_or_else(|| {
624 crate::session_tree::SessionJournal::from_messages(
625 source_session.messages.clone(),
626 source_session.metadata.spawn_depth,
627 )
628 });
629 let forked_journal = journal.fork_from(None).unwrap_or_else(|_| {
630 crate::session_tree::SessionJournal::with_spawn_depth(
631 source_session.metadata.spawn_depth.saturating_add(1),
632 )
633 });
634 let messages = forked_journal.to_messages();
635 let mut forked = crate::session_manager::create_saved_session_with_id_and_mode(
636 uuid::Uuid::new_v4().to_string(),
637 &messages,
638 &source_session.metadata.model,
639 &app.workspace,
640 source_session.metadata.total_tokens,
641 source_session
642 .system_prompt
643 .as_ref()
644 .map(|s| codewhale_models::SystemPrompt::Text(s.clone()))
645 .as_ref(),
646 source_session.metadata.mode.as_deref(),
647 );
648 forked.journal = Some(forked_journal);
649 forked.leaf_id = forked.journal.as_ref().and_then(|j| j.leaf_id.clone());
650 forked.messages = messages;
651 forked.metadata.spawn_depth = forked.journal.as_ref().map(|j| j.spawn_depth).unwrap_or(0);
652 forked.metadata.parent_session_id = Some(source_session.metadata.id.clone());
653 forked.metadata.forked_from_message_count = Some(source_session.metadata.message_count);
654 forked.metadata.set_model_provider_route(
655 source_session.metadata.model_provider.as_str(),
656 source_session.metadata.model_provider_id.as_deref(),
657 );
658 forked.metadata.copy_cost_from(&source_session.metadata);
659 forked.context_references = source_session.context_references.clone();
660 forked.artifacts = source_session.artifacts.clone();
661 forked.work_state = source_session.work_state.clone();
662 forked.last_auto_route = source_session.last_auto_route.clone();
663 let queue_transition =
664 crate::tui::ui::prepare_offline_queue_transition(&app, &forked.metadata.id)?;
665 if let Err(err) = manager.save_session(&forked) {
666 return Err(format!("Failed to save forked session: {err}"));
667 }
668 crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
669 app.current_session_id = Some(forked.metadata.id.clone());
670 app.current_session_metadata = Some(forked.metadata.clone());
671 app.restore_api_messages(forked.messages.clone(), &forked);
672 app.session_title = Some(forked.metadata.title.clone());
673 // A fork starts as its own session: no inherited tab/window title.
674 app.window_title = None;
675 let parent_label =
676 crate::session_manager::truncate_id(&source_session.metadata.id).to_string();
677 let fork_label = crate::session_manager::truncate_id(&forked.metadata.id).to_string();
678 let mode = to_command_mode(app.mode);
679 Ok(SessionForkFromReceipt {
680 parent_label,
681 fork_label,
682 spawn_depth: forked.metadata.spawn_depth.into(),
683 sync: SessionSyncPayload {
684 session_id: Some(forked.metadata.id.clone()),
685 messages: forked.messages.clone(),
686 system_prompt: forked
687 .system_prompt
688 .as_ref()
689 .map(|s| codewhale_models::SystemPrompt::Text(s.clone())),
690 model: forked.metadata.model.clone(),
691 workspace: app.workspace.clone(),
692 mode,
693 },
694 })
695 }
696
697 fn fresh_session(&mut self, force: bool) -> Result<SessionNewReceipt, String> {
698 let mut app = self.host.app.borrow_mut();
699 if !force {
700 let mut blockers: Vec<&'static str> = Vec::new();
701 if !app.input.trim().is_empty() {
702 blockers.push("the composer has unsent text");
703 }
704 if !app.queued_messages.is_empty() || app.queued_draft.is_some() {
705 blockers.push("queued messages are pending");
706 }
707 if !blockers.is_empty() {
708 return Err(format!(
709 "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.",
710 blockers.join(", ")
711 ));
712 }
713 }
714
715 let new_id = uuid::Uuid::new_v4().to_string();
716 let queue_transition = crate::tui::ui::prepare_offline_queue_transition(&app, &new_id)?;
717 if !crate::commands::groups::core::reset_conversation_state(&mut app) {
718 return Err(
719 "Could not start a new session because Work state is busy; retry in a moment."
720 .to_string(),
721 );
722 }
723 crate::tui::ui::install_offline_queue_transition(&mut app, queue_transition);
724 app.clear_input();
725 app.session_artifacts.clear();
726 app.session_context_references.clear();
727 app.tool_evidence.clear();
728 app.current_session_id = Some(new_id.clone());
729 app.current_session_metadata = None;
730 app.session_title = Some(crate::session_manager::DEFAULT_SESSION_TITLE.to_string());
731 // A new session has no tab/window title override yet; the `title`
732 // config default still applies.
733 app.window_title = None;
734 app.scroll_to_bottom();
735 let mode = to_command_mode(app.mode);
736 Ok(SessionNewReceipt {
737 truncated_id: crate::session_manager::truncate_id(&new_id).to_string(),
738 sync: SessionSyncPayload {
739 session_id: Some(new_id),
740 messages: Vec::new(),
741 system_prompt: None,
742 model: app.model.clone(),
743 workspace: app.workspace.clone(),
744 mode,
745 },
746 })
747 }
748
749 fn load_session(&mut self, path: &str) -> Result<PathBuf, String> {
750 let app = self.host.app.borrow();
751 let load_path = if path.contains('/') || path.contains('\\') {
752 PathBuf::from(path)
753 } else {
754 app.workspace.join(path)
755 };
756
757 let content = match std::fs::read_to_string(&load_path) {
758 Ok(c) => c,
759 Err(e) => {
760 return Err(format!("Failed to read session file: {e}"));
761 }
762 };
763
764 let _session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
765 Ok(s) => s,
766 Err(e) => {
767 return Err(format!("Failed to parse session file: {e}"));
768 }
769 };
770 Ok(load_path)
771 }
772
773 fn open_picker(&mut self, preselected: Option<String>) {
774 let mut app = self.host.app.borrow_mut();
775 // Materialize the picker inputs before mutating the view stack so the
776 // `RefCell` borrow of `App` is not simultaneously mutable and shared.
777 let workspace = app.workspace.clone();
778 let ui_locale = app.ui_locale;
779 let current_id = app.current_session_id.clone();
780 match preselected {
781 Some(session_id) => {
782 app.view_stack.push(
783 crate::tui::session_picker::SessionPickerView::new_selecting(
784 &workspace,
785 ui_locale,
786 &session_id,
787 )
788 .with_current_session(current_id.as_deref()),
789 );
790 }
791 None => {
792 app.view_stack.push(
793 crate::tui::session_picker::SessionPickerView::new(&workspace, ui_locale)
794 .with_current_session(current_id.as_deref()),
795 );
796 }
797 }
798 }
799
800 fn set_archived(
801 &mut self,
802 session_id: &str,
803 archived: bool,
804 ) -> Result<SessionArchiveReceipt, String> {
805 let verb = if archived { "archive" } else { "unarchive" };
806 let mut app = self.host.app.borrow_mut();
807 let manager = match crate::session_manager::SessionManager::default_location() {
808 Ok(manager) => manager,
809 Err(err) => {
810 return Err(format!("could not open sessions directory: {err}"));
811 }
812 };
813 match manager.set_session_archived(
814 session_id,
815 archived,
816 crate::session_manager::SessionMutator::Owner,
817 ) {
818 Ok(metadata) => {
819 if let Some(cached) = app.current_session_metadata.as_mut()
820 && cached.id == metadata.id
821 {
822 cached.archived = metadata.archived;
823 }
824 Ok(SessionArchiveReceipt {
825 truncated_id: crate::session_manager::truncate_id(&metadata.id).to_string(),
826 title: metadata.title,
827 })
828 }
829 Err(err) => Err(format!("{verb} failed: {err}")),
830 }
831 }
832
833 fn prune_sessions(&mut self, days: u64) -> Result<usize, String> {
834 let app = self.host.app.borrow();
835 let manager = match crate::session_manager::SessionManager::default_location() {
836 Ok(m) => m,
837 Err(err) => {
838 return Err(format!("could not open sessions directory: {err}"));
839 }
840 };
841
842 let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60));
843 // Never prune the active session, even if its timestamp is stale (a
844 // just-resumed session isn't re-saved until its first post-resume write).
845 let keep = app.current_session_id.as_deref();
846 manager
847 .prune_sessions_older_than_keeping(max_age, keep)
848 .map_err(|err| format!("prune failed: {err}"))
849 }
850 }
851
852 // ---------------------------------------------------------------------------
853 // FEAT-024 Phase 4: relocated host machinery for the control slice.
854 //
855 // These helpers were extracted from the legacy `/remote-env` command body
856 // when that file became portable; they are host-owned and stay in TUI (the
857 // future movable group never names them).
858 // ---------------------------------------------------------------------------
859
860 const HOSTED_WORK_URL: &str = "https://app.codewhale.net/work";
861 const MAX_GIT_VALUE_BYTES: usize = 4 * 1024;
862
863 /// Validated hosted-work Git target (repo slug + checked-out branch).
864 #[derive(Debug, Clone, PartialEq, Eq)]
865 struct RemoteEnvTarget {
866 repo: String,
867 branch: String,
868 }
869
870 /// Resolve the hosted-work launcher target for a workspace: read the origin
871 /// URL and symbolic branch, normalize the repository slug against the
872 /// allowlist, and encode the launcher URL. Credentials never appear in the
873 /// returned values.
874 fn resolve_target(workspace: &Path) -> Option<RemoteEnvTarget> {
875 let origin = read_git_value(
876 workspace,
877 &["config", "--local", "--get", "remote.origin.url"],
878 )?;
879 let repo = normalize_repo_slug(&origin)?;
880 let branch = read_git_value(workspace, &["symbolic-ref", "--quiet", "--short", "HEAD"])?;
881 if !valid_branch_name(&branch) {
882 return None;
883 }
884 Some(RemoteEnvTarget { repo, branch })
885 }
886
887 fn hosted_work_url(repo: &str, branch: &str) -> String {
888 format!(
889 "{HOSTED_WORK_URL}?repo={}&branch={}",
890 urlencoding::encode(repo),
891 urlencoding::encode(branch),
892 )
893 }
894
895 fn read_git_value(workspace: &Path, args: &[&str]) -> Option<String> {
896 let mut command = crate::dependencies::Git::command()?;
897 let output = command.arg("-C").arg(workspace).args(args).output().ok()?;
898 if !output.status.success()
899 || output.stdout.is_empty()
900 || output.stdout.len() > MAX_GIT_VALUE_BYTES
901 {
902 return None;
903 }
904 let value = String::from_utf8(output.stdout).ok()?;
905 let value = value.trim_end_matches(&['\r', '\n'][..]);
906 if value.is_empty() {
907 None
908 } else {
909 Some(value.to_string())
910 }
911 }
912
913 fn valid_branch_name(branch: &str) -> bool {
914 if branch.is_empty() || branch.len() > MAX_GIT_VALUE_BYTES {
915 return false;
916 }
917 let Some(mut command) = crate::dependencies::Git::command() else {
918 return false;
919 };
920 command
921 .args(["check-ref-format", "--branch"])
922 .arg(branch)
923 .stdout(std::process::Stdio::null())
924 .stderr(std::process::Stdio::null())
925 .status()
926 .is_ok_and(|status| status.success())
927 }
928
929 fn normalize_repo_slug(origin: &str) -> Option<String> {
930 let origin = origin.trim();
931 if origin.is_empty()
932 || origin.len() > MAX_GIT_VALUE_BYTES
933 || origin.chars().any(char::is_control)
934 {
935 return None;
936 }
937
938 let (host, path) = if starts_with_ascii_case(origin, "https://") {
939 split_url_origin(&origin["https://".len()..], UrlScheme::Https)?
940 } else if starts_with_ascii_case(origin, "ssh://") {
941 split_url_origin(&origin["ssh://".len()..], UrlScheme::Ssh)?
942 } else {
943 split_scp_origin(origin)?
944 };
945 if !matches!(
946 host.to_ascii_lowercase().as_str(),
947 "github.com" | "cnb.cool"
948 ) {
949 return None;
950 }
951 normalize_repo_path(path)
952 }
953
954 #[derive(Debug, Clone, Copy)]
955 enum UrlScheme {
956 Https,
957 Ssh,
958 }
959
960 fn starts_with_ascii_case(value: &str, prefix: &str) -> bool {
961 value
962 .get(..prefix.len())
963 .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
964 }
965
966 fn split_url_origin(origin: &str, scheme: UrlScheme) -> Option<(&str, &str)> {
967 let (authority, path) = origin.split_once('/')?;
968 if authority.is_empty() || path.is_empty() {
969 return None;
970 }
971 let host_port = authority
972 .rsplit_once('@')
973 .map_or(authority, |(_, host)| host);
974 let host = match host_port.rsplit_once(':') {
975 Some((host, port))
976 if !host.is_empty()
977 && !port.is_empty()
978 && port.bytes().all(|byte| byte.is_ascii_digit())
979 && (matches!(scheme, UrlScheme::Ssh) || port == "443") =>
980 {
981 host
982 }
983 Some(_) => return None,
984 None => host_port,
985 };
986 (!host.is_empty()).then_some((host, path))
987 }
988
989 fn split_scp_origin(origin: &str) -> Option<(&str, &str)> {
990 let (authority, path) = origin.split_once(':')?;
991 let (_, host) = authority.rsplit_once('@')?;
992 if host.is_empty() || path.is_empty() {
993 return None;
994 }
995 Some((host, path))
996 }
997
998 fn normalize_repo_path(path: &str) -> Option<String> {
999 if path.chars().any(|ch| matches!(ch, '?' | '#' | '\\')) {
1000 return None;
1001 }
1002 let path = path.trim_matches('/');
1003 let path = path.strip_suffix(".git").unwrap_or(path);
1004 let mut parts = path.split('/');
1005 let namespace = parts.next()?;
1006 let repository = parts.next()?;
1007 if parts.next().is_some()
1008 || !valid_repo_component(namespace)
1009 || !valid_repo_component(repository)
1010 {
1011 return None;
1012 }
1013 Some(format!("{namespace}/{repository}"))
1014 }
1015
1016 fn valid_repo_component(value: &str) -> bool {
1017 !value.is_empty()
1018 && value.len() <= 255
1019 && !matches!(value, "." | "..")
1020 && value
1021 .bytes()
1022 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
1023 }
1024
1025 // ---------------------------------------------------------------------------
1026 // Session control adapter (FEAT-024 D4/D5)
1027 //
1028 // Sole host owner of concrete control machinery for the six control commands:
1029 // relay snapshot reads (goal/plan/work/todo/compact-template), rename/title
1030 // persistence (sanitization, checkpoint recovery, live synchronization, save,
1031 // publication, redraw), resume routing/imports, remote-control state and the
1032 // synchronous single-attempt browser launch, and hosted-work Git target
1033 // resolution. Every delegate reproduces the baseline check/mutation order
1034 // exactly (transition gate before resume I/O, save before publication,
1035 // browser launch without retry/deferral) and returns portable
1036 // projections/receipts or the exact host-error text the baseline surfaces.
1037 // No `SessionManager`, saved-session/container type, `SessionPickerView`,
1038 // remote-control service, Git wrapper, configuration, model/history type,
1039 // lock, or host callback crosses the facet.
1040 // ---------------------------------------------------------------------------
1041 pub(crate) struct SessionControlAdapter<'a> {
1042 host: SharedCommandHost<'a>,
1043 }
1044
1045 impl CommandSessionControlContext for SessionControlAdapter<'_> {
1046 fn transition_blocked(&self) -> bool {
1047 self.host.app.borrow().session_transition_blocked()
1048 }
1049
1050 fn relay_projection(&self) -> RelayProjection {
1051 let app = self.host.app.borrow();
1052 let plan = match app.plan_state.try_lock() {
1053 Ok(plan) => {
1054 let snapshot = plan.snapshot();
1055 if snapshot.is_empty() {
1056 PlanProjection::Absent
1057 } else {
1058 PlanProjection::Sections(plan_snapshot_to_sections(&snapshot))
1059 }
1060 }
1061 Err(_) => PlanProjection::Busy,
1062 };
1063 let todos = match app.work_state_snapshot() {
1064 Ok(Some(state)) => match crate::todo_snapshot::todo_snapshot_body(&state.todos) {
1065 Some(body) => TodoProjection::Body(body),
1066 None => TodoProjection::Absent,
1067 },
1068 Ok(None) => TodoProjection::Absent,
1069 Err(_) => TodoProjection::Unavailable,
1070 };
1071 RelayProjection {
1072 compact_template: crate::prompts::COMPACT_TEMPLATE.to_string(),
1073 workspace: app.workspace.display().to_string(),
1074 mode: app.mode.label().to_string(),
1075 model: app.model_display_label(),
1076 goal_objective: app.goal.objective.clone(),
1077 goal_token_budget: app.goal.token_budget,
1078 todos,
1079 plan,
1080 }
1081 }
1082
1083 fn open_resume_picker(&mut self) {
1084 let mut app = self.host.app.borrow_mut();
1085 let picker =
1086 crate::tui::session_picker::SessionPickerView::new(&app.workspace, app.ui_locale)
1087 .with_current_session(app.current_session_id.as_deref());
1088 app.view_stack.push(picker);
1089 }
1090
1091 fn resolve_resume_source(&mut self, raw: &str) -> Result<ResumeSource, String> {
1092 // Baseline order: direct path (or `.json` existing path) first, then
1093 // workspace-relative, then session id/prefix, then inline container.
1094 let raw_path = PathBuf::from(raw);
1095 if raw_path.is_file() || (raw.ends_with(".json") && Path::new(raw).exists()) {
1096 return Ok(ResumeSource::File(raw_path));
1097 }
1098 let workspace_relative = {
1099 let app = self.host.app.borrow();
1100 let ws_path = app.workspace.join(raw);
1101 ws_path.is_file().then_some(ws_path)
1102 };
1103 if let Some(ws_path) = workspace_relative {
1104 return Ok(ResumeSource::File(ws_path));
1105 }
1106 let manager = match crate::session_manager::SessionManager::default_location() {
1107 Ok(m) => m,
1108 Err(e) => return Err(format!("could not open sessions directory: {e}")),
1109 };
1110 // Resolution only needs durable identity — the resume that follows
1111 // runs and persists the repair, so probe the snapshot instead of
1112 // running (and logging) an in-memory repair here.
1113 match manager.load_session_snapshot(raw).or_else(|_| {
1114 manager
1115 .resolve_session_id_prefix(raw)
1116 .and_then(|id| manager.load_session_snapshot(&id))
1117 }) {
1118 Ok(sess) => {
1119 let path = manager
1120 .sessions_dir()
1121 .join(format!("{}.json", sess.metadata.id));
1122 Ok(ResumeSource::Session {
1123 load_path: path.exists().then_some(path),
1124 truncated_id: crate::session_manager::truncate_id(&sess.metadata.id)
1125 .to_string(),
1126 title: sess.metadata.title,
1127 })
1128 }
1129 Err(e) => {
1130 if let Ok(container) = crate::session_tree::SessionImportContainer::from_json(raw) {
1131 let mut app = self.host.app.borrow_mut();
1132 let receipt = import_session_container(&mut app, container)?;
1133 Ok(ResumeSource::Imported(receipt))
1134 } else {
1135 Ok(ResumeSource::NotFound {
1136 raw: raw.to_string(),
1137 error: e.to_string(),
1138 })
1139 }
1140 }
1141 }
1142 }
1143
1144 fn import_session_file(&mut self, path: PathBuf) -> Result<ResumeImportReceipt, String> {
1145 let mut app = self.host.app.borrow_mut();
1146 import_foreign_file(&mut app, &path)
1147 }
1148
1149 fn sanitize_session_title(&self, raw_title: &str) -> String {
1150 crate::session_manager::sanitize_session_title(raw_title)
1151 }
1152
1153 fn rename_session(&mut self, new_title: &str) -> Result<SessionTitleReceipt, String> {
1154 let mut app = self.host.app.borrow_mut();
1155 let session_id = match &app.current_session_id {
1156 Some(id) => id.clone(),
1157 None => {
1158 return Err(
1159 "No active session. Send a message first to start a session.".to_string(),
1160 );
1161 }
1162 };
1163 let manager = match crate::session_manager::SessionManager::default_location() {
1164 Ok(m) => m,
1165 Err(e) => return Err(format!("Could not open sessions directory: {e}")),
1166 };
1167
1168 // Mirrors the baseline `/rename` write path exactly: load (with
1169 // first-snapshot recovery), sync live state, snapshot Work state,
1170 // carry context/artifacts/route/cost/model/workspace/mode metadata,
1171 // persist, then publish. Publication failures keep their post-save
1172 // partial-success semantics.
1173 let mut session = match manager.load_session(&session_id) {
1174 Ok(s) => s,
1175 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1176 match live_session_before_first_snapshot(&manager, &session_id, &app) {
1177 Some(s) => s,
1178 None => return Err(format!("Could not load session: {err}")),
1179 }
1180 }
1181 Err(e) => return Err(format!("Could not load session: {e}")),
1182 };
1183 session = crate::session_manager::update_session(
1184 session,
1185 &app.api_messages,
1186 u64::from(app.session.total_tokens),
1187 app.system_prompt.as_ref(),
1188 );
1189 session.work_state = match app.work_state_snapshot() {
1190 Ok(state) => state,
1191 Err(err) => {
1192 return Err(format!(
1193 "Could not snapshot Work state before rename: {err}"
1194 ));
1195 }
1196 };
1197 session.context_references = app.session_context_references.clone();
1198 session.artifacts = app.session_artifacts.clone();
1199 session.last_auto_route = app.auto_route_for_persistence();
1200 session.metadata.model = app.model_selection_for_persistence();
1201 session
1202 .metadata
1203 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
1204 session.metadata.workspace.clone_from(&app.workspace);
1205 session.metadata.mode = Some(app.mode.as_setting().to_string());
1206 app.sync_cost_to_metadata(&mut session.metadata);
1207 session.metadata.title = new_title.to_string();
1208
1209 match manager.save_session(&session) {
1210 Ok(_) => {
1211 app.current_session_metadata = Some(session.metadata.clone());
1212 app.session_title = Some(new_title.to_string());
1213 if let Err(err) = app.publish_pending_work_state() {
1214 return Err(format!(
1215 "Session renamed, but Work views were not published: {err}"
1216 ));
1217 }
1218 Ok(SessionTitleReceipt {
1219 title: new_title.to_string(),
1220 })
1221 }
1222 Err(e) => Err(format!("Could not save session: {e}")),
1223 }
1224 }
1225
1226 fn title_report(&self) -> TitleReport {
1227 let app = self.host.app.borrow();
1228 let source = if app.window_title.is_some() {
1229 TitleSource::Session
1230 } else if app.title_default.is_some() {
1231 TitleSource::ConfigDefault
1232 } else {
1233 TitleSource::None
1234 };
1235 TitleReport {
1236 effective: app.window_title_prefix().unwrap_or("unset").to_string(),
1237 source,
1238 }
1239 }
1240
1241 fn set_window_title(&mut self, title: String) -> Result<(), String> {
1242 let mut app = self.host.app.borrow_mut();
1243 persist_window_title(&mut app, Some(title))
1244 }
1245
1246 fn clear_window_title(&mut self) -> Result<(), String> {
1247 let mut app = self.host.app.borrow_mut();
1248 persist_window_title(&mut app, None)
1249 }
1250
1251 fn remote_status(&self) -> String {
1252 self.host.app.borrow().remote_control.status_line()
1253 }
1254
1255 fn remote_link(&self) -> Option<RemoteLink> {
1256 let app = self.host.app.borrow();
1257 let url = app.remote_control.run_url()?.to_string();
1258 Some(RemoteLink {
1259 computer_url: app.remote_control.computer_url().map(str::to_string),
1260 url,
1261 })
1262 }
1263
1264 fn remote_browser_open(&self) -> RemoteOpenOutcome {
1265 let app = self.host.app.borrow();
1266 let Some(url) = app.remote_control.run_url().map(str::to_string) else {
1267 return RemoteOpenOutcome::NoLink;
1268 };
1269 // Synchronous single attempt through the authoritative URL-opening
1270 // helper; never retried and never deferred to an external-URL action.
1271 let launched = crate::utils::open_url(&url).is_ok();
1272 map_browser_open_result(url, launched)
1273 }
1274
1275 fn remote_start_info(&self) -> RemoteStartInfo {
1276 let app = self.host.app.borrow();
1277 RemoteStartInfo {
1278 connecting: app.is_loading || app.dispatch_in_flight,
1279 }
1280 }
1281
1282 fn remote_stop_refusal(&self) -> Option<String> {
1283 self.host.app.borrow().remote_control.stop_refusal().clone()
1284 }
1285
1286 fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget> {
1287 let app = self.host.app.borrow();
1288 let target = resolve_target(&app.workspace)?;
1289 let url = hosted_work_url(&target.repo, &target.branch);
1290 Some(HostedWorkTarget {
1291 url,
1292 repo: target.repo,
1293 branch: target.branch,
1294 })
1295 }
1296 }
1297
1298 /// Persist an already sanitized window title through the baseline host path.
1299 /// Manager resolution intentionally precedes active-session lookup, matching
1300 /// the original `/title` error precedence for both set and clear operations.
1301 fn persist_window_title(app: &mut App, title: Option<String>) -> Result<(), String> {
1302 let manager = match crate::session_manager::SessionManager::default_location() {
1303 Ok(manager) => manager,
1304 Err(error) => return Err(format!("Could not open sessions directory: {error}")),
1305 };
1306 let session_id = match &app.current_session_id {
1307 Some(id) => id.clone(),
1308 None => {
1309 return Err("No active session. Send a message first to start a session.".to_string());
1310 }
1311 };
1312 let mut session = match manager.load_session(&session_id) {
1313 Ok(session) => session,
1314 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1315 match live_session_before_first_snapshot(&manager, &session_id, app) {
1316 Some(session) => session,
1317 None => return Err(format!("Could not load session: {error}")),
1318 }
1319 }
1320 Err(error) => return Err(format!("Could not load session: {error}")),
1321 };
1322 session = crate::session_manager::update_session(
1323 session,
1324 &app.api_messages,
1325 u64::from(app.session.total_tokens),
1326 app.system_prompt.as_ref(),
1327 );
1328 session.work_state = match app.work_state_snapshot() {
1329 Ok(state) => state,
1330 Err(error) => {
1331 return Err(format!(
1332 "Could not snapshot Work state before setting title: {error}"
1333 ));
1334 }
1335 };
1336 session.context_references = app.session_context_references.clone();
1337 session.artifacts = app.session_artifacts.clone();
1338 session.last_auto_route = app.auto_route_for_persistence();
1339 session.metadata.model = app.model_selection_for_persistence();
1340 session
1341 .metadata
1342 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
1343 session.metadata.workspace.clone_from(&app.workspace);
1344 session.metadata.mode = Some(app.mode.as_setting().to_string());
1345 app.sync_cost_to_metadata(&mut session.metadata);
1346 session.window_title.clone_from(&title);
1347
1348 match manager.save_session(&session) {
1349 Ok(_) => {
1350 app.window_title = title;
1351 // The render loop syncs the resolved prefix into the terminal
1352 // title; force a frame so the change lands immediately.
1353 app.needs_redraw = true;
1354 if let Err(error) = app.publish_pending_work_state() {
1355 return Err(format!(
1356 "Window title saved, but Work views were not published: {error}"
1357 ));
1358 }
1359 Ok(())
1360 }
1361 Err(error) => Err(format!("Could not save session: {error}")),
1362 }
1363 }
1364
1365 /// Map one synchronous browser-launch attempt to its portable outcome.
1366 /// Split out so the delegate's success/failure branches are unit-provable
1367 /// without spawning a real browser (utils tests cover the launcher itself).
1368 fn map_browser_open_result(url: String, launched: bool) -> RemoteOpenOutcome {
1369 if launched {
1370 RemoteOpenOutcome::Opened { url }
1371 } else {
1372 RemoteOpenOutcome::LaunchFailed { url }
1373 }
1374 }
1375
1376 fn plan_snapshot_to_sections(snapshot: &crate::tools::plan::PlanSnapshot) -> PlanSections {
1377 PlanSections {
1378 title: snapshot.title.clone(),
1379 objective: snapshot.objective.clone(),
1380 context_summary: snapshot.context_summary.clone(),
1381 explanation: snapshot.explanation.clone(),
1382 sources_used: snapshot.sources_used.clone(),
1383 critical_files: snapshot.critical_files.clone(),
1384 constraints: snapshot.constraints.clone(),
1385 recommended_approach: snapshot.recommended_approach.clone(),
1386 verification_plan: snapshot.verification_plan.clone(),
1387 risks_and_unknowns: snapshot.risks_and_unknowns.clone(),
1388 handoff_packet: snapshot.handoff_packet.clone(),
1389 items: snapshot
1390 .items
1391 .iter()
1392 .map(|item| PlanStep {
1393 status: match &item.status {
1394 crate::tools::plan::StepStatus::Pending => PlanStepStatus::Pending,
1395 crate::tools::plan::StepStatus::InProgress => PlanStepStatus::InProgress,
1396 crate::tools::plan::StepStatus::Completed => PlanStepStatus::Completed,
1397 },
1398 text: item.step.clone(),
1399 })
1400 .collect(),
1401 }
1402 }
1403
1404 /// Recover the session document for a live turn that has not completed (and
1405 /// therefore persisted) its first snapshot yet (#5430). Mirrors the legacy
1406 /// `/rename`/`/title` recovery exactly.
1407 fn live_session_before_first_snapshot(
1408 manager: &crate::session_manager::SessionManager,
1409 session_id: &str,
1410 app: &App,
1411 ) -> Option<crate::session_manager::SavedSession> {
1412 if let Ok(Some(checkpoint)) = manager.load_session_checkpoint(session_id) {
1413 return Some(checkpoint);
1414 }
1415 Some(
1416 crate::session_manager::create_saved_session_with_id_and_mode(
1417 session_id.to_string(),
1418 &app.api_messages,
1419 &app.model_selection_for_persistence(),
1420 &app.workspace,
1421 u64::from(app.session.total_tokens),
1422 app.system_prompt.as_ref(),
1423 Some(app.mode.as_setting()),
1424 ),
1425 )
1426 }
1427
1428 /// `/resume <file>` import: read, parse a container or plain saved session,
1429 /// and apply it atomically. Errors are the exact baseline text.
1430 fn import_foreign_file(app: &mut App, path: &Path) -> Result<ResumeImportReceipt, String> {
1431 let content = match std::fs::read_to_string(path) {
1432 Ok(c) => c,
1433 Err(e) => {
1434 return Err(format!(
1435 "failed to read import file {}: {e}",
1436 path.display()
1437 ));
1438 }
1439 };
1440 if let Ok(container) = crate::session_tree::SessionImportContainer::from_json(&content) {
1441 return import_session_container(app, container);
1442 }
1443 if let Ok(foreign) = serde_json::from_str::<crate::session_manager::SavedSession>(&content) {
1444 let container = foreign.export_container("foreign");
1445 return import_session_container(app, container);
1446 }
1447 Err(format!(
1448 "File {} is not a recognized session export",
1449 path.display()
1450 ))
1451 }
1452
1453 /// Apply a parsed foreign container: persist, mutate the active session,
1454 /// select it in a fresh picker, and return the portable receipt.
1455 fn import_session_container(
1456 app: &mut App,
1457 container: crate::session_tree::SessionImportContainer,
1458 ) -> Result<ResumeImportReceipt, String> {
1459 let manager = match crate::session_manager::SessionManager::default_location() {
1460 Ok(m) => m,
1461 Err(e) => return Err(format!("could not open sessions directory: {e}")),
1462 };
1463 let model = app.model.clone();
1464 let workspace = app.workspace.clone();
1465 let mut imported =
1466 match crate::session_manager::SavedSession::import_foreign(container, workspace, model) {
1467 Ok(s) => s,
1468 Err(e) => return Err(format!("foreign import failed: {e}")),
1469 };
1470 // The import takes this window's model, so it takes this window's route
1471 // too. Left at the record default it named a different provider, and
1472 // opening the imported session sent its whole history there.
1473 let (provider, provider_id) = (
1474 app.provider_identity_for_persistence().to_string(),
1475 app.provider_id_for_persistence().map(str::to_string),
1476 );
1477 imported
1478 .metadata
1479 .set_model_provider_route(&provider, provider_id.as_deref());
1480 let new_id = imported.metadata.id.clone();
1481 let queue_transition = crate::tui::ui::prepare_offline_queue_transition(app, &new_id)?;
1482 if let Err(e) = manager.save_session(&imported) {
1483 return Err(format!("imported session could not be saved: {e}"));
1484 }
1485 crate::tui::ui::install_offline_queue_transition(app, queue_transition);
1486 app.current_session_id = Some(new_id.clone());
1487 app.current_session_metadata = Some(imported.metadata.clone());
1488 app.restore_api_messages(imported.messages.clone(), &imported);
1489 let picker = crate::tui::session_picker::SessionPickerView::new_selecting(
1490 &app.workspace,
1491 app.ui_locale,
1492 &new_id,
1493 )
1494 .with_current_session(app.current_session_id.as_deref());
1495 app.view_stack.push(picker);
1496 Ok(ResumeImportReceipt {
1497 truncated_id: crate::session_manager::truncate_id(&new_id).to_string(),
1498 entry_count: imported
1499 .journal
1500 .as_ref()
1501 .map(|journal| journal.entries.len())
1502 .unwrap_or(0),
1503 leaf_display: imported.leaf_id.as_deref().unwrap_or("(none)").to_string(),
1504 sync: SessionSyncPayload {
1505 session_id: Some(new_id.clone()),
1506 messages: app.api_messages.as_ref().clone(),
1507 system_prompt: app.system_prompt.clone(),
1508 model: app.model.clone(),
1509 workspace: app.workspace.clone(),
1510 mode: to_command_mode(app.mode),
1511 },
1512 })
1513 }
1514
1515 // ---------------------------------------------------------------------------
1516 // Session export adapter (FEAT-025 D1/D2/D3/D5/D7/D8/D9)
1517 //
1518 // Sole host owner of concrete export machinery for `/export` and `/daochu`:
1519 // metadata derivation, authoritative/visible-history projection, semantic
1520 // restore-point projection, the shared `turn_handoff_markdown` renderer,
1521 // clipboard mode/recovery/delivery, and protected destination resolution/
1522 // writing. Every delegate reproduces the baseline order and returns portable
1523 // data or the exact host-error text; no concrete `App`, clipboard, snapshot,
1524 // history, filesystem, or turn-handoff type crosses the boundary. Hidden
1525 // reasoning bodies, signatures, and inline/local image payloads are excluded
1526 // while the projection is built (D9). The shared recovery writer and protected
1527 // file services live in `commands::session_export_host` (outside the future
1528 // portable group) so `/copy` and `/export` reuse one implementation (D5).
1529 // ---------------------------------------------------------------------------
1530 pub(crate) struct SessionExportAdapter<'a> {
1531 host: SharedCommandHost<'a>,
1532 }
1533
1534 impl CommandSessionExportContext for SessionExportAdapter<'_> {
1535 /// Conversation export projection: metadata, transcript, and restore-point
1536 /// state.
1537 ///
1538 /// Memory note (FEAT-025 audit, finding F3): the projection is an *owned*
1539 /// copy of the transcript, so peak use is roughly the live `api_messages`
1540 /// plus this projection for the duration of one render. That copy is
1541 /// structural, not an oversight: the facet must return owned data because
1542 /// `SharedCommandHost` hands out `App` through a `RefCell`, so no borrow can
1543 /// outlive this method, and a `dyn` facet cannot lend a projection tied to a
1544 /// temporary `Ref`. The baseline rendered straight from `App` and cloned one
1545 /// block at a time, so this is a deliberate D3 cost accepted for the
1546 /// capability boundary. Removing it needs a host proxy that can lend a
1547 /// borrowed projection (tracked with the FEAT-043/046 extraction work); it is
1548 /// not something this slice can fix locally.
1549 fn conversation_projection(&self) -> ConversationExportProjection {
1550 let app = self.host.app.borrow();
1551 ConversationExportProjection {
1552 metadata: export_metadata(&app),
1553 transcript: project_transcript(&app),
1554 restore_points: project_restore_points(&app.workspace),
1555 }
1556 }
1557
1558 fn turn_handoff_projection(&self) -> TurnHandoffProjection {
1559 let app = self.host.app.borrow();
1560 TurnHandoffProjection {
1561 markdown: crate::tui::ui::turn_handoff_markdown(&app),
1562 workspace_path: app.workspace.to_string_lossy().into_owned(),
1563 }
1564 }
1565
1566 fn clipboard_requires_terminal_paste(&self) -> bool {
1567 self.host.app.borrow().clipboard.requires_terminal_paste()
1568 }
1569
1570 fn write_recovery_copy(&self, markdown: &str) -> Option<PathBuf> {
1571 crate::commands::session_export_host::write_last_copy(markdown)
1572 }
1573
1574 fn write_clipboard(&self, markdown: &str) -> Result<(), String> {
1575 self.host
1576 .app
1577 .borrow_mut()
1578 .clipboard
1579 .write_text(markdown)
1580 .map_err(|err| err.to_string())
1581 }
1582
1583 fn resolve_export_path(&self, raw: &str) -> Result<PathBuf, String> {
1584 let app = self.host.app.borrow();
1585 crate::commands::session_export_host::resolve_export_path(&app.workspace, raw)
1586 }
1587
1588 fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> {
1589 crate::commands::session_export_host::write_export_file(path, contents, force)
1590 }
1591 }
1592
1593 /// Maximum restore points listed in the export summary (baseline bound).
1594 const RESTORE_POINT_SUMMARY_MAX: usize = 100;
1595
1596 /// Authoritative export metadata, reusing the baseline host derivations.
1597 fn export_metadata(app: &App) -> ExportMetadata {
1598 let message_count = if app.api_messages.is_empty() {
1599 app.history.len()
1600 } else {
1601 app.api_messages.len()
1602 };
1603 let session_label = app
1604 .current_session_id
1605 .as_deref()
1606 .map(crate::session_manager::truncate_id)
1607 .unwrap_or("unsaved")
1608 .to_string();
1609 let workspace_name = app
1610 .workspace
1611 .file_name()
1612 .and_then(|name| name.to_str())
1613 .unwrap_or("workspace")
1614 .to_string();
1615 ExportMetadata {
1616 session_label,
1617 provider: app.provider_identity_for_persistence().to_string(),
1618 model: app.model_display_label(),
1619 mode: app.mode.display_name().to_string(),
1620 workspace_name,
1621 message_count,
1622 exported_at_unix: chrono::Utc::now().timestamp(),
1623 }
1624 }
1625
1626 /// Authoritative transcript when API messages exist, otherwise the visible
1627 /// history fallback (D3 precedence).
1628 fn project_transcript(app: &App) -> TranscriptProjection {
1629 if app.api_messages.is_empty() {
1630 TranscriptProjection::HistoryFallback(
1631 app.history.iter().map(project_history_cell).collect(),
1632 )
1633 } else {
1634 TranscriptProjection::Authoritative(app.api_messages.iter().map(project_message).collect())
1635 }
1636 }
1637
1638 fn project_message(message: &Message) -> ExportMessage {
1639 ExportMessage {
1640 role: message.role.as_str().to_string(),
1641 // Exact enum identity, not a string comparison: `Role::Unrecognized("user")`
1642 // must not be treated as a user turn (baseline parity, F6).
1643 is_user_role: message.role == codewhale_models::Role::User,
1644 blocks: message.content.iter().map(project_block).collect(),
1645 prompt_snippet: first_text_block(message)
1646 .and_then(crate::core::turn::snapshot_label_prompt_snippet),
1647 }
1648 }
1649
1650 fn first_text_block(message: &Message) -> Option<&str> {
1651 message.content.iter().find_map(|block| match block {
1652 ContentBlock::Text { text, .. } => Some(text.as_str()),
1653 _ => None,
1654 })
1655 }
1656
1657 /// Project one content block; hidden payloads become typed omission markers
1658 /// (D9) and never cross the boundary.
1659 fn project_block(block: &ContentBlock) -> ExportBlock {
1660 match block {
1661 ContentBlock::Text { text, .. } => ExportBlock::Text { text: text.clone() },
1662 ContentBlock::ImageUrl { image_url } => {
1663 if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") {
1664 ExportBlock::ImageReference {
1665 url: image_url.url.clone(),
1666 }
1667 } else {
1668 ExportBlock::ImageOmitted
1669 }
1670 }
1671 ContentBlock::Thinking { .. } => ExportBlock::InternalReasoning,
1672 ContentBlock::ToolUse {
1673 id,
1674 name,
1675 input,
1676 caller,
1677 ..
1678 } => ExportBlock::ToolCall {
1679 id: id.clone(),
1680 name: name.clone(),
1681 caller: caller.as_ref().map(|caller| ToolCallerProjection {
1682 caller_type: caller.caller_type.clone(),
1683 tool_id: caller.tool_id.clone(),
1684 }),
1685 input: input.clone(),
1686 },
1687 ContentBlock::ToolResult {
1688 tool_use_id,
1689 content,
1690 is_error,
1691 content_blocks,
1692 ..
1693 } => ExportBlock::ToolResult {
1694 tool_use_id: tool_use_id.clone(),
1695 content: content.clone(),
1696 is_error: is_error.unwrap_or(false),
1697 structured: content_blocks.as_deref().map(|blocks| {
1698 serde_json::Value::Array(
1699 crate::image_attach::safe_tool_result_content_blocks(Some(blocks))
1700 .unwrap_or_default(),
1701 )
1702 }),
1703 },
1704 ContentBlock::ServerToolUse { id, name, input } => ExportBlock::ServerToolCall {
1705 id: id.clone(),
1706 name: name.clone(),
1707 input: input.clone(),
1708 },
1709 ContentBlock::ToolSearchToolResult {
1710 tool_use_id,
1711 content,
1712 } => ExportBlock::ToolSearchResult {
1713 tool_use_id: tool_use_id.clone(),
1714 content: content.clone(),
1715 },
1716 ContentBlock::CodeExecutionToolResult {
1717 tool_use_id,
1718 content,
1719 } => ExportBlock::CodeExecutionResult {
1720 tool_use_id: tool_use_id.clone(),
1721 content: content.clone(),
1722 },
1723 }
1724 }
1725
1726 fn project_history_cell(cell: &HistoryCell) -> HistoryEntry {
1727 match cell {
1728 HistoryCell::User { content } => HistoryEntry::Sanitized {
1729 role: "user".to_string(),
1730 body: content.clone(),
1731 },
1732 HistoryCell::Assistant { content, .. } => HistoryEntry::Sanitized {
1733 role: "assistant".to_string(),
1734 body: content.clone(),
1735 },
1736 HistoryCell::System { .. } => HistoryEntry::Literal {
1737 role: "system".to_string(),
1738 body: "[internal context omitted]".to_string(),
1739 },
1740 HistoryCell::Error { message, severity } => HistoryEntry::Sanitized {
1741 role: error_severity_role(*severity).to_string(),
1742 body: message.clone(),
1743 },
1744 HistoryCell::Thinking { .. } => HistoryEntry::Literal {
1745 role: "internal reasoning".to_string(),
1746 body: "[internal reasoning omitted]".to_string(),
1747 },
1748 HistoryCell::Tool(tool) => HistoryEntry::Sanitized {
1749 role: "tool".to_string(),
1750 body: flatten_history_lines(tool.lines(120)),
1751 },
1752 HistoryCell::SubAgent(subagent) => HistoryEntry::Sanitized {
1753 role: "sub-agent".to_string(),
1754 body: flatten_history_lines(subagent.lines(120)),
1755 },
1756 HistoryCell::Automation(cell) => HistoryEntry::Sanitized {
1757 role: "automation".to_string(),
1758 body: flatten_history_lines(cell.render(120)),
1759 },
1760 HistoryCell::ArchivedContext {
1761 level,
1762 range,
1763 summary,
1764 ..
1765 } => HistoryEntry::Sanitized {
1766 role: "archived context".to_string(),
1767 body: format!("L{level} [{range}]: {summary}"),
1768 },
1769 }
1770 }
1771
1772 fn error_severity_role(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str {
1773 match severity {
1774 crate::error_taxonomy::ErrorSeverity::Info => "info",
1775 crate::error_taxonomy::ErrorSeverity::Warning => "warning",
1776 crate::error_taxonomy::ErrorSeverity::Error => "error",
1777 crate::error_taxonomy::ErrorSeverity::Critical => "critical error",
1778 }
1779 }
1780
1781 /// Flatten host UI lines/spans to plain text, preserving the baseline width
1782 /// and joining behavior (D3). UI rendering stays behind the adapter.
1783 fn flatten_history_lines(lines: Vec<ratatui::text::Line<'static>>) -> String {
1784 lines
1785 .into_iter()
1786 .map(|line| {
1787 line.spans
1788 .into_iter()
1789 .map(|span| span.content.to_string())
1790 .collect::<String>()
1791 })
1792 .collect::<Vec<_>>()
1793 .join("\n")
1794 }
1795
1796 /// Read the workspace snapshot repository read-only and project its state
1797 /// (D8): only an existing repo is opened, never created.
1798 fn project_restore_points(workspace: &Path) -> RestorePointProjection {
1799 match crate::snapshot::SnapshotRepo::open_existing(workspace) {
1800 Ok(None) => RestorePointProjection::None,
1801 Err(err) => RestorePointProjection::Unreadable {
1802 reason: err.to_string(),
1803 },
1804 Ok(Some(repo)) => match repo.list(RESTORE_POINT_SUMMARY_MAX) {
1805 Ok(snapshots) => RestorePointProjection::Recorded {
1806 snapshots: snapshots.iter().map(project_restore_snapshot).collect(),
1807 },
1808 Err(err) => RestorePointProjection::Unreadable {
1809 reason: err.to_string(),
1810 },
1811 },
1812 }
1813 }
1814
1815 fn project_restore_snapshot(snapshot: &crate::snapshot::Snapshot) -> RestoreSnapshot {
1816 let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label);
1817 RestoreSnapshot {
1818 id: snapshot.id.as_str().to_string(),
1819 label: snapshot.label.clone(),
1820 timestamp_unix: snapshot.timestamp,
1821 kind: parsed.kind,
1822 sequence: parsed.seq,
1823 prompt_snippet: parsed.prompt_snippet,
1824 }
1825 }
1826
1827 /// Session identity, messages, queue operations, and token totals.
1828 pub(crate) struct SessionAdapter<'a> {
1829 host: SharedCommandHost<'a>,
1830 }
1831
1832 impl CommandSessionContext for SessionAdapter<'_> {
1833 fn session_id(&self) -> Option<String> {
1834 self.host.app.borrow().current_session_id.clone()
1835 }
1836
1837 fn api_messages(&self) -> Vec<Message> {
1838 self.host.app.borrow().api_messages.as_ref().clone()
1839 }
1840
1841 fn add_message(&mut self, message: Message) {
1842 self.host.app.borrow_mut().push_api_message(message);
1843 }
1844
1845 fn queued_message_count(&self) -> usize {
1846 self.host.app.borrow().queued_message_count()
1847 }
1848
1849 fn remove_queued_message(&mut self, index: usize) -> Result<(), String> {
1850 self.host
1851 .app
1852 .borrow_mut()
1853 .remove_queued_message(index)
1854 .map(|_| ())
1855 .ok_or_else(|| format!("queued message index {index} out of bounds"))
1856 }
1857
1858 fn total_tokens(&self) -> u64 {
1859 u64::from(self.host.app.borrow().session.total_tokens)
1860 }
1861 }
1862
1863 /// Model selection, provider identity, effort, and fallback chain.
1864 pub(crate) struct ModelAdapter<'a> {
1865 host: SharedCommandHost<'a>,
1866 }
1867
1868 impl CommandModelContext for ModelAdapter<'_> {
1869 fn current_model(&self) -> String {
1870 self.host.app.borrow().model.clone()
1871 }
1872
1873 fn auto_model(&self) -> bool {
1874 self.host.app.borrow().auto_model
1875 }
1876
1877 fn set_model_selection(&mut self, model: String, provider: Option<CommandProviderId>) {
1878 let mut app = self.host.app.borrow_mut();
1879 let admission = (|| {
1880 let config = self.host.config.ok_or_else(|| {
1881 "The model command has no active provider configuration.".to_string()
1882 })?;
1883 let identity = match provider {
1884 Some(provider) => {
1885 let identity = config.resolve_provider_identity(&provider.0)?;
1886 if identity.key.as_str() != provider.0 {
1887 return Err(
1888 "The model command must name the exact admitted provider route."
1889 .to_string(),
1890 );
1891 }
1892 identity
1893 }
1894 None => app.admitted_provider_identity()?.clone(),
1895 };
1896 config.verify_provider_identity(&identity)?;
1897 Ok(identity)
1898 })();
1899 let identity = match admission {
1900 Ok(identity) => identity,
1901 Err(reason) => {
1902 app.push_status_toast(
1903 reason,
1904 crate::tui::app::StatusToastLevel::Error,
1905 Some(8_000),
1906 );
1907 return;
1908 }
1909 };
1910 app.set_provider_identity_record(identity);
1911 app.set_model_selection(model);
1912 }
1913
1914 fn provider_identity(&self) -> Option<CommandProviderId> {
1915 let app = self.host.app.borrow();
1916 let identity = app.provider_identity_for_persistence();
1917 (!identity.trim().is_empty()).then(|| to_provider_id(identity))
1918 }
1919
1920 fn fallback_chain(&self) -> Vec<CommandProviderId> {
1921 self.host
1922 .app
1923 .borrow()
1924 .fallback_chain_entries()
1925 .into_iter()
1926 .map(|(_, provider, _)| to_provider_id(provider.as_str()))
1927 .collect()
1928 }
1929 }
1930
1931 /// Cost display and accounting operations delegated to App's cost authority.
1932 pub(crate) struct CostAdapter<'a> {
1933 host: SharedCommandHost<'a>,
1934 }
1935
1936 fn command_cost_estimate(amount: f64, currency: CommandCurrency) -> crate::pricing::CostEstimate {
1937 match currency {
1938 CommandCurrency::Usd => crate::pricing::CostEstimate {
1939 usd: amount,
1940 cny: 0.0,
1941 },
1942 CommandCurrency::Cny => crate::pricing::CostEstimate {
1943 usd: 0.0,
1944 cny: amount,
1945 },
1946 }
1947 }
1948
1949 impl CommandCostContext for CostAdapter<'_> {
1950 fn display_currency(&self) -> CommandCurrency {
1951 let app = self.host.app.borrow();
1952 to_command_currency(app.cost_display_currency(app.cost_currency))
1953 }
1954
1955 fn session_cost_for_currency(&self, currency: CommandCurrency) -> f64 {
1956 self.host
1957 .app
1958 .borrow()
1959 .session_cost_for_currency(from_command_currency(currency))
1960 }
1961
1962 fn subagent_cost_for_currency(&self, currency: CommandCurrency) -> f64 {
1963 self.host
1964 .app
1965 .borrow()
1966 .subagent_cost_for_currency(from_command_currency(currency))
1967 }
1968
1969 fn accrue_cost_estimate(&mut self, amount: f64, currency: CommandCurrency) {
1970 self.host
1971 .app
1972 .borrow_mut()
1973 .accrue_session_cost_estimate(command_cost_estimate(amount, currency));
1974 }
1975
1976 fn record_turn_cost(
1977 &mut self,
1978 amount: f64,
1979 currency: CommandCurrency,
1980 route_receipt: Option<String>,
1981 ) {
1982 let mut app = self.host.app.borrow_mut();
1983 app.accrue_session_cost_estimate(command_cost_estimate(amount, currency));
1984 if let Some(receipt) = route_receipt {
1985 app.record_turn_cost_route_receipt(receipt);
1986 }
1987 }
1988 }
1989
1990 /// Operating mode, approval posture, shell access, and policy lock.
1991 pub(crate) struct ModePolicyAdapter<'a> {
1992 host: SharedCommandHost<'a>,
1993 }
1994
1995 impl CommandModePolicyContext for ModePolicyAdapter<'_> {
1996 fn mode(&self) -> CommandMode {
1997 to_command_mode(self.host.app.borrow().mode)
1998 }
1999
2000 fn set_mode(&mut self, mode: CommandMode) {
2001 self.host.app.borrow_mut().set_mode(from_command_mode(mode));
2002 }
2003
2004 fn approval_mode(&self) -> CommandApprovalMode {
2005 to_command_approval(self.host.app.borrow().approval_mode)
2006 }
2007
2008 fn allow_shell(&self) -> bool {
2009 self.host.app.borrow().allow_shell
2010 }
2011
2012 fn set_shell_access(&mut self, allow: bool) {
2013 self.host.app.borrow_mut().set_agent_shell_access(allow);
2014 }
2015
2016 fn policy_locked(&self) -> bool {
2017 self.host.app.borrow().approval_policy_locked()
2018 }
2019 }
2020
2021 /// Read access to the effective system prompt.
2022 pub(crate) struct SystemPromptAdapter<'a> {
2023 host: SharedCommandHost<'a>,
2024 }
2025
2026 impl CommandSystemPromptContext for SystemPromptAdapter<'_> {
2027 fn system_prompt(&self) -> Option<SystemPrompt> {
2028 self.host.app.borrow().system_prompt.clone()
2029 }
2030 }
2031
2032 /// Active skill identity and authoritative skill-cache refresh.
2033 pub(crate) struct SkillsAdapter<'a> {
2034 host: SharedCommandHost<'a>,
2035 }
2036
2037 impl CommandSkillsContext for SkillsAdapter<'_> {
2038 fn active_skill(&self) -> Option<String> {
2039 self.host.app.borrow().active_skill.clone()
2040 }
2041
2042 fn active_skill_provenance(&self) -> Option<String> {
2043 self.host
2044 .app
2045 .borrow()
2046 .active_skill_provenance
2047 .as_ref()
2048 .map(|provenance| provenance.authority().plugin_name.clone())
2049 }
2050
2051 fn refresh_skill_cache(&mut self) {
2052 self.host.app.borrow_mut().refresh_skill_cache();
2053 }
2054 }
2055
2056 /// Workspace path and bounded serialized work-state snapshot.
2057 pub(crate) struct WorkspaceAdapter<'a> {
2058 host: SharedCommandHost<'a>,
2059 }
2060
2061 impl CommandWorkspaceContext for WorkspaceAdapter<'_> {
2062 fn workspace(&self) -> PathBuf {
2063 self.host.app.borrow().workspace.clone()
2064 }
2065
2066 fn work_state_snapshot(&self) -> Result<Option<String>, String> {
2067 self.host.app.borrow().work_state_snapshot().map(|state| {
2068 state.and_then(|state| crate::todo_snapshot::todo_snapshot_body(&state.todos))
2069 })
2070 }
2071
2072 fn operation_digest(&mut self) -> Result<String, String> {
2073 let app = self.host.app.borrow();
2074 let Some(work) = app.runtime_services.work.as_ref() else {
2075 return Ok("No active operations or to-do items.".to_string());
2076 };
2077 match work.capture(app.current_session_id.as_deref()) {
2078 Ok(snapshot) => Ok(crate::work_graph::format_operation_digest(
2079 snapshot.as_ref(),
2080 )),
2081 Err(error) => Err(format!(
2082 "Operation digest is temporarily unavailable: {error}"
2083 )),
2084 }
2085 }
2086 }
2087
2088 /// Stable-key translation adapter (FEAT-018 D3).
2089 ///
2090 /// Maps stable snake_case utility message keys to the current catalog and
2091 /// preserves the existing English fallback for intentionally incomplete locale
2092 /// packs. Unknown keys and invalid replacement contracts fail safely; a raw
2093 /// lookup key is never exposed.
2094 pub(crate) struct PresentationAdapter<'a> {
2095 host: SharedCommandHost<'a>,
2096 }
2097
2098 impl CommandPresentationContext for PresentationAdapter<'_> {
2099 fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
2100 let Some(message_id) = key_to_utility_message_id(key)
2101 .or_else(|| key_to_project_message_id(key))
2102 .or_else(|| key_to_plugin_message_id(key))
2103 .or_else(|| key_to_session_message_id(key))
2104 .or_else(|| diagnostics_messages::resolve(key))
2105 else {
2106 return Err("unknown translation key".to_string());
2107 };
2108 let locale = self.host.app.borrow().ui_locale;
2109 let template = tr(locale, message_id);
2110 apply_named_replacements(&template, replacements)
2111 .ok_or_else(|| "invalid translation replacement contract".to_string())
2112 }
2113 }
2114
2115 /// Resolve session-control and structural-copy runtime keys to the current
2116 /// catalog. Other session commands retain metadata-only localization.
2117 pub(crate) fn key_to_session_message_id(key: &str) -> Option<MessageId> {
2118 Some(match key {
2119 "cmd_structcopy_kind_turn" => MessageId::CmdStructcopyKindTurn,
2120 "cmd_structcopy_kind_tool" => MessageId::CmdStructcopyKindTool,
2121 "cmd_structcopy_kind_plan" => MessageId::CmdStructcopyKindPlan,
2122 "cmd_structcopy_kind_workflow" => MessageId::CmdStructcopyKindWorkflow,
2123 "cmd_structcopy_usage_error" => MessageId::CmdStructcopyUsageError,
2124 "cmd_structcopy_unavailable" => MessageId::CmdStructcopyUnavailable,
2125 "cmd_structcopy_busy" => MessageId::CmdStructcopyBusy,
2126 "cmd_structcopy_prepare_failed" => MessageId::CmdStructcopyPrepareFailed,
2127 "cmd_structcopy_receipt_too_large" => MessageId::CmdStructcopyReceiptTooLarge,
2128 "cmd_structcopy_clipboard_queued" => MessageId::CmdStructcopyClipboardQueued,
2129 "cmd_structcopy_clipboard_accepted" => MessageId::CmdStructcopyClipboardAccepted,
2130 "cmd_structcopy_clipboard_failed" => MessageId::CmdStructcopyClipboardFailed,
2131 "cmd_remote_env_overview" => MessageId::CmdRemoteEnvOverview,
2132 "cmd_remote_env_opening" => MessageId::CmdRemoteEnvOpening,
2133 "cmd_remote_env_unavailable" => MessageId::CmdRemoteEnvUnavailable,
2134 "cmd_remote_env_source_custody_policy" => MessageId::CmdRemoteEnvSourceCustodyPolicy,
2135 "cmd_remote_env_browser_label" => MessageId::CmdRemoteEnvBrowserLabel,
2136 _ => return None,
2137 })
2138 }
2139
2140 /// Resolve a stable plugin message key to the current catalog id (FEAT-020 D5).
2141 ///
2142 /// Every plugin-group catalog message uses a stable snake_case key; the TUI
2143 /// adapter maps it to the current `MessageId` value and preserves the
2144 /// authoritative English fallback. Unknown keys fail safely.
2145 pub(crate) fn key_to_plugin_message_id(key: &str) -> Option<MessageId> {
2146 Some(match key {
2147 "cmd_plugin_action_failed" => MessageId::CmdPluginActionFailed,
2148 "cmd_plugin_bundle_detail" => MessageId::CmdPluginBundleDetail,
2149 "cmd_plugin_owner_report" => MessageId::CmdPluginOwnerReport,
2150 "cmd_plugin_owner_activating" => MessageId::CmdPluginOwnerActivating,
2151 "cmd_plugin_owner_active" => MessageId::CmdPluginOwnerActive,
2152 "cmd_plugin_owner_failed" => MessageId::CmdPluginOwnerFailed,
2153 "cmd_plugin_owner_faulted" => MessageId::CmdPluginOwnerFaulted,
2154 "cmd_plugin_owner_revoked" => MessageId::CmdPluginOwnerRevoked,
2155 "cmd_plugin_owner_inactive" => MessageId::CmdPluginOwnerInactive,
2156 "cmd_plugin_bundle_diagnostics_header" => MessageId::CmdPluginBundleDiagnosticsHeader,
2157 "cmd_plugin_bundle_list_header" => MessageId::CmdPluginBundleListHeader,
2158 "cmd_plugin_bundle_mutation_success" => MessageId::CmdPluginBundleMutationSuccess,
2159 "cmd_plugin_bundle_none_found" => MessageId::CmdPluginBundleNoneFound,
2160 "cmd_plugin_bundle_not_found" => MessageId::CmdPluginBundleNotFound,
2161 "cmd_plugin_bundle_reloaded" => MessageId::CmdPluginBundleReloaded,
2162 "cmd_plugin_bundle_usage" => MessageId::CmdPluginBundleUsage,
2163 "cmd_plugin_detail_description" => MessageId::CmdPluginDetailDescription,
2164 "cmd_plugin_detail_approval" => MessageId::CmdPluginDetailApproval,
2165 "cmd_plugin_detail_path" => MessageId::CmdPluginDetailPath,
2166 "cmd_plugin_detail_schema" => MessageId::CmdPluginDetailSchema,
2167 "cmd_plugin_legacy_list_header" => MessageId::CmdPluginLegacyListHeader,
2168 "cmd_plugin_none_found" => MessageId::CmdPluginNoneFound,
2169 "cmd_plugin_not_found" => MessageId::CmdPluginNotFound,
2170 "plugin_kimi_applicable" => MessageId::PluginKimiApplicable,
2171 "plugin_kimi_candidate_changed" => MessageId::PluginKimiCandidateChanged,
2172 "plugin_kimi_candidate_details" => MessageId::PluginKimiCandidateDetails,
2173 "plugin_kimi_candidate_missing" => MessageId::PluginKimiCandidateMissing,
2174 "plugin_kimi_candidate_summary" => MessageId::PluginKimiCandidateSummary,
2175 "plugin_kimi_directory_name_mismatch" => MessageId::PluginKimiDirectoryNameMismatch,
2176 "plugin_kimi_entry_canonicalize_failed" => MessageId::PluginKimiEntryCanonicalizeFailed,
2177 "plugin_kimi_entry_inspect_failed" => MessageId::PluginKimiEntryInspectFailed,
2178 "plugin_kimi_entry_limit" => MessageId::PluginKimiEntryLimit,
2179 "plugin_kimi_entry_links_refused" => MessageId::PluginKimiEntryLinksRefused,
2180 "plugin_kimi_entry_outside_root" => MessageId::PluginKimiEntryOutsideRoot,
2181 "plugin_kimi_entry_read_failed" => MessageId::PluginKimiEntryReadFailed,
2182 "plugin_kimi_hash_unavailable" => MessageId::PluginKimiHashUnavailable,
2183 "plugin_kimi_home_missing" => MessageId::PluginKimiHomeMissing,
2184 "plugin_kimi_inspection_footer" => MessageId::PluginKimiInspectionFooter,
2185 "plugin_kimi_license_unspecified" => MessageId::PluginKimiLicenseUnspecified,
2186 "plugin_kimi_managed_root_heading" => MessageId::PluginKimiManagedRootHeading,
2187 "plugin_kimi_manifest_invalid" => MessageId::PluginKimiManifestInvalid,
2188 "plugin_kimi_manifest_must_be_file" => MessageId::PluginKimiManifestMustBeFile,
2189 "plugin_kimi_manifest_unreadable" => MessageId::PluginKimiManifestUnreadable,
2190 "plugin_kimi_marketplace_gzip_tarball" => MessageId::PluginKimiMarketplaceGzipTarball,
2191 "kimi_zip_unsupported" => MessageId::PluginKimiMarketplaceZipUnsupported,
2192 "kimi_remote_archive_unsupported" => MessageId::PluginKimiMarketplaceRemoteUnsupported,
2193 "kimi_gzip_tarball_url" => MessageId::PluginKimiMarketplaceGzipTarball,
2194 "plugin_kimi_marketplace_remote_unsupported" => {
2195 MessageId::PluginKimiMarketplaceRemoteUnsupported
2196 }
2197 "plugin_kimi_marketplace_zip_unsupported" => MessageId::PluginKimiMarketplaceZipUnsupported,
2198 "plugin_kimi_mismatch_removed" => MessageId::PluginKimiMismatchRemoved,
2199 "plugin_kimi_mismatch_rollback_failed" => MessageId::PluginKimiMismatchRollbackFailed,
2200 "plugin_kimi_none_found" => MessageId::PluginKimiNoneFound,
2201 "plugin_kimi_not_applicable" => MessageId::PluginKimiNotApplicable,
2202 "plugin_kimi_rejected_heading" => MessageId::PluginKimiRejectedHeading,
2203 "plugin_kimi_rollback_destination_missing" => {
2204 MessageId::PluginKimiRollbackDestinationMissing
2205 }
2206 "plugin_kimi_root_canonicalize_failed" => MessageId::PluginKimiRootCanonicalizeFailed,
2207 "plugin_kimi_root_inspect_failed" => MessageId::PluginKimiRootInspectFailed,
2208 "plugin_kimi_root_list_failed" => MessageId::PluginKimiRootListFailed,
2209 "plugin_kimi_root_must_be_directory" => MessageId::PluginKimiRootMustBeDirectory,
2210 "plugin_kimi_usage" => MessageId::PluginKimiUsage,
2211 "plugin_kimi_user_plugin_directory" => MessageId::PluginKimiUserPluginDirectory,
2212 _ => return None,
2213 })
2214 }
2215
2216 /// Resolve a stable utility message key to the current catalog id.
2217 fn key_to_utility_message_id(key: &str) -> Option<MessageId> {
2218 Some(match key {
2219 "automation_usage" => MessageId::AutomationUsage,
2220 "mcp_recommended_unknown_id" => MessageId::McpRecommendedUnknownId,
2221 "mcp_recommendations_heading" => MessageId::McpRecommendationsHeading,
2222 "mcp_recommendations_safety" => MessageId::McpRecommendationsSafety,
2223 "mcp_recommendation_github" => MessageId::McpRecommendationGithub,
2224 "mcp_recommendation_chrome" => MessageId::McpRecommendationChrome,
2225 "mcp_recommendation_playwright" => MessageId::McpRecommendationPlaywright,
2226 "mcp_recommendation_container_use" => MessageId::McpRecommendationContainerUse,
2227 _ => return None,
2228 })
2229 }
2230
2231 /// Resolve a stable project message key to the current catalog id (FEAT-021 D5).
2232 ///
2233 /// Only `/goal` uses runtime translations (`GoalControlAccepted`,
2234 /// `GoalStatusIdleHint`); all four description keys resolve through the
2235 /// metadata bridge (`key_to_message_id`) and do not require the presentation
2236 /// facet.
2237 pub(crate) fn key_to_project_message_id(key: &str) -> Option<MessageId> {
2238 Some(match key {
2239 "goal_control_accepted" => MessageId::GoalControlAccepted,
2240 "goal_status_idle_hint" => MessageId::GoalStatusIdleHint,
2241 _ => return None,
2242 })
2243 }
2244
2245 /// Replace `{name}` placeholders with the supplied named values.
2246 ///
2247 /// Returns `None` when the replacement set does not exactly cover every
2248 /// placeholder in the template (missing, extra, or duplicate names).
2249 fn apply_named_replacements(template: &str, replacements: &[(&str, &str)]) -> Option<String> {
2250 let supplied: std::collections::BTreeMap<&str, &str> = replacements.iter().copied().collect();
2251 if supplied.len() != replacements.len() {
2252 return None; // duplicate replacement name
2253 }
2254 let mut placeholders = std::collections::BTreeSet::new();
2255 let mut cursor = 0usize;
2256 while let Some(start) = template[cursor..].find('{') {
2257 let start = cursor + start;
2258 let Some(end) = template[start + 1..].find('}') else {
2259 break;
2260 };
2261 let end = start + 1 + end;
2262 let name = &template[start + 1..end];
2263 if !name.is_empty() {
2264 placeholders.insert(name);
2265 }
2266 cursor = end + 1;
2267 }
2268 if placeholders != supplied.keys().copied().collect() {
2269 return None;
2270 }
2271 let mut out = template.to_string();
2272 for (name, value) in replacements {
2273 out = out.replace(&format!("{{{name}}}"), value);
2274 }
2275 Some(out)
2276 }
2277
2278 /// Atomic composer/media adapter (FEAT-018 D4).
2279 ///
2280 /// Performs media validation and composer insertion as one host operation by
2281 /// delegating to the authoritative image-validation and attachment behavior.
2282 pub(crate) struct MediaAdapter<'a> {
2283 host: SharedCommandHost<'a>,
2284 }
2285
2286 impl CommandMediaContext for MediaAdapter<'_> {
2287 fn attach_media(&mut self, resolved_path: &Path) -> Result<MediaAttachmentReceipt, String> {
2288 let Ok(path) = resolved_path.canonicalize() else {
2289 return Err(format!("Attachment not found: {}", resolved_path.display()));
2290 };
2291 if !path.is_file() {
2292 return Err(format!("Attachment is not a file: {}", path.display()));
2293 }
2294 let Some(kind) = media_kind(&path) else {
2295 return Err(
2296 "Unsupported attachment type. /attach is for image/video paths; use @path for \
2297 text files or directories."
2298 .to_string(),
2299 );
2300 };
2301 if kind == "image"
2302 && let Err(error) = crate::image_attach::attach_image_from_path(&path)
2303 {
2304 return Err(error.to_string());
2305 }
2306 let mut app = self.host.app.borrow_mut();
2307 app.insert_media_attachment(kind, &path, None);
2308 Ok(MediaAttachmentReceipt {
2309 kind: kind.to_string(),
2310 path,
2311 })
2312 }
2313 }
2314
2315 /// Classify a media path by extension (image or video).
2316 fn media_kind(path: &Path) -> Option<&'static str> {
2317 let ext = path.extension()?.to_str()?.to_ascii_lowercase();
2318 match ext.as_str() {
2319 "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "ppm" => Some("image"),
2320 "mp4" | "mov" | "m4v" | "webm" | "avi" | "mkv" => Some("video"),
2321 _ => None,
2322 }
2323 }
2324
2325 /// Memory host-data adapter (FEAT-019 D1).
2326 ///
2327 /// Derives the authoritative native store exactly like the legacy `/memory`
2328 /// handler (`from_global_path` on the app memory path, falling back to a
2329 /// `memory` root beside it) and converts every host value/error to a portable
2330 /// contract value before it crosses the boundary. All methods are `&self` and
2331 /// borrow `App` only for the duration of one call; workspace state is passed
2332 /// per call and never retained by the facet (D8).
2333 pub(crate) struct MemoryAdapter<'a> {
2334 host: SharedCommandHost<'a>,
2335 }
2336
2337 /// Derive the authoritative native-memory store from the resolved user-memory
2338 /// file path, mirroring the pre-migration `/memory` handler exactly.
2339 fn native_store_from_memory_path(memory_path: &Path) -> crate::native_memory::NativeMemoryStore {
2340 crate::native_memory::NativeMemoryStore::from_memory_anchor(memory_path)
2341 }
2342
2343 /// Convert a TUI-owned native hit into the portable contract hit. Only the
2344 /// semantic fields the handler consumes for rendering cross the boundary (D2).
2345 fn portable_hit(hit: crate::native_memory::MemoryHit) -> MemoryHit {
2346 MemoryHit {
2347 source: hit.source,
2348 line_start: hit.line_start,
2349 line_end: hit.line_end,
2350 text: hit.text,
2351 }
2352 }
2353
2354 impl CommandMemoryContext for MemoryAdapter<'_> {
2355 fn memory_path(&self) -> PathBuf {
2356 self.host.app.borrow().memory_path.clone()
2357 }
2358
2359 fn memory_enabled(&self) -> bool {
2360 self.host.app.borrow().use_memory
2361 }
2362
2363 fn status(&self) -> Result<MemoryStatus, String> {
2364 let app = self.host.app.borrow();
2365 let store = native_store_from_memory_path(&app.memory_path);
2366 Ok(MemoryStatus {
2367 root: store.root().to_path_buf(),
2368 source: store.global_path(),
2369 index: store.index_path(),
2370 })
2371 }
2372
2373 fn path(&self) -> Result<PathBuf, String> {
2374 let app = self.host.app.borrow();
2375 Ok(native_store_from_memory_path(&app.memory_path)
2376 .root()
2377 .to_path_buf())
2378 }
2379
2380 fn workspace_id(&self, workspace: &Path) -> Result<String, String> {
2381 match crate::native_memory::NativeMemoryStore::workspace_id(workspace) {
2382 Ok(Some(id)) => Ok(id),
2383 Ok(None) => {
2384 Err("workspace memory requires a git repository with an origin".to_string())
2385 }
2386 Err(err) => Err(format!("failed to resolve workspace identity: {err}")),
2387 }
2388 }
2389
2390 fn search(
2391 &self,
2392 workspace: &Path,
2393 query: &str,
2394 limit: usize,
2395 ) -> Result<Vec<MemoryHit>, String> {
2396 let app = self.host.app.borrow();
2397 let store = native_store_from_memory_path(&app.memory_path);
2398 match store.search_for_workspace(workspace, query, limit) {
2399 Ok(hits) => Ok(hits.into_iter().map(portable_hit).collect()),
2400 Err(err) => Err(err.to_string()),
2401 }
2402 }
2403
2404 fn remember(
2405 &self,
2406 target: MemoryRememberTarget,
2407 note: &str,
2408 ) -> Result<MemoryRemembered, String> {
2409 let app = self.host.app.borrow();
2410 let store = native_store_from_memory_path(&app.memory_path);
2411 let (scope, workspace_id) = match target {
2412 MemoryRememberTarget::Global => (crate::native_memory::MemoryScope::Global, None),
2413 MemoryRememberTarget::Workspace { workspace_id } => (
2414 crate::native_memory::MemoryScope::Workspace,
2415 Some(workspace_id),
2416 ),
2417 };
2418 match store.remember_reviewed(scope, workspace_id.as_deref(), note) {
2419 Ok(hit) => Ok(MemoryRemembered {
2420 source: hit.source,
2421 line_start: hit.line_start,
2422 }),
2423 Err(err) => Err(err.to_string()),
2424 }
2425 }
2426
2427 fn import(&self) -> Result<MemoryImportOutcome, String> {
2428 let app = self.host.app.borrow();
2429 let store = native_store_from_memory_path(&app.memory_path);
2430 let legacy_path = store
2431 .root()
2432 .parent()
2433 .map(|parent| parent.join("memory.md"))
2434 .unwrap_or_else(|| app.memory_path.clone());
2435 match store.import_legacy(&legacy_path) {
2436 Ok(true) => Ok(MemoryImportOutcome::Imported {
2437 destination: store.global_path(),
2438 }),
2439 Ok(false) => Ok(MemoryImportOutcome::Skipped),
2440 Err(err) => Err(err.to_string()),
2441 }
2442 }
2443
2444 fn get(&self, workspace: &Path, id: i64) -> Result<MemoryGetOutcome, String> {
2445 let app = self.host.app.borrow();
2446 let store = native_store_from_memory_path(&app.memory_path);
2447 match store.get_for_workspace(workspace, id) {
2448 Ok(Some(hit)) => Ok(MemoryGetOutcome::Found(portable_hit(hit))),
2449 Ok(None) => Ok(MemoryGetOutcome::NotFound),
2450 Err(err) => Err(err.to_string()),
2451 }
2452 }
2453
2454 fn export(&self) -> Result<MemoryExport, String> {
2455 let app = self.host.app.borrow();
2456 let store = native_store_from_memory_path(&app.memory_path);
2457 match store.export() {
2458 Ok(content) => Ok(MemoryExport { content }),
2459 Err(err) => Err(err.to_string()),
2460 }
2461 }
2462
2463 fn reindex(&self) -> Result<MemoryReindex, String> {
2464 let app = self.host.app.borrow();
2465 let store = native_store_from_memory_path(&app.memory_path);
2466 match store.reindex() {
2467 Ok(entry_count) => Ok(MemoryReindex { entry_count }),
2468 Err(err) => Err(err.to_string()),
2469 }
2470 }
2471
2472 fn delete(&self, scope: MemoryDeleteScope) -> Result<MemoryDelete, String> {
2473 let app = self.host.app.borrow();
2474 let store = native_store_from_memory_path(&app.memory_path);
2475 let result = match scope {
2476 MemoryDeleteScope::All => store.delete_all(None, None),
2477 MemoryDeleteScope::Global => {
2478 store.delete_all(Some(crate::native_memory::MemoryScope::Global), None)
2479 }
2480 };
2481 result.map(|()| MemoryDelete).map_err(|err| err.to_string())
2482 }
2483
2484 fn delete_workspace(&self, workspace: &Path) -> Result<MemoryDelete, String> {
2485 let app = self.host.app.borrow();
2486 let store = native_store_from_memory_path(&app.memory_path);
2487 match crate::native_memory::NativeMemoryStore::workspace_id(workspace) {
2488 Ok(Some(id)) => store
2489 .delete_all(
2490 Some(crate::native_memory::MemoryScope::Workspace),
2491 Some(&id),
2492 )
2493 .map(|()| MemoryDelete)
2494 .map_err(|err| err.to_string()),
2495 Ok(None) => {
2496 Err("workspace memory requires a git repository with an origin".to_string())
2497 }
2498 Err(err) => Err(format!("failed to resolve workspace identity: {err}")),
2499 }
2500 }
2501 }
2502
2503 // ---------------------------------------------------------------------------
2504 // Project host adapter (FEAT-021 D1/D3)
2505 // ---------------------------------------------------------------------------
2506
2507 /// Concrete TUI host mapping for the project command group (FEAT-021 D1/D3).
2508 ///
2509 /// The only place that touches `App` goal/share/LSP state, `config::config`
2510 /// (cross-group LSP bridge), and the session manager. Every method borrows
2511 /// `App` for one call and converts host values to portable contract values
2512 /// before returning; the `/init` workspace path flows through the existing
2513 /// `WORKSPACE` facet (D2), so no init-specific method exists here.
2514 pub(crate) struct ProjectAdapter<'a> {
2515 host: SharedCommandHost<'a>,
2516 }
2517
2518 /// Map the TUI-owned goal status onto the portable project status.
2519 fn portable_goal_status(status: crate::tools::goal::GoalStatus) -> ProjectGoalStatus {
2520 match status {
2521 crate::tools::goal::GoalStatus::Active => ProjectGoalStatus::Active,
2522 crate::tools::goal::GoalStatus::Paused => ProjectGoalStatus::Paused,
2523 crate::tools::goal::GoalStatus::Complete => ProjectGoalStatus::Complete,
2524 crate::tools::goal::GoalStatus::Blocked => ProjectGoalStatus::Blocked,
2525 }
2526 }
2527
2528 /// Map the durable session goal status onto the portable project status.
2529 fn portable_session_goal_status(
2530 status: crate::session_manager::SessionGoalStatus,
2531 ) -> ProjectGoalStatus {
2532 match status {
2533 crate::session_manager::SessionGoalStatus::Active => ProjectGoalStatus::Active,
2534 crate::session_manager::SessionGoalStatus::Paused => ProjectGoalStatus::Paused,
2535 crate::session_manager::SessionGoalStatus::Complete => ProjectGoalStatus::Complete,
2536 crate::session_manager::SessionGoalStatus::Blocked => ProjectGoalStatus::Blocked,
2537 }
2538 }
2539
2540 impl CommandProjectContext for ProjectAdapter<'_> {
2541 fn lsp_enabled(&self) -> bool {
2542 self.host.app.borrow().lsp_enabled
2543 }
2544
2545 fn lsp_set(&mut self, enabled: bool) -> Result<(), String> {
2546 // Cross-group LSP behavior stays host-side (D3): the adapter owns the
2547 // `config::config::lsp_command` invocation. The portable handler
2548 // composes the byte-identical user-facing message from the typed
2549 // state, so the formatted result is intentionally not forwarded.
2550 let mut app = self.host.app.borrow_mut();
2551 let arg = if enabled { "on" } else { "off" };
2552 let _ = crate::commands::groups::config::config::lsp_command(&mut app, Some(arg));
2553 Ok(())
2554 }
2555
2556 fn goal_state(&self) -> ProjectGoalState {
2557 let app = self.host.app.borrow();
2558 let pending_controls = !app.pending_goal_controls.is_empty();
2559 let last_known = app.last_known_goal_state.as_ref();
2560 ProjectGoalState {
2561 objective: app.goal.objective.clone(),
2562 status: portable_goal_status(app.goal.status),
2563 pause_reason: app
2564 .goal
2565 .pause_reason
2566 .map(|reason| reason.label().to_string()),
2567 started_at_elapsed_seconds: app.goal.started_at.map(|t| t.elapsed().as_secs()),
2568 time_used_seconds: app.goal.time_used_seconds,
2569 token_budget: app.goal.token_budget,
2570 tokens_used: app.goal.tokens_used,
2571 session_total_tokens: app.session.total_conversation_tokens,
2572 continuation_count: app.goal.continuation_count,
2573 pending_controls,
2574 last_known_objective: last_known.map(|goal| goal.objective.clone()),
2575 last_known_status: last_known.map(|goal| portable_session_goal_status(goal.status)),
2576 conversation_present: !app.api_messages.is_empty(),
2577 is_loading: app.is_loading,
2578 goal_continuation_waiting: app.goal_continuation_waiting,
2579 }
2580 }
2581 }
2582
2583 // ---------------------------------------------------------------------------
2584 // Skill group adapter (FEAT-022 D1/D3)
2585 // ---------------------------------------------------------------------------
2586
2587 /// The single new skills-specific host adapter.
2588 ///
2589 /// Owns every concrete skills touch: `App` skill state, `crate::skills`
2590 /// discovery/mutation/install/recommend services, `crate::plugins` authority
2591 /// verification, `SnapshotRepo`, config/network policy, and the async bridge
2592 /// (`tokio::task::block_in_place`). Portable handlers never name these
2593 /// subsystems (D3); every method returns portable contract values or safe
2594 /// error text (D1).
2595 pub(crate) struct SkillGroupAdapter<'a> {
2596 host: SharedCommandHost<'a>,
2597 }
2598
2599 /// Bridge a sync slash-command handler back into the async ecosystem.
2600 ///
2601 /// We are on the TUI's thread, which is part of the multi-threaded runtime;
2602 /// `block_in_place` + `Handle::current().block_on` bridges sync handlers back
2603 /// into the async ecosystem. Mirrors `groups/skills/skills.rs::run_async`;
2604 /// the legacy copy is removed in Phase 4 when the handlers are ported.
2605 fn run_async<F, T>(future: F) -> T
2606 where
2607 F: std::future::Future<Output = T>,
2608 {
2609 tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
2610 }
2611
2612 /// Read the active config knobs for the installer (network policy, max size,
2613 /// registry URL). `Config::load` is cheap and `App` does not carry a `Config`;
2614 /// on parse failure we fall back to defaults so the user still gets a
2615 /// network-gated install rather than a silent crash. Mirrors
2616 /// `groups/skills/skills.rs::installer_settings`.
2617 fn installer_settings() -> (NetworkPolicy, u64, String) {
2618 let cfg = crate::config::Config::load(None, None).unwrap_or_default();
2619 let network = cfg
2620 .network
2621 .clone()
2622 .map(|policy| policy.into_runtime())
2623 .unwrap_or_default();
2624 let skills_cfg = cfg.skills.as_ref();
2625 let max_size = skills_cfg
2626 .and_then(|s| s.max_install_size_bytes)
2627 .unwrap_or(crate::skills::install::DEFAULT_MAX_SIZE_BYTES);
2628 let registry_url = skills_cfg
2629 .and_then(|s| s.registry_url.clone())
2630 .unwrap_or_else(|| crate::skills::install::DEFAULT_REGISTRY_URL.to_string());
2631 (network, max_size, registry_url)
2632 }
2633
2634 /// Resolve the cache destination before loading settings or entering the network bridge.
2635 fn sync_registry_to_cache(cache_dir: Option<PathBuf>) -> Result<SkillSyncOutcome, String> {
2636 use crate::skills::install::{SkillSyncOutcome as TuiSyncOutcome, SyncResult};
2637 let cache_dir =
2638 cache_dir.ok_or_else(|| "global skill mutations require a home directory".to_string())?;
2639 let (network, max_size, registry_url) = installer_settings();
2640 let result = run_async(async move {
2641 crate::skills::install::sync_registry(&network, &registry_url, &cache_dir, max_size).await
2642 });
2643 match result {
2644 Ok(SyncResult::RegistryDenied(host)) => Ok(SkillSyncOutcome::RegistryDenied(host)),
2645 Ok(SyncResult::RegistryNeedsApproval(host)) => {
2646 Ok(SkillSyncOutcome::RegistryNeedsApproval(host))
2647 }
2648 Ok(SyncResult::Done { outcomes }) => {
2649 let total = outcomes.len();
2650 let mut downloaded = 0usize;
2651 let mut fresh = 0usize;
2652 let mut failed = 0usize;
2653 let entries = outcomes
2654 .into_iter()
2655 .map(|outcome| match outcome {
2656 TuiSyncOutcome::Downloaded { name, path } => {
2657 downloaded += 1;
2658 SkillSyncEntry::Downloaded {
2659 name,
2660 path: path.display().to_string(),
2661 }
2662 }
2663 TuiSyncOutcome::Fresh { name } => {
2664 fresh += 1;
2665 SkillSyncEntry::Fresh { name }
2666 }
2667 TuiSyncOutcome::Failed { name, reason } => {
2668 failed += 1;
2669 SkillSyncEntry::Failed { name, reason }
2670 }
2671 TuiSyncOutcome::Denied { name, host } => {
2672 failed += 1;
2673 SkillSyncEntry::Denied { name, host }
2674 }
2675 TuiSyncOutcome::NeedsApproval { name, host } => {
2676 failed += 1;
2677 SkillSyncEntry::NeedsApproval { name, host }
2678 }
2679 })
2680 .collect();
2681 Ok(SkillSyncOutcome::Done {
2682 total,
2683 downloaded,
2684 fresh,
2685 failed,
2686 entries,
2687 })
2688 }
2689 Err(err) => Err(format_registry_error("Sync failed", &err)),
2690 }
2691 }
2692
2693 /// Inspect an anyhow chain and surface a one-line hint pointing at the most
2694 /// common cause of a registry fetch failure (DNS, refused, TLS, HTTP status,
2695 /// timeout). Mirrors `groups/skills/skills.rs::registry_fetch_error_hint`.
2696 fn registry_fetch_error_hint(err: &anyhow::Error) -> Option<&'static str> {
2697 let msg = format!("{err:#}").to_lowercase();
2698 if msg.contains("dns")
2699 || msg.contains("name resolution")
2700 || msg.contains("getaddrinfo")
2701 || msg.contains("nodename nor servname")
2702 {
2703 Some(
2704 "Hint: DNS lookup failed. Check internet/DNS connectivity, or override the registry URL in [skills] of ~/.codewhale/config.toml.",
2705 )
2706 } else if msg.contains("connection refused")
2707 || msg.contains("connection reset")
2708 || msg.contains("connection aborted")
2709 {
2710 Some(
2711 "Hint: connection refused/reset. The registry host may be unreachable from this network (corporate proxy, firewall, offline).",
2712 )
2713 } else if msg.contains("tls")
2714 || msg.contains("certificate")
2715 || msg.contains("ssl")
2716 || msg.contains("handshake")
2717 {
2718 Some(
2719 "Hint: TLS handshake failed. The system trust store may be missing the registry's CA, or a TLS-intercepting proxy is rewriting the certificate.",
2720 )
2721 } else if msg.contains(" 404") || msg.contains("not found") {
2722 Some(
2723 "Hint: registry URL returned 404. Verify the registry URL in [skills] of ~/.codewhale/config.toml.",
2724 )
2725 } else if msg.contains(" 401") || msg.contains(" 403") || msg.contains("forbidden") {
2726 Some(
2727 "Hint: registry returned an auth error. The registry may require credentials or have been moved.",
2728 )
2729 } else if msg.contains(" 429") || msg.contains("rate limit") || msg.contains("too many") {
2730 Some("Hint: rate-limited by the registry. Try again in a moment.")
2731 } else if msg.contains("timed out") || msg.contains("timeout") {
2732 Some("Hint: request timed out. Network may be slow or the registry host may be down.")
2733 } else {
2734 None
2735 }
2736 }
2737
2738 /// Append the actionable hint to a registry fetch error. Mirrors
2739 /// `groups/skills/skills.rs::format_registry_error`.
2740 fn format_registry_error(prefix: &str, err: &anyhow::Error) -> String {
2741 let mut out = format!("{prefix}: {err:#}");
2742 if let Some(hint) = registry_fetch_error_hint(err) {
2743 out.push_str("\n\n");
2744 out.push_str(hint);
2745 }
2746 out
2747 }
2748
2749 /// Discover the enabled visible skills for the current App state.
2750 fn discover_visible(app: &App) -> crate::skills::SkillRegistry {
2751 crate::skills::discover_for_workspace_and_dir_with_mode_and_plugins(
2752 &app.workspace,
2753 &app.skills_dir,
2754 app.skills_discovery_mode,
2755 Some(app.extension_plugin_view().as_ref()),
2756 )
2757 .into_enabled()
2758 }
2759
2760 /// Map a TUI skill to its portable projection entry.
2761 fn portable_skill_entry(skill: &crate::skills::Skill) -> SkillEntry {
2762 let source = match &skill.source {
2763 crate::skills::SkillSource::Native => SkillSourceKind::Native,
2764 crate::skills::SkillSource::Plugin {
2765 plugin_id,
2766 plugin_name,
2767 ..
2768 } => SkillSourceKind::Plugin {
2769 plugin_name: plugin_name.clone(),
2770 plugin_id: plugin_id.clone(),
2771 },
2772 };
2773 let path = match &skill.source {
2774 crate::skills::SkillSource::Native => Some(skill.path.display().to_string()),
2775 crate::skills::SkillSource::Plugin { .. } => None,
2776 };
2777 let bundled_tier = crate::skills::bundled_skill_tier(&skill.name).map(|tier| match tier {
2778 crate::skills::BundledSkillTier::CoreAgentic => SkillBundledTier::CoreAgentic,
2779 crate::skills::BundledSkillTier::FormatTooling => SkillBundledTier::FormatTooling,
2780 });
2781 SkillEntry {
2782 name: skill.name.clone(),
2783 description: skill.description.clone(),
2784 source,
2785 path,
2786 bundled_tier,
2787 }
2788 }
2789
2790 /// Map a TUI mutation receipt to its portable receipt.
2791 fn portable_mutation_receipt(
2792 receipt: &crate::skills::mutation::SkillMutationReceipt,
2793 ) -> SkillMutationReceipt {
2794 use crate::skills::mutation::SkillMutationOutcome as TuiOutcome;
2795 let outcome = match &receipt.outcome {
2796 TuiOutcome::Installed => SkillMutationOutcome::Installed,
2797 TuiOutcome::Updated => SkillMutationOutcome::Updated,
2798 TuiOutcome::NoChange => SkillMutationOutcome::NoChange,
2799 TuiOutcome::Removed => SkillMutationOutcome::Removed,
2800 TuiOutcome::Trusted => SkillMutationOutcome::Trusted,
2801 TuiOutcome::Imported => SkillMutationOutcome::Imported,
2802 TuiOutcome::AlreadyPresent => SkillMutationOutcome::AlreadyPresent,
2803 TuiOutcome::NeedsApproval(host) => SkillMutationOutcome::NeedsApproval(host.clone()),
2804 TuiOutcome::NetworkDenied(host) => SkillMutationOutcome::NetworkDenied(host.clone()),
2805 };
2806 SkillMutationReceipt {
2807 name: receipt.name.clone(),
2808 safe_target_path: receipt.safe_target_path.clone(),
2809 outcome,
2810 }
2811 }
2812
2813 /// Map a portable target scope to the TUI scope.
2814 fn portable_scope(
2815 scope: Option<SkillTargetScope>,
2816 ) -> Option<crate::skills::mutation::SkillTargetScope> {
2817 use crate::skills::mutation::SkillTargetScope as TuiScope;
2818 scope.map(|s| match s {
2819 SkillTargetScope::Project => TuiScope::Project,
2820 SkillTargetScope::Global => TuiScope::Global,
2821 })
2822 }
2823
2824 /// Map a curated registry document to portable entries.
2825 fn portable_registry_entries(
2826 doc: &crate::skills::install::RegistryDocument,
2827 ) -> Vec<RemoteSkillEntry> {
2828 doc.skills
2829 .iter()
2830 .map(|(name, entry)| RemoteSkillEntry {
2831 name: name.clone(),
2832 description: entry.description.clone(),
2833 source: entry.source.clone(),
2834 })
2835 .collect()
2836 }
2837
2838 /// Message shown when a network-policy host requires approval. Moved
2839 /// verbatim from `groups/skills/skills.rs`; the legacy copy is removed in
2840 /// Phase 4. Rendered by the portable handler from the typed outcome.
2841 fn needs_approval_message(host: &str) -> String {
2842 format!(
2843 "Network policy requires approval for {host}.\n\
2844 Add it to your allow list with `/network allow {host}` (or set [network].default = \"allow\" in ~/.codewhale/config.toml), then retry."
2845 )
2846 }
2847
2848 /// Message shown when a network-policy host is denied. Moved verbatim from
2849 /// `groups/skills/skills.rs`; the legacy copy is removed in Phase 4.
2850 fn network_denied_message(host: &str) -> String {
2851 format!(
2852 "Network policy denied access to {host}.\n\
2853 Remove the deny entry from ~/.codewhale/config.toml under [network] or contact your administrator."
2854 )
2855 }
2856
2857 impl CommandSkillGroupContext for SkillGroupAdapter<'_> {
2858 fn skill_registry_projection(&self) -> SkillRegistryProjection {
2859 let app = self.host.app.borrow();
2860 let mode = app.skills_discovery_mode;
2861 let dirs = crate::skills::skill_directories_for_workspace_and_dir(
2862 &app.workspace,
2863 &app.skills_dir,
2864 mode,
2865 );
2866 let registry = discover_visible(&app);
2867 let mode_label = match mode {
2868 crate::skills::SkillDiscoveryMode::Compatible => "compatible",
2869 crate::skills::SkillDiscoveryMode::CompatibleWithFlatWorkspace => {
2870 "compatible (flat workspace enabled)"
2871 }
2872 crate::skills::SkillDiscoveryMode::CodeWhaleOnly => "codewhale-only",
2873 };
2874 SkillRegistryProjection {
2875 workspace: app.workspace.display().to_string(),
2876 skills_dir: app.skills_dir.display().to_string(),
2877 mode_label: mode_label.to_string(),
2878 dirs: dirs.iter().map(|dir| dir.display().to_string()).collect(),
2879 entries: registry.list().iter().map(portable_skill_entry).collect(),
2880 warnings: registry.warnings().to_vec(),
2881 total: registry.len(),
2882 }
2883 }
2884
2885 fn activate_skill(
2886 &mut self,
2887 name: &str,
2888 ) -> Result<SkillActivationOutcome, SkillActivationError> {
2889 let registry = {
2890 let app = self.host.app.borrow();
2891 discover_visible(&app)
2892 };
2893 if let Some(skill) = registry.get(name) {
2894 if !skill.invocation.user_invocable() {
2895 return Err(SkillActivationError::InvocationRejected {
2896 name: skill.name.clone(),
2897 reason: "frontmatter does not allow user invocation".into(),
2898 });
2899 }
2900 let plugin_provenance = skill.source.provenance();
2901 if let Some(provenance) = &plugin_provenance
2902 && let Err(reason) = provenance.verify_for(
2903 &self.host.app.borrow().workspace,
2904 Some(self.host.app.borrow().extension_plugin_view().as_ref()),
2905 )
2906 {
2907 return Err(SkillActivationError::PluginRejected {
2908 name: skill.name.clone(),
2909 reason,
2910 });
2911 }
2912 let skill = skill.clone();
2913 let instruction = format!(
2914 "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
2915 skill.name, skill.body
2916 );
2917 let mut app = self.host.app.borrow_mut();
2918 app.add_message(HistoryCell::System {
2919 content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
2920 });
2921 app.active_skill = Some(instruction);
2922 app.active_skill_provenance = plugin_provenance;
2923 Ok(SkillActivationOutcome {
2924 name: skill.name,
2925 description: skill.description,
2926 })
2927 } else {
2928 let available: Vec<String> = registry.list().iter().map(|s| s.name.clone()).collect();
2929 Err(SkillActivationError::NotFound {
2930 requested: name.to_string(),
2931 available,
2932 warnings: registry.warnings().to_vec(),
2933 })
2934 }
2935 }
2936
2937 fn install_skill(
2938 &mut self,
2939 scope: Option<SkillTargetScope>,
2940 spec: &str,
2941 ) -> Result<SkillMutationReceipt, String> {
2942 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
2943 let source = match crate::skills::install::InstallSource::parse(spec) {
2944 Ok(source) => source,
2945 Err(err) => return Err(format!("Invalid install source: {err}")),
2946 };
2947 let target =
2948 portable_scope(scope).unwrap_or(crate::skills::mutation::SkillTargetScope::Global);
2949 let workspace = self.host.app.borrow().workspace.clone();
2950 let home = crate::config::effective_home_dir();
2951 let (network, max_size, registry_url) = installer_settings();
2952 let outcome = run_async(async move {
2953 let ctx = MutationContext {
2954 workspace: &workspace,
2955 home: home.as_deref(),
2956 configured_skills_dir: None,
2957 network: &network,
2958 max_size,
2959 registry_url: &registry_url,
2960 };
2961 crate::skills::mutation::execute(
2962 SkillMutationRequest::InstallRemote { source, target },
2963 &ctx,
2964 )
2965 .await
2966 });
2967 match outcome {
2968 Ok(receipt) => Ok(portable_mutation_receipt(&receipt)),
2969 Err(err) => Err(format!("Install failed: {err:#}")),
2970 }
2971 }
2972
2973 fn update_skill(
2974 &mut self,
2975 scope: Option<SkillTargetScope>,
2976 name: &str,
2977 ) -> Result<SkillMutationReceipt, String> {
2978 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
2979 let workspace = self.host.app.borrow().workspace.clone();
2980 let home = crate::config::effective_home_dir();
2981 let (network, max_size, registry_url) = installer_settings();
2982 let owned_name = name.to_string();
2983 let scope = portable_scope(scope);
2984 let outcome = run_async(async move {
2985 let ctx = MutationContext {
2986 workspace: &workspace,
2987 home: home.as_deref(),
2988 configured_skills_dir: None,
2989 network: &network,
2990 max_size,
2991 registry_url: &registry_url,
2992 };
2993 crate::skills::mutation::execute(
2994 SkillMutationRequest::UpdateByName {
2995 name: owned_name,
2996 scope,
2997 expected_digest: None,
2998 },
2999 &ctx,
3000 )
3001 .await
3002 });
3003 match outcome {
3004 Ok(receipt) => Ok(portable_mutation_receipt(&receipt)),
3005 Err(err) => Err(format!("Update failed: {err:#}")),
3006 }
3007 }
3008
3009 fn uninstall_skill(
3010 &mut self,
3011 scope: Option<SkillTargetScope>,
3012 name: &str,
3013 ) -> Result<SkillMutationReceipt, String> {
3014 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
3015 let workspace = self.host.app.borrow().workspace.clone();
3016 let home = crate::config::effective_home_dir();
3017 let (network, max_size, registry_url) = installer_settings();
3018 let ctx = MutationContext {
3019 workspace: &workspace,
3020 home: home.as_deref(),
3021 configured_skills_dir: None,
3022 network: &network,
3023 max_size,
3024 registry_url: &registry_url,
3025 };
3026 match crate::skills::mutation::execute_sync(
3027 SkillMutationRequest::RemoveByName {
3028 name: name.to_string(),
3029 scope: portable_scope(scope),
3030 expected_digest: None,
3031 },
3032 &ctx,
3033 ) {
3034 Ok(receipt) => Ok(portable_mutation_receipt(&receipt)),
3035 Err(err) => Err(format!("Uninstall failed: {err:#}")),
3036 }
3037 }
3038
3039 fn trust_skill(
3040 &mut self,
3041 scope: Option<SkillTargetScope>,
3042 name: &str,
3043 ) -> Result<SkillMutationReceipt, String> {
3044 use crate::skills::mutation::{MutationContext, SkillMutationRequest};
3045 let workspace = self.host.app.borrow().workspace.clone();
3046 let home = crate::config::effective_home_dir();
3047 let (network, max_size, registry_url) = installer_settings();
3048 let ctx = MutationContext {
3049 workspace: &workspace,
3050 home: home.as_deref(),
3051 configured_skills_dir: None,
3052 network: &network,
3053 max_size,
3054 registry_url: &registry_url,
3055 };
3056 match crate::skills::mutation::execute_sync(
3057 SkillMutationRequest::TrustByName {
3058 name: name.to_string(),
3059 scope: portable_scope(scope),
3060 expected_digest: None,
3061 },
3062 &ctx,
3063 ) {
3064 Ok(receipt) => Ok(portable_mutation_receipt(&receipt)),
3065 Err(err) => Err(format!("Trust failed: {err:#}")),
3066 }
3067 }
3068
3069 fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
3070 let (network, _max_size, registry_url) = installer_settings();
3071 let registry = run_async(async move {
3072 crate::skills::install::fetch_registry(&network, &registry_url).await
3073 });
3074 match registry {
3075 Ok(crate::skills::install::RegistryFetchResult::Loaded(doc)) => {
3076 Ok(RemoteRegistryOutcome::Loaded {
3077 entries: portable_registry_entries(&doc),
3078 })
3079 }
3080 Ok(crate::skills::install::RegistryFetchResult::NeedsApproval(host)) => {
3081 Ok(RemoteRegistryOutcome::NeedsApproval(host))
3082 }
3083 Ok(crate::skills::install::RegistryFetchResult::Denied(host)) => {
3084 Ok(RemoteRegistryOutcome::Denied(host))
3085 }
3086 Err(err) => Err(format_registry_error("Failed to fetch registry", &err)),
3087 }
3088 }
3089
3090 fn recommend_skills(&mut self, task: &str) -> Result<Vec<SkillRecommendation>, String> {
3091 let (network, _max_size, registry_url) = installer_settings();
3092 let registry = run_async(async move {
3093 crate::skills::install::fetch_registry(&network, &registry_url).await
3094 });
3095 match registry {
3096 Ok(crate::skills::install::RegistryFetchResult::Loaded(doc)) => {
3097 let recommendations =
3098 crate::skills::recommend::recommend_remote_skills(task, &doc, 3);
3099 Ok(recommendations
3100 .into_iter()
3101 .map(|recommendation| SkillRecommendation {
3102 name: recommendation.name.to_string(),
3103 description: recommendation.entry.description.clone(),
3104 matched_terms: recommendation.matched_terms.clone(),
3105 })
3106 .collect())
3107 }
3108 Ok(crate::skills::install::RegistryFetchResult::NeedsApproval(host)) => {
3109 Err(needs_approval_message(&host))
3110 }
3111 Ok(crate::skills::install::RegistryFetchResult::Denied(host)) => {
3112 Err(network_denied_message(&host))
3113 }
3114 Err(err) => Err(format_registry_error("Failed to fetch registry", &err)),
3115 }
3116 }
3117
3118 fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
3119 sync_registry_to_cache(crate::skills::install::default_cache_skills_dir())
3120 }
3121
3122 fn run_review(&mut self) -> Result<ReviewOutcome, String> {
3123 let skills_dir = self.host.app.borrow().skills_dir.clone();
3124 let registry = crate::skills::SkillRegistry::discover(&skills_dir).into_enabled();
3125 let mut warnings: Vec<String> = registry.warnings().to_vec();
3126 let mut skill = registry.get("review").cloned();
3127
3128 let global_dir = crate::skills::default_skills_dir();
3129 if skill.is_none() && global_dir != skills_dir {
3130 let registry = crate::skills::SkillRegistry::discover(&global_dir).into_enabled();
3131 if warnings.is_empty() {
3132 warnings = registry.warnings().to_vec();
3133 } else if !registry.warnings().is_empty() {
3134 warnings.extend(registry.warnings().iter().cloned());
3135 }
3136 skill = registry.get("review").cloned();
3137 }
3138
3139 match skill {
3140 Some(skill) => {
3141 if !skill.invocation.user_invocable() {
3142 return Err(format!(
3143 "Skill '{}' does not allow user invocation",
3144 skill.name
3145 ));
3146 }
3147 // Host-side side effects (D2): session-message insertion and
3148 // active-skill mutation are authoritative App operations; the
3149 // portable handler renders no success message (baseline emits
3150 // only the SendMessage action) and never touches App.
3151 let instruction = format!(
3152 "You are now using a skill. Follow these instructions:\n\n# Skill: {}\n\n{}\n\n---\n\nNow respond to the user's request following the above skill instructions.",
3153 skill.name, skill.body
3154 );
3155 let mut app = self.host.app.borrow_mut();
3156 app.add_message(HistoryCell::System {
3157 content: format!("Activated skill: {}\n\n{}", skill.name, skill.description),
3158 });
3159 app.active_skill = Some(instruction);
3160 app.active_skill_provenance = None;
3161 Ok(ReviewOutcome::Ready)
3162 }
3163 None => Ok(ReviewOutcome::NotFound {
3164 skills_dir: skills_dir.display().to_string(),
3165 global_dir: global_dir.display().to_string(),
3166 warnings,
3167 }),
3168 }
3169 }
3170
3171 fn snapshot_list(&mut self, limit: usize) -> Result<Vec<SnapshotEntry>, String> {
3172 let workspace = self.host.app.borrow().workspace.clone();
3173 let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) {
3174 Ok(repo) => repo,
3175 Err(err) => {
3176 return Err(format!(
3177 "Snapshot repo unavailable for {}: {err}",
3178 workspace.display(),
3179 ));
3180 }
3181 };
3182 let snapshots = match repo.list(limit) {
3183 Ok(snapshots) => snapshots,
3184 Err(err) => return Err(format!("Failed to list snapshots: {err}")),
3185 };
3186 Ok(snapshots
3187 .into_iter()
3188 .map(|snapshot| SnapshotEntry {
3189 id: snapshot.id.into_string(),
3190 label: snapshot.label,
3191 timestamp: snapshot.timestamp,
3192 })
3193 .collect())
3194 }
3195
3196 fn restore_snapshot(&mut self, id: &str) -> Result<(), String> {
3197 if let Some(refusal) =
3198 debug_operations::active_turn_restore_refusal(&self.host.app.borrow())
3199 {
3200 return Err(refusal);
3201 }
3202 let workspace = self.host.app.borrow().workspace.clone();
3203 let id = id.to_owned();
3204 let restore = move || {
3205 let repo = match crate::snapshot::SnapshotRepo::open_or_init(&workspace) {
3206 Ok(repo) => repo,
3207 Err(err) => {
3208 return Err(format!(
3209 "Snapshot repo unavailable for {}: {err}",
3210 workspace.display(),
3211 ));
3212 }
3213 };
3214 let id = crate::snapshot::SnapshotId::parse(&id)
3215 .map_err(|err| format!("Restore failed: {err}"))?;
3216 repo.restore(&id)
3217 .map_err(|err| format!("Restore failed: {err}"))
3218 };
3219 // Standalone synchronous hosts have no runtime worker to protect.
3220 // Live TUI dispatch uses the existing bridge and blocking pool for
3221 // the entire restore, including its mandatory safety snapshot.
3222 if tokio::runtime::Handle::try_current().is_err() {
3223 return restore();
3224 }
3225 // A sealed test's home must follow the work onto the blocking pool.
3226 #[cfg(test)]
3227 let ticket = crate::test_support::env_scope_ticket();
3228 run_async(async move {
3229 tokio::task::spawn_blocking(move || {
3230 #[cfg(test)]
3231 let _membership = crate::test_support::join_env_scope(ticket);
3232 restore()
3233 })
3234 .await
3235 .map_err(|error| format!("Restore task failed: {error}"))?
3236 })
3237 }
3238
3239 fn approval_state(&self) -> CommandApprovalState {
3240 let app = self.host.app.borrow();
3241 CommandApprovalState {
3242 yolo: app.yolo,
3243 trust_mode: app.trust_mode,
3244 }
3245 }
3246 }
3247
3248 // ---------------------------------------------------------------------------
3249 // Plugin host adapter (FEAT-020 D1/D11)
3250 // ---------------------------------------------------------------------------
3251
3252 /// Plugin host-data adapter (FEAT-020 D1/D11).
3253 ///
3254 /// Owns every concrete plugin service the live `/plugin` branch closure
3255 /// consumes: registry reads/mutations, the async mutation/network-policy
3256 /// bridge (D11), export, legacy executable-tool scan, Kimi managed import,
3257 /// and the marketplace store. That store projects the bundled first-party
3258 /// catalog alongside locally added catalogs for every host surface.
3259 /// Every method borrows `App` only for the duration of one call and converts
3260 /// host values to portable contract values before returning. Handlers receive
3261 /// only the portable facet and never name `PluginRegistry`, `LoadedPlugin`,
3262 /// `Config`, or another concrete host service.
3263 pub(crate) struct PluginAdapter<'a> {
3264 host: SharedCommandHost<'a>,
3265 }
3266
3267 /// Convert a TUI-owned diagnostic to the portable contract diagnostic.
3268 fn portable_diagnostic(diagnostic: &crate::plugins::types::PluginDiagnostic) -> PluginDiagnostic {
3269 PluginDiagnostic {
3270 level: match diagnostic.level {
3271 crate::plugins::types::PluginDiagnosticLevel::Warning => PluginDiagnosticLevel::Warning,
3272 crate::plugins::types::PluginDiagnosticLevel::Error => PluginDiagnosticLevel::Error,
3273 },
3274 code: diagnostic.code.to_string(),
3275 message: diagnostic.message.clone(),
3276 path: diagnostic.path.clone(),
3277 }
3278 }
3279
3280 /// Convert a TUI marketplace diagnostic into the portable contract diagnostic.
3281 fn portable_marketplace_diagnostic(
3282 diagnostic: &crate::plugins::marketplace::types::MarketplaceDiagnostic,
3283 ) -> PluginDiagnostic {
3284 PluginDiagnostic {
3285 level: match diagnostic.level {
3286 crate::plugins::types::PluginDiagnosticLevel::Warning => PluginDiagnosticLevel::Warning,
3287 crate::plugins::types::PluginDiagnosticLevel::Error => PluginDiagnosticLevel::Error,
3288 },
3289 code: diagnostic.code.clone(),
3290 message: diagnostic.message.clone(),
3291 path: None,
3292 }
3293 }
3294
3295 /// Convert a TUI-owned loaded plugin into the portable list summary.
3296 fn portable_summary(plugin: &crate::plugins::types::LoadedPlugin) -> PluginSummary {
3297 PluginSummary {
3298 name: plugin.name().to_string(),
3299 id: plugin.id.as_str().to_string(),
3300 state_label: plugin.state_label().to_string(),
3301 scope: plugin.scope.as_str().to_string(),
3302 trust_status: plugin.trust_status.as_str().to_string(),
3303 compatibility: plugin.compatibility().as_str().to_string(),
3304 inventory: plugin.inventory.summary(),
3305 active: plugin.active(),
3306 trusted: plugin.trusted(),
3307 enabled: plugin.enabled,
3308 }
3309 }
3310
3311 /// Convert one TUI MCP server config into the portable review detail.
3312 fn portable_mcp_server(name: &str, server: &crate::mcp::McpServerConfig) -> PluginMcpServerDetail {
3313 let transport = if server.url.is_some() {
3314 PluginMcpTransport::Http
3315 } else if server.command.is_some() {
3316 PluginMcpTransport::Stdio
3317 } else {
3318 PluginMcpTransport::Invalid
3319 };
3320 let mut env = server
3321 .env
3322 .iter()
3323 .map(|(k, v)| (k.clone(), v.clone()))
3324 .collect::<Vec<_>>();
3325 env.sort_unstable();
3326 let mut env_headers = server
3327 .env_headers
3328 .iter()
3329 .map(|(k, v)| (k.clone(), v.clone()))
3330 .collect::<Vec<_>>();
3331 env_headers.sort_unstable();
3332 PluginMcpServerDetail {
3333 name: name.to_string(),
3334 transport,
3335 command: server.command.clone(),
3336 argv: server.args.clone(),
3337 cwd: server.cwd.clone(),
3338 env,
3339 url: server.url.clone(),
3340 env_headers,
3341 bearer_token_env_var: server.bearer_token_env_var.clone(),
3342 connect_timeout_secs: server.connect_timeout,
3343 execute_timeout_secs: server.execute_timeout,
3344 read_timeout_secs: server.read_timeout,
3345 required: server.required,
3346 enabled_tools: server.enabled_tools.clone(),
3347 disabled_tools: server.disabled_tools.clone(),
3348 enabled: server.is_enabled(),
3349 }
3350 }
3351
3352 /// Convert a TUI-owned loaded plugin into the portable full detail.
3353 fn portable_detail(plugin: &crate::plugins::types::LoadedPlugin) -> PluginDetail {
3354 let mcp_servers = plugin
3355 .manifest
3356 .mcp_servers
3357 .as_ref()
3358 .map(|servers| {
3359 let mut list = servers
3360 .iter()
3361 .map(|(name, server)| portable_mcp_server(name, server))
3362 .collect::<Vec<_>>();
3363 list.sort_by(|a, b| a.name.cmp(&b.name));
3364 list
3365 })
3366 .unwrap_or_default();
3367 PluginDetail {
3368 name: plugin.name().to_string(),
3369 id: plugin.id.as_str().to_string(),
3370 inventory_summary: plugin.inventory.summary(),
3371 version: plugin.manifest.plugin.version.clone(),
3372 origin: plugin.origin.as_str().to_string(),
3373 scope: plugin.scope.as_str().to_string(),
3374 state_label: plugin.state_label().to_string(),
3375 trust_status: plugin.trust_status.as_str().to_string(),
3376 compatibility: plugin.compatibility().as_str().to_string(),
3377 content_hash: plugin.content_hash.clone(),
3378 capability_hash: plugin.capability_hash.clone(),
3379 canonical_root: plugin.canonical_root.clone(),
3380 active: plugin.active(),
3381 trusted: plugin.trusted(),
3382 enabled: plugin.enabled,
3383 unsupported_labels: plugin
3384 .inventory
3385 .unsupported_labels()
3386 .into_iter()
3387 .map(str::to_string)
3388 .collect(),
3389 supported_labels: plugin
3390 .inventory
3391 .supported_labels()
3392 .into_iter()
3393 .map(str::to_string)
3394 .collect(),
3395 skills: plugin
3396 .skill_snapshots
3397 .iter()
3398 .map(|skill| format!("{}:{}", plugin.name(), skill.name))
3399 .collect(),
3400 filesystem_roots: plugin.inventory.filesystem_roots.clone(),
3401 network_hosts: plugin.inventory.network_hosts.clone(),
3402 stdio_mcp_servers: plugin.inventory.stdio_mcp_servers,
3403 lifecycle_mutation: plugin.inventory.lifecycle_mutation,
3404 mcp_servers,
3405 diagnostics: plugin.diagnostics.iter().map(portable_diagnostic).collect(),
3406 }
3407 }
3408
3409 /// Convert a TUI mutation receipt into the portable contract receipt.
3410 fn portable_plugin_mutation_receipt(
3411 receipt: &crate::plugins::mutation::PluginMutationReceipt,
3412 ) -> PluginMutationReceipt {
3413 let outcome = match &receipt.outcome {
3414 crate::plugins::mutation::PluginMutationOutcome::Installed => {
3415 PluginMutationOutcome::Installed
3416 }
3417 crate::plugins::mutation::PluginMutationOutcome::Updated => PluginMutationOutcome::Updated,
3418 crate::plugins::mutation::PluginMutationOutcome::NoChange => {
3419 PluginMutationOutcome::NoChange
3420 }
3421 crate::plugins::mutation::PluginMutationOutcome::Uninstalled => {
3422 PluginMutationOutcome::Uninstalled
3423 }
3424 crate::plugins::mutation::PluginMutationOutcome::NeedsApproval(host) => {
3425 PluginMutationOutcome::NeedsApproval(host.clone())
3426 }
3427 crate::plugins::mutation::PluginMutationOutcome::NetworkDenied(host) => {
3428 PluginMutationOutcome::NetworkDenied(host.clone())
3429 }
3430 };
3431 PluginMutationReceipt {
3432 name: receipt.name.clone(),
3433 path: receipt.path.clone(),
3434 content_hash: receipt.content_hash.clone(),
3435 installed_content_hash: receipt.installed_content_hash.clone(),
3436 outcome,
3437 }
3438 }
3439
3440 /// Convert a TUI export receipt into the portable contract receipt.
3441 fn portable_export_receipt(
3442 receipt: &crate::plugins::export::PluginExportReceipt,
3443 ) -> PluginExportReceipt {
3444 PluginExportReceipt {
3445 exported_name: receipt.exported_name.clone(),
3446 target: receipt.target.clone(),
3447 display_name: receipt.display_name.clone(),
3448 wrote_mcp_json: receipt.wrote_mcp_json,
3449 files_copied: receipt.files_copied as u64,
3450 skills_normalized: receipt.skills_normalized,
3451 }
3452 }
3453
3454 /// Convert one TUI legacy tool entry into the portable value.
3455 fn portable_legacy_tool(
3456 path: &Path,
3457 metadata: &crate::tools::plugin::PluginMetadata,
3458 ) -> PluginLegacyTool {
3459 PluginLegacyTool {
3460 name: metadata.name.clone(),
3461 description: metadata.description.clone(),
3462 approval: match metadata.approval {
3463 crate::tools::spec::ApprovalRequirement::Auto => "auto",
3464 crate::tools::spec::ApprovalRequirement::Suggest => "suggest",
3465 crate::tools::spec::ApprovalRequirement::Required => "required",
3466 }
3467 .to_string(),
3468 input_schema: Some(
3469 serde_json::to_string_pretty(&metadata.input_schema).unwrap_or_default(),
3470 ),
3471 path: path.to_path_buf(),
3472 }
3473 }
3474
3475 /// Convert one TUI marketplace candidate into the portable value.
3476 fn portable_marketplace_candidate(
3477 entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
3478 candidate: &crate::plugins::marketplace::types::MarketplaceCandidate,
3479 registry: &crate::plugins::PluginRegistry,
3480 ) -> PluginMarketplaceCandidate {
3481 use crate::plugins::marketplace::document::{
3482 CatalogInstallResolution, resolve_candidate_install,
3483 };
3484 let install_plan = match resolve_candidate_install(entry, candidate, registry) {
3485 CatalogInstallResolution::Supported { spec, source_kind } => {
3486 PluginMarketplaceInstallPlan::Supported { spec, source_kind }
3487 }
3488 CatalogInstallResolution::AlreadyPresent { plugin, reason } => {
3489 PluginMarketplaceInstallPlan::AlreadyPresent {
3490 selector: plugin.id.as_str().to_string(),
3491 reason,
3492 }
3493 }
3494 CatalogInstallResolution::Unsupported { reason } => {
3495 PluginMarketplaceInstallPlan::Unsupported { reason }
3496 }
3497 CatalogInstallResolution::HasErrors { diagnostics } => {
3498 PluginMarketplaceInstallPlan::Unsupported {
3499 reason: diagnostics,
3500 }
3501 }
3502 };
3503 PluginMarketplaceCandidate {
3504 name: candidate.name.clone(),
3505 display_name: candidate.display_name.clone(),
3506 version: candidate.version.clone(),
3507 tier: candidate.provenance.tier.as_str().to_string(),
3508 compatibility: candidate
3509 .compatibility
3510 .as_ref()
3511 .map(|c| c.as_str().to_string()),
3512 install_plan,
3513 description: candidate.description.clone(),
3514 homepage: candidate.homepage.clone(),
3515 repository: candidate.repository.clone(),
3516 author: candidate.author.clone(),
3517 license: candidate.license.clone(),
3518 keywords: candidate.keywords.clone(),
3519 when: candidate.when.as_ref().map(|when| format!("{when:?}")),
3520 diagnostics: candidate
3521 .diagnostics
3522 .iter()
3523 .map(portable_marketplace_diagnostic)
3524 .collect(),
3525 has_errors: candidate.has_errors(),
3526 }
3527 }
3528
3529 /// Convert one stored TUI marketplace catalog (with its source path).
3530 fn portable_marketplace_catalog_with_source(
3531 entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
3532 registry: &crate::plugins::PluginRegistry,
3533 ) -> PluginMarketplaceCatalog {
3534 let catalog = &entry.catalog;
3535 PluginMarketplaceCatalog {
3536 id: catalog.id.as_str().to_string(),
3537 source_path: Some(entry.source_path.clone()),
3538 display_name: catalog.display_name.clone(),
3539 description: catalog.description.clone(),
3540 format: catalog.format.as_str().to_string(),
3541 tier: catalog.provenance.tier.as_str().to_string(),
3542 publisher: catalog.provenance.publisher.clone(),
3543 total_candidates: catalog.total_candidates(),
3544 warning_count: catalog.warning_count(),
3545 candidates: catalog
3546 .candidates
3547 .iter()
3548 .map(|candidate| portable_marketplace_candidate(entry, candidate, registry))
3549 .collect(),
3550 diagnostics: catalog
3551 .diagnostics
3552 .iter()
3553 .map(portable_marketplace_diagnostic)
3554 .collect(),
3555 }
3556 }
3557
3558 /// Kimi managed-plugin scan (host-side, FEAT-020 D1). Mirrors the legacy
3559 /// `/plugin import kimi` scan exactly: only immediate canonical children of
3560 /// `~/.kimi-code/plugins/managed`, rejecting symlinks/reparse points,
3561 /// non-directories, and children that escape the root. Returns portable
3562 /// candidate values; rejection reasons cross as safe text.
3563 fn scan_managed_plugins_portable(
3564 home_override: Option<&Path>,
3565 ) -> Result<PluginManagedScan, String> {
3566 use std::fs;
3567 use std::path::PathBuf;
3568
3569 const MAX_MANAGED_CHILDREN: usize = 128;
3570 const KIMI_PLUGIN_JSON_NAME: &str = crate::plugins::agent_plugin::KIMI_PLUGIN_JSON_NAME;
3571
3572 struct Candidate {
3573 name: String,
3574 version: String,
3575 license: Option<String>,
3576 canonical_path: PathBuf,
3577 content_hash: String,
3578 capability_hash: String,
3579 inventory: String,
3580 applicable: bool,
3581 }
3582
3583 fn inspect_candidate(canonical_path: &Path) -> Result<Candidate, String> {
3584 let manifest_path = canonical_path.join(KIMI_PLUGIN_JSON_NAME);
3585 let metadata = fs::symlink_metadata(&manifest_path).map_err(|error| {
3586 format!(
3587 "Kimi manifest unreadable at {}: {}",
3588 canonical_path.display(),
3589 error
3590 )
3591 })?;
3592 if crate::plugins::metadata_is_link_or_reparse(&metadata) || !metadata.is_file() {
3593 return Err(format!(
3594 "Kimi manifest must be a regular file at {}",
3595 canonical_path.display()
3596 ));
3597 }
3598 let validated = crate::plugins::manifest::PluginManifest::validate_from_path(
3599 &manifest_path,
3600 )
3601 .map_err(|error| {
3602 format!(
3603 "Kimi manifest invalid at {}: {error}",
3604 canonical_path.display()
3605 )
3606 })?;
3607 let name = validated.manifest.plugin.name.clone();
3608 if canonical_path.file_name().and_then(|part| part.to_str()) != Some(name.as_str()) {
3609 return Err(format!(
3610 "Kimi directory name `{}` does not match manifest name `{}`",
3611 canonical_path.display(),
3612 name
3613 ));
3614 }
3615 Ok(Candidate {
3616 name,
3617 version: validated.manifest.plugin.version.clone(),
3618 license: validated.manifest.plugin.license.clone(),
3619 canonical_path: validated.canonical_root,
3620 content_hash: validated.content_hash,
3621 capability_hash: validated.capability_hash,
3622 inventory: validated.inventory.summary(),
3623 applicable: validated.applicable,
3624 })
3625 }
3626
3627 let home = match home_override {
3628 Some(home) => home.to_path_buf(),
3629 None => crate::config::effective_home_dir().ok_or_else(|| {
3630 tr(
3631 codewhale_localization::Locale::En,
3632 codewhale_localization::MessageId::PluginKimiHomeMissing,
3633 )
3634 .into_owned()
3635 .to_string()
3636 })?,
3637 };
3638 let configured_root = home.join(".kimi-code/plugins/managed");
3639 let metadata = match fs::symlink_metadata(&configured_root) {
3640 Ok(metadata) => metadata,
3641 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3642 return Ok(PluginManagedScan {
3643 root: configured_root,
3644 candidates: Vec::new(),
3645 rejected: Vec::new(),
3646 });
3647 }
3648 Err(error) => {
3649 let root_text = escape_review_text(&configured_root.display().to_string());
3650 let error_text = escape_review_text(&error.to_string());
3651 return Err(tr(
3652 codewhale_localization::Locale::En,
3653 codewhale_localization::MessageId::PluginKimiRootInspectFailed,
3654 )
3655 .replace("{root}", &root_text)
3656 .replace("{error}", &error_text));
3657 }
3658 };
3659 if crate::plugins::metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() {
3660 let root_text = escape_review_text(&configured_root.display().to_string());
3661 return Err(tr(
3662 codewhale_localization::Locale::En,
3663 codewhale_localization::MessageId::PluginKimiRootMustBeDirectory,
3664 )
3665 .replace("{root}", &root_text));
3666 }
3667 let canonical_root = configured_root.canonicalize().map_err(|error| {
3668 let root_text = escape_review_text(&configured_root.display().to_string());
3669 let error_text = escape_review_text(&error.to_string());
3670 tr(
3671 codewhale_localization::Locale::En,
3672 codewhale_localization::MessageId::PluginKimiRootCanonicalizeFailed,
3673 )
3674 .replace("{root}", &root_text)
3675 .replace("{error}", &error_text)
3676 })?;
3677 let mut entries = fs::read_dir(&canonical_root)
3678 .map_err(|error| {
3679 let root_text = escape_review_text(&canonical_root.display().to_string());
3680 let error_text = escape_review_text(&error.to_string());
3681 tr(
3682 codewhale_localization::Locale::En,
3683 codewhale_localization::MessageId::PluginKimiRootListFailed,
3684 )
3685 .replace("{root}", &root_text)
3686 .replace("{error}", &error_text)
3687 })?
3688 .collect::<Result<Vec<_>, _>>()
3689 .map_err(|error| {
3690 let error_text = escape_review_text(&error.to_string());
3691 tr(
3692 codewhale_localization::Locale::En,
3693 codewhale_localization::MessageId::PluginKimiEntryReadFailed,
3694 )
3695 .replace("{error}", &error_text)
3696 })?;
3697 if entries.len() > MAX_MANAGED_CHILDREN {
3698 return Err(tr(
3699 codewhale_localization::Locale::En,
3700 codewhale_localization::MessageId::PluginKimiEntryLimit,
3701 )
3702 .replace("{count}", &entries.len().to_string())
3703 .replace("{max}", &MAX_MANAGED_CHILDREN.to_string()));
3704 }
3705 entries.sort_by_key(fs::DirEntry::file_name);
3706
3707 let mut candidates = Vec::new();
3708 let mut rejected = Vec::new();
3709 for entry in entries {
3710 let path = entry.path();
3711 let metadata = match fs::symlink_metadata(&path) {
3712 Ok(metadata) => metadata,
3713 Err(error) => {
3714 let path_text = escape_review_text(&path.display().to_string());
3715 let error_text = escape_review_text(&error.to_string());
3716 rejected.push(
3717 tr(
3718 codewhale_localization::Locale::En,
3719 codewhale_localization::MessageId::PluginKimiEntryInspectFailed,
3720 )
3721 .replace("{path}", &path_text)
3722 .replace("{error}", &error_text),
3723 );
3724 continue;
3725 }
3726 };
3727 if crate::plugins::metadata_is_link_or_reparse(&metadata) {
3728 let path_text = escape_review_path(&path);
3729 rejected.push(
3730 tr(
3731 codewhale_localization::Locale::En,
3732 codewhale_localization::MessageId::PluginKimiEntryLinksRefused,
3733 )
3734 .replace("{path}", &path_text),
3735 );
3736 continue;
3737 }
3738 if !metadata.is_dir() {
3739 continue;
3740 }
3741 let canonical_path = match path.canonicalize() {
3742 Ok(path) if path.parent() == Some(canonical_root.as_path()) => path,
3743 Ok(canonical_path) => {
3744 let path_text = escape_review_text(&path.display().to_string());
3745 let canonical_text = escape_review_text(&canonical_path.display().to_string());
3746 rejected.push(
3747 tr(
3748 codewhale_localization::Locale::En,
3749 codewhale_localization::MessageId::PluginKimiEntryOutsideRoot,
3750 )
3751 .replace("{path}", &path_text)
3752 .replace("{canonical_path}", &canonical_text),
3753 );
3754 continue;
3755 }
3756 Err(error) => {
3757 let path_text = escape_review_text(&path.display().to_string());
3758 let error_text = escape_review_text(&error.to_string());
3759 rejected.push(
3760 tr(
3761 codewhale_localization::Locale::En,
3762 codewhale_localization::MessageId::PluginKimiEntryCanonicalizeFailed,
3763 )
3764 .replace("{path}", &path_text)
3765 .replace("{error}", &error_text),
3766 );
3767 continue;
3768 }
3769 };
3770 match inspect_candidate(&canonical_path) {
3771 Ok(candidate) => candidates.push(candidate),
3772 Err(error) => rejected.push(error),
3773 }
3774 }
3775 candidates.sort_by(|left, right| left.name.cmp(&right.name));
3776 Ok(PluginManagedScan {
3777 root: canonical_root,
3778 candidates: candidates
3779 .into_iter()
3780 .map(|candidate| PluginManagedCandidate {
3781 name: candidate.name,
3782 version: candidate.version,
3783 license: candidate.license,
3784 canonical_path: candidate.canonical_path,
3785 content_hash: candidate.content_hash,
3786 capability_hash: candidate.capability_hash,
3787 inventory: candidate.inventory,
3788 applicable: candidate.applicable,
3789 })
3790 .collect(),
3791 rejected,
3792 })
3793 }
3794
3795 /// Escape review text exactly like the plugin render helpers (FEAT-020 D2).
3796 fn escape_review_text(value: &str) -> String {
3797 crate::commands::groups::plugins::render::escape_review_text(value)
3798 }
3799
3800 /// Escape a review path exactly like the plugin render helpers (FEAT-020 D2).
3801 fn escape_review_path(path: &Path) -> String {
3802 crate::commands::groups::plugins::render::escape_review_path(path)
3803 }
3804
3805 impl CommandPluginContext for PluginAdapter<'_> {
3806 fn summaries(&self) -> Result<Vec<PluginSummary>, String> {
3807 let app = self.host.app.borrow();
3808 Ok(app
3809 .plugin_registry
3810 .list()
3811 .iter()
3812 .map(|plugin| portable_summary(plugin))
3813 .collect())
3814 }
3815
3816 fn detail(&self, selector: &str) -> Result<PluginDetail, String> {
3817 let app = self.host.app.borrow();
3818 let plugin = app
3819 .plugin_registry
3820 .get(selector)
3821 .ok_or_else(|| format!("no plugin named {selector}"))?;
3822 Ok(portable_detail(plugin))
3823 }
3824
3825 fn registry_diagnostics(&self) -> Vec<PluginDiagnostic> {
3826 self.host
3827 .app
3828 .borrow()
3829 .plugin_registry
3830 .diagnostics()
3831 .iter()
3832 .map(portable_diagnostic)
3833 .collect()
3834 }
3835
3836 fn validation_is_clean(&self) -> bool {
3837 self.host.app.borrow().plugin_registry.validation_is_clean()
3838 }
3839
3840 fn len(&self) -> usize {
3841 self.host.app.borrow().plugin_registry.len()
3842 }
3843
3844 fn is_empty(&self) -> bool {
3845 self.host.app.borrow().plugin_registry.is_empty()
3846 }
3847
3848 fn reload(&mut self) -> Result<usize, String> {
3849 let mut app = self.host.app.borrow_mut();
3850 let workspace = app.workspace.clone();
3851 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
3852 app.refresh_skill_cache();
3853 Ok(app.plugin_registry.len())
3854 }
3855
3856 fn reload_nudge(&mut self) -> Option<String> {
3857 let mut app = self.host.app.borrow_mut();
3858 let registry = app.plugin_registry.clone();
3859 crate::plugins::plugin_reload_nudge(registry.as_ref(), &mut app.plugin_reload_nudge_stamp)
3860 .map(str::to_string)
3861 }
3862
3863 fn state_path(&self) -> Option<PathBuf> {
3864 self.host
3865 .app
3866 .borrow()
3867 .plugin_registry
3868 .state_path()
3869 .map(Path::to_path_buf)
3870 }
3871
3872 fn suggest(&self, task: &str) -> Result<Vec<PluginSuggestion>, String> {
3873 let task = task.trim();
3874 if task.chars().count() < 3 {
3875 return Err("Usage: /plugin suggest <task of at least 3 characters>".to_string());
3876 }
3877 let app = self.host.app.borrow();
3878 let marketplace = crate::plugins::recommend::load_marketplace_candidates(
3879 app.plugin_registry.state_path(),
3880 );
3881 let recommendations = crate::plugins::recommend::recommend_plugins_for_task(
3882 task,
3883 app.plugin_registry.as_ref(),
3884 &marketplace,
3885 crate::plugins::recommend::RecommendOptions::default(),
3886 );
3887 Ok(recommendations
3888 .into_iter()
3889 .map(|recommendation| {
3890 let description = match &recommendation.source {
3891 crate::plugins::recommend::PluginMatchSource::Installed { id } => app
3892 .plugin_registry
3893 .get(id)
3894 .and_then(|plugin| plugin.manifest.plugin.description.clone())
3895 .filter(|description| !description.trim().is_empty())
3896 .unwrap_or_else(|| "No description provided.".to_string()),
3897 crate::plugins::recommend::PluginMatchSource::Marketplace { catalog_id } => {
3898 marketplace
3899 .iter()
3900 .find(|candidate| {
3901 candidate.name.eq_ignore_ascii_case(&recommendation.name)
3902 && candidate.catalog_id.as_str() == catalog_id
3903 })
3904 .and_then(|candidate| candidate.description.clone())
3905 .filter(|description| !description.trim().is_empty())
3906 .unwrap_or_else(|| "Catalog plugin.".to_string())
3907 }
3908 };
3909 let state_label = match &recommendation.source {
3910 crate::plugins::recommend::PluginMatchSource::Installed { id } => app
3911 .plugin_registry
3912 .get(id)
3913 .map(|plugin| plugin.state_label().to_string())
3914 .unwrap_or_else(|| "installed".to_string()),
3915 crate::plugins::recommend::PluginMatchSource::Marketplace { .. } => {
3916 "not installed".to_string()
3917 }
3918 };
3919 PluginSuggestion {
3920 name: recommendation.name.clone(),
3921 state_label,
3922 description,
3923 why: recommendation.matched_terms.clone(),
3924 next_step: recommendation.command(),
3925 }
3926 })
3927 .collect())
3928 }
3929
3930 fn trust(&mut self, selector: &str, token: &str) -> Result<(), String> {
3931 let expected = {
3932 let app = self.host.app.borrow();
3933 app.plugin_registry
3934 .get(selector)
3935 .map(crate::plugins::types::LoadedPlugin::review_token)
3936 .ok_or_else(|| format!("no plugin named {selector}"))?
3937 };
3938 if token != expected {
3939 return Err(
3940 "Review token does not match this bundle content and capability set; run `/plugin trust <name>` again"
3941 .to_string(),
3942 );
3943 }
3944 {
3945 let mut app = self.host.app.borrow_mut();
3946 std::sync::Arc::make_mut(&mut app.plugin_registry).trust(selector)?;
3947 app.refresh_skill_cache();
3948 }
3949 Ok(())
3950 }
3951
3952 fn enable(&mut self, selector: &str) -> Result<(), String> {
3953 let needs_review = self
3954 .host
3955 .app
3956 .borrow()
3957 .plugin_registry
3958 .get(selector)
3959 .is_some_and(|plugin| !plugin.trusted());
3960 if needs_review {
3961 // Enabling is the natural entry point; open the capability review
3962 // instead of an opaque denial (matches the legacy handler).
3963 return Err("plugin requires review before enabling".to_string());
3964 }
3965 let mut app = self.host.app.borrow_mut();
3966 std::sync::Arc::make_mut(&mut app.plugin_registry).enable(selector)?;
3967 app.refresh_skill_cache();
3968 Ok(())
3969 }
3970
3971 fn disable(&mut self, selector: &str) -> Result<(), String> {
3972 let mut app = self.host.app.borrow_mut();
3973 std::sync::Arc::make_mut(&mut app.plugin_registry).disable(selector)?;
3974 app.refresh_skill_cache();
3975 app.active_skill = None;
3976 app.active_skill_provenance = None;
3977 Ok(())
3978 }
3979
3980 fn revoke_trust(&mut self, selector: &str) -> Result<(), String> {
3981 let mut app = self.host.app.borrow_mut();
3982 std::sync::Arc::make_mut(&mut app.plugin_registry).revoke_trust(selector)?;
3983 app.refresh_skill_cache();
3984 app.active_skill = None;
3985 app.active_skill_provenance = None;
3986 Ok(())
3987 }
3988
3989 fn install(
3990 &mut self,
3991 source: &str,
3992 expected_content_hash: Option<&str>,
3993 ) -> Result<PluginMutationReceipt, String> {
3994 use crate::plugins::install::PluginInstallSource;
3995 use crate::plugins::mutation::{
3996 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
3997 };
3998
3999 let plugin_source = PluginInstallSource::parse(source).map_err(|error| {
4000 format!(
4001 "Invalid plugin install source `{source}`: {error:#}\n\
4002 Expected a local path, github:owner/repo, an HTTPS tarball URL, or builtin:<name>."
4003 )
4004 })?;
4005 let network = plugin_network_policy();
4006 let expected_content_hash = expected_content_hash.map(str::to_string);
4007 let expected_for_request = expected_content_hash.clone();
4008 let mut app = self.host.app.borrow_mut();
4009 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
4010 let outcome = run_async(async move {
4011 let ctx = PluginMutationContext {
4012 network: &network,
4013 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
4014 };
4015 let request = match expected_for_request {
4016 Some(expected_content_hash) => PluginMutationRequest::InstallExact {
4017 source: plugin_source,
4018 expected_content_hash,
4019 },
4020 None => PluginMutationRequest::Install {
4021 source: plugin_source,
4022 },
4023 };
4024 crate::plugins::mutation::execute(request, &ctx, registry).await
4025 });
4026 match outcome {
4027 Ok(receipt) => {
4028 let portable = portable_plugin_mutation_receipt(&receipt);
4029 // Rediscover and refresh the skill cache after any install.
4030 if matches!(receipt.outcome, PluginMutationOutcome::Installed) {
4031 let workspace = app.workspace.clone();
4032 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
4033 app.refresh_skill_cache();
4034 }
4035 Ok(portable)
4036 }
4037 Err(error) => Err(format!("Plugin install failed: {error:#}")),
4038 }
4039 }
4040
4041 fn update(&mut self, selector: &str) -> Result<PluginMutationReceipt, String> {
4042 use crate::plugins::mutation::{
4043 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
4044 };
4045 let network = plugin_network_policy();
4046 let selector_owned = selector.to_string();
4047 let mut app = self.host.app.borrow_mut();
4048 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
4049 let outcome = run_async(async move {
4050 let ctx = PluginMutationContext {
4051 network: &network,
4052 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
4053 };
4054 crate::plugins::mutation::execute(
4055 PluginMutationRequest::Update {
4056 selector: selector_owned,
4057 },
4058 &ctx,
4059 registry,
4060 )
4061 .await
4062 });
4063 match outcome {
4064 Ok(receipt) => {
4065 let portable = portable_plugin_mutation_receipt(&receipt);
4066 if matches!(receipt.outcome, PluginMutationOutcome::Updated) {
4067 let workspace = app.workspace.clone();
4068 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
4069 app.refresh_skill_cache();
4070 }
4071 Ok(portable)
4072 }
4073 Err(error) => Err(format!("Plugin update failed: {error:#}")),
4074 }
4075 }
4076
4077 fn uninstall(&mut self, selector: &str) -> Result<PluginMutationReceipt, String> {
4078 use crate::plugins::mutation::{
4079 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
4080 };
4081 let network = plugin_network_policy();
4082 let selector_owned = selector.to_string();
4083 let mut app = self.host.app.borrow_mut();
4084 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
4085 let outcome = run_async(async move {
4086 let ctx = PluginMutationContext {
4087 network: &network,
4088 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
4089 };
4090 crate::plugins::mutation::execute(
4091 PluginMutationRequest::Uninstall {
4092 selector: selector_owned,
4093 },
4094 &ctx,
4095 registry,
4096 )
4097 .await
4098 });
4099 match outcome {
4100 Ok(receipt) => {
4101 let portable = portable_plugin_mutation_receipt(&receipt);
4102 if matches!(receipt.outcome, PluginMutationOutcome::Uninstalled) {
4103 let workspace = app.workspace.clone();
4104 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
4105 app.refresh_skill_cache();
4106 app.active_skill = None;
4107 app.active_skill_provenance = None;
4108 }
4109 Ok(portable)
4110 }
4111 Err(error) => Err(format!("Plugin uninstall failed: {error:#}")),
4112 }
4113 }
4114
4115 fn uninstall_path(&mut self, name: &str, plugins_dir: &Path) -> Result<(), String> {
4116 // File-level rollback removal for a bundle whose content hash
4117 // mismatched; no registry resolution, rediscovery, or skill side
4118 // effects (FEAT-020 D1 — the `crate::plugins` call stays host-side).
4119 crate::plugins::install::uninstall(name, plugins_dir).map_err(|error| format!("{error:#}"))
4120 }
4121
4122 fn export(&self, selector: &str, target: &Path) -> Result<PluginExportReceipt, String> {
4123 let app = self.host.app.borrow();
4124 let plugin = app
4125 .plugin_registry
4126 .get(selector)
4127 .ok_or_else(|| format!("no plugin named {selector}"))?
4128 .clone();
4129 let existing_names: std::collections::BTreeSet<String> = app
4130 .plugin_registry
4131 .list()
4132 .iter()
4133 .map(|other| other.name().to_string())
4134 .filter(|name| name != plugin.name())
4135 .collect();
4136 let target = if target.is_absolute() {
4137 target.to_path_buf()
4138 } else {
4139 app.workspace.join(target)
4140 };
4141 crate::plugins::export::export_plugin_bundle(&plugin, &target, &existing_names)
4142 .map(|receipt| portable_export_receipt(&receipt))
4143 .map_err(|error| format!("Export of `{}` failed: {}", plugin.name(), error))
4144 }
4145
4146 fn legacy_scan(&self) -> Result<Option<PluginLegacyScan>, String> {
4147 let app = self.host.app.borrow();
4148 let Some(dir) = app
4149 .legacy_plugin_tools_dir
4150 .clone()
4151 .or_else(default_codewhale_tools_dir)
4152 else {
4153 return Ok(None);
4154 };
4155 if !dir.exists() {
4156 return Ok(None);
4157 }
4158 let discovered = crate::tools::plugin::scan_plugin_dir(&dir);
4159 let diagnostics = discovered
4160 .iter()
4161 .filter(|(_, metadata)| metadata.auto_approval_ignored)
4162 .map(|(path, metadata)| PluginDiagnostic {
4163 level: PluginDiagnosticLevel::Warning,
4164 code: "script_tool_auto_approval_ignored".to_string(),
4165 message: format!(
4166 "script tool '{}': {}",
4167 metadata.name,
4168 crate::tools::plugin::AUTO_APPROVAL_UNSUPPORTED
4169 ),
4170 path: Some(path.clone()),
4171 })
4172 .collect();
4173 let tools = discovered
4174 .iter()
4175 .map(|(path, metadata)| portable_legacy_tool(path, metadata))
4176 .collect();
4177 Ok(Some(PluginLegacyScan {
4178 dir,
4179 tools,
4180 diagnostics,
4181 }))
4182 }
4183
4184 fn managed_scan(&self, home_override: Option<&Path>) -> Result<PluginManagedScan, String> {
4185 scan_managed_plugins_portable(home_override)
4186 }
4187
4188 fn dsh_preview(
4189 &self,
4190 package: &Path,
4191 ) -> Result<codewhale_command_contract::facets::PluginDshPreview, String> {
4192 let canonical = package
4193 .canonicalize()
4194 .map_err(|_| format!("DSH package not found at {}", package.display()))?;
4195 let (conversion, content_hash) = crate::plugins::install::preview_dsh(&canonical)
4196 .map_err(|error| format!("{error:#}"))?;
4197 Ok(codewhale_command_contract::facets::PluginDshPreview {
4198 package_path: canonical,
4199 plugin_name: conversion.plugin_name,
4200 source_package: conversion.source_package,
4201 source_version: conversion.source_version,
4202 content_hash,
4203 skills: conversion.skills,
4204 remote_servers: conversion.remote_servers,
4205 local_servers: conversion.local_servers,
4206 network_hosts: conversion.network_hosts,
4207 requires_node: conversion.requires_node,
4208 manual_ports: conversion
4209 .outcomes
4210 .iter()
4211 .filter(|outcome| outcome.needs_manual_port())
4212 .map(|outcome| {
4213 format!(
4214 "{} ({}) {}: {}",
4215 outcome.row.as_deref().unwrap_or("unlabeled"),
4216 outcome.package.as_deref().unwrap_or("unlabeled"),
4217 outcome.kind,
4218 outcome.reason
4219 )
4220 })
4221 .collect(),
4222 diagnostics: conversion.diagnostics,
4223 })
4224 }
4225
4226 fn managed_install(
4227 &mut self,
4228 canonical_path: &Path,
4229 expected_content_hash: &str,
4230 ) -> Result<PluginMutationReceipt, String> {
4231 use crate::plugins::install::PluginInstallSource;
4232 use crate::plugins::mutation::{
4233 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
4234 };
4235 let network = plugin_network_policy();
4236 let expected_content_hash = expected_content_hash.to_string();
4237 let path = canonical_path.to_path_buf();
4238 let mut app = self.host.app.borrow_mut();
4239 let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
4240 let outcome = run_async(async move {
4241 let ctx = PluginMutationContext {
4242 network: &network,
4243 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
4244 };
4245 crate::plugins::mutation::execute(
4246 PluginMutationRequest::InstallExact {
4247 source: PluginInstallSource::LocalPath(path),
4248 expected_content_hash,
4249 },
4250 &ctx,
4251 registry,
4252 )
4253 .await
4254 });
4255 match outcome {
4256 Ok(receipt) => {
4257 let portable = portable_plugin_mutation_receipt(&receipt);
4258 if matches!(receipt.outcome, PluginMutationOutcome::Installed) {
4259 let workspace = app.workspace.clone();
4260 app.plugin_registry = app.plugin_registry.rediscover_for_workspace(&workspace);
4261 app.refresh_skill_cache();
4262 }
4263 Ok(portable)
4264 }
4265 Err(error) => Err(format!("Plugin install failed: {error:#}")),
4266 }
4267 }
4268
4269 fn marketplace_state(&self) -> Result<PluginMarketplaceState, String> {
4270 let app = self.host.app.borrow();
4271 let store = crate::plugins::marketplace::store::MarketplaceStore::open(
4272 app.plugin_registry.state_path(),
4273 )
4274 .ok_or_else(|| {
4275 "This plugin registry has no persistence store, so marketplace catalogs cannot be saved."
4276 .to_string()
4277 })?;
4278 let state = store.load()?;
4279 let stored = state
4280 .catalogs()
4281 .values()
4282 .map(|entry| portable_marketplace_catalog_with_source(entry, &app.plugin_registry))
4283 .collect();
4284 Ok(PluginMarketplaceState {
4285 official: None,
4286 stored,
4287 })
4288 }
4289
4290 fn marketplace_add(
4291 &mut self,
4292 name: &str,
4293 path: &Path,
4294 ) -> Result<PluginMarketplaceAddReceipt, String> {
4295 let app = self.host.app.borrow();
4296 let store = crate::plugins::marketplace::store::MarketplaceStore::open(
4297 app.plugin_registry.state_path(),
4298 )
4299 .ok_or_else(|| {
4300 "This plugin registry has no persistence store, so marketplace catalogs cannot be saved."
4301 .to_string()
4302 })?;
4303 let raw_path = path.to_string_lossy();
4304 let loaded = crate::plugins::marketplace::document::load_catalog_document(
4305 name,
4306 &app.workspace,
4307 &raw_path,
4308 )?;
4309 let candidate_count = loaded.candidate_count;
4310 let warning_count = loaded.warning_count;
4311 let portable_catalog =
4312 portable_marketplace_catalog_with_source(&loaded.entry, &app.plugin_registry);
4313 store.add(&loaded.entry.catalog.id.clone(), loaded.entry)?;
4314 Ok(PluginMarketplaceAddReceipt {
4315 name: name.to_string(),
4316 candidate_count,
4317 warning_count,
4318 catalog: portable_catalog,
4319 })
4320 }
4321
4322 fn marketplace_remove(&mut self, name: &str) -> Result<bool, String> {
4323 let app = self.host.app.borrow();
4324 let store = crate::plugins::marketplace::store::MarketplaceStore::open(
4325 app.plugin_registry.state_path(),
4326 )
4327 .ok_or_else(|| {
4328 "This plugin registry has no persistence store, so marketplace catalogs cannot be saved."
4329 .to_string()
4330 })?;
4331 store.remove(name)
4332 }
4333
4334 fn marketplace_install(
4335 &mut self,
4336 catalog: &str,
4337 candidate: &str,
4338 ) -> Result<PluginMutationReceipt, String> {
4339 let app = self.host.app.borrow();
4340 let store = crate::plugins::marketplace::store::MarketplaceStore::open(
4341 app.plugin_registry.state_path(),
4342 )
4343 .ok_or_else(|| {
4344 "This plugin registry has no persistence store, so marketplace catalogs cannot be saved."
4345 .to_string()
4346 })?;
4347 let state = store.load()?;
4348 let catalog_text = escape_review_text(catalog);
4349 let candidate_text = escape_review_text(candidate);
4350 let entry = state.get(catalog).cloned().ok_or_else(|| {
4351 format!("No marketplace named `{catalog_text}`. Use /plugin marketplace list.")
4352 })?;
4353 let candidate_entry = entry.catalog.candidate_by_name(candidate).ok_or_else(|| {
4354 format!("No candidate `{candidate_text}` in marketplace `{catalog_text}`.")
4355 })?;
4356 let spec = match crate::plugins::marketplace::document::resolve_candidate_install(
4357 &entry,
4358 candidate_entry,
4359 &app.plugin_registry,
4360 ) {
4361 crate::plugins::marketplace::document::CatalogInstallResolution::Supported {
4362 spec,
4363 ..
4364 } => spec,
4365 crate::plugins::marketplace::document::CatalogInstallResolution::AlreadyPresent {
4366 plugin,
4367 reason,
4368 } => {
4369 // Installing a bundle Codewhale ships succeeds as a no-op
4370 // (B5); any other occupied name stays a refusal.
4371 if plugin.scope == crate::plugins::types::PluginScope::Builtin {
4372 return Ok(PluginMutationReceipt {
4373 name: plugin.id.as_str().to_string(),
4374 path: None,
4375 content_hash: Some(plugin.content_hash.clone()),
4376 installed_content_hash: None,
4377 outcome:
4378 codewhale_command_contract::facets::PluginMutationOutcome::NoChange,
4379 });
4380 }
4381 return Err(escape_review_text(&reason));
4382 }
4383 crate::plugins::marketplace::document::CatalogInstallResolution::Unsupported {
4384 reason,
4385 } => {
4386 let localized = key_to_plugin_message_id(&reason)
4387 .map(|message_id| tr(app.ui_locale, message_id).into_owned())
4388 .unwrap_or(reason);
4389 return Err(format!(
4390 "Candidate `{candidate_text}` cannot be installed by Codewhale: {}",
4391 escape_review_text(&localized)
4392 ));
4393 }
4394 crate::plugins::marketplace::document::CatalogInstallResolution::HasErrors {
4395 diagnostics,
4396 } => {
4397 return Err(format!(
4398 "Candidate `{candidate_text}` has parse errors and cannot be installed:\n{}",
4399 escape_review_text(&diagnostics)
4400 ));
4401 }
4402 };
4403 drop(app);
4404 self.install(&spec, None)
4405 }
4406
4407 fn suggestion_dismissals(
4408 &self,
4409 ) -> Result<codewhale_command_contract::facets::PluginSuggestionDismissals, String> {
4410 // Stored lowercase by the CTA, but a hand-edited settings file may not
4411 // be; fold here so the list matches what suggestions actually skip.
4412 let persisted: std::collections::BTreeSet<String> = crate::settings::Settings::load()
4413 .map_err(|err| format!("could not read saved plugin dismissals: {err}"))?
4414 .dismissed_plugin_suggestions
4415 .iter()
4416 .map(|name| name.to_ascii_lowercase())
4417 .collect();
4418 let app = self.host.app.borrow();
4419 let session = app
4420 .plugin_cta
4421 .dismissed
4422 .iter()
4423 .filter(|name| !persisted.contains(*name))
4424 .cloned()
4425 .collect();
4426 Ok(
4427 codewhale_command_contract::facets::PluginSuggestionDismissals {
4428 persisted: persisted.into_iter().collect(),
4429 session,
4430 },
4431 )
4432 }
4433
4434 fn reset_suggestion_dismissals(&mut self, name: Option<&str>) -> Result<Vec<String>, String> {
4435 let matches =
4436 |candidate: &String| name.is_none_or(|target| candidate.eq_ignore_ascii_case(target));
4437 let mut cleared = std::collections::BTreeSet::new();
4438 crate::settings::Settings::transact_opt(|settings| {
4439 let before = settings.dismissed_plugin_suggestions.len();
4440 settings.dismissed_plugin_suggestions.retain(|candidate| {
4441 let reset = matches(candidate);
4442 if reset {
4443 cleared.insert(candidate.to_ascii_lowercase());
4444 }
4445 !reset
4446 });
4447 Ok((settings.dismissed_plugin_suggestions.len() != before).then_some(()))
4448 })
4449 .map_err(|err| format!("could not save plugin dismissals: {err}"))?;
4450 let mut app = self.host.app.borrow_mut();
4451 app.plugin_cta.dismissed.retain(|candidate| {
4452 let reset = matches(candidate);
4453 if reset {
4454 cleared.insert(candidate.clone());
4455 }
4456 !reset
4457 });
4458 Ok(cleared.into_iter().collect())
4459 }
4460 }
4461
4462 /// Resolve the default Codewhale tools directory (mirrors the legacy handler).
4463 fn default_codewhale_tools_dir() -> Option<PathBuf> {
4464 codewhale_config::codewhale_home()
4465 .ok()
4466 .map(|home| home.join("tools"))
4467 }
4468
4469 // ---------------------------------------------------------------------------
4470 // Envelope construction (D1)
4471 // ---------------------------------------------------------------------------
4472
4473 /// Owns twenty-three facet objects sharing one synchronous TUI host proxy.
4474 ///
4475 /// Handlers borrow only these adapters. Every method delegates to the real App
4476 /// authority and releases its `RefCell` borrow before returning, so facets can
4477 /// be called sequentially without exposing TUI types across the boundary.
4478 pub(crate) struct CommandContextBundle<'a> {
4479 session: SessionAdapter<'a>,
4480 model: ModelAdapter<'a>,
4481 cost: CostAdapter<'a>,
4482 mode_policy: ModePolicyAdapter<'a>,
4483 system_prompt: SystemPromptAdapter<'a>,
4484 skills: SkillsAdapter<'a>,
4485 workspace: WorkspaceAdapter<'a>,
4486 presentation: PresentationAdapter<'a>,
4487 media: MediaAdapter<'a>,
4488 project: ProjectAdapter<'a>,
4489 memory: MemoryAdapter<'a>,
4490 skill_group: SkillGroupAdapter<'a>,
4491 plugin: PluginAdapter<'a>,
4492 lifecycle: SessionLifecycleAdapter<'a>,
4493 control: SessionControlAdapter<'a>,
4494 export: SessionExportAdapter<'a>,
4495 structcopy: SessionStructcopyAdapter<'a>,
4496 debug_receipts: DebugOperationsAdapter<'a>,
4497 debug_change: DebugOperationsAdapter<'a>,
4498 debug_history: DebugOperationsAdapter<'a>,
4499 debug_diff: DebugOperationsAdapter<'a>,
4500 debug_undo: DebugOperationsAdapter<'a>,
4501 debug_diagnostics: DebugDiagnosticsAdapter<'a>,
4502 }
4503
4504 impl<'a> CommandContextBundle<'a> {
4505 /// Expose exactly the capabilities declared by the command registration.
4506 pub(crate) fn contexts(&mut self, capabilities: CommandCapabilities) -> CommandContexts<'_> {
4507 let mut contexts = CommandContexts::empty();
4508 if capabilities.contains(CommandCapabilities::SESSION) {
4509 contexts = contexts.with_session(&mut self.session);
4510 }
4511 if capabilities.contains(CommandCapabilities::MODEL) {
4512 contexts = contexts.with_model(&mut self.model);
4513 }
4514 if capabilities.contains(CommandCapabilities::COST) {
4515 contexts = contexts.with_cost(&mut self.cost);
4516 }
4517 if capabilities.contains(CommandCapabilities::MODE_POLICY) {
4518 contexts = contexts.with_mode_policy(&mut self.mode_policy);
4519 }
4520 if capabilities.contains(CommandCapabilities::SYSTEM_PROMPT) {
4521 contexts = contexts.with_system_prompt(&mut self.system_prompt);
4522 }
4523 if capabilities.contains(CommandCapabilities::SKILLS) {
4524 contexts = contexts.with_skills(&mut self.skills);
4525 }
4526 if capabilities.contains(CommandCapabilities::WORKSPACE) {
4527 contexts = contexts.with_workspace(&mut self.workspace);
4528 }
4529 if capabilities.contains(CommandCapabilities::PRESENTATION) {
4530 contexts = contexts.with_presentation(&mut self.presentation);
4531 }
4532 if capabilities.contains(CommandCapabilities::MEDIA) {
4533 contexts = contexts.with_media(&mut self.media);
4534 }
4535 if capabilities.contains(CommandCapabilities::MEMORY) {
4536 contexts = contexts.with_memory(&mut self.memory);
4537 }
4538 if capabilities.contains(CommandCapabilities::PROJECT) {
4539 contexts = contexts.with_project(&mut self.project);
4540 }
4541 if capabilities.contains(CommandCapabilities::SKILL_GROUP) {
4542 contexts = contexts.with_skill_group(&mut self.skill_group);
4543 }
4544 if capabilities.contains(CommandCapabilities::PLUGIN) {
4545 contexts = contexts.with_plugin(&mut self.plugin);
4546 }
4547 if capabilities.contains(CommandCapabilities::SESSION_LIFECYCLE) {
4548 contexts = contexts.with_lifecycle(&mut self.lifecycle);
4549 }
4550 if capabilities.contains(CommandCapabilities::SESSION_CONTROL) {
4551 contexts = contexts.with_control(&mut self.control);
4552 }
4553 if capabilities.contains(CommandCapabilities::SESSION_STRUCTCOPY) {
4554 contexts = contexts.with_structcopy(&mut self.structcopy);
4555 }
4556 if capabilities.contains(CommandCapabilities::SESSION_EXPORT) {
4557 contexts = contexts.with_export(&mut self.export);
4558 }
4559 if capabilities.contains(CommandCapabilities::DEBUG_RECEIPTS) {
4560 contexts = contexts.with_debug_receipts(&mut self.debug_receipts);
4561 }
4562 if capabilities.contains(CommandCapabilities::DEBUG_CHANGE) {
4563 contexts = contexts.with_debug_change(&mut self.debug_change);
4564 }
4565 if capabilities.contains(CommandCapabilities::DEBUG_HISTORY) {
4566 contexts = contexts.with_debug_history(&mut self.debug_history);
4567 }
4568 if capabilities.contains(CommandCapabilities::DEBUG_DIFF) {
4569 contexts = contexts.with_debug_diff(&mut self.debug_diff);
4570 }
4571 if capabilities.contains(CommandCapabilities::DEBUG_UNDO) {
4572 contexts = contexts.with_debug_undo(&mut self.debug_undo);
4573 }
4574 if capabilities.contains(CommandCapabilities::DEBUG_DIAGNOSTICS) {
4575 contexts = contexts.with_debug_diagnostics(&mut self.debug_diagnostics);
4576 }
4577 contexts
4578 }
4579
4580 /// Test-only: consume the bundle into independent facet parts.
4581 #[cfg(test)]
4582 pub(crate) fn parts(&mut self) -> ContextParts<'_> {
4583 let all_test_capabilities = CommandCapabilities::SESSION
4584 .union(CommandCapabilities::MODEL)
4585 .union(CommandCapabilities::COST)
4586 .union(CommandCapabilities::MODE_POLICY)
4587 .union(CommandCapabilities::SYSTEM_PROMPT)
4588 .union(CommandCapabilities::SKILLS)
4589 .union(CommandCapabilities::WORKSPACE)
4590 .union(CommandCapabilities::PRESENTATION)
4591 .union(CommandCapabilities::MEDIA)
4592 .union(CommandCapabilities::MEMORY)
4593 .union(CommandCapabilities::PROJECT)
4594 .union(CommandCapabilities::SKILL_GROUP)
4595 .union(CommandCapabilities::PLUGIN)
4596 .union(CommandCapabilities::SESSION_LIFECYCLE)
4597 .union(CommandCapabilities::SESSION_CONTROL)
4598 .union(CommandCapabilities::SESSION_EXPORT)
4599 .union(CommandCapabilities::SESSION_STRUCTCOPY)
4600 .union(CommandCapabilities::DEBUG_RECEIPTS)
4601 .union(CommandCapabilities::DEBUG_CHANGE)
4602 .union(CommandCapabilities::DEBUG_HISTORY)
4603 .union(CommandCapabilities::DEBUG_DIFF)
4604 .union(CommandCapabilities::DEBUG_UNDO)
4605 .union(CommandCapabilities::DEBUG_DIAGNOSTICS);
4606 self.contexts(all_test_capabilities).into_parts()
4607 }
4608 }
4609
4610 impl App {
4611 /// Build an App-free capability envelope backed by authoritative TUI
4612 /// operations. The shared proxy is synchronous and local to one dispatch.
4613 #[cfg(test)]
4614 pub(crate) fn command_contexts(&mut self) -> CommandContextBundle<'_> {
4615 self.command_contexts_with_config(None)
4616 }
4617
4618 pub(crate) fn command_contexts_with_config<'a>(
4619 &'a mut self,
4620 config: Option<&'a crate::config::Config>,
4621 ) -> CommandContextBundle<'a> {
4622 let host = Rc::new(CommandHost {
4623 app: RefCell::new(self),
4624 config,
4625 });
4626 CommandContextBundle {
4627 session: SessionAdapter { host: host.clone() },
4628 model: ModelAdapter { host: host.clone() },
4629 cost: CostAdapter { host: host.clone() },
4630 mode_policy: ModePolicyAdapter { host: host.clone() },
4631 system_prompt: SystemPromptAdapter { host: host.clone() },
4632 skills: SkillsAdapter { host: host.clone() },
4633 workspace: WorkspaceAdapter { host: host.clone() },
4634 presentation: PresentationAdapter { host: host.clone() },
4635 media: MediaAdapter { host: host.clone() },
4636 project: ProjectAdapter { host: host.clone() },
4637 memory: MemoryAdapter { host: host.clone() },
4638 skill_group: SkillGroupAdapter { host: host.clone() },
4639 plugin: PluginAdapter { host: host.clone() },
4640 lifecycle: SessionLifecycleAdapter { host: host.clone() },
4641 control: SessionControlAdapter { host: host.clone() },
4642 export: SessionExportAdapter { host: host.clone() },
4643 structcopy: SessionStructcopyAdapter { host: host.clone() },
4644 debug_receipts: DebugOperationsAdapter { host: host.clone() },
4645 debug_change: DebugOperationsAdapter { host: host.clone() },
4646 debug_history: DebugOperationsAdapter { host: host.clone() },
4647 debug_diff: DebugOperationsAdapter { host: host.clone() },
4648 debug_undo: DebugOperationsAdapter { host: host.clone() },
4649 debug_diagnostics: DebugDiagnosticsAdapter { host },
4650 }
4651 }
4652 }
4653
4654 #[cfg(test)]
4655 mod tests {
4656 use super::*;
4657 use codewhale_localization::Locale;
4658 use codewhale_models::Role;
4659 use tempfile::TempDir;
4660
4661 fn test_app() -> App {
4662 crate::test_support::test_app_with_options(crate::test_support::test_tui_options(
4663 PathBuf::from("."),
4664 ))
4665 }
4666
4667 #[test]
4668 fn skill_registry_sync_without_home_refuses_before_the_network_bridge() {
4669 // No Tokio runtime is present: entering run_async would panic rather
4670 // than downloading a registry into an undiscoverable temporary root.
4671 let result = sync_registry_to_cache(None);
4672 assert_eq!(
4673 result.unwrap_err(),
4674 "global skill mutations require a home directory"
4675 );
4676 }
4677
4678 /// A 1x1 PNG for media adapter tests.
4679 const PNG_1X1: &[u8] = &[
4680 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
4681 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f,
4682 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00,
4683 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
4684 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
4685 ];
4686
4687 #[test]
4688 fn pending_groups_is_sorted_unique_and_matches_checked_in_frontier() {
4689 let mut sorted = PENDING_GROUPS.to_vec();
4690 sorted.sort_unstable();
4691 assert_eq!(PENDING_GROUPS, sorted.as_slice(), "frontier must be sorted");
4692 let unique: std::collections::BTreeSet<&str> = PENDING_GROUPS.iter().copied().collect();
4693 assert_eq!(
4694 unique.len(),
4695 PENDING_GROUPS.len(),
4696 "frontier must be unique"
4697 );
4698
4699 let topology: serde_json::Value = serde_json::from_str(include_str!(
4700 "../../../../scripts/command-migration-topology.json"
4701 ))
4702 .expect("checked-in topology must be valid JSON");
4703 let frontier = topology["frontier"]
4704 .as_array()
4705 .expect("topology frontier")
4706 .iter()
4707 .map(|entry| entry.as_str().expect("string frontier entry"))
4708 .collect::<Vec<_>>();
4709 assert_eq!(PENDING_GROUPS, frontier.as_slice());
4710 }
4711
4712 #[test]
4713 fn boundary_mappings_cover_every_variant() {
4714 for mode in [AppMode::Agent, AppMode::Plan, AppMode::Operate] {
4715 let command = to_command_mode(mode);
4716 assert_eq!(from_command_mode(command), mode);
4717 }
4718 for approval in [
4719 ApprovalMode::Auto,
4720 ApprovalMode::Bypass,
4721 ApprovalMode::Suggest,
4722 ApprovalMode::Never,
4723 ] {
4724 let _ = to_command_approval(approval);
4725 }
4726 for currency in [CostCurrency::Usd, CostCurrency::Cny] {
4727 let command = to_command_currency(currency);
4728 assert_eq!(from_command_currency(command), currency);
4729 }
4730 }
4731
4732 #[test]
4733 fn key_to_message_id_resolves_convention_keys_and_rejects_unknown() {
4734 assert_eq!(
4735 key_to_message_id("cmd_balance_description"),
4736 Some(MessageId::CmdBalanceDescription)
4737 );
4738 assert_eq!(
4739 key_to_message_id("cmd_voice_control_description"),
4740 Some(MessageId::CmdVoiceControlDescription)
4741 );
4742 assert_eq!(key_to_message_id("cmd_nonexistent_description"), None);
4743 assert_eq!(key_to_message_id(""), None);
4744 }
4745
4746 #[test]
4747 fn cost_adapter_delegates_totals_high_water_and_route_receipt_to_app() {
4748 let mut app = test_app();
4749 app.cost_currency = CostCurrency::Usd;
4750 {
4751 let mut bundle = app.command_contexts();
4752 let mut parts = bundle.parts();
4753 let cost = parts.cost.as_mut().expect("cost facet");
4754 cost.accrue_cost_estimate(3.0, CommandCurrency::Usd);
4755 cost.record_turn_cost(
4756 4.0,
4757 CommandCurrency::Cny,
4758 Some("provider=deepseek model=x".to_string()),
4759 );
4760 assert_eq!(cost.session_cost_for_currency(CommandCurrency::Usd), 3.0);
4761 assert_eq!(cost.session_cost_for_currency(CommandCurrency::Cny), 4.0);
4762 }
4763 assert_eq!(app.session_cost_for_currency(CostCurrency::Usd), 3.0);
4764 assert_eq!(app.session_cost_for_currency(CostCurrency::Cny), 4.0);
4765 assert_eq!(
4766 app.displayed_session_cost_for_currency(CostCurrency::Usd),
4767 3.0
4768 );
4769 assert!(
4770 app.session
4771 .cost_route_receipts
4772 .contains("provider=deepseek model=x")
4773 );
4774 }
4775
4776 #[test]
4777 fn session_adapter_delegates_message_and_queue_operations_to_app() {
4778 let mut app = test_app();
4779 app.current_session_id = Some("s1".to_string());
4780 app.session.total_tokens = 42;
4781 app.queue_message(crate::tui::app::QueuedMessage {
4782 display: "q".to_string(),
4783 skill_instruction: None,
4784 skill_provenance: None,
4785 history_echoed: false,
4786 });
4787 {
4788 let mut bundle = app.command_contexts();
4789 let mut parts = bundle.parts();
4790 let session = parts.session.as_mut().expect("session facet");
4791 assert_eq!(session.session_id().as_deref(), Some("s1"));
4792 session.add_message(Message {
4793 role: Role::User,
4794 content: vec![],
4795 });
4796 assert_eq!(session.api_messages().len(), 1);
4797 assert_eq!(session.queued_message_count(), 1);
4798 assert!(session.remove_queued_message(0).is_ok());
4799 assert!(session.remove_queued_message(5).is_err());
4800 assert_eq!(session.total_tokens(), 42);
4801 }
4802 assert_eq!(app.api_messages.len(), 1);
4803 assert_eq!(app.queued_message_count(), 0);
4804 }
4805
4806 #[test]
4807 fn model_adapter_delegates_selection_and_route_invalidation_to_app() {
4808 let mut app = test_app();
4809 app.last_effective_model = Some("stale-model".to_string());
4810 let config = crate::config::Config::default();
4811 {
4812 let mut bundle = app.command_contexts_with_config(Some(&config));
4813 let mut parts = bundle.parts();
4814 let model = parts.model.as_mut().expect("model facet");
4815 model.set_model_selection("auto".to_string(), Some(to_provider_id("deepseek")));
4816 assert!(model.auto_model());
4817 assert_eq!(model.current_model(), "auto");
4818 assert_eq!(
4819 model.provider_identity().map(|id| id.0).as_deref(),
4820 Some("deepseek")
4821 );
4822 }
4823 assert!(app.last_effective_model.is_none());
4824 assert_eq!(app.provider_identity_for_persistence(), "deepseek");
4825 }
4826
4827 #[test]
4828 fn model_adapter_admits_cross_provider_and_case_distinct_custom_routes_from_borrowed_config() {
4829 use crate::config::{Config, ProviderConfig, ProvidersConfig};
4830 let config = Config {
4831 provider: Some("openai".to_string()),
4832 providers: Some(ProvidersConfig {
4833 custom: [
4834 ("CustomA".to_string(), "http://127.0.0.1:4111/v1"),
4835 ("customa".to_string(), "http://127.0.0.1:4222/v1"),
4836 ]
4837 .into_iter()
4838 .map(|(key, endpoint)| {
4839 (
4840 key,
4841 ProviderConfig {
4842 kind: Some("openai-compatible".to_string()),
4843 base_url: Some(endpoint.to_string()),
4844 ..Default::default()
4845 },
4846 )
4847 })
4848 .collect(),
4849 ..Default::default()
4850 }),
4851 ..Config::default()
4852 };
4853 let mut app = test_app();
4854 app.set_provider_identity_record(config.active_provider_identity().unwrap());
4855 for (key, model) in [
4856 ("CustomA", "private-a"),
4857 ("customa", "private-b"),
4858 ("openai", "gpt-5.5"),
4859 ] {
4860 {
4861 let mut bundle = app.command_contexts_with_config(Some(&config));
4862 let mut parts = bundle.parts();
4863 parts
4864 .model
4865 .as_mut()
4866 .unwrap()
4867 .set_model_selection(model.to_string(), Some(to_provider_id(key)));
4868 }
4869 assert_eq!(
4870 app.admitted_provider_identity().unwrap(),
4871 &config.resolve_provider_identity(key).unwrap()
4872 );
4873 assert_eq!(app.provider_identity_for_persistence(), key);
4874 assert_eq!(app.model, model);
4875 }
4876 // Exact keys do not inherit a same-kind sibling or a generic custom route.
4877 for refused in ["CUSTOMA", "custom", "antigravity"] {
4878 let captured = app.admitted_provider_identity().unwrap().clone();
4879 {
4880 let mut bundle = app.command_contexts_with_config(Some(&config));
4881 let mut parts = bundle.parts();
4882 parts.model.as_mut().unwrap().set_model_selection(
4883 "must-not-apply".to_string(),
4884 Some(to_provider_id(refused)),
4885 );
4886 }
4887 assert_eq!(app.admitted_provider_identity().unwrap(), &captured);
4888 assert_eq!(app.model, "gpt-5.5");
4889 }
4890 }
4891
4892 #[test]
4893 fn model_adapter_without_active_config_refuses_before_route_or_model_mutation() {
4894 let mut app = test_app();
4895 let model = app.model.clone();
4896 let captured = app.admitted_provider_identity().unwrap().clone();
4897 app.last_effective_model = Some("captured-live-route".to_string());
4898 {
4899 let mut bundle = app.command_contexts();
4900 let mut parts = bundle.parts();
4901 let facet = parts.model.as_mut().unwrap();
4902 facet.set_model_selection("must-not-apply".to_string(), Some(to_provider_id("openai")));
4903 facet.set_model_selection("must-not-apply".to_string(), None);
4904 }
4905 assert_eq!(app.model, model);
4906 assert_eq!(app.admitted_provider_identity().unwrap(), &captured);
4907 assert_eq!(
4908 app.last_effective_model.as_deref(),
4909 Some("captured-live-route")
4910 );
4911 }
4912
4913 #[test]
4914 fn mode_policy_adapter_delegates_mode_and_shell_policy_to_app() {
4915 let mut app = test_app();
4916 app.set_agent_shell_access(false);
4917 {
4918 let mut bundle = app.command_contexts();
4919 let mut parts = bundle.parts();
4920 let policy = parts.mode_policy.as_mut().expect("mode facet");
4921 policy.set_mode(CommandMode::Operate);
4922 policy.set_shell_access(true);
4923 assert!(policy.allow_shell());
4924 assert_eq!(policy.mode(), CommandMode::Operate);
4925 }
4926 assert_eq!(
4927 app.mode,
4928 AppMode::Operate,
4929 "adapter delegates to App authority"
4930 );
4931 assert!(app.allow_shell);
4932 }
4933
4934 #[test]
4935 fn system_prompt_adapter_returns_owned_prompt() {
4936 let mut app = test_app();
4937 app.system_prompt = Some(SystemPrompt::Text("system".to_string()));
4938 let mut bundle = app.command_contexts();
4939 let parts = bundle.parts();
4940 assert!(
4941 parts
4942 .system_prompt
4943 .expect("system prompt facet")
4944 .system_prompt()
4945 .is_some()
4946 );
4947 }
4948
4949 #[test]
4950 fn workspace_adapter_returns_path_and_snapshot() {
4951 let mut app = test_app();
4952 let expected = app.workspace.clone();
4953 let mut bundle = app.command_contexts();
4954 let parts = bundle.parts();
4955 let workspace = parts.workspace.expect("workspace facet");
4956 assert_eq!(workspace.workspace(), expected);
4957 assert!(workspace.work_state_snapshot().is_ok());
4958 }
4959
4960 #[test]
4961 fn envelope_exposes_all_facets_without_app_in_handler_surface() {
4962 let mut app = test_app();
4963 let mut bundle = app.command_contexts();
4964 let parts = bundle.parts();
4965 assert!(parts.session.is_some());
4966 assert!(parts.model.is_some());
4967 assert!(parts.cost.is_some());
4968 assert!(parts.mode_policy.is_some());
4969 assert!(parts.system_prompt.is_some());
4970 assert!(parts.skills.is_some());
4971 assert!(parts.workspace.is_some());
4972 assert!(parts.presentation.is_some());
4973 assert!(parts.media.is_some());
4974 }
4975
4976 #[test]
4977 fn diagnostics_envelope_exposes_only_declared_authority() {
4978 let mut harness =
4979 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
4980 let mut bundle = harness.app.command_contexts();
4981 let parts = bundle
4982 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
4983 .into_parts();
4984 assert!(parts.debug_diagnostics.is_some());
4985 for (name, present) in [
4986 ("session", parts.session.is_some()),
4987 ("model", parts.model.is_some()),
4988 ("cost", parts.cost.is_some()),
4989 ("mode_policy", parts.mode_policy.is_some()),
4990 ("system_prompt", parts.system_prompt.is_some()),
4991 ("skills", parts.skills.is_some()),
4992 ("workspace", parts.workspace.is_some()),
4993 ("presentation", parts.presentation.is_some()),
4994 ("media", parts.media.is_some()),
4995 ("memory", parts.memory.is_some()),
4996 ("project", parts.project.is_some()),
4997 ("skill_group", parts.skill_group.is_some()),
4998 ("plugin", parts.plugin.is_some()),
4999 ("lifecycle", parts.lifecycle.is_some()),
5000 ("control", parts.control.is_some()),
5001 ("export", parts.export.is_some()),
5002 ] {
5003 assert!(
5004 !present,
5005 "{name} must not be exposed to diagnostics-only commands"
5006 );
5007 }
5008 let parts = bundle.contexts(CommandCapabilities::NONE).into_parts();
5009 assert!(parts.debug_diagnostics.is_none());
5010 }
5011
5012 #[test]
5013 fn diagnostics_adapter_projects_host_balance_system_and_optional_usage() {
5014 let mut harness =
5015 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
5016 harness.app.system_prompt = Some(SystemPrompt::Text("policy".to_string()));
5017 harness.app.session.last_prompt_tokens = None;
5018 harness.app.session.last_completion_tokens = Some(0);
5019 let expected_provider = harness
5020 .app
5021 .api_provider
5022 .provider()
5023 .display_name()
5024 .to_string();
5025 let expected_support = crate::config::provider_has_balance_api(harness.app.api_provider);
5026 let mut bundle = harness.app.command_contexts();
5027 let mut parts = bundle
5028 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5029 .into_parts();
5030 let diagnostics = parts
5031 .debug_diagnostics
5032 .as_mut()
5033 .expect("declared diagnostics");
5034 let balance = diagnostics.balance_projection();
5035 assert_eq!(balance.provider_display_name, expected_provider);
5036 assert_eq!(balance.supports_balance_api, expected_support);
5037 let system = diagnostics.system_projection();
5038 assert_eq!(
5039 system.prompt,
5040 codewhale_command_contract::facets::DebugSystemPrompt::Text("policy".into())
5041 );
5042 let usage = diagnostics.token_projection();
5043 assert_eq!(usage.last_input, None);
5044 assert_eq!(usage.last_output, Some(0));
5045 assert_eq!(usage.cost, diagnostics.cost_projection());
5046 assert!(diagnostics.tool_snapshot().is_none());
5047 assert!(diagnostics.cache_telemetry().history.is_empty());
5048 }
5049
5050 #[test]
5051 fn diagnostics_adapter_cost_and_cache_telemetry_preserve_real_distinctions() {
5052 let mut harness =
5053 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
5054 let app = &mut harness.app;
5055 app.session.cost_priced_turns = 1;
5056 app.accrue_session_cost_estimate(crate::pricing::CostEstimate {
5057 usd: 0.05,
5058 cny: 0.0,
5059 });
5060 app.accrue_subagent_cost_estimate(crate::pricing::CostEstimate {
5061 usd: 0.02,
5062 cny: 0.0,
5063 });
5064 app.session.displayed_cost_high_water = 0.10;
5065 app.push_turn_cache_record(crate::tui::app::TurnCacheRecord {
5066 provider: None,
5067 provider_identity: None,
5068 model: None,
5069 auto_model: false,
5070 input_tokens: 120,
5071 output_tokens: 0,
5072 cache_hit_tokens: Some(0),
5073 cache_miss_tokens: None,
5074 cache_write_tokens: None,
5075 reasoning_tokens: None,
5076 reasoning_replay_tokens: None,
5077 cost_audit: None,
5078 recorded_at: std::time::Instant::now(),
5079 });
5080 let expected_total =
5081 app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd);
5082 let mut bundle = app.command_contexts();
5083 let mut parts = bundle
5084 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5085 .into_parts();
5086 let diagnostic = parts.debug_diagnostics.as_mut().unwrap();
5087 let cost = diagnostic.cost_projection();
5088 assert_eq!(cost.parent_turns, 0.05);
5089 assert_eq!(cost.subagents, 0.02);
5090 assert!(cost.display_floor > 0.0);
5091 assert_eq!(
5092 cost.parent_turns + cost.subagents + cost.display_floor,
5093 expected_total
5094 );
5095 let telemetry = diagnostic.cache_telemetry();
5096 assert_eq!(telemetry.history.len(), 1);
5097 let turn = &telemetry.history[0];
5098 assert_eq!(
5099 turn.cache_hit_tokens,
5100 Some(0),
5101 "reported zero is not missing telemetry"
5102 );
5103 assert_eq!(turn.cache_miss_tokens, None);
5104 assert_eq!(turn.priced_amount, None, "no audit is not priced zero");
5105 assert_eq!(turn.priced_cache_miss, 120);
5106 assert!(turn.age_seconds < 3);
5107 assert!(
5108 diagnostic.tool_snapshot().is_none(),
5109 "no request differs from an empty catalog"
5110 );
5111 }
5112
5113 #[test]
5114 fn diagnostics_adapter_limits_sensitive_content_to_declared_operations() {
5115 let mut harness =
5116 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
5117 let secret = "DIAGNOSTICS-PRIVATE-SYSTEM-SENTINEL";
5118 harness.app.system_prompt = Some(SystemPrompt::Text(secret.into()));
5119 let mut bundle = harness.app.command_contexts();
5120 let mut parts = bundle
5121 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5122 .into_parts();
5123 let diagnostics = parts.debug_diagnostics.as_mut().unwrap();
5124 for public_projection in [
5125 format!("{:?}", diagnostics.balance_projection()),
5126 format!("{:?}", diagnostics.cost_projection()),
5127 format!("{:?}", diagnostics.token_projection()),
5128 format!("{:?}", diagnostics.cache_telemetry()),
5129 ] {
5130 assert!(
5131 !public_projection.contains(secret),
5132 "unrelated operation must not include the prompt"
5133 );
5134 }
5135 let system = diagnostics.system_projection();
5136 assert_eq!(
5137 system.prompt,
5138 codewhale_command_contract::facets::DebugSystemPrompt::Text(secret.into())
5139 );
5140 assert!(
5141 serde_json::to_string(&diagnostics.prompt_context())
5142 .unwrap()
5143 .contains(secret)
5144 );
5145 }
5146
5147 #[test]
5148 fn diagnostics_adapter_retains_full_tool_snapshot_schema() {
5149 let mut harness =
5150 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
5151 let snapshot = crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request(
5152 "turn-1",
5153 2,
5154 Some(&[]),
5155 );
5156 harness.app.session.last_tool_request_snapshot = Some(snapshot.clone());
5157 let mut bundle = harness.app.command_contexts();
5158 let mut parts = bundle
5159 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5160 .into_parts();
5161 let projected = parts
5162 .debug_diagnostics
5163 .as_mut()
5164 .unwrap()
5165 .tool_snapshot()
5166 .unwrap();
5167 assert_eq!(
5168 serde_json::to_value(&projected).unwrap(),
5169 serde_json::to_value(&snapshot).unwrap()
5170 );
5171 assert_eq!(projected.tool_count, 0);
5172 }
5173
5174 #[test]
5175 fn diagnostics_adapter_projects_full_context_and_prepared_tool_json() {
5176 let mut harness =
5177 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
5178 harness.app.system_prompt = Some(SystemPrompt::Text("source-map policy".into()));
5179 let tool = codewhale_models::Tool {
5180 tool_type: Some("function".into()),
5181 name: "search".into(),
5182 description: "Find records".into(),
5183 input_schema: serde_json::json!({"type":"object","properties":{"q":{"type":"string"}}}),
5184 allowed_callers: Some(vec!["assistant".into()]),
5185 defer_loading: Some(false),
5186 input_examples: None,
5187 strict: Some(true),
5188 cache_control: None,
5189 };
5190 harness.app.session.last_tool_catalog = Some(vec![tool.clone()]);
5191 let original_context = crate::context_report::build_prompt_context(&harness.app);
5192 let original_snapshot =
5193 crate::tool_inspection::ToolInspectionSnapshot::from_prepared_request(
5194 "turn",
5195 3,
5196 Some(&[tool]),
5197 );
5198 harness.app.session.last_tool_request_snapshot = Some(original_snapshot.clone());
5199 let mut bundle = harness.app.command_contexts();
5200 let mut parts = bundle
5201 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5202 .into_parts();
5203 let diagnostics = parts.debug_diagnostics.as_mut().unwrap();
5204 let projected_context = diagnostics.prompt_context();
5205 let actual = crate::commands::debug_diagnostics_test_support::normalize_generated_at(
5206 &serde_json::to_string_pretty(&projected_context).unwrap(),
5207 );
5208 let expected = crate::commands::debug_diagnostics_test_support::normalize_generated_at(
5209 &serde_json::to_string_pretty(&original_context).unwrap(),
5210 );
5211 assert_eq!(projected_context.tools.len(), 1);
5212 assert_eq!(projected_context.tools[0].name, "search");
5213 assert_eq!(
5214 actual, expected,
5215 "projection retains the full ordered prompt JSON"
5216 );
5217 let projected_snapshot = diagnostics.tool_snapshot().unwrap();
5218 assert_eq!(
5219 serde_json::to_value(projected_snapshot).unwrap(),
5220 serde_json::to_value(original_snapshot).unwrap()
5221 );
5222 }
5223
5224 #[test]
5225 fn diagnostics_adapter_inspection_failure_does_not_write_and_success_commits_once() {
5226 let mut harness =
5227 crate::commands::debug_diagnostics_test_support::DiagnosticsHarness::new();
5228 harness.app.auto_model = true;
5229 harness.app.model = "auto".into();
5230 {
5231 let mut bundle = harness.app.command_contexts();
5232 let mut parts = bundle
5233 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5234 .into_parts();
5235 let diagnostics = parts.debug_diagnostics.as_mut().unwrap();
5236 assert_eq!(diagnostics.inspect_cache(), Err(codewhale_command_contract::facets::DebugCacheInspectionUnavailable::NoConcreteRoute));
5237 }
5238 assert!(harness.app.session.last_cache_inspection.is_none());
5239 harness.app.auto_model = false;
5240 harness.app.model = "deepseek-v4-pro".into();
5241 harness.app.active_route_base_url = "https://example.invalid/v1".into();
5242 let first = {
5243 let mut bundle = harness.app.command_contexts();
5244 let mut parts = bundle
5245 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5246 .into_parts();
5247 let diagnostics = parts.debug_diagnostics.as_mut().unwrap();
5248 let observation = diagnostics.inspect_cache().expect("concrete local route");
5249 assert!(observation.previous.is_none());
5250 assert!(
5251 diagnostics.inspect_cache().unwrap().previous.is_none(),
5252 "observation must not commit itself"
5253 );
5254 diagnostics.remember_cache_inspection(observation.current.clone());
5255 observation.current
5256 };
5257 assert_eq!(
5258 harness
5259 .app
5260 .session
5261 .last_cache_inspection
5262 .as_ref()
5263 .unwrap()
5264 .base_static_prefix_hash,
5265 first.base_static_prefix_hash
5266 );
5267 harness.app.active_route_base_url.clear();
5268 {
5269 let mut bundle = harness.app.command_contexts();
5270 let mut parts = bundle
5271 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5272 .into_parts();
5273 assert_eq!(
5274 parts.debug_diagnostics.as_mut().unwrap().inspect_cache(),
5275 Err(codewhale_command_contract::facets::DebugCacheInspectionUnavailable::MissingCapturedEndpoint),
5276 );
5277 }
5278 assert_eq!(
5279 harness
5280 .app
5281 .session
5282 .last_cache_inspection
5283 .as_ref()
5284 .unwrap()
5285 .base_static_prefix_hash,
5286 first.base_static_prefix_hash,
5287 "failed endpoint lookup must not write"
5288 );
5289 harness.app.active_route_base_url = "https://example.invalid/v1".into();
5290 harness.app.system_prompt = Some(SystemPrompt::Text("changed".into()));
5291 let mut bundle = harness.app.command_contexts();
5292 let mut parts = bundle
5293 .contexts(CommandCapabilities::DEBUG_DIAGNOSTICS)
5294 .into_parts();
5295 let observation = parts
5296 .debug_diagnostics
5297 .as_mut()
5298 .unwrap()
5299 .inspect_cache()
5300 .unwrap();
5301 assert_eq!(
5302 observation.previous.unwrap().base_static_prefix_hash,
5303 first.base_static_prefix_hash
5304 );
5305 assert_ne!(
5306 observation.current.base_static_prefix_hash,
5307 first.base_static_prefix_hash
5308 );
5309 }
5310
5311 // -----------------------------------------------------------------------
5312 // FEAT-018 adapter tests: presentation (D3), media (D4), digest (D5)
5313 // -----------------------------------------------------------------------
5314
5315 #[test]
5316 fn presentation_adapter_resolves_utility_keys_with_english_fallback() {
5317 let mut app = test_app();
5318 app.ui_locale = Locale::En;
5319 let mut bundle = app.command_contexts();
5320 let mut parts = bundle.parts();
5321 let presentation = parts.presentation.as_mut().expect("presentation facet");
5322
5323 // automation_usage has no placeholders.
5324 let usage = presentation
5325 .translate("automation_usage", &[])
5326 .expect("automation usage key");
5327 assert!(
5328 usage.contains("/automation"),
5329 "expected usage text, got {usage}"
5330 );
5331
5332 // mcp_recommended_unknown_id needs {recommendations_command}.
5333 let unknown = presentation
5334 .translate(
5335 "mcp_recommended_unknown_id",
5336 &[("recommendations_command", "/mcp recommendations")],
5337 )
5338 .expect("mcp unknown-id key");
5339 assert!(
5340 unknown.contains("/mcp recommendations"),
5341 "expected replacement text, got {unknown}"
5342 );
5343
5344 // mcp_recommendation_github needs {endpoint}, {login_command}, {add_command}.
5345 let github = presentation
5346 .translate(
5347 "mcp_recommendation_github",
5348 &[
5349 ("endpoint", "https://api.githubcopilot.com/mcp/"),
5350 ("login_command", "/mcp login github"),
5351 ("add_command", "/mcp add recommended github"),
5352 ],
5353 )
5354 .expect("github recommendation key");
5355 assert!(
5356 github.contains("https://api.githubcopilot.com/mcp/"),
5357 "{github}"
5358 );
5359 assert!(
5360 !github.contains("{endpoint}"),
5361 "placeholder must be replaced"
5362 );
5363 }
5364
5365 #[test]
5366 fn presentation_adapter_rejects_unknown_keys_and_invalid_replacements() {
5367 let mut app = test_app();
5368 app.ui_locale = Locale::En;
5369 let mut bundle = app.command_contexts();
5370 let mut parts = bundle.parts();
5371 let presentation = parts.presentation.as_mut().expect("presentation facet");
5372
5373 let unknown = presentation.translate("no_such_key", &[]);
5374 assert!(unknown.is_err(), "unknown key must fail safely");
5375 let err = unknown.unwrap_err();
5376 assert!(
5377 !err.contains("no_such_key"),
5378 "no raw lookup key exposure (D3): {err}"
5379 );
5380
5381 // Missing required replacement.
5382 assert!(
5383 presentation
5384 .translate("mcp_recommendation_github", &[])
5385 .is_err()
5386 );
5387 // Extra replacement not present in the template.
5388 assert!(
5389 presentation
5390 .translate("automation_usage", &[("no_such_placeholder", "value")],)
5391 .is_err()
5392 );
5393 // Duplicate replacement names.
5394 assert!(
5395 presentation
5396 .translate(
5397 "mcp_recommendation_github",
5398 &[
5399 ("endpoint", "a"),
5400 ("endpoint", "b"),
5401 ("login_command", "c"),
5402 ("add_command", "d"),
5403 ],
5404 )
5405 .is_err()
5406 );
5407 }
5408
5409 #[test]
5410 fn media_adapter_attaches_valid_image_and_preserves_confirm() {
5411 let tmpdir = tempfile::TempDir::new().expect("tempdir");
5412 let image_path = tmpdir.path().join("photo.png");
5413 std::fs::write(&image_path, PNG_1X1).expect("write image fixture");
5414
5415 let mut app = test_app();
5416 let mut bundle = app.command_contexts();
5417 let mut parts = bundle.parts();
5418 let media = parts.media.as_mut().expect("media facet");
5419 let receipt = media
5420 .attach_media(&image_path)
5421 .expect("valid image attaches");
5422 assert_eq!(receipt.kind, "image");
5423 assert_eq!(receipt.path, image_path.canonicalize().expect("canonical"));
5424 assert!(
5425 app.input.contains("[Attached image:"),
5426 "composer must contain the attachment reference"
5427 );
5428 }
5429
5430 #[test]
5431 fn media_adapter_rejects_invalid_media_atomically() {
5432 let tmpdir = tempfile::TempDir::new().expect("tempdir");
5433
5434 // Missing path.
5435 let mut app = test_app();
5436 {
5437 let mut bundle = app.command_contexts();
5438 let mut parts = bundle.parts();
5439 let media = parts.media.as_mut().expect("media facet");
5440 let missing = tmpdir.path().join("missing.png");
5441 let err = media.attach_media(&missing).unwrap_err();
5442 assert!(err.contains("Attachment not found"), "{err}");
5443 }
5444 assert!(
5445 app.input.is_empty(),
5446 "refused attachment must not reach composer"
5447 );
5448
5449 // Directory is not a file.
5450 {
5451 let mut bundle = app.command_contexts();
5452 let mut parts = bundle.parts();
5453 let media = parts.media.as_mut().expect("media facet");
5454 let dir = tmpdir.path().to_path_buf();
5455 let err = media.attach_media(&dir).unwrap_err();
5456 assert!(err.contains("Attachment is not a file"), "{err}");
5457 }
5458 assert!(app.input.is_empty());
5459
5460 // Unsupported extension.
5461 std::fs::write(tmpdir.path().join("notes.txt"), b"text").expect("write fixture");
5462 {
5463 let mut bundle = app.command_contexts();
5464 let mut parts = bundle.parts();
5465 let media = parts.media.as_mut().expect("media facet");
5466 let err = media
5467 .attach_media(&tmpdir.path().join("notes.txt"))
5468 .unwrap_err();
5469 assert!(err.contains("Unsupported attachment type"), "{err}");
5470 }
5471 assert!(app.input.is_empty());
5472
5473 // Corrupt image with a valid extension.
5474 std::fs::write(tmpdir.path().join("bad.png"), b"not an image").expect("write fixture");
5475 {
5476 let mut bundle = app.command_contexts();
5477 let mut parts = bundle.parts();
5478 let media = parts.media.as_mut().expect("media facet");
5479 let err = media
5480 .attach_media(&tmpdir.path().join("bad.png"))
5481 .unwrap_err();
5482 assert!(!err.is_empty(), "corrupt image must fail");
5483 }
5484 assert!(app.input.is_empty());
5485 }
5486
5487 #[test]
5488 fn media_adapter_attaches_valid_video_reference() {
5489 // A real (non-image) media file with a video extension passes the
5490 // extension gate without byte validation, matching baseline /attach.
5491 let tmpdir = tempfile::TempDir::new().expect("tempdir");
5492 let video_path = tmpdir.path().join("clip.mp4");
5493 std::fs::write(&video_path, b"not a real mp4 but extension-gated").expect("write");
5494
5495 let mut app = test_app();
5496 let mut bundle = app.command_contexts();
5497 let mut parts = bundle.parts();
5498 let media = parts.media.as_mut().expect("media facet");
5499 let receipt = media
5500 .attach_media(&video_path)
5501 .expect("video path attaches by extension");
5502 assert_eq!(receipt.kind, "video");
5503 assert!(app.input.contains("[Attached video:"), "{}", app.input);
5504 }
5505
5506 #[test]
5507 fn workspace_digest_adapter_preserves_no_active_and_failure_semantics() {
5508 let mut app = test_app();
5509 app.runtime_services.work = None;
5510 {
5511 let mut bundle = app.command_contexts();
5512 let mut parts = bundle.parts();
5513 let workspace = parts.workspace.as_mut().expect("workspace facet");
5514 assert_eq!(
5515 workspace.operation_digest().expect("no-runtime digest"),
5516 "No active operations or to-do items."
5517 );
5518 }
5519 }
5520
5521 #[test]
5522 fn bundle_construction_performs_no_eager_work() {
5523 let mut app = test_app();
5524 let input_before = app.input.clone();
5525 {
5526 let mut bundle = app.command_contexts();
5527 let parts = bundle.parts();
5528 // Merely constructing the bundle must not mutate composer state or
5529 // perform capability work; the adapters only run on method calls.
5530 let _ = parts.media.is_some();
5531 let _ = parts.presentation.is_some();
5532 let _ = parts.memory.is_some();
5533 let _ = parts.project.is_some();
5534 }
5535 assert_eq!(app.input, input_before, "no eager composer mutation");
5536 }
5537 // FEAT-021 project adapter tests
5538 // ---------------------------------------------------------------------
5539
5540 #[test]
5541 fn key_to_project_message_id_resolves_goal_runtime_keys_and_rejects_unknown() {
5542 // FEAT-021 D5: only /goal uses runtime translations via the project
5543 // key map; unknown keys fail safely.
5544 assert_eq!(
5545 key_to_project_message_id("goal_control_accepted"),
5546 Some(MessageId::GoalControlAccepted)
5547 );
5548 assert_eq!(
5549 key_to_project_message_id("goal_status_idle_hint"),
5550 Some(MessageId::GoalStatusIdleHint)
5551 );
5552 assert_eq!(key_to_project_message_id("goal_bogus_key"), None);
5553 assert_eq!(key_to_project_message_id(""), None);
5554 }
5555
5556 #[test]
5557 fn presentation_translate_resolves_project_keys_with_locale_and_fallback() {
5558 // The presentation facet resolves the project runtime keys through the
5559 // current catalog (authoritative English fallback preserved).
5560 let mut app = test_app();
5561 let mut bundle = app.command_contexts();
5562 let mut parts = bundle.parts();
5563 let presentation = parts.presentation.as_mut().expect("presentation facet");
5564 let accepted = presentation
5565 .translate("goal_control_accepted", &[])
5566 .expect("goal_control_accepted must resolve");
5567 assert!(
5568 accepted.contains("Goal control saved"),
5569 "English fallback text expected: {accepted}"
5570 );
5571 let hint = presentation
5572 .translate("goal_status_idle_hint", &[])
5573 .expect("goal_status_idle_hint must resolve");
5574 assert!(hint.contains("not running now"), "hint: {hint}");
5575 assert!(
5576 presentation.translate("goal_bogus", &[]).is_err(),
5577 "unknown key must fail safely"
5578 );
5579 }
5580
5581 // -----------------------------------------------------------------------
5582 // FEAT-019: memory adapter mappings (D6/D9)
5583 // -----------------------------------------------------------------------
5584
5585 /// App with an isolated temp memory file; memory feature enabled or not.
5586 fn memory_test_app(tmpdir: &TempDir, use_memory: bool) -> App {
5587 let options = crate::test_support::test_tui_options(tmpdir.path());
5588 let options = crate::tui::app::TuiOptions {
5589 memory_path: tmpdir.path().join("memory.md"),
5590 use_memory,
5591 ..options
5592 };
5593 crate::test_support::test_app_with_options(options)
5594 }
5595
5596 /// Give a temp workspace a git origin so workspace identity resolves.
5597 fn git_origin(workspace: &Path) {
5598 let init = std::process::Command::new("git")
5599 .arg("-C")
5600 .arg(workspace)
5601 .args(["init", "-q"])
5602 .status()
5603 .unwrap();
5604 assert!(init.success(), "git init must succeed");
5605 let remote = std::process::Command::new("git")
5606 .arg("-C")
5607 .arg(workspace)
5608 .args(["remote", "add", "origin", "https://example.test/repo.git"])
5609 .status()
5610 .unwrap();
5611 assert!(remote.success(), "git remote add must succeed");
5612 }
5613
5614 #[test]
5615 fn memory_adapter_maps_path_and_enablement() {
5616 let tmp = TempDir::new().unwrap();
5617 let mut enabled = memory_test_app(&tmp, true);
5618 let mut bundle = enabled.command_contexts();
5619 let memory = bundle.parts().memory.expect("memory facet must be present");
5620 assert_eq!(memory.memory_path(), tmp.path().join("memory.md"));
5621 assert!(memory.memory_enabled());
5622
5623 let mut disabled = memory_test_app(&tmp, false);
5624 let mut bundle = disabled.command_contexts();
5625 let memory = bundle.parts().memory.expect("memory facet must be present");
5626 assert!(!memory.memory_enabled());
5627 }
5628
5629 #[test]
5630 fn memory_adapter_status_and_path_map_native_store() {
5631 let tmp = TempDir::new().unwrap();
5632 let mut app = memory_test_app(&tmp, true);
5633 let mut bundle = app.command_contexts();
5634 let memory = bundle.parts().memory.expect("memory facet");
5635
5636 // Fallback root derivation mirrors the legacy handler: a plain
5637 // `memory.md` file is not a native global source, so the root is the
5638 // sibling `memory` directory.
5639 let status = memory.status().expect("status");
5640 assert_eq!(status.root, tmp.path().join("memory"));
5641 assert_eq!(
5642 status.source,
5643 tmp.path().join("memory").join("global").join("MEMORY.md")
5644 );
5645 assert_eq!(
5646 status.index,
5647 tmp.path().join("memory").join("store.sqlite3")
5648 );
5649 assert_eq!(memory.path().expect("path"), tmp.path().join("memory"));
5650 }
5651
5652 #[test]
5653 fn memory_adapter_workspace_identity_resolves_and_preserves_errors() {
5654 let tmp = TempDir::new().unwrap();
5655 git_origin(tmp.path());
5656 let mut app = memory_test_app(&tmp, true);
5657 let mut bundle = app.command_contexts();
5658 let memory = bundle.parts().memory.expect("memory facet");
5659 // A git origin resolves to a stable workspace identity (sha256 digest).
5660 let id = memory.workspace_id(tmp.path()).expect("workspace id");
5661 assert!(!id.is_empty());
5662 assert_eq!(id, memory.workspace_id(tmp.path()).expect("stable id"));
5663
5664 // A plain directory without git origin preserves the established error.
5665 let plain = TempDir::new().unwrap();
5666 let err = memory
5667 .workspace_id(plain.path())
5668 .expect_err("missing origin");
5669 assert_eq!(
5670 err,
5671 "workspace memory requires a git repository with an origin"
5672 );
5673 }
5674
5675 #[test]
5676 fn project_adapter_maps_lsp_state() {
5677 let mut app = test_app();
5678 app.lsp_enabled = false;
5679 assert!(!app.lsp_enabled);
5680 {
5681 let mut bundle = app.command_contexts();
5682 let project = bundle
5683 .parts()
5684 .project
5685 .expect("project facet must be present");
5686 assert!(!project.lsp_enabled());
5687
5688 project.lsp_set(true).unwrap();
5689 assert!(project.lsp_enabled());
5690 project.lsp_set(false).unwrap();
5691 assert!(!project.lsp_enabled());
5692 }
5693 assert!(!app.lsp_enabled);
5694 }
5695
5696 #[test]
5697 fn project_adapter_goal_projection_preserves_visible_and_effective_state() {
5698 let mut app = test_app();
5699 app.goal.objective = Some("Ship FEAT-021".to_string());
5700 app.goal.status = crate::tools::goal::GoalStatus::Active;
5701 app.goal.time_used_seconds = 42;
5702 app.goal.token_budget = Some(50_000);
5703 app.goal.tokens_used = 1_000;
5704 app.goal.continuation_count = 3;
5705 app.session.total_conversation_tokens = 2_000;
5706 app.goal_continuation_waiting = true;
5707 app.is_loading = false;
5708 app.api_messages_mut().push(codewhale_models::Message {
5709 role: codewhale_models::Role::User,
5710 content: vec![codewhale_models::ContentBlock::Text {
5711 text: "work".to_string(),
5712 cache_control: None,
5713 }],
5714 });
5715
5716 let mut bundle = app.command_contexts();
5717 let project = bundle
5718 .parts()
5719 .project
5720 .expect("project facet must be present");
5721 let goal = project.goal_state();
5722 assert_eq!(goal.objective.as_deref(), Some("Ship FEAT-021"));
5723 assert_eq!(goal.status, ProjectGoalStatus::Active);
5724 assert_eq!(goal.time_used_seconds, 42);
5725 assert_eq!(goal.token_budget, Some(50_000));
5726 assert_eq!(goal.tokens_used, 1_000);
5727 assert_eq!(goal.session_total_tokens, 2_000);
5728 assert_eq!(goal.continuation_count, 3);
5729 assert!(!goal.pending_controls);
5730 assert!(goal.goal_continuation_waiting);
5731 assert!(goal.conversation_present);
5732
5733 // Pending controls flip the effective source to the durable state.
5734 app.pending_goal_controls
5735 .push_back(crate::tui::app::PendingGoalControl {
5736 goal_id: None,
5737 intent: crate::tui::app::GoalControlIntent::SetStatus {
5738 status: crate::tools::goal::GoalStatus::Paused,
5739 clear: false,
5740 },
5741 dispatched: false,
5742 });
5743 app.last_known_goal_state = Some(crate::session_manager::SessionGoalState {
5744 schema_version: 1,
5745 goal_id: None,
5746 last_gap_fingerprint: None,
5747 repeated_gap_count: 0,
5748 last_gap_pass: None,
5749 objective: "Durable objective".to_string(),
5750 status: crate::session_manager::SessionGoalStatus::Paused,
5751 token_budget: None,
5752 tokens_used: 0,
5753 time_used_seconds: 0,
5754 continuation_count: 0,
5755 elapsed_seconds: 0,
5756 pause_reason: None,
5757 });
5758 let mut bundle = app.command_contexts();
5759 let project = bundle
5760 .parts()
5761 .project
5762 .expect("project facet must be present");
5763 let goal = project.goal_state();
5764 assert!(goal.pending_controls);
5765 assert_eq!(
5766 goal.last_known_objective.as_deref(),
5767 Some("Durable objective")
5768 );
5769 assert_eq!(goal.last_known_status, Some(ProjectGoalStatus::Paused));
5770 }
5771
5772 #[test]
5773 fn project_adapter_exposure_matches_main_envelope_model() {
5774 // main's envelope always populates every adapter (no capability
5775 // bitmask yet); the project facet is present and usable, and the
5776 // handlers destructure only the facets they need.
5777 let mut app = test_app();
5778 let mut bundle = app.command_contexts();
5779 let parts = bundle.parts();
5780 assert!(parts.project.is_some());
5781 assert!(parts.workspace.is_some());
5782 assert!(parts.presentation.is_some());
5783 }
5784
5785 #[test]
5786 fn memory_adapter_search_remember_get_export_reindex_work() {
5787 let tmp = TempDir::new().unwrap();
5788 let mut app = memory_test_app(&tmp, true);
5789 let mut bundle = app.command_contexts();
5790 let memory = bundle.parts().memory.expect("memory facet");
5791
5792 // Global remember produces a portable remembered location.
5793 let remembered = memory
5794 .remember(MemoryRememberTarget::Global, "alpha note")
5795 .expect("remember global");
5796 assert!(remembered.source.ends_with("global/MEMORY.md"));
5797 // Structured records have no line position; the anchor is the scope's
5798 // compatibility MEMORY.md path, not a byte offset into it.
5799 assert_eq!(remembered.line_start, 0);
5800
5801 // Workspace remember targets the workspace scope with the typed id.
5802 git_origin(tmp.path());
5803 let workspace_id = memory.workspace_id(tmp.path()).expect("id");
5804 let workspace_note = memory
5805 .remember(
5806 MemoryRememberTarget::Workspace { workspace_id },
5807 "workspace-only note",
5808 )
5809 .expect("remember workspace");
5810 assert!(
5811 workspace_note
5812 .source
5813 .to_string_lossy()
5814 .contains("workspace")
5815 );
5816
5817 // Search finds workspace-scoped content only for the given workspace.
5818 let hits = memory
5819 .search(tmp.path(), "workspace-only", 10)
5820 .expect("search");
5821 assert_eq!(hits.len(), 1);
5822 assert!(hits[0].text.contains("workspace-only note"));
5823 assert_eq!(hits[0].line_start, 0);
5824 // Empty results stay a typed empty vec, never an error.
5825 assert!(
5826 memory
5827 .search(tmp.path(), "zzz-no-match", 10)
5828 .expect("empty search")
5829 .is_empty()
5830 );
5831
5832 // Get distinguishes found from not-found (first rowid is 1).
5833 match memory.get(tmp.path(), 1) {
5834 Ok(MemoryGetOutcome::Found(hit)) => assert!(!hit.text.is_empty()),
5835 other => panic!("expected found entry, got {other:?}"),
5836 }
5837 assert_eq!(
5838 memory.get(tmp.path(), 999_999).expect("get"),
5839 MemoryGetOutcome::NotFound
5840 );
5841
5842 // Export carries the document; reindex reports the typed count.
5843 let exported = memory.export().expect("export");
5844 assert!(exported.content.contains("alpha note"));
5845 assert!(exported.content.contains("workspace-only note"));
5846 assert!(memory.reindex().expect("reindex").entry_count >= 1);
5847 }
5848
5849 #[test]
5850 fn memory_adapter_import_distinguishes_imported_from_skipped() {
5851 let tmp = TempDir::new().unwrap();
5852 let legacy = tmp.path().join("memory.md");
5853 std::fs::write(&legacy, "# legacy\n\n- imported line").unwrap();
5854 let mut app = memory_test_app(&tmp, true);
5855 let mut bundle = app.command_contexts();
5856 let memory = bundle.parts().memory.expect("memory facet");
5857
5858 let imported = memory.import().expect("import");
5859 let MemoryImportOutcome::Imported { destination } = imported else {
5860 panic!("first import must be imported");
5861 };
5862 assert!(destination.ends_with("global/MEMORY.md"));
5863
5864 // Idempotent: an existing global source reports skipped.
5865 assert_eq!(
5866 memory.import().expect("second"),
5867 MemoryImportOutcome::Skipped
5868 );
5869 }
5870
5871 #[test]
5872 fn memory_adapter_deletes_are_scoped_and_preserve_other_memory() {
5873 let tmp = TempDir::new().unwrap();
5874 git_origin(tmp.path());
5875 let mut app = memory_test_app(&tmp, true);
5876 let mut bundle = app.command_contexts();
5877 let memory = bundle.parts().memory.expect("memory facet");
5878
5879 memory
5880 .remember(MemoryRememberTarget::Global, "keep global")
5881 .expect("global");
5882 let workspace_id = memory.workspace_id(tmp.path()).expect("id");
5883 memory
5884 .remember(
5885 MemoryRememberTarget::Workspace { workspace_id },
5886 "remove workspace",
5887 )
5888 .expect("workspace");
5889
5890 // Workspace deletion removes only the workspace scope.
5891 memory
5892 .delete_workspace(tmp.path())
5893 .expect("workspace delete");
5894 assert!(
5895 memory
5896 .search(tmp.path(), "remove workspace", 10)
5897 .expect("search")
5898 .is_empty()
5899 );
5900 assert_eq!(
5901 memory.search(tmp.path(), "keep global", 10).unwrap().len(),
5902 1
5903 );
5904
5905 // Global deletion removes the global scope but keeps the workspace one.
5906 memory
5907 .remember(
5908 MemoryRememberTarget::Workspace {
5909 workspace_id: memory.workspace_id(tmp.path()).expect("id"),
5910 },
5911 "workspace survivor",
5912 )
5913 .expect("workspace again");
5914 memory
5915 .delete(MemoryDeleteScope::Global)
5916 .expect("global delete");
5917 assert!(
5918 memory
5919 .search(tmp.path(), "keep global", 10)
5920 .expect("search")
5921 .is_empty()
5922 );
5923 assert_eq!(
5924 memory
5925 .search(tmp.path(), "workspace survivor", 10)
5926 .unwrap()
5927 .len(),
5928 1
5929 );
5930
5931 // All deletion removes every scope.
5932 memory.delete(MemoryDeleteScope::All).expect("all delete");
5933 assert!(
5934 memory
5935 .search(tmp.path(), "workspace survivor", 10)
5936 .expect("search")
5937 .is_empty()
5938 );
5939 }
5940
5941 #[test]
5942 fn memory_adapter_preserves_workspace_delete_error_text() {
5943 let tmp = TempDir::new().unwrap();
5944 let mut app = memory_test_app(&tmp, true);
5945 let mut bundle = app.command_contexts();
5946 let memory = bundle.parts().memory.expect("memory facet");
5947 let err = memory
5948 .delete_workspace(tmp.path())
5949 .expect_err("missing origin");
5950 assert_eq!(
5951 err,
5952 "workspace memory requires a git repository with an origin"
5953 );
5954 }
5955
5956 #[test]
5957 fn envelope_exposes_only_declared_capabilities() {
5958 let tmp = TempDir::new().unwrap();
5959 let mut app = memory_test_app(&tmp, true);
5960 let mut bundle = app.command_contexts();
5961
5962 // Memory-only: memory present, workspace/session absent.
5963 let parts = bundle.contexts(CommandCapabilities::MEMORY).into_parts();
5964 assert!(parts.memory.is_some());
5965 assert!(parts.workspace.is_none());
5966 assert!(parts.session.is_none());
5967
5968 // Workspace-only: memory absent.
5969 let parts = bundle.contexts(CommandCapabilities::WORKSPACE).into_parts();
5970 assert!(parts.workspace.is_some());
5971 assert!(parts.memory.is_none());
5972
5973 // Workspace | MEMORY: both present, presentation/media absent.
5974 let parts = bundle
5975 .contexts(CommandCapabilities::WORKSPACE.union(CommandCapabilities::MEMORY))
5976 .into_parts();
5977 assert!(parts.workspace.is_some());
5978 assert!(parts.memory.is_some());
5979 assert!(parts.presentation.is_none());
5980 assert!(parts.media.is_none());
5981
5982 // Lifecycle-only: lifecycle present and every unrelated slot absent.
5983 let parts = bundle
5984 .contexts(CommandCapabilities::SESSION_LIFECYCLE)
5985 .into_parts();
5986 assert!(parts.lifecycle.is_some());
5987 assert!(parts.session.is_none());
5988 assert!(parts.model.is_none());
5989 assert!(parts.cost.is_none());
5990 assert!(parts.mode_policy.is_none());
5991 assert!(parts.system_prompt.is_none());
5992 assert!(parts.skills.is_none());
5993 assert!(parts.workspace.is_none());
5994 assert!(parts.presentation.is_none());
5995 assert!(parts.media.is_none());
5996 assert!(parts.memory.is_none());
5997 assert!(parts.project.is_none());
5998 assert!(parts.skill_group.is_none());
5999 assert!(parts.plugin.is_none());
6000
6001 // Control-only: control present and every unrelated slot absent.
6002 let parts = bundle
6003 .contexts(CommandCapabilities::SESSION_CONTROL)
6004 .into_parts();
6005 assert!(parts.control.is_some());
6006 assert!(parts.session.is_none());
6007 assert!(parts.model.is_none());
6008 assert!(parts.cost.is_none());
6009 assert!(parts.mode_policy.is_none());
6010 assert!(parts.system_prompt.is_none());
6011 assert!(parts.skills.is_none());
6012 assert!(parts.workspace.is_none());
6013 assert!(parts.presentation.is_none());
6014 assert!(parts.media.is_none());
6015 assert!(parts.memory.is_none());
6016 assert!(parts.project.is_none());
6017 assert!(parts.skill_group.is_none());
6018 assert!(parts.plugin.is_none());
6019 assert!(parts.lifecycle.is_none());
6020
6021 // `/remote-env`: exactly control plus presentation.
6022 let parts = bundle
6023 .contexts(CommandCapabilities::SESSION_CONTROL.union(CommandCapabilities::PRESENTATION))
6024 .into_parts();
6025 assert!(parts.control.is_some());
6026 assert!(parts.presentation.is_some());
6027 assert!(parts.session.is_none());
6028 assert!(parts.workspace.is_none());
6029 assert!(parts.lifecycle.is_none());
6030 assert!(parts.plugin.is_none());
6031
6032 // Unrelated capability: memory, lifecycle, and control all absent.
6033 let parts = bundle.contexts(CommandCapabilities::SESSION).into_parts();
6034 assert!(parts.session.is_some());
6035 assert!(parts.memory.is_none());
6036 assert!(parts.lifecycle.is_none());
6037 assert!(parts.control.is_none());
6038 }
6039
6040 // ─── FEAT-022 skill-group adapter tests ───────────────────────────────────
6041
6042 /// Seals the user's home for the duration of the test under the
6043 /// crate-wide env mutex (keeps global skill/snapshot discovery hermetic).
6044 fn scoped_home(_workspace: &TempDir) -> crate::test_support::SealedHome {
6045 crate::test_support::SealedHome::new()
6046 }
6047
6048 /// The fixture's skills dir lives inside its workspace, so the workspace
6049 /// must be trusted for those skills to load.
6050 fn skill_test_app(tmp: &TempDir, skills_dir: &Path) -> App {
6051 crate::test_support::trust_workspace(tmp.path());
6052 let mut options = crate::test_support::test_tui_options(tmp.path());
6053 options.skills_dir = skills_dir.to_path_buf();
6054 crate::test_support::test_app_with_options(options)
6055 }
6056
6057 fn write_skill(dir: &Path, name: &str) {
6058 let skill_dir = dir.join(name);
6059 std::fs::create_dir_all(&skill_dir).unwrap();
6060 std::fs::write(
6061 skill_dir.join("SKILL.md"),
6062 format!("---\nname: {name}\ndescription: {name} skill\n---\n{name} instructions"),
6063 )
6064 .unwrap();
6065 }
6066
6067 #[test]
6068 fn skill_group_projection_maps_native_skills_and_dirs() {
6069 let tmp = TempDir::new().unwrap();
6070 let _home = scoped_home(&tmp);
6071 crate::test_support::trust_workspace(tmp.path());
6072 let skills_dir = tmp.path().join("skills");
6073 write_skill(&skills_dir, "demo");
6074 let mut app = skill_test_app(&tmp, &skills_dir);
6075 let mut bundle = app.command_contexts();
6076 let group = bundle
6077 .parts()
6078 .skill_group
6079 .expect("skill_group facet must be present");
6080 let projection = group.skill_registry_projection();
6081 assert_eq!(projection.total, 1);
6082 assert_eq!(projection.entries.len(), 1);
6083 assert_eq!(projection.entries[0].name, "demo");
6084 assert_eq!(projection.entries[0].description, "demo skill");
6085 assert_eq!(projection.entries[0].source, SkillSourceKind::Native);
6086 assert!(projection.entries[0].path.is_some());
6087 assert_eq!(projection.skills_dir, skills_dir.display().to_string());
6088 assert!(!projection.dirs.is_empty());
6089 assert!(projection.warnings.is_empty());
6090 }
6091
6092 #[test]
6093 fn skill_group_projection_reports_empty_registry() {
6094 let tmp = TempDir::new().unwrap();
6095 let _home = scoped_home(&tmp);
6096 let skills_dir = tmp.path().join("skills");
6097 std::fs::create_dir_all(&skills_dir).unwrap();
6098 let mut app = skill_test_app(&tmp, &skills_dir);
6099 let mut bundle = app.command_contexts();
6100 let group = bundle
6101 .parts()
6102 .skill_group
6103 .expect("skill_group facet must be present");
6104 let projection = group.skill_registry_projection();
6105 assert_eq!(projection.total, 0);
6106 assert!(projection.entries.is_empty());
6107 }
6108
6109 #[test]
6110 fn skill_group_activation_sets_active_skill_and_history() {
6111 let tmp = TempDir::new().unwrap();
6112 let _home = scoped_home(&tmp);
6113 let skills_dir = tmp.path().join("skills");
6114 write_skill(&skills_dir, "demo");
6115 let mut app = skill_test_app(&tmp, &skills_dir);
6116 {
6117 let mut bundle = app.command_contexts();
6118 let group = bundle
6119 .parts()
6120 .skill_group
6121 .expect("skill_group facet must be present");
6122 let outcome = group.activate_skill("demo").unwrap();
6123 assert_eq!(outcome.name, "demo");
6124 assert_eq!(outcome.description, "demo skill");
6125 }
6126 assert!(app.active_skill.is_some());
6127 assert!(
6128 app.active_skill
6129 .as_deref()
6130 .unwrap()
6131 .contains("# Skill: demo")
6132 );
6133 assert!(app.active_skill_provenance.is_none());
6134 assert!(!app.history.is_empty());
6135 }
6136
6137 #[test]
6138 fn skill_group_activation_looks_up_exact_name() {
6139 // The `/skill new` -> skill-creator alias is handler-side parsing
6140 // (Phase 4); the delegate performs an exact host lookup.
6141 let tmp = TempDir::new().unwrap();
6142 let _home = scoped_home(&tmp);
6143 let skills_dir = tmp.path().join("skills");
6144 write_skill(&skills_dir, "skill-creator");
6145 let mut app = skill_test_app(&tmp, &skills_dir);
6146 {
6147 let mut bundle = app.command_contexts();
6148 let group = bundle
6149 .parts()
6150 .skill_group
6151 .expect("skill_group facet must be present");
6152 let outcome = group.activate_skill("skill-creator").unwrap();
6153 assert_eq!(outcome.name, "skill-creator");
6154 }
6155 assert!(app.active_skill.is_some());
6156 }
6157
6158 #[test]
6159 fn skill_group_activation_not_found_lists_available() {
6160 let tmp = TempDir::new().unwrap();
6161 let _home = scoped_home(&tmp);
6162 let skills_dir = tmp.path().join("skills");
6163 write_skill(&skills_dir, "demo");
6164 let mut app = skill_test_app(&tmp, &skills_dir);
6165 {
6166 let mut bundle = app.command_contexts();
6167 let group = bundle
6168 .parts()
6169 .skill_group
6170 .expect("skill_group facet must be present");
6171 let err = group.activate_skill("missing").unwrap_err();
6172 match err {
6173 SkillActivationError::NotFound {
6174 requested,
6175 available,
6176 ..
6177 } => {
6178 assert_eq!(requested, "missing");
6179 assert!(available.contains(&"demo".to_string()));
6180 }
6181 _ => panic!("expected NotFound"),
6182 }
6183 }
6184 assert!(app.active_skill.is_none());
6185 }
6186
6187 #[test]
6188 fn skill_group_install_invalid_source_returns_safe_error() {
6189 let tmp = TempDir::new().unwrap();
6190 let _home = scoped_home(&tmp);
6191 let skills_dir = tmp.path().join("skills");
6192 std::fs::create_dir_all(&skills_dir).unwrap();
6193 let mut app = skill_test_app(&tmp, &skills_dir);
6194 {
6195 let mut bundle = app.command_contexts();
6196 let group = bundle
6197 .parts()
6198 .skill_group
6199 .expect("skill_group facet must be present");
6200 let err = group.install_skill(None, " ").unwrap_err();
6201 assert!(err.contains("Invalid install source"), "{err}");
6202 }
6203 }
6204
6205 #[test]
6206 fn skill_group_review_ready_sets_side_effects() {
6207 let tmp = TempDir::new().unwrap();
6208 let _home = scoped_home(&tmp);
6209 let skills_dir = tmp.path().join("skills");
6210 write_skill(&skills_dir, "review");
6211 let mut app = skill_test_app(&tmp, &skills_dir);
6212 {
6213 let mut bundle = app.command_contexts();
6214 let group = bundle
6215 .parts()
6216 .skill_group
6217 .expect("skill_group facet must be present");
6218 let outcome = group.run_review().unwrap();
6219 assert_eq!(outcome, ReviewOutcome::Ready);
6220 }
6221 assert!(app.active_skill.is_some());
6222 assert!(app.active_skill_provenance.is_none());
6223 assert!(!app.history.is_empty());
6224 }
6225
6226 #[test]
6227 fn skill_group_review_not_found_reports_searched_dirs() {
6228 let tmp = TempDir::new().unwrap();
6229 let _home = scoped_home(&tmp);
6230 let skills_dir = tmp.path().join("skills");
6231 std::fs::create_dir_all(&skills_dir).unwrap();
6232 let mut app = skill_test_app(&tmp, &skills_dir);
6233 {
6234 let mut bundle = app.command_contexts();
6235 let group = bundle
6236 .parts()
6237 .skill_group
6238 .expect("skill_group facet must be present");
6239 let outcome = group.run_review().unwrap();
6240 match outcome {
6241 ReviewOutcome::NotFound {
6242 skills_dir: found_dir,
6243 global_dir,
6244 warnings,
6245 } => {
6246 assert_eq!(found_dir, skills_dir.display().to_string());
6247 assert_eq!(
6248 global_dir,
6249 crate::skills::default_skills_dir().display().to_string()
6250 );
6251 assert!(warnings.is_empty());
6252 }
6253 _ => panic!("expected NotFound"),
6254 }
6255 }
6256 assert!(app.active_skill.is_none());
6257 }
6258
6259 #[test]
6260 fn skill_group_snapshot_list_and_restore_roundtrip() {
6261 for in_runtime in [false, true] {
6262 let tmp = TempDir::new().unwrap();
6263 let _home = scoped_home(&tmp);
6264 let skills_dir = tmp.path().join("skills");
6265 let file = tmp.path().join("a.txt");
6266 let repo = crate::snapshot::SnapshotRepo::open_or_init(tmp.path()).unwrap();
6267 std::fs::write(&file, b"v1").unwrap();
6268 repo.snapshot("pre-turn:1").unwrap();
6269 std::fs::write(&file, b"v2").unwrap();
6270 let mut app = skill_test_app(&tmp, &skills_dir);
6271 {
6272 let mut bundle = app.command_contexts();
6273 let group = bundle
6274 .parts()
6275 .skill_group
6276 .expect("skill_group facet must be present");
6277 let entries = group.snapshot_list(20).unwrap();
6278 assert_eq!(entries.len(), 1);
6279 assert_eq!(entries[0].label, "pre-turn:1");
6280 assert!(!entries[0].id.is_empty());
6281 let mut restore = || {
6282 group.restore_snapshot(&entries[0].id).unwrap();
6283 assert!(
6284 group
6285 .restore_snapshot("not-a-snapshot")
6286 .unwrap_err()
6287 .starts_with("Restore failed:")
6288 );
6289 };
6290 if in_runtime {
6291 tokio::runtime::Builder::new_multi_thread()
6292 .worker_threads(1)
6293 .enable_all()
6294 .build()
6295 .unwrap()
6296 .block_on(async { restore() });
6297 } else {
6298 restore();
6299 }
6300 }
6301 assert_eq!(std::fs::read_to_string(&file).unwrap(), "v1");
6302 }
6303 }
6304
6305 #[test]
6306 fn skill_group_approval_state_reflects_app_posture() {
6307 let tmp = TempDir::new().unwrap();
6308 let _home = scoped_home(&tmp);
6309 let skills_dir = tmp.path().join("skills");
6310 let mut app = skill_test_app(&tmp, &skills_dir);
6311 app.yolo = true;
6312 app.trust_mode = false;
6313 {
6314 let mut bundle = app.command_contexts();
6315 let group = bundle
6316 .parts()
6317 .skill_group
6318 .expect("skill_group facet must be present");
6319 let state = group.approval_state();
6320 assert!(state.yolo);
6321 assert!(!state.trust_mode);
6322 }
6323 app.yolo = false;
6324 app.trust_mode = true;
6325 {
6326 let mut bundle = app.command_contexts();
6327 let group = bundle
6328 .parts()
6329 .skill_group
6330 .expect("skill_group facet must be present");
6331 let state = group.approval_state();
6332 assert!(!state.yolo);
6333 assert!(state.trust_mode);
6334 }
6335 }
6336
6337 #[test]
6338 fn portable_scope_maps_both_scopes_and_none() {
6339 use crate::skills::mutation::SkillTargetScope as TuiScope;
6340 assert_eq!(
6341 portable_scope(Some(SkillTargetScope::Project)),
6342 Some(TuiScope::Project)
6343 );
6344 assert_eq!(
6345 portable_scope(Some(SkillTargetScope::Global)),
6346 Some(TuiScope::Global)
6347 );
6348 assert_eq!(portable_scope(None), None);
6349 }
6350
6351 #[test]
6352 fn portable_mutation_receipt_maps_distinct_outcomes() {
6353 use crate::skills::audit::SkillActionKind;
6354 use crate::skills::mutation::{
6355 SkillMutationOutcome as TuiOutcome, SkillMutationReceipt as TuiReceipt,
6356 };
6357 use crate::skills::roots::SkillScope;
6358 let make = |outcome: TuiOutcome| TuiReceipt {
6359 action: SkillActionKind::Install,
6360 name: "demo".to_string(),
6361 scope: SkillScope::Global,
6362 safe_target_path: "/tmp/demo".to_string(),
6363 before_digest: None,
6364 after_digest: None,
6365 outcome,
6366 };
6367 let installed = portable_mutation_receipt(&make(TuiOutcome::Installed));
6368 assert_eq!(installed.outcome, SkillMutationOutcome::Installed);
6369 assert_eq!(installed.name, "demo");
6370 assert_eq!(installed.safe_target_path, "/tmp/demo");
6371
6372 let approval =
6373 portable_mutation_receipt(&make(TuiOutcome::NeedsApproval("acme.com".to_string())));
6374 assert_eq!(
6375 approval.outcome,
6376 SkillMutationOutcome::NeedsApproval("acme.com".to_string())
6377 );
6378
6379 let denied =
6380 portable_mutation_receipt(&make(TuiOutcome::NetworkDenied("acme.com".to_string())));
6381 assert_eq!(
6382 denied.outcome,
6383 SkillMutationOutcome::NetworkDenied("acme.com".to_string())
6384 );
6385 assert_ne!(installed.outcome, denied.outcome);
6386 }
6387
6388 #[test]
6389 fn skill_group_adapter_exposure_matches_main_envelope_model() {
6390 // The envelope populates the skill_group slot alongside the other
6391 // adapters; handlers destructure only their declared facets (D4).
6392 let mut app = test_app();
6393 let mut bundle = app.command_contexts();
6394 let parts = bundle.parts();
6395 assert!(parts.skill_group.is_some());
6396 assert!(parts.project.is_some());
6397 assert!(parts.skills.is_some());
6398 }
6399
6400 // ------------------------------------------------------------------
6401 // FEAT-020 plugin adapter tests
6402 // ------------------------------------------------------------------
6403
6404 fn plugin_test_app(tmpdir: &TempDir) -> App {
6405 let options = crate::test_support::test_tui_options(tmpdir.path());
6406 let mut app = crate::test_support::test_app_with_options(options);
6407 app.ui_locale = Locale::En;
6408 app
6409 }
6410
6411 /// Write a minimal plugin bundle into the temp workspace's
6412 /// `.codewhale/plugins` so the adapter can read real host data.
6413 fn write_demo_bundle(root: &Path) {
6414 let bundle = root.join(".codewhale/plugins/demo");
6415 std::fs::create_dir_all(bundle.join("skills/hello")).unwrap();
6416 std::fs::write(
6417 bundle.join("plugin.toml"),
6418 "schema_version = 1\n[plugin]\nname = \"demo\"\nversion = \"1.0.0\"\ndescription = \"Import spreadsheet data safely\"\n[skills]\npath = \"skills\"\n",
6419 )
6420 .unwrap();
6421 std::fs::write(
6422 bundle.join("skills/hello/SKILL.md"),
6423 "---\nname: hello\ndescription: hello\n---\nbody\n",
6424 )
6425 .unwrap();
6426 }
6427
6428 #[test]
6429 fn plugin_adapter_summaries_and_detail_project_host_data() {
6430 let tmp = TempDir::new().unwrap();
6431 write_demo_bundle(tmp.path());
6432 let mut app = plugin_test_app(&tmp);
6433 // Discover only the demo bundle: the host's real `~/.codewhale/plugins`
6434 // and materialized builtin plugins must not leak diagnostics into this
6435 // assertion (they did on a shared CI agent).
6436 let plugin_config = crate::plugins::discovery::DiscoveryConfig {
6437 workspace: tmp.path().to_path_buf(),
6438 user_plugins_dir: tmp.path().join("user-plugins"),
6439 workspace_plugins_dir: tmp.path().join(".codewhale/plugins"),
6440 builtin_plugin_dirs: Vec::new(),
6441 state_path: tmp.path().join("user-plugins/state.json"),
6442 };
6443 let discovery = crate::plugins::PluginDiscoveryContext::from_config_and_environment(
6444 &plugin_config,
6445 crate::plugins::HostEnvironment::capture(),
6446 );
6447 app.plugin_registry = discovery.registry_for_workspace(tmp.path());
6448 let mut bundle = app.command_contexts();
6449 let mut parts = bundle
6450 .contexts(
6451 CommandCapabilities::WORKSPACE
6452 .union(CommandCapabilities::PRESENTATION)
6453 .union(CommandCapabilities::PLUGIN),
6454 )
6455 .into_parts();
6456 let plugin = parts.plugin.as_deref_mut().unwrap();
6457
6458 let summaries = plugin.summaries().unwrap();
6459 assert!(!summaries.is_empty());
6460 let summary = summaries
6461 .iter()
6462 .find(|s| s.name == "demo")
6463 .expect("demo summary");
6464 assert_eq!(summary.compatibility, "full");
6465 assert!(
6466 summary.inventory.starts_with("skills=1"),
6467 "inventory summary: {}",
6468 summary.inventory
6469 );
6470
6471 let detail = plugin.detail("demo").unwrap();
6472 assert_eq!(detail.name, "demo");
6473 assert_eq!(detail.version, "1.0.0");
6474 assert_eq!(detail.skills, vec!["demo:hello"]);
6475 assert_eq!(detail.trust_status, "not-reviewed");
6476
6477 // Unknown selector fails safely.
6478 assert!(plugin.detail("nope").is_err());
6479 // Registry diagnostics empty for a clean bundle.
6480 assert!(plugin.registry_diagnostics().is_empty());
6481 assert!(plugin.validation_is_clean());
6482 }
6483
6484 #[test]
6485 fn plugin_adapter_registry_mutations_and_suggest_are_behavior_faithful() {
6486 let tmp = TempDir::new().unwrap();
6487 write_demo_bundle(tmp.path());
6488 let mut app = plugin_test_app(&tmp);
6489 // Discover only the demo bundle: the host's real `~/.codewhale/plugins`
6490 // and materialized builtin plugins must not leak diagnostics into this
6491 // assertion (they did on a shared CI agent).
6492 let plugin_config = crate::plugins::discovery::DiscoveryConfig {
6493 workspace: tmp.path().to_path_buf(),
6494 user_plugins_dir: tmp.path().join("user-plugins"),
6495 workspace_plugins_dir: tmp.path().join(".codewhale/plugins"),
6496 builtin_plugin_dirs: Vec::new(),
6497 state_path: tmp.path().join("user-plugins/state.json"),
6498 };
6499 let discovery = crate::plugins::PluginDiscoveryContext::from_config_and_environment(
6500 &plugin_config,
6501 crate::plugins::HostEnvironment::capture(),
6502 );
6503 app.plugin_registry = discovery.registry_for_workspace(tmp.path());
6504 // Capture the review token before borrowing the mutable facet.
6505 let demo = app.plugin_registry.get("demo").unwrap();
6506 let token = format!("{}.{}", demo.content_hash, demo.capability_hash);
6507
6508 let mut bundle = app.command_contexts();
6509 let mut parts = bundle.contexts(CommandCapabilities::PLUGIN).into_parts();
6510 let plugin = parts.plugin.as_deref_mut().unwrap();
6511
6512 // Read-only suggest does not mutate anything.
6513 let before = plugin.len();
6514 let _ = plugin.suggest("spreadsheet");
6515 assert_eq!(plugin.len(), before);
6516 assert_eq!(plugin.summaries().unwrap().len(), before);
6517
6518 // enable on an untrusted bundle routes to review (safe error), not a mutation.
6519 let err = plugin.enable("demo").unwrap_err();
6520 assert!(err.contains("requires review"));
6521
6522 // trust with a wrong token fails safely.
6523 assert!(plugin.trust("demo", "bogus.token").is_err());
6524
6525 // trust with the exact token succeeds.
6526 plugin.trust("demo", &token).unwrap();
6527 assert!(plugin.detail("demo").unwrap().trusted);
6528
6529 // enable now succeeds.
6530 plugin.enable("demo").unwrap();
6531 assert!(plugin.detail("demo").unwrap().enabled);
6532
6533 // disable clears active skill and marks disabled.
6534 plugin.disable("demo").unwrap();
6535 assert!(!plugin.detail("demo").unwrap().enabled);
6536
6537 // revoke_trust flips trust back off.
6538 plugin.revoke_trust("demo").unwrap();
6539 assert!(!plugin.detail("demo").unwrap().trusted);
6540 }
6541
6542 #[test]
6543 fn plugin_adapter_exposure_is_exactly_declared_capabilities() {
6544 let tmp = TempDir::new().unwrap();
6545 let mut app = plugin_test_app(&tmp);
6546 let mut bundle = app.command_contexts();
6547
6548 // Plugin-only: plugin present, everything else absent.
6549 let parts = bundle.contexts(CommandCapabilities::PLUGIN).into_parts();
6550 assert!(parts.plugin.is_some());
6551 assert!(parts.workspace.is_none());
6552 assert!(parts.presentation.is_none());
6553 assert!(parts.memory.is_none());
6554
6555 // Workspace | PRESENTATION | PLUGIN: all three present, media/memory absent.
6556 let parts = bundle
6557 .contexts(
6558 CommandCapabilities::WORKSPACE
6559 .union(CommandCapabilities::PRESENTATION)
6560 .union(CommandCapabilities::PLUGIN),
6561 )
6562 .into_parts();
6563 assert!(parts.plugin.is_some());
6564 assert!(parts.workspace.is_some());
6565 assert!(parts.presentation.is_some());
6566 assert!(parts.media.is_none());
6567 assert!(parts.memory.is_none());
6568
6569 // Undeclared capability: plugin absent.
6570 let parts = bundle.contexts(CommandCapabilities::SESSION).into_parts();
6571 assert!(parts.session.is_some());
6572 assert!(parts.plugin.is_none());
6573 }
6574
6575 // ---------------------------------------------------------------------------
6576 // FEAT-023 Phase 3: SessionLifecycleAdapter tests (Tasks 3.2/3.4).
6577 // Every delegate is exercised over the real App with an isolated CODEWHALE_HOME
6578 // so SessionManager writes stay inside the temp directory. The bundle borrows
6579 // `App` for its whole life, so each test scopes the facet and re-reads `App`
6580 // only after dropping it (adapters borrow through the host `RefCell` at call
6581 // time, but the bundle itself holds the `&mut App`).
6582 // ---------------------------------------------------------------------------
6583
6584 fn lifecycle_test_app(tmpdir: &TempDir) -> App {
6585 let options = crate::test_support::test_tui_options(tmpdir.path());
6586 App::new(options, &crate::config::Config::default())
6587 }
6588
6589 /// Point CODEWHALE_HOME at `tmp/home` with a pre-created sessions directory so
6590 /// `SessionManager::default_location()` resolves inside the temp sandbox.
6591 fn lifecycle_home_guard(tmpdir: &TempDir) -> crate::test_support::EnvVarGuard {
6592 let home = tmpdir.path().join("home");
6593 let sessions = home.join("sessions");
6594 std::fs::create_dir_all(&sessions).expect("create sandbox sessions dir");
6595 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home)
6596 }
6597
6598 fn user_message(text: &str) -> Message {
6599 Message {
6600 role: codewhale_models::Role::User,
6601 content: vec![codewhale_models::ContentBlock::Text {
6602 text: text.to_string(),
6603 cache_control: None,
6604 }],
6605 }
6606 }
6607
6608 #[test]
6609 fn lifecycle_dispatch_transition_blocking_wins_over_io() {
6610 let tmpdir = TempDir::new().unwrap();
6611 let _lock = crate::test_support::lock_test_env();
6612 let mut app = lifecycle_test_app(&tmpdir);
6613 app.is_loading = true;
6614 app.current_session_id = Some("active-session".to_string());
6615 app.api_messages_mut().push(user_message("in flight"));
6616
6617 for (command, expected) in [
6618 ("/fork", "Cannot fork a session"),
6619 ("/fork other-session", "Cannot fork a session"),
6620 ("/load does-not-exist.json", "Cannot load a session"),
6621 ("/new", "Cannot start a new session"),
6622 ("/branch entry-1", "Cannot branch"),
6623 ] {
6624 let result = crate::commands::execute(command, &mut app);
6625 assert!(result.is_error, "{command}: {result:?}");
6626 assert!(result.action.is_none(), "{command}: {result:?}");
6627 assert!(
6628 result
6629 .message
6630 .as_deref()
6631 .is_some_and(|text| text.contains(expected)),
6632 "{command}: {result:?}"
6633 );
6634 assert_eq!(app.current_session_id.as_deref(), Some("active-session"));
6635 assert_eq!(app.api_messages.len(), 1);
6636 }
6637 }
6638
6639 #[test]
6640 fn lifecycle_adapter_save_and_fork_roundtrip_preserves_history() {
6641 let tmpdir = TempDir::new().unwrap();
6642 let _lock = crate::test_support::lock_test_env();
6643 let _home = lifecycle_home_guard(&tmpdir);
6644 let mut app = lifecycle_test_app(&tmpdir);
6645 app.api_messages_mut()
6646 .push(user_message("try another path"));
6647
6648 let save_path = tmpdir.path().join("parent.json");
6649 {
6650 let mut bundle = app.command_contexts();
6651 let mut parts = bundle.parts();
6652 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6653 let saved = facet
6654 .save_session(Some(save_path.display().to_string()))
6655 .expect("save ok");
6656 assert!(save_path.exists());
6657 assert!(!saved.display_path.is_empty());
6658 assert!(!saved.truncated_id.is_empty());
6659 }
6660 let parent_id = app
6661 .current_session_id
6662 .clone()
6663 .expect("save sets session id");
6664 {
6665 let mut bundle = app.command_contexts();
6666 let mut parts = bundle.parts();
6667 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6668 let forked = facet.fork_active().expect("fork ok");
6669 assert!(!forked.parent_label.is_empty());
6670 assert!(!forked.fork_label.is_empty());
6671 assert!(forked.sync.session_id.is_some());
6672 assert_eq!(forked.sync.messages.len(), 1);
6673 assert_eq!(forked.sync.workspace, tmpdir.path());
6674 }
6675 let child_id = app
6676 .current_session_id
6677 .clone()
6678 .expect("fork switches session");
6679 assert_ne!(child_id, parent_id);
6680
6681 let manager = crate::session_manager::SessionManager::default_location().unwrap();
6682 let parent = manager.load_session(&parent_id).expect("parent loadable");
6683 let child = manager.load_session(&child_id).expect("child loadable");
6684 assert_eq!(parent.messages.len(), 1, "parent history preserved");
6685 assert_eq!(
6686 child.metadata.parent_session_id.as_deref(),
6687 Some(parent_id.as_str())
6688 );
6689 assert_eq!(child.metadata.forked_from_message_count, Some(1));
6690 }
6691
6692 #[test]
6693 fn lifecycle_adapter_explicit_fork_reports_spawn_depth_and_preserves_source() {
6694 let tmpdir = TempDir::new().unwrap();
6695 let _lock = crate::test_support::lock_test_env();
6696 let _home = lifecycle_home_guard(&tmpdir);
6697 let mut app = lifecycle_test_app(&tmpdir);
6698 app.api_messages_mut().push(user_message("parent turn"));
6699 {
6700 let mut bundle = app.command_contexts();
6701 let mut parts = bundle.parts();
6702 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6703 let saved = facet.save_session(None).expect("save into managed dir");
6704 assert!(!saved.truncated_id.is_empty());
6705 }
6706 let parent_id = app
6707 .current_session_id
6708 .clone()
6709 .expect("save sets session id");
6710 let source_len = {
6711 let manager = crate::session_manager::SessionManager::default_location().unwrap();
6712 manager
6713 .load_session(&parent_id)
6714 .expect("saved parent")
6715 .messages
6716 .len()
6717 };
6718 {
6719 let mut bundle = app.command_contexts();
6720 let mut parts = bundle.parts();
6721 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6722 let forked = facet.fork_from(&parent_id).expect("explicit fork ok");
6723 assert_eq!(forked.spawn_depth, 1);
6724 assert_eq!(
6725 forked.parent_label,
6726 crate::session_manager::truncate_id(&parent_id)
6727 );
6728 assert_eq!(forked.sync.messages.len(), 1);
6729 }
6730 let manager = crate::session_manager::SessionManager::default_location().unwrap();
6731 let reloaded = manager
6732 .load_session(&parent_id)
6733 .expect("source still loadable");
6734 assert_eq!(
6735 reloaded.messages.len(),
6736 source_len,
6737 "source history never rewritten by forking"
6738 );
6739 }
6740
6741 #[test]
6742 fn lifecycle_adapter_new_session_is_all_or_nothing_when_work_state_is_busy() {
6743 let tmpdir = TempDir::new().unwrap();
6744 let _lock = crate::test_support::lock_test_env();
6745 let _home = lifecycle_home_guard(&tmpdir);
6746 let mut app = lifecycle_test_app(&tmpdir);
6747 app.current_session_id = Some("current-session".to_string());
6748 app.api_messages_mut().push(user_message("work"));
6749 let todos = app.todos.clone();
6750 let _held = todos.try_lock().expect("hold todos lock");
6751
6752 {
6753 let mut bundle = app.command_contexts();
6754 let mut parts = bundle.parts();
6755 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6756 let err = facet.fresh_session(true).expect_err("busy work state");
6757 assert!(err.contains("Work state is busy"), "{err}");
6758 }
6759 assert_eq!(app.api_messages.len(), 1);
6760 assert_eq!(app.current_session_id.as_deref(), Some("current-session"));
6761 }
6762
6763 #[test]
6764 fn lifecycle_adapter_new_session_blocks_unsent_input_without_force() {
6765 let tmpdir = TempDir::new().unwrap();
6766 let _lock = crate::test_support::lock_test_env();
6767 let _home = lifecycle_home_guard(&tmpdir);
6768 let mut app = lifecycle_test_app(&tmpdir);
6769 app.current_session_id = Some("old-session".to_string());
6770 app.input = "draft text".to_string();
6771 {
6772 let mut bundle = app.command_contexts();
6773 let mut parts = bundle.parts();
6774 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6775 let err = facet.fresh_session(false).expect_err("blocker text");
6776 assert!(err.contains("/new --force"), "{err}");
6777 }
6778 assert_eq!(app.input, "draft text");
6779 assert_eq!(app.current_session_id.as_deref(), Some("old-session"));
6780
6781 {
6782 let mut bundle = app.command_contexts();
6783 let mut parts = bundle.parts();
6784 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6785 let ok = facet.fresh_session(true).expect("force discards draft");
6786 assert_ne!(app.current_session_id.as_deref(), Some("old-session"));
6787 assert!(app.input.is_empty());
6788 assert!(!ok.truncated_id.is_empty());
6789 assert!(ok.sync.messages.is_empty());
6790 }
6791 }
6792
6793 #[test]
6794 fn lifecycle_adapter_load_validates_shape_without_applying_state() {
6795 let tmpdir = TempDir::new().unwrap();
6796 let _lock = crate::test_support::lock_test_env();
6797 let _home = lifecycle_home_guard(&tmpdir);
6798 let mut app = lifecycle_test_app(&tmpdir);
6799 app.api_messages_mut().push(user_message("checkpoint"));
6800 let save_path = tmpdir.path().join("checkpoint.json");
6801 {
6802 let mut bundle = app.command_contexts();
6803 let mut parts = bundle.parts();
6804 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6805 facet
6806 .save_session(Some(save_path.display().to_string()))
6807 .expect("seed session file");
6808 }
6809 let before = app.api_messages.clone();
6810 {
6811 let mut bundle = app.command_contexts();
6812 let mut parts = bundle.parts();
6813 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6814 let missing = facet
6815 .load_session("does-not-exist.json")
6816 .expect_err("missing file");
6817 assert!(missing.contains("Failed to read session file"), "{missing}");
6818 let bad = tmpdir.path().join("bad.json");
6819 std::fs::write(&bad, "not json").unwrap();
6820 let parse = facet
6821 .load_session(bad.display().to_string().as_str())
6822 .expect_err("invalid json");
6823 assert!(parse.contains("Failed to parse session file"), "{parse}");
6824 let resolved = facet
6825 .load_session(save_path.display().to_string().as_str())
6826 .expect("valid session resolves");
6827 assert_eq!(resolved, save_path);
6828 }
6829 assert_eq!(
6830 app.api_messages, before,
6831 "no state applied by /load delegate"
6832 );
6833 }
6834
6835 #[test]
6836 fn lifecycle_adapter_picker_archive_and_prune_behavior() {
6837 let tmpdir = TempDir::new().unwrap();
6838 let _lock = crate::test_support::lock_test_env();
6839 let _home = lifecycle_home_guard(&tmpdir);
6840 let mut app = lifecycle_test_app(&tmpdir);
6841 let before_kind = app.view_stack.top_kind();
6842 {
6843 let mut bundle = app.command_contexts();
6844 let mut parts = bundle.parts();
6845 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6846 facet.open_picker(None);
6847 facet.open_picker(Some("pick-me".to_string()));
6848 }
6849 {
6850 let mut bundle = app.command_contexts();
6851 let mut parts = bundle.parts();
6852 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6853 facet.save_session(None).expect("seed archive target");
6854 }
6855 let archived_id = app.current_session_id.clone().unwrap();
6856 {
6857 let mut bundle = app.command_contexts();
6858 let mut parts = bundle.parts();
6859 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6860 let receipt = facet.set_archived(&archived_id, true).expect("archive ok");
6861 assert_eq!(
6862 receipt.truncated_id,
6863 crate::session_manager::truncate_id(&archived_id)
6864 );
6865 assert!(!receipt.title.is_empty());
6866 let restored = facet.set_archived(&archived_id, false).expect("restore ok");
6867 assert_eq!(restored.truncated_id, receipt.truncated_id);
6868 let pruned = facet.prune_sessions(36500).expect("prune runs");
6869 assert_eq!(pruned, 0, "no inactive session older than the window");
6870 }
6871 assert_ne!(
6872 app.view_stack.top_kind(),
6873 before_kind,
6874 "picker pushed a view"
6875 );
6876 let manager = crate::session_manager::SessionManager::default_location().unwrap();
6877 assert!(
6878 manager.load_session(&archived_id).is_ok(),
6879 "active session survives pruning"
6880 );
6881 }
6882
6883 #[test]
6884 fn lifecycle_adapter_tree_projections_cover_all_states() {
6885 let tmpdir = TempDir::new().unwrap();
6886 let _lock = crate::test_support::lock_test_env();
6887 let _home = lifecycle_home_guard(&tmpdir);
6888
6889 // No active session.
6890 let mut no_session_app = lifecycle_test_app(&tmpdir);
6891 {
6892 let mut bundle = no_session_app.command_contexts();
6893 let mut parts = bundle.parts();
6894 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6895 assert!(matches!(
6896 facet.tree_body().expect("tree ok"),
6897 TreeBodyProjection::NoSession
6898 ));
6899 }
6900
6901 // Active session with no messages and no saved journal.
6902 let mut empty_app = lifecycle_test_app(&tmpdir);
6903 empty_app.current_session_id = Some("empty-session".to_string());
6904 {
6905 let mut bundle = empty_app.command_contexts();
6906 let mut parts = bundle.parts();
6907 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6908 assert!(matches!(
6909 facet.tree_body().expect("tree ok"),
6910 TreeBodyProjection::EmptySession
6911 ));
6912 }
6913
6914 // Linear transcript before the journal exists.
6915 let mut linear_app = lifecycle_test_app(&tmpdir);
6916 linear_app.current_session_id = Some("linear-session".to_string());
6917 linear_app
6918 .api_messages_mut()
6919 .push(user_message("first message with a long tail"));
6920 {
6921 let mut bundle = linear_app.command_contexts();
6922 let mut parts = bundle.parts();
6923 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6924 match facet.tree_body().expect("tree ok") {
6925 TreeBodyProjection::Linear { rendered } => {
6926 assert!(rendered.contains("Active branch (linear"), "{rendered}");
6927 assert!(rendered.contains("[0]"), "{rendered}");
6928 }
6929 other => panic!("expected Linear projection, got {other:?}"),
6930 }
6931 }
6932
6933 // Journal projection once the session is saved with messages.
6934 let mut journal_app = lifecycle_test_app(&tmpdir);
6935 journal_app
6936 .api_messages_mut()
6937 .push(user_message("journaled turn"));
6938 {
6939 let mut bundle = journal_app.command_contexts();
6940 let mut parts = bundle.parts();
6941 let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot");
6942 facet.save_session(None).expect("seed journaled session");
6943 match facet.tree_body().expect("tree ok") {
6944 TreeBodyProjection::Journal { rendered } => {
6945 assert!(!rendered.is_empty());
6946 }
6947 other => panic!("expected Journal projection, got {other:?}"),
6948 }
6949 }
6950 }
6951
6952 // -------------------------------------------------------------------
6953 // FEAT-024 Phase 3: SessionControlAdapter tests (Tasks 3.2/3.4/3.6).
6954 // The bundle borrows `App` for its whole life, so each test scopes the
6955 // facet (via a bound `parts` value) and re-reads `App` only after
6956 // dropping it.
6957 // -------------------------------------------------------------------
6958
6959 fn control_test_app(tmpdir: &TempDir) -> App {
6960 lifecycle_test_app(tmpdir)
6961 }
6962
6963 fn control_home_guard(tmpdir: &TempDir) -> crate::test_support::EnvVarGuard {
6964 lifecycle_home_guard(tmpdir)
6965 }
6966
6967 fn save_control_session(tmpdir: &TempDir, id: &str) {
6968 let manager = crate::session_manager::SessionManager::default_location().unwrap();
6969 let mut session = crate::session_manager::create_saved_session_with_mode(
6970 &[],
6971 "deepseek-v4-pro",
6972 tmpdir.path(),
6973 0,
6974 None,
6975 None,
6976 );
6977 session.metadata.id = id.to_string();
6978 session.metadata.title = "Control Session".to_string();
6979 manager.save_session(&session).unwrap();
6980 }
6981
6982 #[test]
6983 fn control_relay_projection_maps_authoritative_snapshot() {
6984 let tmpdir = TempDir::new().unwrap();
6985 let _lock = crate::test_support::lock_test_env();
6986 let mut app = control_test_app(&tmpdir);
6987 app.goal.objective = Some("ship the control slice".to_string());
6988 app.goal.token_budget = Some(42_000);
6989 let expected_workspace = app.workspace.display().to_string();
6990 let expected_mode = app.mode.label().to_string();
6991 let expected_model = app.model_display_label();
6992
6993 {
6994 let mut bundle = app.command_contexts();
6995 let mut parts = bundle.parts();
6996 let facet = parts.control.as_deref_mut().expect("control slot");
6997 let projection = facet.relay_projection();
6998 assert_eq!(projection.workspace, expected_workspace);
6999 assert_eq!(projection.mode, expected_mode);
7000 assert_eq!(projection.model, expected_model);
7001 assert_eq!(
7002 projection.goal_objective.as_deref(),
7003 Some("ship the control slice")
7004 );
7005 assert_eq!(projection.goal_token_budget, Some(42_000));
7006 assert_eq!(
7007 projection.compact_template.trim(),
7008 crate::prompts::COMPACT_TEMPLATE.trim()
7009 );
7010 assert!(matches!(projection.todos, TodoProjection::Absent));
7011 assert!(matches!(projection.plan, PlanProjection::Absent));
7012 }
7013
7014 // Plan state held by another owner -> busy state is represented,
7015 // never a panic or lock wait.
7016 {
7017 let plan_state = app.plan_state.clone();
7018 let guard = plan_state.try_lock().unwrap();
7019 {
7020 let mut bundle = app.command_contexts();
7021 let mut parts = bundle.parts();
7022 let facet = parts.control.as_deref_mut().expect("control slot");
7023 assert!(matches!(
7024 facet.relay_projection().plan,
7025 PlanProjection::Busy
7026 ));
7027 }
7028 drop(guard);
7029 }
7030
7031 // Seeded plan sections transport the status label mapping.
7032 {
7033 let plan_state = app.plan_state.clone();
7034 let mut plan = plan_state.try_lock().unwrap();
7035 plan.update(crate::tools::plan::UpdatePlanArgs {
7036 title: Some("Relay Plan".to_string()),
7037 plan: vec![crate::tools::plan::PlanItemArg {
7038 step: "port the control slice".to_string(),
7039 status: crate::tools::plan::StepStatus::InProgress,
7040 }],
7041 ..crate::tools::plan::UpdatePlanArgs::default()
7042 });
7043 }
7044 {
7045 let mut bundle = app.command_contexts();
7046 let mut parts = bundle.parts();
7047 let facet = parts.control.as_deref_mut().expect("control slot");
7048 match facet.relay_projection().plan {
7049 PlanProjection::Sections(sections) => {
7050 assert_eq!(sections.title.as_deref(), Some("Relay Plan"));
7051 assert_eq!(sections.items.len(), 1);
7052 assert_eq!(sections.items[0].status, PlanStepStatus::InProgress);
7053 assert_eq!(sections.items[0].text, "port the control slice");
7054 }
7055 other => panic!("expected Sections plan after update, got {other:?}"),
7056 }
7057 }
7058 }
7059
7060 #[test]
7061 fn control_hosted_work_target_resolves_and_never_echoes_credentials() {
7062 let tmpdir = TempDir::new().unwrap();
7063 let _lock = crate::test_support::lock_test_env();
7064 let secret = "top-secret-token";
7065 let mut app = control_test_app(&tmpdir);
7066 init_control_git_repo(
7067 tmpdir.path(),
7068 &format!("https://hunter:{secret}@github.com/codewhale-hq/CodeWhale.git"),
7069 "main",
7070 );
7071
7072 {
7073 let mut bundle = app.command_contexts();
7074 let mut parts = bundle.parts();
7075 let facet = parts.control.as_deref_mut().expect("control slot");
7076 let target = facet.resolve_hosted_work_target().expect("target");
7077 assert_eq!(target.repo, "codewhale-hq/CodeWhale");
7078 assert_eq!(target.branch, "main");
7079 assert_eq!(
7080 target.url,
7081 "https://app.codewhale.net/work?repo=codewhale-hq%2FCodeWhale&branch=main"
7082 );
7083 assert!(!target.url.contains(secret));
7084 assert!(!target.repo.contains(secret));
7085 }
7086
7087 // Unsupported host resolves to None.
7088 init_control_git_repo(tmpdir.path(), "git@gitlab.com:acme/widgets.git", "main");
7089 {
7090 let mut bundle = app.command_contexts();
7091 let mut parts = bundle.parts();
7092 let facet = parts.control.as_deref_mut().expect("control slot");
7093 assert_eq!(facet.resolve_hosted_work_target(), None);
7094 }
7095 }
7096
7097 fn init_control_git_repo(dir: &Path, origin: &str, branch: &str) {
7098 let init = std::process::Command::new("git")
7099 .args(["init", "--quiet"])
7100 .arg(dir)
7101 .status()
7102 .expect("run git init");
7103 assert!(init.success());
7104 let set_origin = std::process::Command::new("git")
7105 .arg("-C")
7106 .arg(dir)
7107 .args(["config", "--local", "remote.origin.url", origin])
7108 .status()
7109 .expect("set origin");
7110 assert!(set_origin.success());
7111 let set_branch = std::process::Command::new("git")
7112 .arg("-C")
7113 .arg(dir)
7114 .args(["symbolic-ref", "HEAD"])
7115 .arg(format!("refs/heads/{branch}"))
7116 .status()
7117 .expect("set branch");
7118 assert!(set_branch.success());
7119 }
7120
7121 #[test]
7122 fn control_rename_session_persists_title_and_preserves_order() {
7123 let tmpdir = TempDir::new().unwrap();
7124 let _lock = crate::test_support::lock_test_env();
7125 let _home = control_home_guard(&tmpdir);
7126 save_control_session(&tmpdir, "rename-1");
7127 let mut app = control_test_app(&tmpdir);
7128 app.current_session_id = Some("rename-1".to_string());
7129
7130 let receipt = {
7131 let mut bundle = app.command_contexts();
7132 let mut parts = bundle.parts();
7133 let facet = parts.control.as_deref_mut().expect("control slot");
7134 facet.rename_session("Brand New Title").expect("rename ok")
7135 };
7136 assert_eq!(receipt.title, "Brand New Title");
7137 assert_eq!(app.session_title.as_deref(), Some("Brand New Title"));
7138 let manager = crate::session_manager::SessionManager::default_location().unwrap();
7139 let reloaded = manager.load_session("rename-1").unwrap();
7140 assert_eq!(reloaded.metadata.title, "Brand New Title");
7141
7142 // The facet exposes the authoritative sanitizer while the portable
7143 // handler owns empty and length policy.
7144 let sanitized = {
7145 let mut bundle = app.command_contexts();
7146 let mut parts = bundle.parts();
7147 let facet = parts.control.as_deref_mut().expect("control slot");
7148 facet.sanitize_session_title("\u{1b}\u{7}\u{200b}")
7149 };
7150 assert!(sanitized.is_empty());
7151 assert_eq!(app.window_title, None);
7152 }
7153
7154 #[test]
7155 fn control_rename_recovers_first_snapshot_from_checkpoint() {
7156 let tmpdir = TempDir::new().unwrap();
7157 let _lock = crate::test_support::lock_test_env();
7158 let _home = control_home_guard(&tmpdir);
7159 let manager = crate::session_manager::SessionManager::default_location().unwrap();
7160 let mut checkpoint = crate::session_manager::create_saved_session_with_mode(
7161 &[],
7162 "deepseek-v4-pro",
7163 tmpdir.path(),
7164 0,
7165 None,
7166 None,
7167 );
7168 checkpoint.metadata.id = "midturn-1".to_string();
7169 manager.save_checkpoint(&checkpoint).unwrap();
7170
7171 let mut app = control_test_app(&tmpdir);
7172 app.current_session_id = Some("midturn-1".to_string());
7173 app.api_messages = std::sync::Arc::new(vec![user_message("first turn still streaming")]);
7174
7175 let receipt = {
7176 let mut bundle = app.command_contexts();
7177 let mut parts = bundle.parts();
7178 let facet = parts.control.as_deref_mut().expect("control slot");
7179 facet.rename_session("Midturn Rename").expect("rename ok")
7180 };
7181 assert_eq!(receipt.title, "Midturn Rename");
7182 assert_eq!(app.session_title.as_deref(), Some("Midturn Rename"));
7183 let persisted = manager.load_session("midturn-1").unwrap();
7184 assert_eq!(persisted.metadata.title, "Midturn Rename");
7185 assert_eq!(persisted.messages.len(), 1);
7186 }
7187
7188 #[test]
7189 fn control_rename_errors_match_the_baseline() {
7190 let tmpdir = TempDir::new().unwrap();
7191 let _lock = crate::test_support::lock_test_env();
7192 let _home = control_home_guard(&tmpdir);
7193 let mut app = control_test_app(&tmpdir);
7194 app.current_session_id = None;
7195 let err = {
7196 let mut bundle = app.command_contexts();
7197 let mut parts = bundle.parts();
7198 let facet = parts.control.as_deref_mut().expect("control slot");
7199 facet.rename_session("Anything").unwrap_err()
7200 };
7201 assert!(err.contains("No active session"));
7202 }
7203
7204 #[test]
7205 fn control_title_report_set_and_clear_preserve_semantics() {
7206 let tmpdir = TempDir::new().unwrap();
7207 let _lock = crate::test_support::lock_test_env();
7208 let _home = control_home_guard(&tmpdir);
7209 save_control_session(&tmpdir, "title-1");
7210 let mut app = control_test_app(&tmpdir);
7211 app.current_session_id = Some("title-1".to_string());
7212
7213 // No session window title and no config default -> unset, no source.
7214 let report = {
7215 let mut bundle = app.command_contexts();
7216 let mut parts = bundle.parts();
7217 let facet = parts.control.as_deref_mut().expect("control slot");
7218 facet.title_report()
7219 };
7220 assert_eq!(report.effective, "unset");
7221 assert!(matches!(report.source, TitleSource::None));
7222
7223 // Set a window title: session name untouched, redraw requested.
7224 {
7225 let mut bundle = app.command_contexts();
7226 let mut parts = bundle.parts();
7227 let facet = parts.control.as_deref_mut().expect("control slot");
7228 facet
7229 .set_window_title("parallel-task".to_string())
7230 .expect("set ok");
7231 }
7232 assert_eq!(app.window_title.as_deref(), Some("parallel-task"));
7233 assert!(app.needs_redraw);
7234 assert_eq!(
7235 app.session_title, None,
7236 "/title never changes the session name"
7237 );
7238 let manager = crate::session_manager::SessionManager::default_location().unwrap();
7239 let reloaded = manager.load_session("title-1").unwrap();
7240 assert_eq!(reloaded.window_title.as_deref(), Some("parallel-task"));
7241 assert_eq!(reloaded.metadata.title, "Control Session");
7242
7243 // Control-char-only input is normalized to empty before the portable
7244 // handler applies its exact user-facing validation message.
7245 let sanitized = {
7246 let mut bundle = app.command_contexts();
7247 let mut parts = bundle.parts();
7248 let facet = parts.control.as_deref_mut().expect("control slot");
7249 facet.sanitize_session_title("\u{1b}\u{7}\u{200b}")
7250 };
7251 assert!(sanitized.is_empty());
7252
7253 // Clear removes the session-level title.
7254 {
7255 let mut bundle = app.command_contexts();
7256 let mut parts = bundle.parts();
7257 let facet = parts.control.as_deref_mut().expect("control slot");
7258 facet.clear_window_title().expect("clear ok");
7259 }
7260 assert_eq!(app.window_title, None);
7261 }
7262
7263 #[test]
7264 fn control_resume_gate_picker_and_resolution_routes() {
7265 let tmpdir = TempDir::new().unwrap();
7266 let _lock = crate::test_support::lock_test_env();
7267 let _home = control_home_guard(&tmpdir);
7268 save_control_session(&tmpdir, "resume-target-1");
7269 let mut app = control_test_app(&tmpdir);
7270
7271 // Transition gate mirrors the host state.
7272 app.is_loading = true;
7273 {
7274 let mut bundle = app.command_contexts();
7275 let mut parts = bundle.parts();
7276 let facet = parts.control.as_deref_mut().expect("control slot");
7277 assert!(facet.transition_blocked());
7278 }
7279 app.is_loading = false;
7280
7281 // Bare resume pushes the picker.
7282 assert!(app.view_stack.is_empty());
7283 {
7284 let mut bundle = app.command_contexts();
7285 let mut parts = bundle.parts();
7286 let facet = parts.control.as_deref_mut().expect("control slot");
7287 facet.open_resume_picker();
7288 }
7289 assert!(!app.view_stack.is_empty());
7290
7291 // Full id and prefix resolve to the durable session file.
7292 let by_id = {
7293 let mut bundle = app.command_contexts();
7294 let mut parts = bundle.parts();
7295 let facet = parts.control.as_deref_mut().expect("control slot");
7296 facet
7297 .resolve_resume_source("resume-target-1")
7298 .expect("resolve ok")
7299 };
7300 match by_id {
7301 ResumeSource::Session {
7302 load_path,
7303 truncated_id,
7304 title,
7305 } => {
7306 assert!(load_path.as_ref().is_some_and(|p| p.exists()));
7307 assert!(!truncated_id.is_empty());
7308 assert_eq!(title, "Control Session");
7309 }
7310 other => panic!("expected Session resolution, got {other:?}"),
7311 }
7312 let by_prefix = {
7313 let mut bundle = app.command_contexts();
7314 let mut parts = bundle.parts();
7315 let facet = parts.control.as_deref_mut().expect("control slot");
7316 facet
7317 .resolve_resume_source("resume-target")
7318 .expect("prefix ok")
7319 };
7320 assert!(matches!(by_prefix, ResumeSource::Session { .. }));
7321
7322 // A readable file resolves as the direct-file route.
7323 let export_file = tmpdir.path().join("session-export.json");
7324 std::fs::write(&export_file, "{}").unwrap();
7325 let as_file = {
7326 let mut bundle = app.command_contexts();
7327 let mut parts = bundle.parts();
7328 let facet = parts.control.as_deref_mut().expect("control slot");
7329 facet
7330 .resolve_resume_source(&export_file.display().to_string())
7331 .expect("file ok")
7332 };
7333 assert!(matches!(as_file, ResumeSource::File(_)));
7334
7335 // Unknown input resolves to NotFound with the raw value for the
7336 // handler's exact fallback message.
7337 let missing = {
7338 let mut bundle = app.command_contexts();
7339 let mut parts = bundle.parts();
7340 let facet = parts.control.as_deref_mut().expect("control slot");
7341 facet
7342 .resolve_resume_source("not-a-real-session-xyz")
7343 .expect("notfound ok")
7344 };
7345 match missing {
7346 ResumeSource::NotFound { raw, error } => {
7347 assert_eq!(raw, "not-a-real-session-xyz");
7348 assert!(!error.is_empty());
7349 }
7350 other => panic!("expected NotFound, got {other:?}"),
7351 }
7352 }
7353
7354 #[test]
7355 fn control_resume_import_rejects_unrecognized_and_applies_foreign() {
7356 let tmpdir = TempDir::new().unwrap();
7357 let _lock = crate::test_support::lock_test_env();
7358 let _home = control_home_guard(&tmpdir);
7359 let mut app = control_test_app(&tmpdir);
7360
7361 let bad_file = tmpdir.path().join("not-an-export.json");
7362 std::fs::write(&bad_file, "not session json at all").unwrap();
7363 let err = {
7364 let mut bundle = app.command_contexts();
7365 let mut parts = bundle.parts();
7366 let facet = parts.control.as_deref_mut().expect("control slot");
7367 facet.import_session_file(bad_file).unwrap_err()
7368 };
7369 assert!(err.contains("is not a recognized session export"), "{err}");
7370
7371 // A real export container round-trips through import and mutates the
7372 // active session atomically.
7373 let manager = crate::session_manager::SessionManager::default_location().unwrap();
7374 let mut source = crate::session_manager::create_saved_session_with_mode(
7375 &[],
7376 "deepseek-v4-pro",
7377 tmpdir.path(),
7378 0,
7379 None,
7380 None,
7381 );
7382 source.metadata.id = "foreign-source".to_string();
7383 source.metadata.title = "Foreign Name".to_string();
7384 let container = source.export_container("foreign");
7385 let json = serde_json::to_string(&container).expect("serialize container");
7386 let import_file = tmpdir.path().join("foreign-export.json");
7387 std::fs::write(&import_file, &json).unwrap();
7388
7389 // This window is on a non-default route; the import must bind to it.
7390 app.set_provider_identity_record(
7391 crate::config::Config::default()
7392 .resolve_provider_identity(crate::config::ProviderKind::Openai.as_str())
7393 .expect("captured fixture provider"),
7394 );
7395 let receipt = {
7396 let mut bundle = app.command_contexts();
7397 let mut parts = bundle.parts();
7398 let facet = parts.control.as_deref_mut().expect("control slot");
7399 facet.import_session_file(import_file).expect("import ok")
7400 };
7401 assert!(!receipt.truncated_id.is_empty());
7402 assert_eq!(receipt.entry_count, 0);
7403 assert_eq!(receipt.leaf_display, "(none)");
7404 let imported_id = app.current_session_id.clone().expect("active session");
7405 let saved = manager
7406 .load_session(&imported_id)
7407 .expect("import persisted");
7408 // Host import_foreign rebuilds the document with default metadata
7409 // (fresh id/title), matching the baseline import path exactly.
7410 assert_eq!(saved.metadata.title, "New Session");
7411 assert_ne!(saved.metadata.id, "foreign-source");
7412 assert_eq!(
7413 saved.metadata.model_provider, "openai",
7414 "an imported session runs on this window's route, not a default one"
7415 );
7416 assert!(
7417 manager
7418 .sessions_dir()
7419 .join(format!("{imported_id}.json"))
7420 .exists()
7421 );
7422 }
7423
7424 #[test]
7425 fn control_remote_state_and_routing_are_deterministic() {
7426 let tmpdir = TempDir::new().unwrap();
7427 let _lock = crate::test_support::lock_test_env();
7428 let mut app = control_test_app(&tmpdir);
7429
7430 // Off state: status line, no link, no browser open.
7431 {
7432 let mut bundle = app.command_contexts();
7433 let mut parts = bundle.parts();
7434 let facet = parts.control.as_deref_mut().expect("control slot");
7435 assert_eq!(facet.remote_status(), "Remote control: off");
7436 assert_eq!(facet.remote_link(), None);
7437 assert!(matches!(
7438 facet.remote_browser_open(),
7439 RemoteOpenOutcome::NoLink
7440 ));
7441 assert_eq!(facet.remote_stop_refusal(), None);
7442 }
7443
7444 // Start wording distinguishes the active-turn copy.
7445 app.is_loading = true;
7446 {
7447 let mut bundle = app.command_contexts();
7448 let mut parts = bundle.parts();
7449 let facet = parts.control.as_deref_mut().expect("control slot");
7450 assert!(facet.remote_start_info().connecting);
7451 }
7452 app.is_loading = false;
7453 {
7454 let mut bundle = app.command_contexts();
7455 let mut parts = bundle.parts();
7456 let facet = parts.control.as_deref_mut().expect("control slot");
7457 assert!(!facet.remote_start_info().connecting);
7458 }
7459
7460 // A live advertised link composes without spawning a browser.
7461 app.remote_control.install_live_link_for_test(
7462 "https://app.codewhale.net/session?run=run-1",
7463 Some("https://app.codewhale.net/settings"),
7464 );
7465 let link = {
7466 let mut bundle = app.command_contexts();
7467 let mut parts = bundle.parts();
7468 let facet = parts.control.as_deref_mut().expect("control slot");
7469 facet.remote_link().expect("live link")
7470 };
7471 assert_eq!(link.url, "https://app.codewhale.net/session?run=run-1");
7472 assert_eq!(
7473 link.computer_url.as_deref(),
7474 Some("https://app.codewhale.net/settings")
7475 );
7476 }
7477
7478 #[test]
7479 fn control_remote_stop_refusal_guards_active_turns() {
7480 let tmpdir = TempDir::new().unwrap();
7481 let _lock = crate::test_support::lock_test_env();
7482 let mut app = control_test_app(&tmpdir);
7483 app.remote_control
7484 .activate_prompt("run-1", "turn-1")
7485 .unwrap();
7486 let refusal = {
7487 let mut bundle = app.command_contexts();
7488 let mut parts = bundle.parts();
7489 let facet = parts.control.as_deref_mut().expect("control slot");
7490 facet.remote_stop_refusal().expect("refusal present")
7491 };
7492 assert!(refusal.contains("active remote turn"), "{refusal}");
7493 }
7494
7495 #[test]
7496 fn control_browser_open_outcome_mapping_is_exact() {
7497 let url = "https://app.codewhale.net/session?run=run-9".to_string();
7498 assert!(matches!(
7499 map_browser_open_result(url.clone(), true),
7500 RemoteOpenOutcome::Opened { url: u } if u == url
7501 ));
7502 assert!(matches!(
7503 map_browser_open_result(url.clone(), false),
7504 RemoteOpenOutcome::LaunchFailed { url: u } if u == url
7505 ));
7506 }
7507 }
7508
7508 lines RUST