Every desktop notification was a free-form `String` assembled at the call
site and handed straight to the OS. Notification Center on macOS is a
lock-screen-capable surface, and the OSC 9 / OSC 99 / OSC 777 paths land
in the same window chrome, so whatever happened to be in that string was
rendered verbatim to anyone near the machine.
The worst case was not hypothetical: the approval banner was
`format!("Approval needed: {tool_name} - {description}")`, where the
description is the pending shell command. Waiting for approval on a
destructive command put that command on the lock screen. Turn-complete
notifications pasted up to 363 characters of raw assistant text, which is
exactly where a quoted API key or an absolute path naming the user and
their client would appear.
Replace the string with `NotificationPayload`: a closed set of six event
kinds (turn complete, sub-agent terminal, approval needed, input needed,
elevation needed, model `notify`) over three bounded fields — headline
(80 chars), detail (120), preview (200). Every constructor sanitizes;
there is no path that bypasses it. The preview is gated by
`NotificationKind::allows_preview`, not by the caller, which is what
makes "an approval banner never shows the command" a property of the type
rather than a convention at five call sites.
Sanitization strips complete escape sequences (`sanitize_stream_chunk`
drops the ESC byte but leaves the `[31m` tail, which a terminal ignores
and a notification banner renders), collapses whitespace, then redacts:
credential-shaped strings to `[redacted]`, absolute POSIX and Windows
paths down to `…/basename` (the prefix is the identifying part; the
basename is the useful part), and JSON-shaped tool input to
`[details hidden]`. The credential rule is deliberately over-eager — an
unbroken 40-character run is redacted whether or not it is a secret,
because losing an opaque identifier from a glance surface costs nothing
and leaking one is unrecoverable.
Deliberate behavior changes, beyond the bounds:
- Approval notifications lose the tool description. It stays in the
terminal, in context, where it belongs.
- Elevation notifications name the tool and the denial reason instead of
interpolating both into a sentence; the reason is engine-authored but
not a closed vocabulary, so it is sanitized like everything else.
- The turn-complete preview cap moves from 363 characters to 200,
inclusive of the ellipsis, so the bound the type promises is the bound
the OS receives.
Tests updated rather than preserved, on purpose:
- The `macos_notification_parts` tests pinned the old design, where the
subtitle/body split was re-derived by splitting a free-form string on
its first newline. That split can drift from what the composer meant;
the projection from a typed payload cannot. They now assert the
projection, plus a new case pinning that an approval banner carries
only the tool name.
- The `completed_turn_message` / `subagent_terminal_message` tests
asserted whole rendered strings including the `\n` join. They now
assert headline/detail/preview separately, which is the thing worth
pinning.
- `completed_turn_notification_truncates_long_text` used `"a".repeat(500)`,
which is now credential-shaped and redacted wholesale. It uses
word-shaped text; a separate test pins the redaction of the opaque run
so the over-eagerness is a documented decision, not a surprise.
Not fixed here: the Script Editor icon, the other half of #4834.
`display notification` is AppleScript Standard Additions and posts on
behalf of the bundled host process; `/usr/bin/osascript` is unbundled, so
macOS attributes the banner to `com.apple.ScriptEditor2`, which supplies
the icon and owns the System Settings entry, previews policy, alert
style, and Do Not Disturb. `display notification` takes no icon
parameter, so no change in this file can fix it — it needs a real `.app`
bundle, which is a separate, larger piece of work. The limitation is now
documented at `Method::MacOS` and in docs/CONFIGURATION.md, including the
precise scope: only macOS terminals with no notification escape of their
own (Apple Terminal, VS Code, JetBrains, plain tmux without
`LC_TERMINAL`) ever reach that path.
The ARGV-based osascript invocation is preserved unchanged — it is the
existing fix for AppleScript string injection and must stay.
Squashed to a single commit deliberately: an intermediate revision used the
textbook base64 JWT header as the bearer-token redaction fixture, which is
exactly what secret scanners match. It raised a real GitGuardian bearer-token
incident against a value that was never a credential, and the incident
persists as long as that blob exists anywhere in the branch history. The
fixture is now an obviously-fake opaque token, which proves the same thing:
the `Bearer <value>` rule does not inspect the value's shape.
Also scopes NotificationPayload::headline's dead-code allow to
not(target_os = "macos"), since macos_notification_parts is its only
non-test caller and Linux CI runs with -D warnings.
This commit is contained in:
@@ -24,17 +24,11 @@ pub fn declare_rerun_conditions(manifest_dir: &Path) {
|
||||
/// `manifest_dir` and `package_version` are the calling build script's
|
||||
/// `CARGO_MANIFEST_DIR` and `CARGO_PKG_VERSION`.
|
||||
pub fn emit_build_version(manifest_dir: &Path, package_version: &str) {
|
||||
let commit = build_commit(manifest_dir);
|
||||
let build_version = commit
|
||||
.as_ref()
|
||||
.and_then(|sha| short_sha(sha.clone()))
|
||||
let build_version = build_sha(manifest_dir)
|
||||
.map(|sha| format!("{package_version} ({sha})"))
|
||||
.unwrap_or_else(|| package_version.to_string());
|
||||
|
||||
println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
|
||||
if let Some(commit) = commit {
|
||||
println!("cargo:rustc-env=CODEWHALE_BUILD_COMMIT={commit}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell Cargo to invalidate the cached build script output when `HEAD`
|
||||
@@ -123,17 +117,17 @@ fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn build_commit(manifest_dir: &Path) -> Option<String> {
|
||||
env_commit("DEEPSEEK_BUILD_SHA")
|
||||
.or_else(|| env_commit("GITHUB_SHA"))
|
||||
.or_else(|| git_commit(manifest_dir))
|
||||
fn build_sha(manifest_dir: &Path) -> Option<String> {
|
||||
env_sha("DEEPSEEK_BUILD_SHA")
|
||||
.or_else(|| env_sha("GITHUB_SHA"))
|
||||
.or_else(|| git_sha(manifest_dir))
|
||||
}
|
||||
|
||||
fn env_commit(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().and_then(full_sha)
|
||||
fn env_sha(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().and_then(short_sha)
|
||||
}
|
||||
|
||||
fn git_commit(manifest_dir: &Path) -> Option<String> {
|
||||
fn git_sha(manifest_dir: &Path) -> Option<String> {
|
||||
let top_level_output = Command::new("git")
|
||||
.args(["-C"])
|
||||
.arg(manifest_dir)
|
||||
@@ -151,22 +145,14 @@ fn git_commit(manifest_dir: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["-C"])
|
||||
.arg(top_level)
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.args(["rev-parse", "--short=12", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
full_sha(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn full_sha(value: String) -> Option<String> {
|
||||
let trimmed = value.trim().to_ascii_lowercase();
|
||||
if trimmed.len() != 40 || !trimmed.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
Some(trimmed)
|
||||
short_sha(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
}
|
||||
|
||||
fn short_sha(value: String) -> Option<String> {
|
||||
@@ -179,7 +165,7 @@ fn short_sha(value: String) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{full_sha, git_common_dir, parse_symbolic_ref, short_sha};
|
||||
use super::{git_common_dir, parse_symbolic_ref};
|
||||
use std::{
|
||||
fs,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -201,23 +187,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_commit_requires_exact_forty_hex_characters() {
|
||||
assert_eq!(
|
||||
full_sha("ABCDEF0123456789ABCDEF0123456789ABCDEF01".to_string()),
|
||||
Some("abcdef0123456789abcdef0123456789abcdef01".to_string())
|
||||
);
|
||||
assert_eq!(full_sha("abc123".to_string()), None);
|
||||
assert_eq!(
|
||||
full_sha("gggggggggggggggggggggggggggggggggggggggg".to_string()),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
short_sha("abcdef0123456789abcdef0123456789abcdef01".to_string()),
|
||||
Some("abcdef012345".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_head_is_not_a_symbolic_ref() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -233,8 +233,6 @@ enum Commands {
|
||||
Sessions(TuiPassthroughArgs),
|
||||
/// Resume a saved TUI session.
|
||||
Resume(TuiPassthroughArgs),
|
||||
/// Launch an interactive session and hand it to the Codewhale web app.
|
||||
Rc(TuiPassthroughArgs),
|
||||
/// Fork a saved TUI session.
|
||||
Fork(TuiPassthroughArgs),
|
||||
/// Create a default AGENTS.md in the current directory.
|
||||
@@ -1663,12 +1661,6 @@ fn run() -> Result<()> {
|
||||
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
||||
run_resume_command(&cli, &resolved_runtime, args)
|
||||
}
|
||||
Some(Commands::Rc(args)) => {
|
||||
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
||||
let mut passthrough = vec!["--remote-control".to_string()];
|
||||
passthrough.extend(args.args);
|
||||
delegate_to_tui(&cli, &resolved_runtime, passthrough)
|
||||
}
|
||||
Some(Commands::Fork(args)) => {
|
||||
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
||||
delegate_to_tui(&cli, &resolved_runtime, tui_args("fork", args))
|
||||
@@ -8277,16 +8269,6 @@ model = "qwen-2.5-7b"
|
||||
assert_eq!(root_tui_passthrough(&cli).unwrap(), vec!["--continue"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rc_as_the_account_owned_interactive_handoff() {
|
||||
let cli = parse_ok(&["codewhale", "rc"]);
|
||||
|
||||
let Some(Commands::Rc(args)) = cli.command else {
|
||||
panic!("rc should parse as the remote-control TUI handoff");
|
||||
};
|
||||
assert!(args.args.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_continue_rejects_startup_prompt() {
|
||||
let cli = parse_ok(&["codewhale", "--continue", "-p", "follow up"]);
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "Index must be a positive number",
|
||||
"CmdQueueIndexMin": "Index must be >= 1",
|
||||
"CmdRelayDescription": "Create a session relay (接力) for a fresh thread",
|
||||
"CmdRemoteControlDescription": "Resume this exact session from your Codewhale web account",
|
||||
"CmdRenameDescription": "Rename the current session",
|
||||
"CmdRestoreDescription": "Roll back the workspace to a prior pre/post-turn snapshot. With no arg, lists recent snapshots.",
|
||||
"CmdRetryDescription": "Retry the last request",
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "El índice debe ser un número positivo",
|
||||
"CmdQueueIndexMin": "El índice debe ser >= 1",
|
||||
"CmdRelayDescription": "Crear un relay de sesión (接力) para un hilo nuevo",
|
||||
"CmdRemoteControlDescription": "Reanudar esta sesión exacta desde tu cuenta web de Codewhale",
|
||||
"CmdRenameDescription": "Renombrar la sesión actual",
|
||||
"CmdRestoreDescription": "Revertir el workspace a un snapshot pre/post-turno anterior. Sin argumento, lista los snapshots recientes.",
|
||||
"CmdRetryDescription": "Repetir la última solicitud",
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "インデックスは正の数値である必要があります",
|
||||
"CmdQueueIndexMin": "インデックスは 1 以上である必要があります",
|
||||
"CmdRelayDescription": "新しいスレッド用のセッションリレー(接力)を作成",
|
||||
"CmdRemoteControlDescription": "Codewhaleウェブアカウントからこのセッションを再開",
|
||||
"CmdRenameDescription": "現在のセッションの名前を変更",
|
||||
"CmdRestoreDescription": "ワークスペースを以前のターン前/後スナップショットへロールバック。引数なしで最近のスナップショットを一覧表示。",
|
||||
"CmdRetryDescription": "直前のリクエストを再試行",
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "인덱스는 양수여야 합니다",
|
||||
"CmdQueueIndexMin": "인덱스는 1 이상이어야 합니다",
|
||||
"CmdRelayDescription": "새 스레드를 위한 세션 릴레이(接力)를 생성합니다",
|
||||
"CmdRemoteControlDescription": "Codewhale 웹 계정에서 이 세션을 그대로 재개합니다",
|
||||
"CmdRenameDescription": "현재 세션 이름을 바꿉니다",
|
||||
"CmdRestoreDescription": "작업 공간을 이전 턴 전/후 스냅샷으로 되돌립니다. 인자가 없으면 최근 스냅샷 목록을 표시합니다.",
|
||||
"CmdRetryDescription": "마지막 요청을 재시도합니다",
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "O índice deve ser um número positivo",
|
||||
"CmdQueueIndexMin": "O índice deve ser >= 1",
|
||||
"CmdRelayDescription": "Criar um relay da sessão para um novo thread",
|
||||
"CmdRemoteControlDescription": "Retomar esta sessão exata pela sua conta web do Codewhale",
|
||||
"CmdRenameDescription": "Renomear a sessão atual",
|
||||
"CmdRestoreDescription": "Reverter o workspace a um snapshot pré/pós-turno anterior. Sem argumento, lista os snapshots recentes.",
|
||||
"CmdRetryDescription": "Repetir a última requisição",
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "Chỉ mục phải là số dương",
|
||||
"CmdQueueIndexMin": "Chỉ mục phải >= 1",
|
||||
"CmdRelayDescription": "Tạo một phiên tiếp sức cho một luồng mới",
|
||||
"CmdRemoteControlDescription": "Tiếp tục chính phiên này từ tài khoản web Codewhale",
|
||||
"CmdRenameDescription": "Đổi tên phiên làm việc hiện tại",
|
||||
"CmdRestoreDescription": "Khôi phục không gian làm việc về bản chụp trước/sau lượt. Nếu không có đối số, hiển thị các bản chụp gần đây.",
|
||||
"CmdRetryDescription": "Thử lại yêu cầu gần nhất",
|
||||
|
||||
@@ -252,7 +252,6 @@
|
||||
"CmdQueueIndexPositive": "索引必须为正数",
|
||||
"CmdQueueIndexMin": "索引必须 >= 1",
|
||||
"CmdRelayDescription": "为新线程创建会话接力摘要",
|
||||
"CmdRemoteControlDescription": "从 Codewhale 网页账户继续这个会话",
|
||||
"CmdRenameDescription": "重命名当前会话",
|
||||
"CmdRestoreDescription": "将工作区回滚到此前的轮次前/后快照。不带参数时列出最近的快照。",
|
||||
"CmdRetryDescription": "重试上一次请求",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"ComposerDispatchFailedRestored": "訊息未傳送({error});已還原到輸入框。",
|
||||
"CmdAuthDescription": "管理提供者驗證流程",
|
||||
"CmdRelayDescription": "為新執行緒建立會話接力摘要",
|
||||
"CmdRemoteControlDescription": "從 Codewhale 網頁帳戶繼續這個會話",
|
||||
"CmdHotbarDescription": "開啟 Hotbar 設定",
|
||||
"KbJumpPlanAgentYolo": "觸發 Hotbar 槽位",
|
||||
"KbAltJumpPlanAgentYolo": "替代快捷鍵跳到 Plan / Act / Operate 模式",
|
||||
|
||||
@@ -10,7 +10,6 @@ mod load;
|
||||
mod new;
|
||||
mod purge;
|
||||
mod relay;
|
||||
mod remote_control;
|
||||
mod rename;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rename::rename_with_manager;
|
||||
@@ -66,10 +65,6 @@ impl CommandGroup for SessionCommands {
|
||||
relay::RelayCmd::info(),
|
||||
relay::RelayCmd::execute,
|
||||
)),
|
||||
Box::new(FunctionCommand::new(
|
||||
remote_control::RemoteControlCmd::info(),
|
||||
remote_control::RemoteControlCmd::execute,
|
||||
)),
|
||||
Box::new(FunctionCommand::new(
|
||||
export::ExportCmd::info(),
|
||||
export::ExportCmd::execute,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
//! `/rc` account-owned web remote control.
|
||||
|
||||
use crate::commands::traits::{CommandInfo, RegisterCommand};
|
||||
use crate::localization::MessageId;
|
||||
use crate::remote_control::RemoteControlAction;
|
||||
use crate::tui::app::{App, AppAction};
|
||||
|
||||
use super::CommandResult;
|
||||
|
||||
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
|
||||
name: "rc",
|
||||
aliases: &["remote-control"],
|
||||
usage: "/rc [status|stop]",
|
||||
description_id: MessageId::CmdRemoteControlDescription,
|
||||
};
|
||||
|
||||
pub(in crate::commands) struct RemoteControlCmd;
|
||||
|
||||
impl RegisterCommand for RemoteControlCmd {
|
||||
fn info() -> &'static CommandInfo {
|
||||
&COMMAND_INFO
|
||||
}
|
||||
|
||||
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
match arg.map(str::trim).filter(|value| !value.is_empty()) {
|
||||
None | Some("start") => {
|
||||
if app.is_loading {
|
||||
return CommandResult::error(
|
||||
"Finish or interrupt the current turn before handing this session to the web.",
|
||||
);
|
||||
}
|
||||
CommandResult::with_message_and_action(
|
||||
"Starting account-owned web remote control…",
|
||||
AppAction::RemoteControl(RemoteControlAction::Start),
|
||||
)
|
||||
}
|
||||
Some("status") => CommandResult::message(app.remote_control.status_line()),
|
||||
Some("stop") => CommandResult::with_message_and_action(
|
||||
"Stopping web remote control…",
|
||||
AppAction::RemoteControl(RemoteControlAction::Stop),
|
||||
),
|
||||
Some(_) => CommandResult::error("Usage: /rc [status|stop]"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn start_is_blocked_during_an_active_turn() {
|
||||
let options = TuiOptions {
|
||||
..crate::test_support::test_tui_options(PathBuf::from("."))
|
||||
};
|
||||
let mut app = crate::test_support::test_app_with_options(options);
|
||||
app.is_loading = true;
|
||||
let result = RemoteControlCmd::execute(&mut app, None);
|
||||
assert!(result.is_error);
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,6 @@ pub enum MessageId {
|
||||
CmdQueueIndexPositive,
|
||||
CmdQueueIndexMin,
|
||||
CmdRelayDescription,
|
||||
CmdRemoteControlDescription,
|
||||
CmdRenameDescription,
|
||||
CmdRestoreDescription,
|
||||
CmdRetryDescription,
|
||||
@@ -1526,7 +1525,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdQueueIndexPositive,
|
||||
MessageId::CmdQueueIndexMin,
|
||||
MessageId::CmdRelayDescription,
|
||||
MessageId::CmdRemoteControlDescription,
|
||||
MessageId::CmdRenameDescription,
|
||||
MessageId::CmdRestoreDescription,
|
||||
MessageId::CmdRetryDescription,
|
||||
|
||||
@@ -86,7 +86,6 @@ mod provider_lake;
|
||||
mod provider_readiness;
|
||||
mod purge;
|
||||
mod regex_cache;
|
||||
mod remote_control;
|
||||
mod remote_setup;
|
||||
pub mod repl;
|
||||
mod repo_law;
|
||||
@@ -228,10 +227,6 @@ struct Cli {
|
||||
#[arg(long)]
|
||||
skip_onboarding: bool,
|
||||
|
||||
/// Start account-owned web remote control for this interactive session.
|
||||
#[arg(long, hide = true)]
|
||||
remote_control: bool,
|
||||
|
||||
/// Start a fresh session, ignoring any crash-recovery checkpoint
|
||||
#[arg(long = "fresh")]
|
||||
fresh: bool,
|
||||
@@ -8916,11 +8911,6 @@ async fn run_interactive(
|
||||
initial_input: Option<tui::InitialInput>,
|
||||
plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
|
||||
) -> Result<()> {
|
||||
let initial_input = if cli.remote_control {
|
||||
Some(tui::InitialInput::RemoteControl)
|
||||
} else {
|
||||
initial_input
|
||||
};
|
||||
let workspace = cli
|
||||
.workspace
|
||||
.clone()
|
||||
@@ -12656,12 +12646,6 @@ mod terminal_mode_tests {
|
||||
Cli::try_parse_from(args).expect("CLI args should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_remote_control_flag_starts_the_interactive_handoff() {
|
||||
let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
|
||||
assert!(cli.remote_control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_registry_discovery_is_route_independent_and_read_only() {
|
||||
let _env_lock = crate::test_support::lock_test_env();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, required_str,
|
||||
};
|
||||
use crate::tui::notifications::{Method, notify_done};
|
||||
use crate::tui::notifications::{Method, NotificationPayload, notify_done};
|
||||
|
||||
/// Maximum chars passed through for the title — keeps the OSC 9 escape
|
||||
/// reasonable on terminals that wrap long titles awkwardly.
|
||||
@@ -93,11 +93,14 @@ impl ToolSpec for NotifyTool {
|
||||
return Err(ToolError::execution_failed("title must not be empty"));
|
||||
}
|
||||
|
||||
let msg = if body.is_empty() {
|
||||
title.to_string()
|
||||
} else {
|
||||
format!("{title}: {body}")
|
||||
};
|
||||
// #4834: model-authored text is the least trusted input that can
|
||||
// reach Notification Center, so it goes through the typed payload
|
||||
// like every other event kind — bounded, control-byte-stripped,
|
||||
// and redacted for credentials, absolute paths, and raw tool JSON.
|
||||
let payload = NotificationPayload::model_notify(
|
||||
title,
|
||||
if body.is_empty() { None } else { Some(body) },
|
||||
);
|
||||
|
||||
let in_tmux = std::env::var("TMUX")
|
||||
.map(|v| !v.is_empty())
|
||||
@@ -108,7 +111,7 @@ impl ToolSpec for NotifyTool {
|
||||
notify_done(
|
||||
Method::Auto,
|
||||
in_tmux,
|
||||
&msg,
|
||||
&payload,
|
||||
std::time::Duration::ZERO,
|
||||
std::time::Duration::from_secs(1),
|
||||
);
|
||||
|
||||
@@ -978,9 +978,6 @@ pub struct App {
|
||||
/// Monotonic counter used to issue fresh per-cell revisions.
|
||||
pub next_history_revision: u64,
|
||||
pub api_messages: Vec<Message>,
|
||||
/// Typed account-owned browser relay for this exact TUI session.
|
||||
pub remote_control: crate::remote_control::RemoteControlController,
|
||||
pub start_remote_control_on_launch: bool,
|
||||
pub is_loading: bool,
|
||||
/// Sender for spawned dispatch tasks to report completion back to the
|
||||
/// event loop. The closure is called with `&mut App` so the async phase
|
||||
|
||||
@@ -473,7 +473,6 @@ impl App {
|
||||
|
||||
let input_history = crate::composer_history::load_history();
|
||||
let mention_cwd = std::env::current_dir().ok();
|
||||
let start_remote_control = matches!(initial_input, Some(InitialInput::RemoteControl));
|
||||
let (initial_input_text, initial_input_cursor, auto_submit_initial_input) =
|
||||
match initial_input {
|
||||
// #451: pre-populate the composer when invoked via
|
||||
@@ -488,7 +487,6 @@ impl App {
|
||||
let cursor = text.chars().count();
|
||||
(text, cursor, true)
|
||||
}
|
||||
Some(InitialInput::RemoteControl) => (String::new(), 0, false),
|
||||
_ => (String::new(), 0, false),
|
||||
};
|
||||
let mcp_configured_count = crate::mcp::load_config_with_workspace_and_plugins(
|
||||
@@ -553,8 +551,6 @@ impl App {
|
||||
history_revisions: Vec::new(),
|
||||
next_history_revision: 1,
|
||||
api_messages: Vec::new(),
|
||||
remote_control: crate::remote_control::RemoteControlController::default(),
|
||||
start_remote_control_on_launch: start_remote_control,
|
||||
is_loading: false,
|
||||
dispatch_completion_tx: None,
|
||||
dispatch_in_flight: false,
|
||||
|
||||
@@ -540,8 +540,6 @@ pub enum InitialInput {
|
||||
/// Pre-populate the composer, submit it once startup is ready, then keep
|
||||
/// the interactive session open for follow-up messages (#2370).
|
||||
Submit(String),
|
||||
/// Begin account-owned web remote control after the TUI is initialized.
|
||||
RemoteControl,
|
||||
}
|
||||
|
||||
// === Sub-state structs for App field organization (#377) ===
|
||||
@@ -693,7 +691,6 @@ pub enum AppAction {
|
||||
SaveSession(PathBuf),
|
||||
#[allow(dead_code)] // For explicit /load command
|
||||
LoadSession(PathBuf),
|
||||
RemoteControl(crate::remote_control::RemoteControlAction),
|
||||
SyncSession {
|
||||
session_id: Option<String>,
|
||||
messages: Vec<Message>,
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) mod mention_completion;
|
||||
pub mod model_picker;
|
||||
pub mod motion;
|
||||
pub mod mouse_ui;
|
||||
pub mod notification_payload;
|
||||
pub mod notifications;
|
||||
pub mod ocean;
|
||||
pub mod onboarding;
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
//! Typed, bounded, redaction-aware desktop notification payloads (#4834).
|
||||
//!
|
||||
//! Before this module every desktop notification was a single free-form
|
||||
//! `String` assembled at the call site and handed straight to the OS.
|
||||
//! Notification Center on macOS (and the equivalent surface behind OSC 9 /
|
||||
//! OSC 99 / OSC 777) is lock-screen capable: whatever happened to be in
|
||||
//! that string — a pasted API key, an absolute path that names the user
|
||||
//! and their client, the full shell command awaiting approval — was
|
||||
//! rendered verbatim to anyone looking at the machine.
|
||||
//!
|
||||
//! [`NotificationPayload`] replaces the string with a closed set of event
|
||||
//! kinds and three bounded fields:
|
||||
//!
|
||||
//! | field | max chars | contents |
|
||||
//! |------------|-----------|-------------------------------------------|
|
||||
//! | `headline` | 80 | localized event label (+ elapsed/cost) |
|
||||
//! | `detail` | 120 | short, event-specific identifier |
|
||||
//! | `preview` | 200 | assistant text — two kinds only |
|
||||
//!
|
||||
//! Every field passes through [`sanitize_field`], which strips control
|
||||
//! bytes, collapses newlines and whitespace runs, and redacts credentials,
|
||||
//! absolute local paths, and structured tool input. There is no
|
||||
//! constructor that bypasses it, and `preview` is gated by
|
||||
//! [`NotificationKind::allows_preview`] rather than by the caller.
|
||||
//!
|
||||
//! ## What each kind is allowed to show
|
||||
//!
|
||||
//! - [`NotificationKind::TurnComplete`] — the localized "Turn complete"
|
||||
//! headline (plus elapsed/cost when `include_summary` is on) and a
|
||||
//! preview of the assistant's own reply. Unchanged in spirit from the
|
||||
//! previous behavior; now bounded and redacted.
|
||||
//! - [`NotificationKind::SubagentTerminal`] — localized status headline,
|
||||
//! the sub-agent id as detail, and a preview of the child's summary
|
||||
//! line.
|
||||
//! - [`NotificationKind::ApprovalNeeded`] — headline plus the *tool name*.
|
||||
//! Never the tool description or arguments: an approval prompt fires
|
||||
//! precisely when those arguments are untrusted, and the previous code
|
||||
//! put the full description on the lock screen.
|
||||
//! - [`NotificationKind::InputNeeded`] — headline only. The question text
|
||||
//! stays in the terminal.
|
||||
//! - [`NotificationKind::ElevationNeeded`] — headline plus tool name and
|
||||
//! the sandbox denial reason. The reason is engine-authored but not a
|
||||
//! closed vocabulary, so it is sanitized like everything else.
|
||||
//! - [`NotificationKind::ModelNotify`] — the model-callable `notify` tool.
|
||||
//! Title and body are model-authored, so they are the least trusted
|
||||
//! input here and carry no preview on top.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
/// Maximum characters in the headline (the macOS subtitle line).
|
||||
pub const HEADLINE_MAX_CHARS: usize = 80;
|
||||
/// Maximum characters in the detail line.
|
||||
pub const DETAIL_MAX_CHARS: usize = 120;
|
||||
/// Maximum characters in the assistant preview.
|
||||
pub const PREVIEW_MAX_CHARS: usize = 200;
|
||||
|
||||
/// Separator between the detail and preview segments of a rendered body.
|
||||
const BODY_SEPARATOR: &str = " — ";
|
||||
|
||||
/// Placeholder substituted for anything that must never reach a
|
||||
/// lock-screen-capable surface.
|
||||
pub const REDACTED: &str = "[redacted]";
|
||||
/// Placeholder substituted for structured tool input/output.
|
||||
pub const HIDDEN_DETAILS: &str = "[details hidden]";
|
||||
|
||||
/// Fallback headline when sanitization leaves nothing behind.
|
||||
const FALLBACK_HEADLINE: &str = "Codewhale";
|
||||
|
||||
/// The closed set of events that can produce a desktop notification.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotificationKind {
|
||||
/// An agent turn finished successfully.
|
||||
TurnComplete,
|
||||
/// A sub-agent reached a terminal status (complete/failed/cancelled/…).
|
||||
SubagentTerminal,
|
||||
/// A tool call is blocked waiting for the user to approve it.
|
||||
ApprovalNeeded,
|
||||
/// The agent asked the user a question and is blocked on the answer.
|
||||
InputNeeded,
|
||||
/// The sandbox denied an operation and the user must elevate.
|
||||
ElevationNeeded,
|
||||
/// The model called the `notify` tool.
|
||||
ModelNotify,
|
||||
}
|
||||
|
||||
impl NotificationKind {
|
||||
/// Whether this kind may carry assistant preview text at all.
|
||||
///
|
||||
/// Interactive prompts (approval/input/elevation) never do: the whole
|
||||
/// point of the prompt is that the pending content is not yet trusted.
|
||||
/// `ModelNotify` does not either — its body *is* model-authored text
|
||||
/// and already occupies the body budget.
|
||||
#[must_use]
|
||||
pub const fn allows_preview(self) -> bool {
|
||||
matches!(self, Self::TurnComplete | Self::SubagentTerminal)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded, sanitized notification ready to hand to the OS.
|
||||
///
|
||||
/// Construct via the per-kind constructors; every one of them sanitizes
|
||||
/// and truncates. There is no way to smuggle raw text through.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NotificationPayload {
|
||||
kind: NotificationKind,
|
||||
headline: String,
|
||||
detail: Option<String>,
|
||||
preview: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationPayload {
|
||||
fn new(kind: NotificationKind, headline: &str, detail: Option<&str>) -> Self {
|
||||
let headline = bounded(headline, HEADLINE_MAX_CHARS);
|
||||
Self {
|
||||
kind,
|
||||
headline: if headline.is_empty() {
|
||||
FALLBACK_HEADLINE.to_string()
|
||||
} else {
|
||||
headline
|
||||
},
|
||||
detail: detail
|
||||
.map(|d| bounded(d, DETAIL_MAX_CHARS))
|
||||
.filter(|d| !d.is_empty()),
|
||||
preview: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn finished. `headline` is the already-localized status line
|
||||
/// (optionally carrying elapsed/cost when `include_summary` is on).
|
||||
#[must_use]
|
||||
pub fn turn_complete(headline: &str) -> Self {
|
||||
Self::new(NotificationKind::TurnComplete, headline, None)
|
||||
}
|
||||
|
||||
/// Sub-agent reached a terminal status. `detail` is the agent id.
|
||||
#[must_use]
|
||||
pub fn subagent_terminal(headline: &str, agent_id: &str) -> Self {
|
||||
Self::new(NotificationKind::SubagentTerminal, headline, Some(agent_id))
|
||||
}
|
||||
|
||||
/// A tool call needs approval. Only the tool *name* is disclosed —
|
||||
/// never the description or the arguments.
|
||||
#[must_use]
|
||||
pub fn approval_needed(headline: &str, tool_name: &str) -> Self {
|
||||
Self::new(NotificationKind::ApprovalNeeded, headline, Some(tool_name))
|
||||
}
|
||||
|
||||
/// The agent is blocked on a user answer. The question stays in the
|
||||
/// terminal; the banner only says "come back".
|
||||
#[must_use]
|
||||
pub fn input_needed(headline: &str) -> Self {
|
||||
Self::new(NotificationKind::InputNeeded, headline, None)
|
||||
}
|
||||
|
||||
/// The sandbox denied an operation and the user must decide whether
|
||||
/// to elevate.
|
||||
#[must_use]
|
||||
pub fn elevation_needed(headline: &str, tool_name: &str, reason: &str) -> Self {
|
||||
let detail = if reason.trim().is_empty() {
|
||||
tool_name.to_string()
|
||||
} else {
|
||||
format!("{tool_name}{BODY_SEPARATOR}{reason}")
|
||||
};
|
||||
Self::new(NotificationKind::ElevationNeeded, headline, Some(&detail))
|
||||
}
|
||||
|
||||
/// The model-callable `notify` tool. Both fields are model-authored
|
||||
/// and therefore fully sanitized like everything else.
|
||||
#[must_use]
|
||||
pub fn model_notify(title: &str, body: Option<&str>) -> Self {
|
||||
Self::new(NotificationKind::ModelNotify, title, body)
|
||||
}
|
||||
|
||||
/// Attach assistant preview text.
|
||||
///
|
||||
/// A no-op unless the kind permits a preview. Callers cannot override
|
||||
/// the kind policy — routing every preview through this method is
|
||||
/// what makes "approval banners never show the command" a type-level
|
||||
/// property instead of a call-site convention.
|
||||
#[must_use]
|
||||
pub fn with_preview(mut self, preview: Option<&str>) -> Self {
|
||||
if !self.kind.allows_preview() {
|
||||
return self;
|
||||
}
|
||||
self.preview = preview
|
||||
.map(|p| bounded(p, PREVIEW_MAX_CHARS))
|
||||
.filter(|p| !p.is_empty());
|
||||
self
|
||||
}
|
||||
|
||||
/// The event kind.
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> NotificationKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
/// Bounded, sanitized headline. Never empty.
|
||||
///
|
||||
/// Only the macOS path reads this today — `display notification` is the
|
||||
/// one backend that takes a separate subtitle, while the escape-sequence
|
||||
/// backends send a single string via [`Self::body`]. Tests exercise it on
|
||||
/// every platform, but `#[cfg(test)]` uses do not keep it alive in a
|
||||
/// non-macOS release build, so the allow is scoped to exactly that case
|
||||
/// rather than blanket-silencing dead_code on the accessor.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
#[must_use]
|
||||
pub fn headline(&self) -> &str {
|
||||
&self.headline
|
||||
}
|
||||
|
||||
/// Bounded, sanitized detail line, if the kind carries one.
|
||||
#[must_use]
|
||||
pub fn detail(&self) -> Option<&str> {
|
||||
self.detail.as_deref()
|
||||
}
|
||||
|
||||
/// Bounded, sanitized assistant preview. `None` unless the kind
|
||||
/// allows one and the caller supplied non-empty text.
|
||||
#[must_use]
|
||||
pub fn preview(&self) -> Option<&str> {
|
||||
self.preview.as_deref()
|
||||
}
|
||||
|
||||
/// The body lines below the headline, joined for surfaces that take a
|
||||
/// single body string (macOS Notification Center).
|
||||
#[must_use]
|
||||
pub fn body(&self) -> String {
|
||||
let mut parts: Vec<&str> = Vec::with_capacity(2);
|
||||
if let Some(detail) = self.detail() {
|
||||
parts.push(detail);
|
||||
}
|
||||
if let Some(preview) = self.preview() {
|
||||
parts.push(preview);
|
||||
}
|
||||
parts.join(BODY_SEPARATOR)
|
||||
}
|
||||
|
||||
/// Single-line rendering for terminal escape protocols (OSC 9 / 99 /
|
||||
/// 777), which cannot express a title/subtitle/body hierarchy.
|
||||
#[must_use]
|
||||
pub fn render_inline(&self) -> String {
|
||||
let body = self.body();
|
||||
if body.is_empty() {
|
||||
self.headline.clone()
|
||||
} else {
|
||||
format!("{}: {body}", self.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize then truncate to `max_chars`, appending an ellipsis when the
|
||||
/// input was longer. Character-based, not byte-based, so multi-byte text
|
||||
/// is never sliced mid-scalar.
|
||||
fn bounded(text: &str, max_chars: usize) -> String {
|
||||
truncate_chars(&sanitize_field(text), max_chars)
|
||||
}
|
||||
|
||||
/// Truncate to `max_chars` characters *inclusive* of the `...` marker, so
|
||||
/// the result never exceeds the declared bound.
|
||||
fn truncate_chars(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let take = max_chars.saturating_sub(3);
|
||||
let mut out: String = text.chars().take(take).collect();
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
/// Strip control bytes and redact anything that must not reach a
|
||||
/// lock-screen-capable surface.
|
||||
///
|
||||
/// Redaction runs per line so a credential cannot be hidden by wrapping,
|
||||
/// then the lines are joined into one bounded field.
|
||||
#[must_use]
|
||||
pub fn sanitize_field(text: &str) -> String {
|
||||
// Strip whole escape sequences *before* `sanitize_stream_chunk`, which
|
||||
// drops the ESC byte but leaves the parameter tail behind — good
|
||||
// enough for a terminal that will never re-interpret it, wrong for a
|
||||
// notification banner that would render a literal `[31m`.
|
||||
super::ui::sanitize_stream_chunk(&strip_escape_sequences(text))
|
||||
.lines()
|
||||
.map(|line| {
|
||||
let redacted = redact_structured(line.trim());
|
||||
let redacted = redact_credentials(&redacted);
|
||||
let redacted = redact_absolute_paths(&redacted);
|
||||
// Collapse whitespace runs so a bounded field cannot be
|
||||
// padded out with invisible filler.
|
||||
redacted.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
})
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
fn regex_cache<const N: usize>(
|
||||
cell: &'static OnceLock<Vec<Regex>>,
|
||||
patterns: [&str; N],
|
||||
) -> &'static [Regex] {
|
||||
cell.get_or_init(|| {
|
||||
patterns
|
||||
.iter()
|
||||
.map(|p| Regex::new(p).expect("static notification redaction pattern must compile"))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove complete ANSI escape sequences (CSI, OSC, and single-character
|
||||
/// escapes) so neither the sequence nor its parameter tail survives into a
|
||||
/// notification field.
|
||||
fn strip_escape_sequences(text: &str) -> String {
|
||||
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
|
||||
let res = regex_cache(
|
||||
&PATTERNS,
|
||||
[
|
||||
// OSC: ESC ] … terminated by BEL or ST.
|
||||
r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?",
|
||||
// CSI: ESC [ params intermediates final.
|
||||
r"\x1b\[[0-9;?<>=]*[ -/]*[@-~]?",
|
||||
// Any remaining two-character escape.
|
||||
r"\x1b.",
|
||||
],
|
||||
);
|
||||
let mut out = text.to_string();
|
||||
for re in res {
|
||||
out = re.replace_all(&out, "").into_owned();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace structured tool input/output (JSON objects and arrays) with a
|
||||
/// placeholder. Raw tool arguments are the single most likely place for a
|
||||
/// credential or a private path to appear verbatim.
|
||||
fn redact_structured(text: &str) -> String {
|
||||
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
|
||||
let res = regex_cache(
|
||||
&PATTERNS,
|
||||
[
|
||||
// A JSON-ish object: braces containing a `"key":` pair.
|
||||
r#"\{[^{}]*"[^"]*"\s*:[^{}]*\}"#,
|
||||
// A JSON-ish array of objects or quoted strings.
|
||||
r#"\[\s*(?:\{[^\[\]]*\}|"[^"]*"(?:\s*,\s*"[^"]*")*)\s*\]"#,
|
||||
],
|
||||
);
|
||||
let mut out = text.to_string();
|
||||
for re in res {
|
||||
out = re.replace_all(&out, HIDDEN_DETAILS).into_owned();
|
||||
}
|
||||
// A field that is *entirely* a structured blob (possibly nested, so
|
||||
// the brace-matching patterns above may not have fired) is dropped
|
||||
// whole rather than partially rewritten.
|
||||
let trimmed = out.trim();
|
||||
if (trimmed.starts_with('{') || trimmed.starts_with('[')) && trimmed.contains('"') {
|
||||
return HIDDEN_DETAILS.to_string();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace credential-shaped substrings with [`REDACTED`].
|
||||
///
|
||||
/// This is deliberately over-eager: a notification banner is a glance
|
||||
/// surface, so losing a long opaque identifier costs almost nothing while
|
||||
/// leaking one is unrecoverable.
|
||||
fn redact_credentials(text: &str) -> String {
|
||||
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
|
||||
let res = regex_cache(
|
||||
&PATTERNS,
|
||||
[
|
||||
// PEM private key headers.
|
||||
r"-----BEGIN[A-Z ]*PRIVATE KEY-----",
|
||||
// Provider-prefixed keys: OpenAI/Anthropic/DeepSeek style
|
||||
// `sk-…`, GitHub `ghp_/gho_/ghu_/ghs_/ghr_`, AWS `AKIA…`,
|
||||
// Slack `xoxb-…`, Google `AIza…`.
|
||||
r"(?i)\bsk-[A-Za-z0-9_\-]{8,}",
|
||||
r"\bgh[pousr]_[A-Za-z0-9]{16,}",
|
||||
r"\bAKIA[0-9A-Z]{12,}",
|
||||
r"(?i)\bxox[baprse]-[A-Za-z0-9\-]{8,}",
|
||||
r"\bAIza[0-9A-Za-z_\-]{20,}",
|
||||
// `Bearer <token>` / `Basic <token>` authorization values.
|
||||
r"(?i)\b(?:bearer|basic)\s+[A-Za-z0-9_\-\.=+/]{8,}",
|
||||
// `NAME=value` / `name: value` where the name says secret.
|
||||
r"(?i)\b[A-Za-z0-9_\-]*(?:api[_\-]?key|secret|token|password|passwd|credential)[A-Za-z0-9_\-]*\s*[:=]\s*\S+",
|
||||
// Long opaque blobs with no word structure.
|
||||
r"\b[A-Za-z0-9_\-]{40,}\b",
|
||||
],
|
||||
);
|
||||
let mut out = text.to_string();
|
||||
for re in res {
|
||||
out = re.replace_all(&out, REDACTED).into_owned();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Replace absolute local filesystem paths with `…/<basename>`.
|
||||
///
|
||||
/// The identifying information in `/Users/jane/clients/acme/contract.md`
|
||||
/// is the prefix, not the leaf: it names the account, the machine layout,
|
||||
/// and often the customer. Keeping only the basename preserves the "which
|
||||
/// file?" utility of the banner while the identifying prefix never
|
||||
/// reaches the lock screen.
|
||||
///
|
||||
/// URLs are left alone — the POSIX pattern only fires when the slash run
|
||||
/// is not preceded by `:` or another `/`.
|
||||
fn redact_absolute_paths(text: &str) -> String {
|
||||
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
|
||||
let res = regex_cache(
|
||||
&PATTERNS,
|
||||
[
|
||||
// POSIX: at least two components so a bare `/tmp` or a lone
|
||||
// slash in prose is not mangled.
|
||||
r"(^|[^A-Za-z0-9_:/\\])((?:/[A-Za-z0-9._~%+@\-]+){2,}/?)",
|
||||
// Windows drive-letter paths.
|
||||
r"(^|[^A-Za-z0-9_])([A-Za-z]:[\\/](?:[^\\/:*?<>|\s]+[\\/]?)+)",
|
||||
],
|
||||
);
|
||||
let mut out = text.to_string();
|
||||
for re in res {
|
||||
out = re
|
||||
.replace_all(&out, |caps: ®ex::Captures<'_>| {
|
||||
let lead = caps.get(1).map_or("", |m| m.as_str());
|
||||
let path = caps.get(2).map_or("", |m| m.as_str());
|
||||
let basename = path
|
||||
.trim_end_matches(['/', '\\'])
|
||||
.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
if basename.is_empty() {
|
||||
format!("{lead}…")
|
||||
} else {
|
||||
format!("{lead}…/{basename}")
|
||||
}
|
||||
})
|
||||
.into_owned();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One payload of every kind, fed pathological input. This is the
|
||||
/// enumeration test the issue asks for: if a new kind is added
|
||||
/// without a bound, this array stops compiling or the assertion
|
||||
/// fires.
|
||||
fn every_kind(text: &str) -> Vec<NotificationPayload> {
|
||||
vec![
|
||||
NotificationPayload::turn_complete(text).with_preview(Some(text)),
|
||||
NotificationPayload::subagent_terminal(text, text).with_preview(Some(text)),
|
||||
NotificationPayload::approval_needed(text, text),
|
||||
NotificationPayload::input_needed(text),
|
||||
NotificationPayload::elevation_needed(text, text, text),
|
||||
NotificationPayload::model_notify(text, Some(text)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_renders_within_declared_bounds() {
|
||||
for payload in every_kind(&"word ".repeat(400)) {
|
||||
assert!(
|
||||
payload.headline().chars().count() <= HEADLINE_MAX_CHARS,
|
||||
"{:?} headline unbounded: {}",
|
||||
payload.kind(),
|
||||
payload.headline()
|
||||
);
|
||||
assert!(
|
||||
payload
|
||||
.detail()
|
||||
.is_none_or(|d| d.chars().count() <= DETAIL_MAX_CHARS),
|
||||
"{:?} detail unbounded",
|
||||
payload.kind()
|
||||
);
|
||||
assert!(
|
||||
payload
|
||||
.preview()
|
||||
.is_none_or(|p| p.chars().count() <= PREVIEW_MAX_CHARS),
|
||||
"{:?} preview unbounded",
|
||||
payload.kind()
|
||||
);
|
||||
assert!(!payload.headline().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// The redaction guarantee, asserted for *every* event kind rather
|
||||
/// than one convenient constructor: a payload carrying an API key, an
|
||||
/// absolute local path, and raw tool JSON must leak none of them.
|
||||
#[test]
|
||||
fn no_kind_leaks_credentials_paths_or_raw_tool_input() {
|
||||
let hostile = concat!(
|
||||
"sk-proj-abc123DEF456ghi789jkl012 ",
|
||||
"wrote /Users/jane/clients/acme/contract.md ",
|
||||
r#"input {"command":"curl -H 'Authorization: Bearer abcdef123456'","cwd":"/Users/jane"}"#,
|
||||
);
|
||||
|
||||
for payload in every_kind(hostile) {
|
||||
let rendered = payload.render_inline();
|
||||
for leak in [
|
||||
"sk-proj-abc123DEF456ghi789jkl012",
|
||||
"/Users/jane",
|
||||
"clients/acme",
|
||||
"Bearer abcdef123456",
|
||||
"\"command\"",
|
||||
] {
|
||||
assert!(
|
||||
!rendered.contains(leak),
|
||||
"{:?} leaked {leak:?}: {rendered}",
|
||||
payload.kind()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_char_based_not_byte_based() {
|
||||
let payload = NotificationPayload::turn_complete(&"日".repeat(200));
|
||||
assert_eq!(payload.headline().chars().count(), HEADLINE_MAX_CHARS);
|
||||
assert!(payload.headline().ends_with("..."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_is_kind_gated_not_caller_gated() {
|
||||
let on = NotificationPayload::turn_complete("Turn complete")
|
||||
.with_preview(Some("assistant said something"));
|
||||
assert_eq!(on.preview(), Some("assistant said something"));
|
||||
|
||||
// Prompt kinds refuse a preview no matter what the caller does.
|
||||
for payload in [
|
||||
NotificationPayload::approval_needed("Approval needed", "bash"),
|
||||
NotificationPayload::input_needed("Input needed"),
|
||||
NotificationPayload::elevation_needed("Elevation needed", "bash", "network blocked"),
|
||||
NotificationPayload::model_notify("Build done", None),
|
||||
] {
|
||||
let kind = payload.kind();
|
||||
assert_eq!(
|
||||
payload.with_preview(Some("leaky")).preview(),
|
||||
None,
|
||||
"{kind:?} must never carry assistant preview"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #4834: the approval banner used to render
|
||||
/// `Approval needed: {tool} - {description}`, where the description
|
||||
/// is the pending shell command. Only the tool name survives.
|
||||
#[test]
|
||||
fn approval_payload_carries_only_the_tool_name() {
|
||||
let payload = NotificationPayload::approval_needed("Approval needed", "bash");
|
||||
assert_eq!(payload.detail(), Some("bash"));
|
||||
assert_eq!(payload.render_inline(), "Approval needed: bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_needed_body_is_empty() {
|
||||
let payload = NotificationPayload::input_needed("Input needed");
|
||||
assert_eq!(payload.detail(), None);
|
||||
assert_eq!(payload.body(), "");
|
||||
assert_eq!(payload.render_inline(), "Input needed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_keys_are_redacted() {
|
||||
let cases = [
|
||||
"here is the key sk-proj-abc123DEF456ghi789jkl012",
|
||||
"token ghp_0123456789abcdefghijABCDEFGHIJ0123",
|
||||
"aws AKIAIOSFODNN7EXAMPLE",
|
||||
"slack xoxb-1234567890-abcdefghij",
|
||||
"google AIzaSyA1234567890abcdefghijklmnopqrstu",
|
||||
// Deliberately NOT a JWT-shaped literal. The obvious fixture here
|
||||
// is the textbook base64 JWT header, but that is exactly what
|
||||
// secret scanners match: it fired a bearer-token incident on the
|
||||
// first push of this branch and trains people to ignore the
|
||||
// scanner. What this case actually exercises is the
|
||||
// `Bearer <value>` authorization rule, which does not care about
|
||||
// the value's shape.
|
||||
"Authorization: Bearer not-a-real-token-0123456789abcdef",
|
||||
"DEEPSEEK_API_KEY=sk-livekeyvalue1234567890",
|
||||
"password: hunter2correctbattery",
|
||||
"-----BEGIN RSA PRIVATE KEY-----",
|
||||
];
|
||||
for case in cases {
|
||||
let payload = NotificationPayload::model_notify("Heads up", Some(case));
|
||||
let body = payload.body();
|
||||
assert!(
|
||||
body.contains(REDACTED),
|
||||
"expected redaction marker for {case:?}, got {body:?}"
|
||||
);
|
||||
for leak in [
|
||||
"sk-proj-abc123DEF456ghi789jkl012",
|
||||
"ghp_0123456789abcdefghijABCDEFGHIJ0123",
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"xoxb-1234567890-abcdefghij",
|
||||
"AIzaSyA1234567890abcdefghijklmnopqrstu",
|
||||
"not-a-real-token-0123456789abcdef",
|
||||
"sk-livekeyvalue1234567890",
|
||||
"hunter2correctbattery",
|
||||
"PRIVATE KEY",
|
||||
] {
|
||||
assert!(
|
||||
!body.contains(leak),
|
||||
"leaked {leak:?} from {case:?}: {body:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deliberate over-eagerness, pinned so it is a decision and not a
|
||||
/// surprise: an unbroken 40+ character run has no word structure, so
|
||||
/// it is treated as credential-shaped even when it is not.
|
||||
#[test]
|
||||
fn long_opaque_runs_are_treated_as_credential_shaped() {
|
||||
let payload = NotificationPayload::turn_complete("Turn complete")
|
||||
.with_preview(Some(&"a".repeat(500)));
|
||||
assert_eq!(payload.preview(), Some(REDACTED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_paths_are_reduced_to_basename() {
|
||||
let payload = NotificationPayload::turn_complete("Turn complete").with_preview(Some(
|
||||
"wrote /Users/jane/clients/acme/contract.md and C:\\Users\\jane\\secret\\plan.docx",
|
||||
));
|
||||
let preview = payload.preview().expect("preview should survive");
|
||||
assert!(!preview.contains("/Users/jane"), "{preview}");
|
||||
assert!(!preview.contains("clients/acme"), "{preview}");
|
||||
assert!(!preview.contains("C:\\Users"), "{preview}");
|
||||
assert!(preview.contains("…/contract.md"), "{preview}");
|
||||
assert!(preview.contains("…/plan.docx"), "{preview}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn urls_survive_path_redaction() {
|
||||
let payload = NotificationPayload::model_notify(
|
||||
"Deployed",
|
||||
Some("live at https://app.example.com/status/ok"),
|
||||
);
|
||||
assert!(
|
||||
payload.body().contains("https://app.example.com/status/ok"),
|
||||
"{}",
|
||||
payload.body()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_tool_input_json_is_hidden() {
|
||||
let raw =
|
||||
r#"{"command":"curl -H 'Authorization: Bearer abc' https://x","cwd":"/Users/jane"}"#;
|
||||
let payload = NotificationPayload::model_notify("Ran tool", Some(raw));
|
||||
let body = payload.body();
|
||||
assert_eq!(body, HIDDEN_DETAILS, "{body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_tool_json_is_hidden_inline() {
|
||||
let payload = NotificationPayload::turn_complete("Turn complete").with_preview(Some(
|
||||
r#"called write with {"path":"/etc/passwd"} then stopped"#,
|
||||
));
|
||||
let preview = payload.preview().expect("preview should survive");
|
||||
assert!(preview.contains(HIDDEN_DETAILS), "{preview}");
|
||||
assert!(!preview.contains("/etc/passwd"), "{preview}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_bytes_and_newlines_are_collapsed() {
|
||||
let payload = NotificationPayload::turn_complete("Turn\x1b[31m complete\n\nsecond line");
|
||||
assert_eq!(payload.headline(), "Turn complete second line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_still_yields_a_headline() {
|
||||
let payload = NotificationPayload::turn_complete(" \n ");
|
||||
assert_eq!(payload.headline(), FALLBACK_HEADLINE);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@
|
||||
//! When `method = "auto"`, the resolver picks the best method for the
|
||||
//! current terminal; Windows falls back to `Bel`, which is routed through
|
||||
//! `MessageBeep(MB_OK)` for an audible default notification sound.
|
||||
//!
|
||||
//! Every mechanism is fed a [`NotificationPayload`] — a typed, bounded,
|
||||
//! redaction-aware value — rather than a free-form `String` (#4834). See
|
||||
//! [`crate::tui::notification_payload`] for the per-kind disclosure
|
||||
//! policy.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use windows::Win32::System::Diagnostics::Debug::MessageBeep;
|
||||
@@ -23,6 +28,8 @@ use std::sync::atomic::{AtomicU8, AtomicU64};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
pub use super::notification_payload::NotificationPayload;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -41,7 +48,22 @@ pub enum Method {
|
||||
Osc9,
|
||||
/// Plain BEL character: `\x07`
|
||||
Bel,
|
||||
/// osascript
|
||||
/// macOS Notification Center via `osascript`.
|
||||
///
|
||||
/// Only reachable through [`Method::Auto`], and only on the macOS
|
||||
/// terminals that expose no notification escape of their own (Apple
|
||||
/// Terminal, the VS Code and JetBrains embedded terminals, plain tmux
|
||||
/// without `LC_TERMINAL`). iTerm2, WezTerm, Ghostty, and kitty are
|
||||
/// matched earlier in [`resolve_method`] and never get here.
|
||||
///
|
||||
/// Known limitation (#4834): `display notification` is a Standard
|
||||
/// Additions command, so the banner is attributed to the *bundled*
|
||||
/// host process. `/usr/bin/osascript` is unbundled, so macOS credits
|
||||
/// `com.apple.ScriptEditor2` — which is what supplies the Script
|
||||
/// Editor icon and owns the System Settings → Notifications entry
|
||||
/// (alert style, previews, Do Not Disturb). `display notification`
|
||||
/// takes no icon parameter; fixing the attribution requires shipping
|
||||
/// a real `.app` bundle, not a change in this file.
|
||||
MacOS,
|
||||
/// Kitty notification protocol (OSC 99) with ST terminator.
|
||||
/// Uses `ESC ] 99 ; params ST` — no audible beep, unlike BEL.
|
||||
@@ -173,14 +195,14 @@ fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a turn-complete notification to `sink` if the elapsed time meets or
|
||||
/// exceeds `threshold`, and `method` is not `Off`.
|
||||
/// Emit a notification to `sink` if the elapsed time meets or exceeds
|
||||
/// `threshold`, and `method` is not `Off`.
|
||||
///
|
||||
/// This variant takes a `W: Write` sink for testability.
|
||||
pub fn notify_done_to<W: Write>(
|
||||
method: Method,
|
||||
in_tmux: bool,
|
||||
msg: &str,
|
||||
payload: &NotificationPayload,
|
||||
threshold: Duration,
|
||||
elapsed: Duration,
|
||||
sink: &mut W,
|
||||
@@ -194,14 +216,23 @@ pub fn notify_done_to<W: Write>(
|
||||
other => other,
|
||||
};
|
||||
|
||||
// "I get no notifications" and "the wrong app posted it" (#4834) are
|
||||
// both diagnosed by knowing which kind resolved to which mechanism.
|
||||
tracing::debug!(
|
||||
kind = ?payload.kind(),
|
||||
method = ?effective,
|
||||
in_tmux,
|
||||
"emitting desktop notification"
|
||||
);
|
||||
|
||||
// macOS Notification Center: handled via osascript, not terminal escapes.
|
||||
#[cfg(target_os = "macos")]
|
||||
if Method::MacOS == effective {
|
||||
macos_display_notification(msg);
|
||||
macos_display_notification(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
let bytes = build_escape(effective, in_tmux, msg);
|
||||
let bytes = build_escape(effective, in_tmux, &payload.render_inline());
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -218,7 +249,7 @@ pub fn notify_done_to<W: Write>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a turn-complete notification to **stdout** if `elapsed >= threshold`.
|
||||
/// Emit a notification to **stdout** if `elapsed >= threshold`.
|
||||
///
|
||||
/// With `method = Auto`, selects the best protocol for the current terminal
|
||||
/// (OSC 9, Kitty OSC 99, Ghostty OSC 777, or Bel). The unknown-terminal
|
||||
@@ -230,11 +261,18 @@ pub fn notify_done_to<W: Write>(
|
||||
pub fn notify_done(
|
||||
method: Method,
|
||||
in_tmux: bool,
|
||||
msg: &str,
|
||||
payload: &NotificationPayload,
|
||||
threshold: Duration,
|
||||
elapsed: Duration,
|
||||
) {
|
||||
notify_done_to(method, in_tmux, msg, threshold, elapsed, &mut io::stdout());
|
||||
notify_done_to(
|
||||
method,
|
||||
in_tmux,
|
||||
payload,
|
||||
threshold,
|
||||
elapsed,
|
||||
&mut io::stdout(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Set the terminal taskbar progress state via OSC 9 ; 4.
|
||||
@@ -593,13 +631,14 @@ fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option
|
||||
///
|
||||
/// The notification includes:
|
||||
/// - **Title**: "Codewhale"
|
||||
/// - **Subtitle**: First line of `msg` (when the message contains a newline,
|
||||
/// e.g. the localized completion status from a completed turn)
|
||||
/// - **Body**: Remaining lines of `msg`, if any
|
||||
/// - **Subtitle**: [`NotificationPayload::headline`] (≤ 80 chars)
|
||||
/// - **Body**: [`NotificationPayload::body`] (≤ 322 chars: a ≤ 120-char
|
||||
/// detail, a separator, and a ≤ 200-char preview)
|
||||
/// - **Sound**: Default macOS notification sound
|
||||
///
|
||||
/// The message body is capped at 200 **characters** (not bytes) to keep the
|
||||
/// bubble readable while correctly handling multi-byte text.
|
||||
/// Both fields arrive already sanitized, redacted, and character-bounded
|
||||
/// by [`NotificationPayload`]; this function does not re-derive them from
|
||||
/// free-form text (#4834).
|
||||
///
|
||||
/// **Security**: The message is passed to `osascript` as a command-line
|
||||
/// argument via `ARGV`, never embedded inline in the AppleScript source.
|
||||
@@ -609,13 +648,18 @@ fn completion_sound_state_for_tests() -> (crate::config::CompletionSound, Option
|
||||
/// evaluated as raw AppleScript code — a code-injection vector for
|
||||
/// AI-generated notification text. Passing via `ARGV` avoids this
|
||||
/// entirely because the message is never parsed as AppleScript syntax.
|
||||
/// Keep it that way.
|
||||
///
|
||||
/// **Attribution**: the banner is posted on behalf of `osascript`, which
|
||||
/// is unbundled, so macOS attributes it to `com.apple.ScriptEditor2`. See
|
||||
/// [`Method::MacOS`] — that is not fixable from here.
|
||||
///
|
||||
/// This is best-effort: if `osascript` is not available (e.g. headless SSH
|
||||
/// session) the error is logged via `tracing::warn!` instead of silently
|
||||
/// swallowed.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_display_notification(msg: &str) {
|
||||
let message = msg.to_string();
|
||||
fn macos_display_notification(payload: &NotificationPayload) {
|
||||
let (subtitle, body) = macos_notification_parts(payload);
|
||||
|
||||
// Spawn on a background thread so we don't block the caller.
|
||||
// osascript itself is fast (~50 ms), but spawning a subprocess
|
||||
@@ -629,7 +673,6 @@ fn macos_display_notification(msg: &str) {
|
||||
// string literals, so `\"` would terminate the string at
|
||||
// the `"` and leave a dangling `\`. Passing the message as
|
||||
// a command-line argument avoids any injection risk.
|
||||
let (subtitle, body) = macos_notification_parts(&message);
|
||||
let args = [
|
||||
"-e".to_string(),
|
||||
"on run argv".to_string(),
|
||||
@@ -662,36 +705,12 @@ fn macos_display_notification(msg: &str) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Split a payload into the `(subtitle, body)` pair `display notification`
|
||||
/// wants. Both halves are already bounded and redacted by the payload
|
||||
/// constructors, so this is a projection, not a sanitizer.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_notification_parts(msg: &str) -> (String, String) {
|
||||
const SUBTITLE_MAX_CHARS: usize = 80;
|
||||
const BODY_MAX_CHARS: usize = 200;
|
||||
|
||||
let sanitized = super::ui::sanitize_stream_chunk(msg);
|
||||
let lines: Vec<&str> = sanitized
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect();
|
||||
|
||||
if lines.is_empty() {
|
||||
return ("Codewhale".to_string(), String::new());
|
||||
}
|
||||
|
||||
let subtitle = truncate_notification_text(lines[0], SUBTITLE_MAX_CHARS);
|
||||
let body = truncate_notification_text(&lines[1..].join("\n"), BODY_MAX_CHARS);
|
||||
(subtitle, body)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn truncate_notification_text(text: &str, max_chars: usize) -> String {
|
||||
if text.chars().count() <= max_chars {
|
||||
return text.to_string();
|
||||
}
|
||||
let take = max_chars.saturating_sub(3);
|
||||
let mut out = text.chars().take(take).collect::<String>();
|
||||
out.push_str("...");
|
||||
out
|
||||
fn macos_notification_parts(payload: &NotificationPayload) -> (String, String) {
|
||||
(payload.headline().to_string(), payload.body())
|
||||
}
|
||||
|
||||
// ── Per-turn notification composition ────────────────────────────────
|
||||
@@ -746,47 +765,47 @@ pub fn settings(config: &crate::config::Config) -> Option<(Method, Duration, boo
|
||||
))
|
||||
}
|
||||
|
||||
/// Build the notification body for a completed turn. Prefers the live
|
||||
/// Build the notification payload for a completed turn. Prefers the live
|
||||
/// streaming text the user just saw; falls back to the latest assistant
|
||||
/// message in `api_messages` if streaming text is empty (for example, the
|
||||
/// turn finished entirely through tool output). When `include_summary` is
|
||||
/// true, an elapsed/cost line is appended.
|
||||
pub fn completed_turn_message(
|
||||
/// true, an elapsed/cost suffix is appended to the headline.
|
||||
///
|
||||
/// The assistant text becomes the payload's *preview*, which means it is
|
||||
/// redacted and capped at 200 characters before it can reach the OS.
|
||||
pub fn completed_turn_payload(
|
||||
app: &App,
|
||||
current_streaming_text: &str,
|
||||
include_summary: bool,
|
||||
turn_elapsed: Duration,
|
||||
turn_cost: Option<crate::pricing::CostEstimate>,
|
||||
) -> String {
|
||||
let mut msg = completion_status(
|
||||
) -> NotificationPayload {
|
||||
let headline = completion_status(
|
||||
&tr(app.ui_locale, MessageId::NotificationTurnComplete),
|
||||
include_summary,
|
||||
turn_elapsed,
|
||||
turn_cost.map(|cost| crate::pricing::format_cost_estimate(cost, app.cost_currency)),
|
||||
);
|
||||
|
||||
if let Some(preview) =
|
||||
text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages))
|
||||
{
|
||||
msg.push('\n');
|
||||
msg.push_str(&preview);
|
||||
}
|
||||
let preview =
|
||||
text_summary(current_streaming_text).or_else(|| latest_assistant_text(&app.api_messages));
|
||||
|
||||
msg
|
||||
NotificationPayload::turn_complete(&headline).with_preview(preview.as_deref())
|
||||
}
|
||||
|
||||
/// Compose a notification body for a terminal sub-agent outcome. Falls back
|
||||
/// to the agent id if no human-readable line can be teased out of the child's
|
||||
/// transcript. The heading reflects the actual status so a Stop/failed worker
|
||||
/// is never announced as successfully complete (#4408).
|
||||
pub fn subagent_terminal_message(
|
||||
/// Compose a notification payload for a terminal sub-agent outcome. The
|
||||
/// agent id is always the detail line; the child's first human-readable
|
||||
/// summary line, when there is one, becomes the (redacted, bounded)
|
||||
/// preview. The headline reflects the actual status so a Stop/failed
|
||||
/// worker is never announced as successfully complete (#4408).
|
||||
pub fn subagent_terminal_payload(
|
||||
locale: Locale,
|
||||
id: &str,
|
||||
result: &str,
|
||||
status: &SubAgentStatus,
|
||||
include_summary: bool,
|
||||
elapsed: Duration,
|
||||
) -> String {
|
||||
) -> NotificationPayload {
|
||||
let result_line = result
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
@@ -799,16 +818,10 @@ pub fn subagent_terminal_message(
|
||||
SubAgentStatus::BudgetExhausted => MessageId::NotificationSubagentBudgetExhausted,
|
||||
SubAgentStatus::Running => MessageId::NotificationSubagentComplete,
|
||||
};
|
||||
let mut msg = completion_status(&tr(locale, label), include_summary, elapsed, None);
|
||||
let detail = result_line
|
||||
.and_then(text_summary)
|
||||
.map(|summary| format!("{id}: {summary}"))
|
||||
.unwrap_or_else(|| id.to_string());
|
||||
let headline = completion_status(&tr(locale, label), include_summary, elapsed, None);
|
||||
let preview = result_line.and_then(text_summary);
|
||||
|
||||
msg.push('\n');
|
||||
msg.push_str(&detail);
|
||||
|
||||
msg
|
||||
NotificationPayload::subagent_terminal(&headline, id).with_preview(preview.as_deref())
|
||||
}
|
||||
|
||||
fn completion_status(
|
||||
@@ -929,6 +942,8 @@ mod tests {
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
/// Escape-protocol tests care about the bytes, not the composition
|
||||
/// policy, so they go through the least-privileged constructor.
|
||||
fn capture(
|
||||
method: Method,
|
||||
in_tmux: bool,
|
||||
@@ -940,7 +955,7 @@ mod tests {
|
||||
notify_done_to(
|
||||
method,
|
||||
in_tmux,
|
||||
msg,
|
||||
&NotificationPayload::input_needed(msg),
|
||||
Duration::from_secs(threshold_secs),
|
||||
Duration::from_secs(elapsed_secs),
|
||||
&mut buf,
|
||||
@@ -1013,26 +1028,55 @@ mod tests {
|
||||
assert!(!out.is_empty());
|
||||
}
|
||||
|
||||
/// The subtitle is the localized status headline and the body is
|
||||
/// everything else. Previously this was re-derived by splitting a
|
||||
/// free-form string on its first newline; now it is a projection of
|
||||
/// the typed payload, so the split cannot drift from what the
|
||||
/// composer intended (#4834).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_notification_keeps_localized_status_as_subtitle() {
|
||||
let (subtitle, body) = macos_notification_parts("ターン完了 (1m 5s)\n完了しました。");
|
||||
let payload = NotificationPayload::turn_complete("ターン完了 (1m 5s)")
|
||||
.with_preview(Some("完了しました。"));
|
||||
|
||||
let (subtitle, body) = macos_notification_parts(&payload);
|
||||
|
||||
assert_eq!(subtitle, "ターン完了 (1m 5s)");
|
||||
assert_eq!(body, "完了しました。");
|
||||
}
|
||||
|
||||
/// The preview is capped at `PREVIEW_MAX_CHARS` *inclusive* of the
|
||||
/// ellipsis, so the string handed to `osascript` never exceeds the
|
||||
/// declared bound.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_notification_truncates_body_after_status_line() {
|
||||
let msg = format!("Turn complete\n{}", "assistant preview ".repeat(40));
|
||||
fn macos_notification_truncates_preview() {
|
||||
let payload = NotificationPayload::turn_complete("Turn complete")
|
||||
.with_preview(Some(&"assistant preview ".repeat(40)));
|
||||
|
||||
let (subtitle, body) = macos_notification_parts(&msg);
|
||||
let (subtitle, body) = macos_notification_parts(&payload);
|
||||
|
||||
assert_eq!(subtitle, "Turn complete");
|
||||
assert!(body.starts_with("assistant preview"));
|
||||
assert!(body.ends_with("..."));
|
||||
assert_eq!(body.chars().count(), 200);
|
||||
assert_eq!(
|
||||
body.chars().count(),
|
||||
super::super::notification_payload::PREVIEW_MAX_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
/// #4834: an approval banner is the one place a raw shell command
|
||||
/// used to reach Notification Center. Pin the macOS projection, not
|
||||
/// just the payload, so a future refactor of either half is caught.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_approval_notification_never_carries_the_command() {
|
||||
let payload = NotificationPayload::approval_needed("Approval needed", "bash");
|
||||
|
||||
let (subtitle, body) = macos_notification_parts(&payload);
|
||||
|
||||
assert_eq!(subtitle, "Approval needed");
|
||||
assert_eq!(body, "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+31
-350
@@ -138,9 +138,9 @@ use super::key_actions;
|
||||
use super::app::{
|
||||
ActiveTurnMetadata, AgentCurrentActivity, AgentCurrentActivityStatus, App, AppAction, AppMode,
|
||||
HuntVerdict, OnboardingState, PendingProviderSwitch, QueuedMessage, ReasoningEffort,
|
||||
SidebarFocus, StatusToast, StatusToastLevel, SubmitDisposition, TaskPanelEntry,
|
||||
TaskPanelEntryKind, ToolEvidence, TuiOptions, bound_agent_activity_text,
|
||||
looks_like_slash_command_input, shell_command_from_bang_input,
|
||||
SidebarFocus, StatusToastLevel, SubmitDisposition, TaskPanelEntry, TaskPanelEntryKind,
|
||||
ToolEvidence, TuiOptions, bound_agent_activity_text, looks_like_slash_command_input,
|
||||
shell_command_from_bang_input,
|
||||
};
|
||||
use super::approval::{
|
||||
ApprovalMode, ApprovalRequest, ApprovalView, ElevationRequest, ElevationView, ReviewDecision,
|
||||
@@ -870,238 +870,6 @@ fn surface_prompt_override_notices(app: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_remote_control_events(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
engine_handle: &EngineHandle,
|
||||
) -> Result<bool> {
|
||||
let mut changed = false;
|
||||
while let Some(event) = app.remote_control.try_next_event() {
|
||||
changed = true;
|
||||
match event {
|
||||
crate::remote_control::RemoteEvent::Notice(message) => {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
app.status_message = Some(message.clone());
|
||||
app.sticky_status =
|
||||
Some(StatusToast::new(message, StatusToastLevel::Warning, None));
|
||||
}
|
||||
crate::remote_control::RemoteEvent::Connected {
|
||||
account_ref,
|
||||
runner_id,
|
||||
..
|
||||
} => {
|
||||
let status = format!(
|
||||
"REMOTE CONTROL · account {account_ref} · runner {runner_id} · /rc stop returns input here"
|
||||
);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!(
|
||||
"{status}\n\nThe web now owns new prompts and approvals. This terminal remains readable."
|
||||
),
|
||||
});
|
||||
app.status_message = Some(status.clone());
|
||||
app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
|
||||
}
|
||||
crate::remote_control::RemoteEvent::Failed(error) => {
|
||||
let status = format!(
|
||||
"REMOTE CONTROL LOST · {error} · input stays locked until the server lease expires"
|
||||
);
|
||||
app.status_message = Some(status.clone());
|
||||
app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Error, None));
|
||||
}
|
||||
crate::remote_control::RemoteEvent::Stopped => {
|
||||
app.sticky_status = None;
|
||||
app.status_message =
|
||||
Some("Remote control stopped; this terminal owns input again.".to_string());
|
||||
}
|
||||
crate::remote_control::RemoteEvent::OwnershipRestored { approvals } => {
|
||||
app.sticky_status = None;
|
||||
app.status_message = Some(
|
||||
"The remote lease expired safely; this terminal owns input again.".to_string(),
|
||||
);
|
||||
for approval in approvals {
|
||||
push_approval_request_view(
|
||||
app,
|
||||
&approval.tool_id,
|
||||
&approval.tool_name,
|
||||
&approval.description,
|
||||
&approval.input,
|
||||
&approval.approval_key,
|
||||
approval.intent_summary.as_deref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::remote_control::RemoteEvent::Command {
|
||||
run_id,
|
||||
seq,
|
||||
command,
|
||||
} => {
|
||||
match app.remote_control.claim_command(&run_id, seq, &command) {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(error) => {
|
||||
app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some(error.clone()),
|
||||
);
|
||||
app.remote_control.stop();
|
||||
app.sticky_status = None;
|
||||
app.status_message = Some(error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
match command.clone() {
|
||||
crate::remote_control::RemoteCommand::Prompt { turn_id, prompt } => {
|
||||
if app.is_loading || app.dispatch_in_flight {
|
||||
app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some(
|
||||
"The exact session is already running a turn; no second owner was started."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
app.remote_control
|
||||
.upload_snapshot(&run_id, &app.api_messages);
|
||||
app.remote_control.activate_prompt(&run_id, &turn_id);
|
||||
let message = QueuedMessage::new(prompt, None);
|
||||
app.remote_control.set_applying_remote_command(true);
|
||||
let result =
|
||||
dispatch_user_message(app, config, engine_handle, message).await;
|
||||
app.remote_control.set_applying_remote_command(false);
|
||||
match result {
|
||||
Ok(()) if app.is_loading || app.dispatch_in_flight => {
|
||||
app.remote_control
|
||||
.acknowledge(&run_id, seq, &command, "applied", None);
|
||||
}
|
||||
Ok(()) => {
|
||||
app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some(
|
||||
"The remote prompt was blocked before dispatch."
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some(error.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::remote_control::RemoteCommand::Approval { gate, approved } => {
|
||||
let Some(tool_id) = app.remote_control.take_pending_approval(&gate) else {
|
||||
app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some("This approval is no longer pending.".to_string()),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let result = if approved {
|
||||
engine_handle.approve_tool_call(tool_id).await
|
||||
} else {
|
||||
engine_handle.deny_tool_call(tool_id).await
|
||||
};
|
||||
match result {
|
||||
Ok(()) => app
|
||||
.remote_control
|
||||
.acknowledge(&run_id, seq, &command, "applied", None),
|
||||
Err(error) => app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some(error.to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
crate::remote_control::RemoteCommand::Control { .. } => {
|
||||
if !app.remote_control.active_run_matches(&run_id) {
|
||||
app.remote_control.acknowledge(
|
||||
&run_id,
|
||||
seq,
|
||||
&command,
|
||||
"failed",
|
||||
Some("This run no longer owns an active turn.".to_string()),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
engine_handle.cancel();
|
||||
mark_active_turn_cancelled_locally(app);
|
||||
app.remote_control
|
||||
.acknowledge(&run_id, seq, &command, "applied", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn start_remote_control_session(app: &mut App) {
|
||||
if app.is_loading {
|
||||
app.status_message = Some(
|
||||
"Finish or interrupt the current turn before handing this session to the web."
|
||||
.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let session_id = app
|
||||
.current_session_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
app.current_session_id = Some(session_id.clone());
|
||||
let target_ref = crate::remote_control::target_ref(&app.workspace, &session_id);
|
||||
let workspace_label = app
|
||||
.workspace
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("Codewhale session")
|
||||
.to_string();
|
||||
let runtime_commit = option_env!("CODEWHALE_BUILD_COMMIT")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
match app
|
||||
.remote_control
|
||||
.start(crate::remote_control::RemoteStart {
|
||||
workspace_label,
|
||||
target_ref,
|
||||
session_id,
|
||||
runtime_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
runtime_commit,
|
||||
}) {
|
||||
Ok(()) => {
|
||||
let status = app.remote_control.status_line();
|
||||
app.status_message = Some(status.clone());
|
||||
app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
|
||||
}
|
||||
Err(error) => {
|
||||
app.status_message = Some(error.clone());
|
||||
app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the interactive TUI event loop.
|
||||
///
|
||||
/// # Examples
|
||||
@@ -1507,9 +1275,6 @@ pub async fn run_tui(
|
||||
tokio::sync::mpsc::unbounded_channel::<crate::tui::app::DispatchApplyFn>();
|
||||
app.dispatch_completion_tx = Some(dispatch_completion_tx);
|
||||
|
||||
if std::mem::take(&mut app.start_remote_control_on_launch) {
|
||||
start_remote_control_session(&mut app);
|
||||
}
|
||||
submit_initial_input_if_ready(&mut app, config, &engine_handle).await?;
|
||||
|
||||
crate::startup_trace::log_summary();
|
||||
@@ -2798,10 +2563,6 @@ async fn run_event_loop(
|
||||
// potentially long engine batch so composer/modal input stays live.
|
||||
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
|
||||
|
||||
if drain_remote_control_events(app, config, &engine_handle).await? {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
// First, poll for engine events (non-blocking)
|
||||
let mut received_engine_event = false;
|
||||
let mut transcript_batch_updated = false;
|
||||
@@ -2858,9 +2619,6 @@ async fn run_event_loop(
|
||||
} else if !app.is_loading && ignore_stale_stream_event_while_idle(&event) {
|
||||
continue;
|
||||
}
|
||||
if !matches!(event, EngineEvent::ApprovalRequired { .. }) {
|
||||
app.remote_control.observe_engine_event(&event);
|
||||
}
|
||||
record_turn_activity(app, &event, Instant::now());
|
||||
match event {
|
||||
EngineEvent::MessageStarted { .. } => {
|
||||
@@ -3481,7 +3239,7 @@ async fn run_event_loop(
|
||||
notifications::settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
let msg = notifications::completed_turn_message(
|
||||
let payload = notifications::completed_turn_payload(
|
||||
app,
|
||||
¤t_streaming_text,
|
||||
include_summary,
|
||||
@@ -3491,7 +3249,7 @@ async fn run_event_loop(
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
&msg,
|
||||
&payload,
|
||||
threshold,
|
||||
turn_elapsed,
|
||||
);
|
||||
@@ -4029,7 +3787,7 @@ async fn run_event_loop(
|
||||
notifications::settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
let msg = notifications::subagent_terminal_message(
|
||||
let payload = notifications::subagent_terminal_payload(
|
||||
app.ui_locale,
|
||||
&id,
|
||||
&result,
|
||||
@@ -4040,7 +3798,7 @@ async fn run_event_loop(
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
&msg,
|
||||
&payload,
|
||||
threshold,
|
||||
subagent_elapsed,
|
||||
);
|
||||
@@ -4137,27 +3895,6 @@ async fn run_event_loop(
|
||||
intent_summary,
|
||||
approval_force_prompt,
|
||||
} => {
|
||||
if app.remote_control.blocks_local_input() {
|
||||
let gate = app.remote_control.record_remote_approval(
|
||||
&id,
|
||||
&tool_name,
|
||||
&description,
|
||||
&input,
|
||||
&approval_key,
|
||||
intent_summary.as_deref(),
|
||||
);
|
||||
app.status_message = Some(format!(
|
||||
"Remote approval required for '{tool_name}' ({gate}); decide in the web session."
|
||||
));
|
||||
app.sticky_status = Some(StatusToast::new(
|
||||
format!(
|
||||
"REMOTE CONTROL · approval waiting in web · {tool_name} · /rc stop"
|
||||
),
|
||||
StatusToastLevel::Warning,
|
||||
None,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
use crate::core::authority::ApprovalRequestDisposition;
|
||||
// One disposition path for every ApprovalRequired (#4412):
|
||||
// session denial, Full Access policy hold, session/FA
|
||||
@@ -4254,10 +3991,20 @@ async fn run_event_loop(
|
||||
{
|
||||
let in_tmux =
|
||||
std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
// #4834: the tool *description* is the
|
||||
// pending command. It stays in the
|
||||
// terminal, where the user can read it
|
||||
// in context; the banner names only the
|
||||
// tool.
|
||||
let payload =
|
||||
crate::tui::notifications::NotificationPayload::approval_needed(
|
||||
"Approval needed",
|
||||
&tool_name,
|
||||
);
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
&format!("Approval needed: {tool_name} - {description}"),
|
||||
&payload,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
);
|
||||
@@ -4269,27 +4016,7 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
EngineEvent::UserInputRequired { id, request } => {
|
||||
if app.remote_control.blocks_local_input() {
|
||||
// Remote-control v1 deliberately admits only prompts, approval
|
||||
// decisions, and run control. Do not leak a second controller
|
||||
// through a local structured-question modal.
|
||||
log_sensitive_event(
|
||||
"tool.user_input.cancelled_remote_control",
|
||||
serde_json::json!({
|
||||
"tool_id": id.clone(),
|
||||
"session_id": app.current_session_id,
|
||||
}),
|
||||
);
|
||||
let _ = engine_handle.cancel_user_input(id).await;
|
||||
app.pending_user_input_prompt = None;
|
||||
let notice = "A structured question was cancelled because the web owns input; ask it as a normal web prompt instead.".to_string();
|
||||
app.push_status_toast(
|
||||
notice.clone(),
|
||||
StatusToastLevel::Warning,
|
||||
Some(8_000),
|
||||
);
|
||||
app.status_message = Some(notice);
|
||||
} else if should_suppress_user_input_prompt(app) {
|
||||
if should_suppress_user_input_prompt(app) {
|
||||
// A question may have been planned just before the
|
||||
// user switched to Auto-Review. Cancel the stale
|
||||
// request instead of opening a modal under an Auto
|
||||
@@ -4313,10 +4040,14 @@ async fn run_event_loop(
|
||||
crate::tui::notifications::settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
let payload =
|
||||
crate::tui::notifications::NotificationPayload::input_needed(
|
||||
"Action required: please respond in the terminal",
|
||||
);
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
"Action required: please respond in the terminal",
|
||||
&payload,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
);
|
||||
@@ -4378,10 +4109,16 @@ async fn run_event_loop(
|
||||
crate::tui::notifications::settings(config)
|
||||
{
|
||||
let in_tmux = std::env::var("TMUX").is_ok_and(|v| !v.is_empty());
|
||||
let payload =
|
||||
crate::tui::notifications::NotificationPayload::elevation_needed(
|
||||
"Sandbox blocked a tool",
|
||||
&tool_name,
|
||||
&denial_reason,
|
||||
);
|
||||
crate::tui::notifications::notify_done(
|
||||
method,
|
||||
in_tmux,
|
||||
&format!("Sandbox: {denial_reason} for '{tool_name}'"),
|
||||
&payload,
|
||||
Duration::ZERO,
|
||||
Duration::ZERO,
|
||||
);
|
||||
@@ -6428,9 +6165,6 @@ async fn run_event_loop(
|
||||
&& !key.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
if let Some(input) = app.submit_input() {
|
||||
if reject_local_input_while_remote(app, &input) {
|
||||
continue;
|
||||
}
|
||||
if handle_bang_shell_input(app, &engine_handle, &input).await? {
|
||||
continue;
|
||||
}
|
||||
@@ -6482,9 +6216,6 @@ async fn run_event_loop(
|
||||
&& (matches!(key.code, KeyCode::Enter) || app.is_loading) =>
|
||||
{
|
||||
if let Some(input) = app.submit_input() {
|
||||
if reject_local_input_while_remote(app, &input) {
|
||||
continue;
|
||||
}
|
||||
if handle_bang_shell_input(app, &engine_handle, &input).await? {
|
||||
continue;
|
||||
}
|
||||
@@ -6547,9 +6278,6 @@ async fn run_event_loop(
|
||||
}
|
||||
}
|
||||
if let Some(input) = app.handle_composer_enter() {
|
||||
if reject_local_input_while_remote(app, &input) {
|
||||
continue;
|
||||
}
|
||||
// `# foo` quick-add (#492) — when memory is enabled,
|
||||
// a single line starting with `#` (but not `##` /
|
||||
// `#!` shebangs / Markdown headings the user might
|
||||
@@ -11314,25 +11042,6 @@ async fn apply_command_result(
|
||||
content: format_task_list(&tasks),
|
||||
});
|
||||
}
|
||||
AppAction::RemoteControl(action) => match action {
|
||||
crate::remote_control::RemoteControlAction::Start => {
|
||||
start_remote_control_session(app);
|
||||
}
|
||||
crate::remote_control::RemoteControlAction::Stop => {
|
||||
app.remote_control.stop();
|
||||
let status = app.remote_control.status_line();
|
||||
if app.remote_control.blocks_local_input() {
|
||||
app.sticky_status = Some(StatusToast::new(
|
||||
status.clone(),
|
||||
StatusToastLevel::Warning,
|
||||
None,
|
||||
));
|
||||
} else {
|
||||
app.sticky_status = None;
|
||||
}
|
||||
app.status_message = Some(status);
|
||||
}
|
||||
},
|
||||
AppAction::TaskShow { id } => match task_manager.get_task(&id).await {
|
||||
Ok(task) => open_task_pager(app, &task),
|
||||
Err(err) => {
|
||||
@@ -12220,16 +11929,6 @@ async fn submit_or_steer_message(
|
||||
engine_handle: &EngineHandle,
|
||||
message: QueuedMessage,
|
||||
) -> Result<()> {
|
||||
if app.remote_control.blocks_local_input() {
|
||||
app.input = message.display;
|
||||
app.cursor_position = app.input.chars().count();
|
||||
let status =
|
||||
"Web remote control owns prompts. Use /rc stop to return input to this terminal."
|
||||
.to_string();
|
||||
app.status_message = Some(status.clone());
|
||||
app.push_status_toast(status, StatusToastLevel::Warning, Some(6_000));
|
||||
return Ok(());
|
||||
}
|
||||
match app
|
||||
.enter_with_double_tap()
|
||||
.unwrap_or(SubmitDisposition::Immediate)
|
||||
@@ -12275,24 +11974,6 @@ async fn submit_or_steer_message(
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_local_input_while_remote(app: &mut App, input: &str) -> bool {
|
||||
if !app.remote_control.blocks_local_input()
|
||||
|| input
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.is_some_and(|value| matches!(value, "/rc" | "/remote-control"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
app.input = input.to_string();
|
||||
app.cursor_position = app.input.chars().count();
|
||||
let status = "Web remote control owns prompts. Use /rc stop to return input to this terminal."
|
||||
.to_string();
|
||||
app.status_message = Some(status.clone());
|
||||
app.push_status_toast(status, StatusToastLevel::Warning, Some(6_000));
|
||||
true
|
||||
}
|
||||
|
||||
fn restore_failed_immediate_submit(app: &mut App, message: QueuedMessage, error: &anyhow::Error) {
|
||||
tracing::warn!(
|
||||
error = %error,
|
||||
|
||||
@@ -17006,14 +17006,18 @@ fn notification_settings_no_tui_override_uses_notifications_block() {
|
||||
#[test]
|
||||
fn completed_turn_notification_uses_streaming_text() {
|
||||
let app = create_test_app();
|
||||
let msg = crate::tui::notifications::completed_turn_message(
|
||||
let payload = crate::tui::notifications::completed_turn_payload(
|
||||
&app,
|
||||
"Hello there.\n\nWhat's next?",
|
||||
false,
|
||||
Duration::from_secs(12),
|
||||
None,
|
||||
);
|
||||
assert_eq!(msg, "Turn complete\nHello there.\nWhat's next?");
|
||||
assert_eq!(payload.headline(), "Turn complete");
|
||||
// #4834: the assistant text is now a *preview* field, so it is
|
||||
// collapsed to a single bounded line instead of riding along as
|
||||
// free-form newline-separated text.
|
||||
assert_eq!(payload.preview(), Some("Hello there. What's next?"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -17041,65 +17045,77 @@ fn completed_turn_notification_falls_back_to_latest_assistant_message() {
|
||||
}],
|
||||
});
|
||||
|
||||
let msg = crate::tui::notifications::completed_turn_message(
|
||||
let payload = crate::tui::notifications::completed_turn_payload(
|
||||
&app,
|
||||
"",
|
||||
false,
|
||||
Duration::from_secs(75),
|
||||
None,
|
||||
);
|
||||
assert_eq!(msg, "Turn complete\nLatest reply");
|
||||
assert_eq!(payload.headline(), "Turn complete");
|
||||
assert_eq!(payload.preview(), Some("Latest reply"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_falls_back_to_default_when_empty() {
|
||||
let app = create_test_app();
|
||||
let msg = crate::tui::notifications::completed_turn_message(
|
||||
let payload = crate::tui::notifications::completed_turn_payload(
|
||||
&app,
|
||||
"",
|
||||
false,
|
||||
Duration::from_secs(5),
|
||||
None,
|
||||
);
|
||||
assert_eq!(msg, "Turn complete");
|
||||
assert_eq!(payload.headline(), "Turn complete");
|
||||
assert_eq!(payload.preview(), None);
|
||||
assert_eq!(payload.render_inline(), "Turn complete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_truncates_long_text() {
|
||||
let app = create_test_app();
|
||||
let long = "a".repeat(500);
|
||||
let msg = crate::tui::notifications::completed_turn_message(
|
||||
// Word-shaped text on purpose: a 500-character unbroken run is
|
||||
// credential-shaped and is now redacted wholesale (see
|
||||
// `notification_payload::redact_credentials`), which would test the
|
||||
// wrong thing here.
|
||||
let long = "assistant preview ".repeat(40);
|
||||
let payload = crate::tui::notifications::completed_turn_payload(
|
||||
&app,
|
||||
&long,
|
||||
false,
|
||||
Duration::from_secs(5),
|
||||
None,
|
||||
);
|
||||
assert!(msg.ends_with("..."));
|
||||
let preview = msg
|
||||
.strip_prefix("Turn complete\n")
|
||||
.expect("notification should lead with completion status");
|
||||
// 360-char body + 3-char ellipsis
|
||||
assert_eq!(preview.chars().count(), 363);
|
||||
assert_eq!(payload.headline(), "Turn complete");
|
||||
let preview = payload.preview().expect("long text should yield a preview");
|
||||
assert!(preview.ends_with("..."));
|
||||
// #4834: the cap moved from 363 chars of untyped tail text to the
|
||||
// payload's declared PREVIEW_MAX_CHARS, *inclusive* of the ellipsis,
|
||||
// so the bound the type promises is the bound the OS receives.
|
||||
assert_eq!(
|
||||
preview.chars().count(),
|
||||
crate::tui::notification_payload::PREVIEW_MAX_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_turn_notification_leads_with_user_locale() {
|
||||
let mut app = create_test_app();
|
||||
app.ui_locale = crate::localization::Locale::Ja;
|
||||
let msg = crate::tui::notifications::completed_turn_message(
|
||||
let payload = crate::tui::notifications::completed_turn_payload(
|
||||
&app,
|
||||
"完了しました。",
|
||||
true,
|
||||
Duration::from_secs(65),
|
||||
None,
|
||||
);
|
||||
assert_eq!(msg, "ターン完了 (1m 05s)\n完了しました。");
|
||||
assert_eq!(payload.headline(), "ターン完了 (1m 05s)");
|
||||
assert_eq!(payload.preview(), Some("完了しました。"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_completion_notification_uses_summary_line_not_sentinel() {
|
||||
let msg = crate::tui::notifications::subagent_terminal_message(
|
||||
let payload = crate::tui::notifications::subagent_terminal_payload(
|
||||
crate::localization::Locale::En,
|
||||
"agent_live",
|
||||
"Finished the docs audit.\n<codewhale:subagent.done>{}</codewhale:subagent.done>",
|
||||
@@ -17108,16 +17124,15 @@ fn subagent_completion_notification_uses_summary_line_not_sentinel() {
|
||||
Duration::from_secs(42),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
msg,
|
||||
"Sub-agent complete\nagent_live: Finished the docs audit."
|
||||
);
|
||||
assert!(!msg.contains("codewhale:subagent.done"));
|
||||
assert_eq!(payload.headline(), "Sub-agent complete");
|
||||
assert_eq!(payload.detail(), Some("agent_live"));
|
||||
assert_eq!(payload.preview(), Some("Finished the docs audit."));
|
||||
assert!(!payload.render_inline().contains("codewhale:subagent.done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_completion_notification_can_include_elapsed_summary() {
|
||||
let msg = crate::tui::notifications::subagent_terminal_message(
|
||||
let payload = crate::tui::notifications::subagent_terminal_payload(
|
||||
crate::localization::Locale::En,
|
||||
"agent_live",
|
||||
"",
|
||||
@@ -17126,12 +17141,14 @@ fn subagent_completion_notification_can_include_elapsed_summary() {
|
||||
Duration::from_secs(65),
|
||||
);
|
||||
|
||||
assert_eq!(msg, "Sub-agent complete (1m 05s)\nagent_live");
|
||||
assert_eq!(payload.headline(), "Sub-agent complete (1m 05s)");
|
||||
assert_eq!(payload.detail(), Some("agent_live"));
|
||||
assert_eq!(payload.preview(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_cancelled_notification_never_claims_completion() {
|
||||
let msg = crate::tui::notifications::subagent_terminal_message(
|
||||
let payload = crate::tui::notifications::subagent_terminal_payload(
|
||||
crate::localization::Locale::En,
|
||||
"agent_stopped",
|
||||
"Cancelled\n<codewhale:subagent.done>{\"status\":\"cancelled\"}</codewhale:subagent.done>",
|
||||
@@ -17140,8 +17157,10 @@ fn subagent_cancelled_notification_never_claims_completion() {
|
||||
Duration::from_secs(2),
|
||||
);
|
||||
|
||||
assert_eq!(msg, "Sub-agent cancelled\nagent_stopped: Cancelled");
|
||||
assert!(!msg.contains("Sub-agent complete"));
|
||||
assert_eq!(payload.headline(), "Sub-agent cancelled");
|
||||
assert_eq!(payload.detail(), Some("agent_stopped"));
|
||||
assert_eq!(payload.preview(), Some("Cancelled"));
|
||||
assert!(!payload.render_inline().contains("Sub-agent complete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1660,6 +1660,47 @@ Windows users who run inside a known OSC-9 terminal (e.g. WezTerm on Windows) ke
|
||||
completion sound without changing the global Windows sound scheme. It plays the
|
||||
configured WAV `sound_file` asynchronously via the native Windows audio API.
|
||||
|
||||
#### What a notification can contain
|
||||
|
||||
A desktop notification is a glance surface: on macOS it can appear on the
|
||||
lock screen, and on every platform it is visible to anyone near the machine.
|
||||
Codewhale therefore builds notifications from a typed payload with a fixed
|
||||
per-event disclosure policy rather than from whatever text was on hand:
|
||||
|
||||
| Event | Shown | Never shown |
|
||||
|---|---|---|
|
||||
| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |
|
||||
| Sub-agent finished | status line, agent id, preview of the child's summary line | — |
|
||||
| Approval needed | the tool name | the tool description, the command, the arguments |
|
||||
| Input needed | "please respond in the terminal" | the question |
|
||||
| Sandbox elevation needed | the tool name and the denial reason | the command |
|
||||
| `notify` tool | model-supplied title and body | — |
|
||||
|
||||
Every field is capped (80 characters for the status line, 120 for the
|
||||
identifier, 200 for the preview), stripped of control bytes and escape
|
||||
sequences, and passed through a redactor that replaces credential-shaped
|
||||
strings with `[redacted]`, reduces absolute local paths to `…/basename`,
|
||||
and replaces raw tool JSON with `[details hidden]`. The redactor is
|
||||
deliberately over-eager: an unbroken 40-character run has no word
|
||||
structure, so it is redacted even when it is not a secret.
|
||||
|
||||
#### macOS: why the banner says "Script Editor"
|
||||
|
||||
On macOS terminals that provide no notification escape of their own —
|
||||
Apple Terminal, the VS Code and JetBrains embedded terminals, plain tmux
|
||||
without `LC_TERMINAL` — `method = "auto"` falls back to `osascript`'s
|
||||
`display notification`. That command posts on behalf of the *bundled*
|
||||
host process, and `/usr/bin/osascript` is unbundled, so macOS attributes
|
||||
the banner to `com.apple.ScriptEditor2`. That attribution supplies the
|
||||
Script Editor icon and owns the System Settings → Notifications entry
|
||||
(alert style, previews, Do Not Disturb). `display notification` has no
|
||||
icon parameter, so this cannot be fixed from the notification code; it
|
||||
needs Codewhale to ship a real `.app` bundle. Tracked in
|
||||
[#4834](https://github.com/Hmbown/CodeWhale/issues/4834). In the meantime,
|
||||
iTerm2, WezTerm, Ghostty, and kitty are matched first and use their own
|
||||
notification protocols, and `method = "osc9"` / `"bel"` / `"off"` opt out
|
||||
of the `osascript` path explicitly.
|
||||
|
||||
### Parsed but currently unused (reserved for future versions)
|
||||
|
||||
These keys are accepted by the config loader but not currently used by the interactive TUI or built-in tools:
|
||||
|
||||
@@ -296,7 +296,6 @@ Common commands for first-time users:
|
||||
| `/memory` | Inspect or manage memory when enabled |
|
||||
| `/mcp` | Configure or inspect MCP server integration |
|
||||
| `/plugin` | Review and manage disabled-by-default local plugin bundles |
|
||||
| `/rc` | Hand this exact session to the signed-in Codewhale web app |
|
||||
|
||||
Toolbox commands stay searchable when you type them directly: `/models`
|
||||
fetches live endpoint IDs, `/modeldb` opens the bundled model reference, and
|
||||
@@ -567,14 +566,6 @@ attached to the project you opened. Press `a` in the picker to show sessions
|
||||
from every workspace, or run `codewhale sessions` to list all saved sessions
|
||||
with last-updated timestamps before resuming a specific id.
|
||||
|
||||
To continue the exact running session from the web app, type `/rc` or launch
|
||||
with `codewhale rc`. Approve the one-time code in the system browser. While the
|
||||
lease is active, the browser owns new prompts and approvals and the terminal is
|
||||
a readable safety surface; `/rc status` shows ownership, `/rc stop` returns it
|
||||
to the terminal, and interrupt remains available. A dropped connection keeps
|
||||
local input locked until the last web lease expires so two controllers never
|
||||
race.
|
||||
|
||||
### What should I do when the model gets confused?
|
||||
|
||||
Stop and restate the goal, constraints, and current evidence. If the transcript
|
||||
|
||||
Reference in New Issue
Block a user