Synced from monorepo
Changes: - Stop hooks for session lifecycle - Add x.ai/session/state and x.ai/session/import ACP methods - Deny-and-continue for auto-mode classifier blocks with denial limits - Drop codebase-upload from dhat soak test - scheduler_create upsert via task_id; retire one-shot tasks - Clipboard: copy file fallback + honest toasts for SSH/Apple Terminal - Polarity-safe syntax colors in minimal mode - Auto mode classifies unvetted env prefixes instead of hard-prompting - Add GROK_CLIPBOARD_NO_OSC52 kill switch to force OSC 52 off
This commit is contained in:
parent
7cfcb20d2b
commit
ba76b0a683
143 changed files with 9465 additions and 3419 deletions
|
|
@ -1,9 +1,17 @@
|
|||
//! `/copy` -- copy the last (or Nth) assistant message to the clipboard.
|
||||
//!
|
||||
//! Optional file path writes instead of (or when) the clipboard is unreachable:
|
||||
//! - `/copy` — latest → clipboard (file fallback on failure)
|
||||
//! - `/copy 2` — 2nd-latest → clipboard
|
||||
//! - `/copy out.txt` — latest → file
|
||||
//! - `/copy 2 out.txt` — 2nd-latest → file
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app::actions::Action;
|
||||
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
|
||||
|
||||
/// Copy an assistant message to the clipboard.
|
||||
/// Copy an assistant message to the clipboard (or an optional file).
|
||||
pub struct CopyCommand;
|
||||
|
||||
impl SlashCommand for CopyCommand {
|
||||
|
|
@ -12,7 +20,7 @@ impl SlashCommand for CopyCommand {
|
|||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Copy last response to clipboard (/copy N for Nth-latest)"
|
||||
"Copy last response to clipboard or file (/copy [N] [file])"
|
||||
}
|
||||
|
||||
fn session_scoped(&self) -> bool {
|
||||
|
|
@ -20,7 +28,7 @@ impl SlashCommand for CopyCommand {
|
|||
}
|
||||
|
||||
fn usage(&self) -> &str {
|
||||
"/copy [N]"
|
||||
"/copy [N] [file]"
|
||||
}
|
||||
|
||||
fn takes_args(&self) -> bool {
|
||||
|
|
@ -28,29 +36,42 @@ impl SlashCommand for CopyCommand {
|
|||
}
|
||||
|
||||
fn arg_placeholder(&self) -> Option<&str> {
|
||||
Some("[N]")
|
||||
Some("[N] [file]")
|
||||
}
|
||||
|
||||
fn run(&self, _ctx: &mut CommandExecCtx, args: &str) -> CommandResult {
|
||||
let trimmed = args.trim();
|
||||
let n = if trimmed.is_empty() {
|
||||
1
|
||||
} else {
|
||||
match trimmed.parse::<usize>() {
|
||||
Ok(0) => {
|
||||
return CommandResult::Error(
|
||||
"Usage: /copy [N] where N is 1 (latest), 2, 3, ...".to_string(),
|
||||
);
|
||||
}
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return CommandResult::Error(format!(
|
||||
"/copy {trimmed} (invalid number)\nUsage: /copy [N] where N is 1 (latest), 2, 3, ..."
|
||||
));
|
||||
}
|
||||
match parse_copy_args(args) {
|
||||
Ok((n, file_path)) => {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path })
|
||||
}
|
||||
};
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n })
|
||||
Err(msg) => CommandResult::Error(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `/copy` args into `(n, optional_file_path)`.
|
||||
///
|
||||
/// - empty → `(1, None)`
|
||||
/// - `2` → `(2, None)`
|
||||
/// - `out.txt` → `(1, Some(out.txt))`
|
||||
/// - `2 out.txt` → `(2, Some(out.txt))` (rest of line is the path, spaces ok)
|
||||
fn parse_copy_args(args: &str) -> Result<(usize, Option<PathBuf>), String> {
|
||||
let trimmed = args.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok((1, None));
|
||||
}
|
||||
|
||||
let mut parts = trimmed.splitn(2, char::is_whitespace);
|
||||
let first = parts.next().unwrap_or("");
|
||||
let rest = parts.next().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
match first.parse::<usize>() {
|
||||
Ok(0) => Err("Usage: /copy [N] [file] where N is 1 (latest), 2, 3, ...".to_string()),
|
||||
Ok(n) => Ok((n, rest.map(PathBuf::from))),
|
||||
Err(_) => {
|
||||
// Non-numeric first token: treat the whole args string as a path.
|
||||
Ok((1, Some(PathBuf::from(trimmed))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +109,10 @@ mod tests {
|
|||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +123,10 @@ mod tests {
|
|||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "1") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +137,10 @@ mod tests {
|
|||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "3") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 3),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 3);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -124,23 +154,37 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn non_numeric_returns_error() {
|
||||
fn path_only_writes_latest_to_file() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, "abc") {
|
||||
CommandResult::Error(msg) => assert!(msg.contains("invalid number")),
|
||||
other => panic!("expected Error, got {other:?}"),
|
||||
match cmd.run(&mut ctx, "out.txt") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(file_path.as_deref(), Some(std::path::Path::new("out.txt")));
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_and_path_with_spaces() {
|
||||
assert_eq!(
|
||||
parse_copy_args("2 ~/exports/my note.txt").unwrap(),
|
||||
(2, Some(PathBuf::from("~/exports/my note.txt")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_copies_latest() {
|
||||
let models = ModelState::default();
|
||||
let mut ctx = make_ctx(&models);
|
||||
let cmd = CopyCommand;
|
||||
match cmd.run(&mut ctx, " ") {
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n }) => assert_eq!(n, 1),
|
||||
CommandResult::Action(Action::CopyAssistantMessage { n, file_path }) => {
|
||||
assert_eq!(n, 1);
|
||||
assert!(file_path.is_none());
|
||||
}
|
||||
other => panic!("expected Action(CopyAssistantMessage), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue