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