返回 CodeWhale
copy.rs
根目录 / crates / tui / src / commands / groups / core / copy.rs
1 //! `/copy` command — copy the last completed assistant response.
2
3 use crate::commands::traits::{CommandInfo, RegisterCommand};
4 use crate::tui::app::App;
5 use codewhale_localization::MessageId;
6
7 use super::CommandResult;
8
9 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
10 name: "copy",
11 aliases: &[],
12 usage: "/copy",
13 description_id: MessageId::CmdCopyDescription,
14 };
15
16 pub(in crate::commands) struct CopyCmd;
17
18 impl RegisterCommand for CopyCmd {
19 fn info() -> &'static CommandInfo {
20 &COMMAND_INFO
21 }
22
23 fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
24 execute_copy(app)
25 }
26 }
27
28 fn last_completed_assistant_output(app: &App) -> Option<String> {
29 app.completed_assistant_output_receipt().map(str::to_owned)
30 }
31
32 fn execute_copy(app: &mut App) -> CommandResult {
33 let Some(content) = last_completed_assistant_output(app) else {
34 return CommandResult::message(app.tr(MessageId::CmdCopyNoOutput).into_owned());
35 };
36
37 let terminal_client = app.clipboard.requires_terminal_paste();
38 // Any native-host attempt may fall through to the asynchronous terminal
39 // transport. Preserve /export's durable recovery contract before the
40 // write so every optimistic receipt names (or explicitly lacks) a backup.
41 let recovery = crate::commands::session_export_host::write_last_copy(&content);
42 match app.clipboard.write_text(&content) {
43 Ok(()) if terminal_client => match recovery {
44 Some(path) => CommandResult::message(
45 app.tr(MessageId::CmdCopyQueued)
46 .replace("{path}", &path.display().to_string()),
47 ),
48 None => CommandResult::message(app.tr(MessageId::CmdCopyQueuedNoBackup).into_owned()),
49 },
50 Ok(()) => match recovery {
51 Some(path) => CommandResult::message(
52 app.tr(MessageId::CmdCopySuccess)
53 .replace("{path}", &path.display().to_string()),
54 ),
55 None => CommandResult::message(app.tr(MessageId::CmdCopySuccessNoBackup).into_owned()),
56 },
57 Err(error) => match recovery {
58 Some(path) => CommandResult::error(
59 app.tr(MessageId::CmdCopyFailed)
60 .replace("{error}", &error.to_string())
61 .replace("{path}", &path.display().to_string()),
62 ),
63 None => CommandResult::error(
64 app.tr(MessageId::CmdCopyFailedNoBackup)
65 .replace("{error}", &error.to_string()),
66 ),
67 },
68 }
69 }
70
71 #[cfg(test)]
72 mod tests {
73 use super::*;
74 use crate::config::Config;
75 use crate::tui::app::TuiOptions;
76 use crate::tui::clipboard::ClipboardHandler;
77 use crate::tui::history::{HistoryCell, history_cells_from_message};
78 use codewhale_models::{ContentBlock, Message, Role};
79 use std::path::{Path, PathBuf};
80 use tempfile::TempDir;
81
82 fn test_app() -> App {
83 App::new(
84 TuiOptions {
85 model: "deepseek-v4-flash".to_string(),
86 ..crate::test_support::test_tui_options(PathBuf::from("."))
87 },
88 &Config::default(),
89 )
90 }
91
92 fn add_completed_assistant(app: &mut App, text: &str) -> usize {
93 let history_index = app.history.len();
94 app.add_message(HistoryCell::Assistant {
95 content: text.to_string(),
96 streaming: false,
97 });
98 app.record_completed_assistant_output(history_index, text);
99 history_index
100 }
101
102 fn isolate_state_home(
103 path: &Path,
104 ) -> (
105 crate::test_support::EnvVarGuard,
106 crate::test_support::EnvVarGuard,
107 ) {
108 (
109 crate::test_support::EnvVarGuard::set("HOME", path),
110 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", path),
111 )
112 }
113
114 #[test]
115 fn copies_the_latest_completed_assistant_output_only() {
116 let tmp = TempDir::new().expect("tempdir");
117 let _env_lock = crate::test_support::lock_test_env();
118 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
119 let mut app = test_app();
120 app.clipboard = ClipboardHandler::for_test(false, false);
121 add_completed_assistant(&mut app, "older");
122 app.add_message(HistoryCell::System {
123 content: "system".to_string(),
124 });
125 app.add_message(HistoryCell::Thinking {
126 content: "hidden reasoning".to_string(),
127 streaming: false,
128 duration_secs: None,
129 });
130 add_completed_assistant(&mut app, "latest **answer**\nsecond line");
131 // Interrupted salvage may be visible, but it never gets a completion
132 // receipt and therefore cannot become `/copy` authority.
133 app.add_message(HistoryCell::Assistant {
134 content: "active partial".to_string(),
135 streaming: false,
136 });
137
138 let result = execute_copy(&mut app);
139
140 let expected = format!(
141 "Accepted the last completed assistant response for clipboard delivery; a recovery copy is at {}",
142 tmp.path().join("exports").join("last-copy.md").display()
143 );
144 assert_eq!(result.message.as_deref(), Some(expected.as_str()));
145 assert_eq!(
146 app.clipboard.last_written_text(),
147 Some("latest **answer**\nsecond line")
148 );
149 assert_eq!(
150 std::fs::read_to_string(tmp.path().join("exports").join("last-copy.md"))
151 .expect("recovery copy"),
152 "latest **answer**\nsecond line"
153 );
154 }
155
156 #[test]
157 fn skips_empty_and_non_assistant_history() {
158 let mut app = test_app();
159 add_completed_assistant(&mut app, " \n");
160 app.add_message(HistoryCell::System {
161 content: "system".to_string(),
162 });
163 app.add_message(HistoryCell::Assistant {
164 content: "partial".to_string(),
165 streaming: false,
166 });
167
168 let result = execute_copy(&mut app);
169
170 assert_eq!(
171 result.message.as_deref(),
172 Some("No completed assistant response is available to copy")
173 );
174 }
175
176 #[test]
177 fn active_turn_does_not_change_which_completed_output_is_copied() {
178 let tmp = TempDir::new().expect("tempdir");
179 let _env_lock = crate::test_support::lock_test_env();
180 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
181 let mut app = test_app();
182 app.clipboard = ClipboardHandler::for_test(false, false);
183 add_completed_assistant(&mut app, "completed before the active turn");
184 app.is_loading = true;
185
186 let result = execute_copy(&mut app);
187
188 assert!(!result.is_error);
189 assert_eq!(
190 app.clipboard.last_written_text(),
191 Some("completed before the active turn")
192 );
193 assert!(app.is_loading);
194 }
195
196 #[test]
197 fn interrupted_assistant_role_never_replaces_the_last_completed_answer() {
198 let tmp = TempDir::new().expect("tempdir");
199 let _env_lock = crate::test_support::lock_test_env();
200 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
201 let mut app = test_app();
202 app.clipboard = ClipboardHandler::for_test(false, false);
203 add_completed_assistant(&mut app, "completed answer");
204 app.add_message(HistoryCell::Assistant {
205 content: "salvaged partial".to_string(),
206 streaming: false,
207 });
208
209 let result = execute_copy(&mut app);
210
211 assert!(!result.is_error);
212 assert_eq!(app.clipboard.last_written_text(), Some("completed answer"));
213 }
214
215 #[test]
216 fn compacted_context_uses_the_typed_completed_output_receipt() {
217 let tmp = TempDir::new().expect("tempdir");
218 let _env_lock = crate::test_support::lock_test_env();
219 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
220 let mut app = test_app();
221 app.clipboard = ClipboardHandler::for_test(false, false);
222 add_completed_assistant(&mut app, "visible answer before compaction");
223 app.api_messages = std::sync::Arc::new(vec![
224 Message {
225 role: Role::User,
226 content: vec![ContentBlock::Text {
227 text: "compaction checkpoint".to_string(),
228 cache_control: None,
229 }],
230 },
231 Message {
232 role: Role::InterruptedAssistant,
233 content: vec![ContentBlock::Text {
234 text: "partial after compaction".to_string(),
235 cache_control: None,
236 }],
237 },
238 ]);
239
240 let result = execute_copy(&mut app);
241
242 assert!(!result.is_error);
243 assert_eq!(
244 app.clipboard.last_written_text(),
245 Some("visible answer before compaction")
246 );
247 }
248
249 #[test]
250 fn completed_receipt_survives_history_folding() {
251 let mut app = test_app();
252 for index in 0..(App::HISTORY_SOFT_CAP - 1) {
253 app.add_message(HistoryCell::System {
254 content: format!("status {index}"),
255 });
256 }
257 add_completed_assistant(&mut app, "completed answer before fold");
258 app.add_message(HistoryCell::System {
259 content: "trigger fold".to_string(),
260 });
261
262 assert_eq!(
263 app.completed_assistant_output_receipt(),
264 Some("completed answer before fold")
265 );
266 }
267
268 #[test]
269 fn popping_the_latest_cell_reveals_the_prior_completed_receipt() {
270 let tmp = TempDir::new().expect("tempdir");
271 let _env_lock = crate::test_support::lock_test_env();
272 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
273 let mut app = test_app();
274 app.clipboard = ClipboardHandler::for_test(false, false);
275 add_completed_assistant(&mut app, "older answer");
276 add_completed_assistant(&mut app, "newer answer");
277 app.api_messages_mut().clear();
278 app.pop_history();
279
280 let result = execute_copy(&mut app);
281
282 assert!(!result.is_error);
283 assert_eq!(app.clipboard.last_written_text(), Some("older answer"));
284 }
285
286 #[test]
287 fn backtrack_truncation_drops_receipts_after_the_selected_boundary() {
288 let tmp = TempDir::new().expect("tempdir");
289 let _env_lock = crate::test_support::lock_test_env();
290 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
291 let mut app = test_app();
292 app.clipboard = ClipboardHandler::for_test(false, false);
293 add_completed_assistant(&mut app, "older answer");
294 app.add_message(HistoryCell::User {
295 content: "next prompt".to_string(),
296 });
297 add_completed_assistant(&mut app, "newer answer");
298 app.api_messages_mut().clear();
299 app.truncate_history_to(2);
300
301 let result = execute_copy(&mut app);
302
303 assert!(!result.is_error);
304 assert_eq!(app.clipboard.last_written_text(), Some("older answer"));
305 }
306
307 #[test]
308 fn restored_repair_receipt_is_never_copyable_assistant_output() {
309 let tmp = TempDir::new().expect("tempdir");
310 let _env_lock = crate::test_support::lock_test_env();
311 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
312 let mut app = test_app();
313 app.clipboard = ClipboardHandler::for_test(false, false);
314 let visible = Message {
315 role: Role::Assistant,
316 content: vec![ContentBlock::Text {
317 text: "real assistant answer".to_string(),
318 cache_control: None,
319 }],
320 };
321 let repair = Message {
322 role: Role::Assistant,
323 content: vec![ContentBlock::Text {
324 text: "[tool_history_repair] synthetic recovery receipt".to_string(),
325 cache_control: None,
326 }],
327 };
328 app.extend_history(history_cells_from_message(&visible));
329 app.extend_history(history_cells_from_message(&repair));
330 app.rebuild_completed_assistant_outputs_from_restored_history();
331
332 let result = execute_copy(&mut app);
333
334 assert!(!result.is_error);
335 assert_eq!(
336 app.clipboard.last_written_text(),
337 Some("real assistant answer")
338 );
339 }
340
341 #[test]
342 fn successful_delivery_without_recovery_file_is_explicit() {
343 let tmp = TempDir::new().expect("tempdir");
344 let unusable_home = tmp.path().join("home-file");
345 std::fs::write(&unusable_home, "not a directory").expect("home fixture");
346 let _env_lock = crate::test_support::lock_test_env();
347 let (_home, _codewhale_home) = isolate_state_home(&unusable_home);
348 let mut app = test_app();
349 app.clipboard = ClipboardHandler::for_test(false, false);
350 add_completed_assistant(&mut app, "answer");
351
352 let result = execute_copy(&mut app);
353
354 assert!(!result.is_error);
355 let message = result.message.as_deref().unwrap_or_default();
356 assert!(
357 message.contains("no recovery file could be written"),
358 "{message}"
359 );
360 assert!(message.contains("/export file <path>"), "{message}");
361 }
362
363 #[test]
364 fn terminal_client_copy_is_queued_and_names_the_recovery_file() {
365 let tmp = TempDir::new().expect("tempdir");
366 let _env_lock = crate::test_support::lock_test_env();
367 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
368 let mut app = test_app();
369 app.clipboard = ClipboardHandler::for_test(true, true);
370 add_completed_assistant(&mut app, "remote answer");
371
372 let result = execute_copy(&mut app);
373
374 assert!(!result.is_error);
375 let message = result.message.as_deref().unwrap_or_default();
376 assert!(message.contains("Queued"), "{message}");
377 assert!(message.contains("last-copy.md"), "{message}");
378 assert_eq!(
379 std::fs::read_to_string(tmp.path().join("exports").join("last-copy.md"))
380 .expect("recovery copy"),
381 "remote answer"
382 );
383 }
384
385 #[test]
386 fn clipboard_failure_names_the_written_recovery_file() {
387 let tmp = TempDir::new().expect("tempdir");
388 let _env_lock = crate::test_support::lock_test_env();
389 let (_home, _codewhale_home) = isolate_state_home(tmp.path());
390 let mut app = test_app();
391 app.clipboard = ClipboardHandler::unavailable_for_test(false);
392 add_completed_assistant(&mut app, "recoverable answer");
393
394 let result = execute_copy(&mut app);
395
396 assert!(result.is_error);
397 let message = result.message.as_deref().unwrap_or_default();
398 assert!(message.contains("last-copy.md"), "{message}");
399 assert_eq!(
400 std::fs::read_to_string(tmp.path().join("exports").join("last-copy.md"))
401 .expect("recovery copy"),
402 "recoverable answer"
403 );
404 }
405
406 #[test]
407 fn clipboard_and_recovery_failure_explain_the_explicit_export_path() {
408 let tmp = TempDir::new().expect("tempdir");
409 let unusable_home = tmp.path().join("home-file");
410 std::fs::write(&unusable_home, "not a directory").expect("home fixture");
411 let _env_lock = crate::test_support::lock_test_env();
412 let (_home, _codewhale_home) = isolate_state_home(&unusable_home);
413 let mut app = test_app();
414 app.clipboard = ClipboardHandler::unavailable_for_test(false);
415 add_completed_assistant(&mut app, "answer");
416
417 let result = execute_copy(&mut app);
418
419 assert!(result.is_error);
420 let message = result.message.as_deref().unwrap_or_default();
421 assert!(message.contains("/export file <path>"), "{message}");
422 }
423 }
424
424 lines RUST