From 67b9349a7ecde217cbb2ea198b7d04fdd95ab243 Mon Sep 17 00:00:00 2001 From: MrCertis Date: Tue, 13 Oct 2026 16:20:11 +0300 Subject: [PATCH 1/4] fix(mac): Claude Code draft keys and UTF-8 locale on macOS - Enter, Shift+Enter and Command+Enter reach Claude Code as distinct sequences, as they already do for Codex. - Command+A selects the whole Claude draft (shown as a layer over the prompt rows); Command+C/X copy/cut it, typing, paste or Backspace replace or clear it. Actions run through Claude's Ctrl+G external editor round trip with a bundled $VISUAL helper; input is held while it runs and a Ctrl+G picked up late is cancelled, never opening an editor. - macOS launches without any locale get LC_CTYPE=UTF-8 so pbcopy and friends stop turning Cyrillic into mojibake. Co-Authored-By: Claude Opus 5.5 --- electron-builder.yml | 1 + src/agent-runtime/draft-editor.sh | 25 +++ src/main/index.ts | 10 +- src/main/ipc/registerIpc.ts | 6 + src/main/services/ClaudeDraftEditor.ts | 135 +++++++++++++ src/main/services/TerminalManager.ts | 35 +++- src/preload/index.ts | 2 + .../src/features/terminal/TerminalCard.tsx | 104 ++++++++-- .../features/terminal/terminalShortcuts.ts | 50 +++++ src/renderer/src/styles/app.css | 9 + src/shared/contracts.ts | 6 + tests/agent-control-capabilities.test.mjs | 21 +- tests/claude-draft-editor.test.mjs | 187 ++++++++++++++++++ tests/terminal-lifecycle.test.mjs | 163 ++++++++++++++- 14 files changed, 727 insertions(+), 27 deletions(-) create mode 100644 src/agent-runtime/draft-editor.sh create mode 100644 src/main/services/ClaudeDraftEditor.ts create mode 100644 tests/claude-draft-editor.test.mjs diff --git a/electron-builder.yml b/electron-builder.yml index ab72403ef..c09bd23fa 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -99,6 +99,7 @@ extraResources: to: agent-runtime filter: - "*.mjs" + - "*.sh" - from: agent/browser to: agent/browser filter: diff --git a/src/agent-runtime/draft-editor.sh b/src/agent-runtime/draft-editor.sh new file mode 100644 index 000000000..b4f182428 --- /dev/null +++ b/src/agent-runtime/draft-editor.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# $VISUAL for macOS Claude sessions. Claude Code exposes no select-all, so CanvasTTY +# acts on the whole prompt draft through Claude's external-editor round trip (Ctrl+G): +# it adds one line per Ctrl+G it sends to CANVASTTY_DRAFT_DIR/request. A "cancel" line +# stands for a Ctrl+G that Claude took too long to pick up: it changes nothing and opens +# no editor. Without a request this is an ordinary Ctrl+G and the person's own editor opens. +file=$1 +dir=$CANVASTTY_DRAFT_DIR +if [ -n "$dir" ] && [ -s "$dir/request" ]; then + action=$(head -n 1 "$dir/request") + tail -n +2 "$dir/request" > "$dir/request.next" + mv -f "$dir/request.next" "$dir/request" + [ -s "$dir/request" ] || rm -f "$dir/request" + [ "$action" = cancel ] && exit 0 + cp "$file" "$dir/draft.txt" + case "$action" in + cut|clear) : > "$file" ;; + esac + : > "$dir/done" + exit 0 +fi +if [ -n "$CANVASTTY_DRAFT_EDITOR" ]; then + exec /bin/sh -c "$CANVASTTY_DRAFT_EDITOR \"\$1\"" canvastty-editor "$file" +fi +exec vi "$file" diff --git a/src/main/index.ts b/src/main/index.ts index b9832dcee..1ce2e5d62 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto"; import { dirname, isAbsolute } from "node:path"; import { EvenG2Controller } from "./services/companion/EvenG2Controller"; import { join } from "node:path"; -import { app, BrowserWindow, dialog, Menu, net, Notification, protocol, safeStorage, session } from "electron"; +import { app, BrowserWindow, clipboard, dialog, Menu, net, Notification, protocol, safeStorage, session } from "electron"; import { IPC, type LocaleId, @@ -29,6 +29,7 @@ import { FileAccessService } from "./services/FileAccessService"; import { SkinRegistry } from "./services/SkinRegistry"; import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry"; import { TerminalManager, reachesObservers, reachesRenderer } from "./services/TerminalManager"; +import { ClaudeDraftEditor } from "./services/ClaudeDraftEditor"; import { TerminalRendererOutbox } from "./services/TerminalRendererOutbox"; import { AgentControlGateway } from "./services/agent-control/AgentControlGateway"; import { TerminalSessionStore } from "./services/TerminalSessionStore"; @@ -642,6 +643,13 @@ async function initializeServices(ipc: IpcRegistrar): Promise { terminalManager.configureYoloAcknowledgement((provider) => settings.get().acknowledgedDangerousProfiles.includes(provider as AgentProviderId)); // OpenCode's auto profile runs shell commands without asking only while base protection guards them. terminalManager.configureBaseProtection(() => settings.get().baseProtectionEnabled); + terminalManager.configureDraftEditor(new ClaudeDraftEditor({ + root: join(userDataPath, "claude-draft"), + helperPath: app.isPackaged + ? join(process.resourcesPath, "agent-runtime", "draft-editor.sh") + : join(app.getAppPath(), "src", "agent-runtime", "draft-editor.sh"), + writeClipboard: (text) => clipboard.writeText(text) + })); const terminalSessionStore = new TerminalSessionStore(userDataPath); terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().sessionRestoreMode); agentChatHistory = new AgentChatHistoryService(settings, providerClis, terminalManager, hermesHomeDirectory); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 00d3feb6c..667099c5a 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -33,6 +33,7 @@ import type { TerminalManager } from "../services/TerminalManager"; import type { AgentChatHistoryService } from "../services/AgentChatHistoryService"; import { withoutAccountScope, type LimitsService } from "../services/LimitsService"; import type { UsageHistoryService } from "../services/UsageHistoryService"; +import { isClaudeDraftAction } from "../services/ClaudeDraftEditor"; import type { PluginManager } from "../services/PluginManager"; import type { MascotManager } from "../services/MascotManager"; import type { PluginServiceSupervisor } from "../services/PluginServiceSupervisor"; @@ -954,6 +955,11 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (!isMainRenderer(event, getMainWindow)) return; terminals.input(id, data); }); + ipcMain.handle(IPC.terminalDraftAction, (event, id: unknown, action: unknown, followUp: unknown) => { + assertMainRenderer(event, getMainWindow); + if (typeof id !== "string" || !isClaudeDraftAction(action)) return false; + return terminals.draftAction(id, action, typeof followUp === "string" ? followUp : ""); + }); ipcMain.on(IPC.terminalResize, (event, id: string, cols: number, rows: number) => { if (!isMainRenderer(event, getMainWindow) || typeof id !== "string") return; terminals.resize(id, cols, rows); diff --git a/src/main/services/ClaudeDraftEditor.ts b/src/main/services/ClaudeDraftEditor.ts new file mode 100644 index 000000000..543bf96e3 --- /dev/null +++ b/src/main/services/ClaudeDraftEditor.ts @@ -0,0 +1,135 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { ClaudeDraftAction } from "../../shared/contracts.ts"; + +export const CLAUDE_DRAFT_ACTIONS: readonly ClaudeDraftAction[] = ["copy", "cut", "clear"]; +export function isClaudeDraftAction(value: unknown): value is ClaudeDraftAction { + return CLAUDE_DRAFT_ACTIONS.includes(value as ClaudeDraftAction); +} + +const EXTERNAL_EDITOR_KEY = "\u0007"; +const DONE_TIMEOUT_MS = 3000; +const POLL_MS = 15; + +export interface ClaudeDraftEditorOptions { + /** Private folder for the per-session request/done files; created on demand. */ + root: string; + /** draft-editor.sh; its path becomes part of $VISUAL, so it must not contain whitespace. */ + helperPath: string; + writeClipboard: (text: string) => void; + timeoutMs?: number; +} + +/** + * Whole-draft copy/cut/clear for Claude Code on macOS. Claude has no select-all and + * no draft API, but its Ctrl+G external-editor round trip hands the full draft (hidden + * lines and pasted text included) to $VISUAL and reloads whatever $VISUAL leaves. + * Claude drops keystrokes that arrive while the editor runs, so input for the session + * is held until the helper reports done and then delivered in order. The request file + * has one line per Ctrl+G sent; a Ctrl+G Claude has not picked up by the deadline stays + * queued as "cancel", so arriving late it opens no editor and changes nothing. + */ +export class ClaudeDraftEditor { + private readonly pending = new Map(); + private readonly options: ClaudeDraftEditorOptions; + private readonly timeoutMs: number; + + constructor(options: ClaudeDraftEditorOptions) { + this.options = options; + this.timeoutMs = options.timeoutMs ?? DONE_TIMEOUT_MS; + } + + /** Launch variables for one Claude session, or none when the helper cannot be named safely. */ + environment(sessionId: string, source: Readonly>): Record { + if (/\s/.test(this.options.helperPath) || !isSafeId(sessionId)) return {}; + // A new Claude process has no Ctrl+G outstanding from an earlier one. + rmSync(this.directory(sessionId), { recursive: true, force: true }); + return { + VISUAL: `/bin/sh ${this.options.helperPath}`, + CANVASTTY_DRAFT_DIR: this.directory(sessionId), + CANVASTTY_DRAFT_EDITOR: source.VISUAL || source.EDITOR || "" + }; + } + + /** Queues input written while an action runs; true when the caller must not write it now. */ + hold(sessionId: string, data: string): boolean { + const queue = this.pending.get(sessionId); + if (!queue) return false; + queue.push(data); + return true; + } + + /** + * Runs one action and then writes followUp (the key or paste that replaces a selected + * draft) and anything typed meanwhile. Resolves true when Claude's editor round trip ran. + */ + async run(sessionId: string, action: ClaudeDraftAction, followUp: string, write: (data: string) => boolean): Promise { + if (!isSafeId(sessionId) || this.pending.has(sessionId)) return false; + const directory = this.directory(sessionId); + const request = join(directory, "request"); + this.pending.set(sessionId, []); + let done = false; + let sent = false; + try { + mkdirSync(directory, { recursive: true, mode: 0o700 }); + rmSync(join(directory, "done"), { force: true }); + rmSync(join(directory, "draft.txt"), { force: true }); + this.writeRequest(request, [...this.readRequest(request), action]); + sent = write(EXTERNAL_EDITOR_KEY); + if (!sent) return false; + const deadline = Date.now() + this.timeoutMs; + while (!(done = existsSync(join(directory, "done"))) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + } + if (done && action !== "clear") { + const text = readFileSync(join(directory, "draft.txt"), "utf8"); + if (text) this.options.writeClipboard(text); + } + return done; + } catch { + return false; + } finally { + try { + const queued = this.readRequest(request); + if (!sent) queued.pop(); + // Every line still queued is a Ctrl+G on its way; none of them may act any more. + this.writeRequest(request, done ? queued : queued.map(() => "cancel")); + rmSync(join(directory, "done"), { force: true }); + rmSync(join(directory, "draft.txt"), { force: true }); + } catch { + // The folder is private and rebuilt with the next action or launch. + } + const held = this.pending.get(sessionId) ?? []; + this.pending.delete(sessionId); + const rest = followUp + held.join(""); + if (rest) write(rest); + } + } + + forget(sessionId: string): void { + this.pending.delete(sessionId); + if (isSafeId(sessionId)) rmSync(this.directory(sessionId), { recursive: true, force: true }); + } + + private readRequest(request: string): string[] { + return existsSync(request) ? readFileSync(request, "utf8").split("\n").filter(Boolean) : []; + } + + private writeRequest(request: string, lines: readonly string[]): void { + if (lines.length === 0) { + rmSync(request, { force: true }); + return; + } + // Renamed into place so the helper never reads a half-written queue. + writeFileSync(`${request}.write`, `${lines.join("\n")}\n`, { mode: 0o600 }); + renameSync(`${request}.write`, request); + } + + private directory(sessionId: string): string { + return join(this.options.root, sessionId); + } +} + +function isSafeId(sessionId: string): boolean { + return /^[A-Za-z0-9_-]{1,128}$/.test(sessionId); +} diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 2b70689de..8a7dad285 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -5,6 +5,7 @@ import { isPathInside } from "../../agent-runtime/path-inside.mjs"; import * as pty from "node-pty"; import type { IPty } from "node-pty"; import type { + ClaudeDraftAction, CreateSessionRequest, GitRiskReport, Point, @@ -58,6 +59,7 @@ import { onDiskPath } from "./onDiskPath.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; import { launchEffortProblem, launchModelProblem, type ReasoningEffort } from "../../shared/launchModel.ts"; import { SecretRedactionRegistry } from "./safety/SecretRedaction.ts"; +import type { ClaudeDraftEditor } from "./ClaudeDraftEditor.ts"; import type { DecisionSession } from "./DecisionHooks.ts"; import { tryPtyOperation } from "./ptySafety.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; @@ -245,6 +247,8 @@ export class TerminalManager { private environments: EnvironmentService | null = null; // Every text an agent reads from another card passes through it (EP-8). private redaction = new SecretRedactionRegistry(); + // macOS Claude cards: whole-draft copy/cut/clear through Claude's Ctrl+G editor round trip. + private draftEditor: ClaudeDraftEditor | null = null; // Where each running card was actually started (an environment may move it) and its agent config folder. private readonly launchContexts = new Map(); private modelCheck: (provider: ProviderId, model: string) => string | null = () => null; @@ -323,6 +327,10 @@ export class TerminalManager { this.keyboardShortcuts = { ...shortcuts }; } + configureDraftEditor(editor: ClaudeDraftEditor | null): void { + this.draftEditor = editor; + } + /** Plugin agent tools: a session any of them applies to gets the canvastty_agents bridge. */ configureAgentTools(names: ((role: SessionRole, provider: ProviderId) => string[]) | null): void { this.pluginToolNames = names ?? (() => []); @@ -949,12 +957,23 @@ export class TerminalManager { if (typeof data !== "string" || data.length === 0) return false; const session = this.sessions.get(id); if (!session || session.metadata.exitCode !== null || !session.process) return false; + if (this.draftEditor?.hold(id, data)) return true; const process = session.process; const written = tryPtyOperation(() => process.write(data)); if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; } + draftAction(id: string, action: ClaudeDraftAction, followUp: string): Promise { + const session = this.sessions.get(id); + const process = session?.process; + if (!this.draftEditor || !session || !process || session.metadata.exitCode !== null + || session.metadata.provider !== "claude") return Promise.resolve(false); + return this.draftEditor.run(id, action, followUp, (data) => ( + this.sessions.get(id) === session && session.process === process && tryPtyOperation(() => process.write(data)) + )); + } + /** * A hooked Claude card waits at its permission prompt (needs_approval) and the person answered it. When they declined * (Esc, or "No"), Claude interrupts the turn and runs no Stop hook, and its «✳» title defers to the hooks, so the card @@ -1162,6 +1181,7 @@ export class TerminalManager { this.initialCodexInputs.delete(id); this.redaction.clear(`session:${id}`); this.releaseIsolation(id); + this.draftEditor?.forget(id); session.launchToken += 1; void session.launchCleanup?.().catch(() => undefined); session.launchCleanup = null; @@ -1750,6 +1770,11 @@ export class TerminalManager { }; // OpenCode: the project folder in its other Unicode spelling is still this folder, not an external one. if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); + // A plugin or a remote environment that sets its own editor keeps it; the helper only exists locally. + const contributesEditor = ["VISUAL", "EDITOR"].some((key) => key in (contribution?.env ?? {})); + if (provider === "claude" && process.platform === "darwin" && !environmentWrapped && !contributesEditor && this.draftEditor) { + Object.assign(providerEnvironment, this.draftEditor.environment(id, baseEnvironment)); + } const providerArgs = [...(agentRuntime?.args ?? []), ...(agentBrowser?.args ?? [])]; // Stable terminal observations for the CLI controller; leave ordinary launches unchanged. if (captureResult && provider === "codex") providerArgs.push("-c", "tui.animations=false"); @@ -2300,7 +2325,8 @@ export function launchSearchPath( } export function terminalEnvironment( - source: Readonly> = process.env + source: Readonly> = process.env, + platform: NodeJS.Platform = process.platform ): Record { const reserved = new Set([ ...Object.values(AGENT_BROWSER_ENV), @@ -2317,10 +2343,17 @@ export function terminalEnvironment( typeof entry[1] === "string" && !reserved.has(entry[0]) && !entry[0].startsWith("CANVASTTY_PLUGIN_HOOK_") + && !entry[0].startsWith("CANVASTTY_DRAFT_") + // A Claude card's draft helper never becomes the editor of a launch made from inside one. + && !(entry[0] === "VISUAL" && entry[1].endsWith("/draft-editor.sh")) && entry[0] !== "CANVASTTY_LIFECYCLE_HOOKS_ENABLED" && entry[0] !== "ELECTRON_RUN_AS_NODE" )) ); + // A Finder-launched app gets no locale, and macOS tools such as pbcopy then + // read UTF-8 as the legacy region encoding (Cyrillic turns into mojibake). + const hasLocale = ["LC_ALL", "LC_CTYPE", "LANG"].some((name) => environment[name]); + if (platform === "darwin" && !hasLocale) environment.LC_CTYPE = "UTF-8"; return { ...environment, TERM: "xterm-256color", COLORTERM: "truecolor" }; } diff --git a/src/preload/index.ts b/src/preload/index.ts index 32f38cf65..670268a3d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ import type { CanvasNavigationOverrideStateEvent, CanvasNavigationPointerBindingInput, CanvasTTYApi, + ClaudeDraftAction, CustomTerminalBorderSkinId, CreateSessionRequest, FileRootReference, @@ -312,6 +313,7 @@ const api: CanvasTTYApi = { create: (request: CreateSessionRequest) => ipcRenderer.invoke(IPC.terminalCreate, request), restart: (id: string, options?: { resume?: boolean }) => ipcRenderer.invoke(IPC.terminalRestart, id, options), input: (id: string, data: string) => ipcRenderer.send(IPC.terminalInput, id, data), + draftAction: (id: string, action: ClaudeDraftAction, followUp?: string) => ipcRenderer.invoke(IPC.terminalDraftAction, id, action, followUp ?? ""), resize: (id: string, cols: number, rows: number) => ipcRenderer.send(IPC.terminalResize, id, cols, rows), setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index b32ada7a6..450e937c0 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -35,6 +35,9 @@ import { attachTerminalCopyOnSelect } from "./terminalCopyOnSelect"; import { CODEX_SELECT_ALL_SEQUENCE, SHIFT_ENTER_SEQUENCE, + claudeCommandEnterSequence, + claudeDraftKeyIntent, + claudePromptRows, codexEnterSequence, codexShortcutSequence, shouldCopyTerminalSelection, @@ -212,6 +215,7 @@ function TerminalCardView({ sessionExited.current = session.exitCode !== null; const sessionStartedAt = useRef(session.startedAt); sessionStartedAt.current = session.startedAt; + const claudeDraftSelected = useRef(false); const restartAction = useRef<(resume?: boolean) => Promise>(async () => undefined); const invertTerminalWheelRef = useRef(invertTerminalWheel); invertTerminalWheelRef.current = invertTerminalWheel; @@ -555,7 +559,80 @@ function TerminalCardView({ } }; fitRef.current = fit; + const pasteClipboard = (withImages: boolean): void => { + const pasteStartedAt = sessionStartedAt.current; + const acceptsPaste = () => terminalRef.current === terminal && !sessionExited.current + && sessionStartedAt.current === pasteStartedAt; + if (!acceptsPaste()) return; + void (withImages ? window.canvasTTY.clipboard.hasImage() : Promise.resolve(false)) + .then(async (hasImage) => { + if (!acceptsPaste()) return; + if (hasImage) { + window.canvasTTY.terminal.input(session.id, "\u0016"); + return; + } + const text = await window.canvasTTY.clipboard.readText(); + if (text && acceptsPaste()) terminal.paste(text); + }) + .catch(() => undefined); + }; + // Claude runs fullscreen with mouse reporting on, where xterm drops its own selection at the + // first mouse move, so the draft selection is a CanvasTTY layer over the prompt rows instead. + const draftHighlight = document.createElement("div"); + draftHighlight.className = "terminal-card__draft-highlight"; + draftHighlight.hidden = true; + screen?.append(draftHighlight); + const placeDraftHighlight = (): void => { + // The visible prompt rows show the selection; the action always covers the whole draft. + const buffer = terminal.buffer.active; + const lines = Array.from({ length: terminal.rows }, (_, row) => ( + buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "" + )); + const rows = claudeDraftSelected.current ? claudePromptRows(lines) : null; + const rowHeight = (draftHighlight.parentElement?.clientHeight ?? 0) / terminal.rows; + draftHighlight.hidden = !rows || !rowHeight; + if (!rows || !rowHeight) return; + draftHighlight.style.top = `${rows.first * rowHeight}px`; + draftHighlight.style.height = `${(rows.last - rows.first + 1) * rowHeight}px`; + }; + const deselectClaudeDraft = (): void => { + claudeDraftSelected.current = false; + draftHighlight.hidden = true; + }; + const selectClaudeDraft = (): void => { + claudeDraftSelected.current = true; + placeDraftHighlight(); + }; + // Claude repaints the prompt as it streams or resizes; the layer follows it. + const draftRepaint = terminal.onWriteParsed(() => { + if (claudeDraftSelected.current) placeDraftHighlight(); + }); + const draftResize = terminal.onResize(() => { + if (claudeDraftSelected.current) placeDraftHighlight(); + }); + // A click or a mouse selection replaces the draft selection; moving the mouse does not. + const draftMouseDown = (): void => deselectClaudeDraft(); + terminal.element?.addEventListener("mousedown", draftMouseDown, true); terminal.attachCustomKeyEventHandler((event) => { + const draftIntent = claudeDraftKeyIntent( + event, window.canvasTTY.window.isMacOS, session.provider, claudeDraftSelected.current + ); + if (draftIntent === "deselect") deselectClaudeDraft(); + else if (draftIntent !== null) { + event.preventDefault(); + event.stopPropagation(); + if (draftIntent === "select") selectClaudeDraft(); + else if (draftIntent === "copy") void window.canvasTTY.terminal.draftAction(session.id, "copy"); + else if (draftIntent !== "swallow") { + deselectClaudeDraft(); + const action = draftIntent === "cut" ? "cut" : "clear"; + const followUp = draftIntent === "replace" ? event.key : ""; + void window.canvasTTY.terminal.draftAction(session.id, action, followUp).then(() => { + if (draftIntent === "paste") pasteClipboard(true); + }); + } + return false; + } const editor = nativeEditorRef.current; if (editor && Object.values(editor).some((binding) => matchesShortcut(event, binding))) { const sequence = codexShortcutSequence(event); @@ -574,6 +651,13 @@ function TerminalCardView({ window.canvasTTY.terminal.input(session.id, codexEnter); return false; } + const claudeEnter = claudeCommandEnterSequence(event, window.canvasTTY.window.isMacOS, session.provider); + if (claudeEnter !== null) { + event.preventDefault(); + event.stopPropagation(); + window.canvasTTY.terminal.input(session.id, claudeEnter); + return false; + } const selectCodexDraft = nativeEditorRef.current ? false : shouldSelectCodexDraft(event, window.canvasTTY.window.isMacOS, session.provider); if (selectCodexDraft) { @@ -633,21 +717,7 @@ function TerminalCardView({ event.preventDefault(); event.stopPropagation(); - const pasteStartedAt = sessionStartedAt.current; - const acceptsPaste = () => terminalRef.current === terminal && !sessionExited.current - && sessionStartedAt.current === pasteStartedAt; - if (!acceptsPaste()) return false; - void (event.metaKey ? window.canvasTTY.clipboard.hasImage() : Promise.resolve(false)) - .then(async (hasImage) => { - if (!acceptsPaste()) return; - if (hasImage) { - window.canvasTTY.terminal.input(session.id, "\u0016"); - return; - } - const text = await window.canvasTTY.clipboard.readText(); - if (text && acceptsPaste()) terminal.paste(text); - }) - .catch(() => undefined); + pasteClipboard(event.metaKey); return false; }); const detachMouseCoordinateAdapter = screen @@ -724,6 +794,10 @@ function TerminalCardView({ pinnedScroll.dispose(); pinnedCursor.dispose(); pinnedOutput.dispose(); + draftRepaint.dispose(); + draftResize.dispose(); + terminal.element?.removeEventListener("mousedown", draftMouseDown, true); + draftHighlight.remove(); pinnedRefresh.dispose(); if (pinnedFitFrame !== null) cancelAnimationFrame(pinnedFitFrame); if (pinnedInputRefreshRef.current === updatePinnedInput) pinnedInputRefreshRef.current = null; diff --git a/src/renderer/src/features/terminal/terminalShortcuts.ts b/src/renderer/src/features/terminal/terminalShortcuts.ts index 340845cef..fc101413f 100644 --- a/src/renderer/src/features/terminal/terminalShortcuts.ts +++ b/src/renderer/src/features/terminal/terminalShortcuts.ts @@ -45,6 +45,56 @@ export function codexEnterSequence(event: TerminalKeyEvent, provider: string): s return modifiers === 1 ? "\r" : `\u001b[13;${modifiers}u`; } +/** Command+Enter for a macOS Claude card; xterm would send it as a plain carriage return. */ +export function claudeCommandEnterSequence(event: TerminalKeyEvent, isMacOS: boolean, provider: string): string | null { + if (!isMacOS || provider !== "claude" || event.type !== "keydown" || event.isComposing + || !(event.key === "Enter" || event.code === "Enter" || event.code === "NumpadEnter") + || !event.metaKey || event.shiftKey || event.ctrlKey || event.altKey) return null; + return "\u001b[13;9u"; +} + +/** + * What a key does to a macOS Claude card's whole-draft selection (Command+A). Claude has + * no selection of its own, so CanvasTTY keeps it: Command+C copies the draft, Command+X + * cuts it, typing, Command+V, Backspace or Delete replace it, any other key drops the + * selection and reaches Claude as usual. Without a selection Command+X does nothing. + * null: not a draft key, handle the event normally. + */ +export type ClaudeDraftKeyIntent = "select" | "copy" | "cut" | "clear" | "replace" | "paste" | "swallow" | "deselect"; + +const MODIFIER_KEYS = new Set(["Shift", "Meta", "Alt", "Control", "CapsLock", "Fn"]); + +export function claudeDraftKeyIntent( + event: TerminalKeyEvent, + isMacOS: boolean, + provider: string, + selected: boolean +): ClaudeDraftKeyIntent | null { + if (!isMacOS || provider !== "claude" || event.type !== "keydown" || event.isComposing + || MODIFIER_KEYS.has(event.key)) return null; + const command = event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; + // A held shortcut repeats; only its first press acts, so holding Command+X cannot fire a burst of cuts. + const once = (intent: ClaudeDraftKeyIntent): ClaudeDraftKeyIntent => event.repeat ? "swallow" : intent; + if (command && matchesPhysicalOrLayoutKey(event, "KeyA", "a")) return once("select"); + if (command && matchesPhysicalOrLayoutKey(event, "KeyX", "x")) return selected ? once("cut") : "swallow"; + if (!selected) return null; + if (command && matchesPhysicalOrLayoutKey(event, "KeyC", "c")) return once("copy"); + if (command && matchesPhysicalOrLayoutKey(event, "KeyV", "v")) return once("paste"); + if (event.metaKey || event.ctrlKey) return "deselect"; + if (event.key === "Backspace" || event.key === "Delete") return "clear"; + return [...event.key].length === 1 ? "replace" : "deselect"; +} + +/** Rows of Claude's prompt box on screen: between the last two horizontal rules. */ +export function claudePromptRows(lines: readonly string[]): { first: number; last: number } | null { + const rules: number[] = []; + for (let row = lines.length - 1; row >= 0 && rules.length < 2; row -= 1) { + if (/^─{20,}/.test(lines[row]!.trimStart())) rules.push(row); + } + if (rules.length < 2 || rules[0]! - rules[1]! < 2) return null; + return { first: rules[1]! + 1, last: rules[0]! - 1 }; +} + export function shouldSelectCodexDraft(event: TerminalKeyEvent, isMacOS: boolean, provider: string): boolean { return isMacOS && provider === "codex" && event.type === "keydown" diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index f4786c22b..1296bb3ce 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -615,6 +615,15 @@ button { border: 0; } Keep the surface and terminal geometry while suspending the screen's renderer. */ .terminal-card__surface[data-suspended="true"] .xterm-screen { display: none; } .terminal-card__surface .xterm-viewport { background-color: var(--terminal-background, #202430); } +/* Claude draft selection (Command+A); same tint as the terminal selection. */ +.terminal-card__draft-highlight { + position: absolute; + left: 0; + right: 0; + z-index: 5; + background: #7b789966; + pointer-events: none; +} .terminal-card__summary { position: absolute; inset: var(--card-header-height) 0 0; width: 100%; height: calc(100% - var(--card-header-height)); display: none; align-items: center; justify-content: center; overflow: hidden; padding: 0; color: white; background: var(--terminal-background, #202430); cursor: zoom-in; text-align: left; } .terminal-card__summary[data-focus-activation="off"] { cursor: pointer; } .terminal-card__summary:hover { background: #242936; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 12db40b04..67718ed69 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -248,6 +248,9 @@ export interface Size { height: number; } +/** Whole-draft operations CanvasTTY performs for Claude Code on macOS (Command+A/C/X, replace-on-type). */ +export type ClaudeDraftAction = "copy" | "cut" | "clear"; + export interface SessionBounds { position: Point; size: Size; @@ -2060,6 +2063,8 @@ export interface CanvasTTYApi { /** `resume` continues the card's own conversation instead of starting a new one. */ restart(id: string, options?: { resume?: boolean }): Promise; input(id: string, data: string): void; + /** Copies, cuts or clears a macOS Claude card's whole draft, then types followUp; false when nothing ran. */ + draftAction(id: string, action: ClaudeDraftAction, followUp?: string): Promise; resize(id: string, cols: number, rows: number): void; setBounds(id: string, bounds: SessionBounds): void; rename(id: string, title: string): Promise; @@ -2256,6 +2261,7 @@ export const IPC = { agentChatHistoryResume: "agent-chat-history:resume", terminalRestart: "terminal:restart", terminalInput: "terminal:input", + terminalDraftAction: "terminal:draft-action", terminalResize: "terminal:resize", terminalBounds: "terminal:bounds", terminalRename: "terminal:rename", diff --git a/tests/agent-control-capabilities.test.mjs b/tests/agent-control-capabilities.test.mjs index 265a32563..144b10991 100644 --- a/tests/agent-control-capabilities.test.mjs +++ b/tests/agent-control-capabilities.test.mjs @@ -64,12 +64,31 @@ test("terminalEnvironment strips an inherited control grant like the other reser PATH: "/usr/bin", CANVASTTY_CONTROL_CONNECTION: "/leaked/connection.json", CANVASTTY_CONTROL_CLI: "/leaked/cli.mjs", - CANVASTTY_RUNTIME_CAPTURE_RESULT: "1" + CANVASTTY_RUNTIME_CAPTURE_RESULT: "1", + CANVASTTY_DRAFT_DIR: "/leaked/claude-draft/card", + CANVASTTY_DRAFT_EDITOR: "vi", + VISUAL: "/bin/sh /opt/app/agent-runtime/draft-editor.sh" }); assert.equal(environment.PATH, "/usr/bin"); assert.equal("CANVASTTY_CONTROL_CONNECTION" in environment, false); assert.equal("CANVASTTY_CONTROL_CLI" in environment, false); assert.equal("CANVASTTY_RUNTIME_CAPTURE_RESULT" in environment, false); + for (const name of ["CANVASTTY_DRAFT_DIR", "CANVASTTY_DRAFT_EDITOR", "VISUAL"]) assert.equal(name in environment, false, name); + assert.equal(terminalEnvironment({ VISUAL: "code --wait" }).VISUAL, "code --wait", "a person's own editor is kept"); +}); + +test("terminalEnvironment gives a localeless macOS launch UTF-8 and keeps any explicit locale", () => { + assert.equal(terminalEnvironment({ PATH: "/usr/bin" }, "darwin").LC_CTYPE, "UTF-8"); + for (const locale of [{ LANG: "C" }, { LC_ALL: "en_US.UTF-8" }, { LC_CTYPE: "ru_RU.KOI8-R" }]) { + const environment = terminalEnvironment({ PATH: "/usr/bin", ...locale }, "darwin"); + assert.deepEqual( + ["LANG", "LC_ALL", "LC_CTYPE"].filter((name) => name in environment), + Object.keys(locale) + ); + } + for (const platform of ["linux", "win32"]) { + assert.equal("LC_CTYPE" in terminalEnvironment({ PATH: "/usr/bin" }, platform), false); + } }); test("TerminalManager hands the descriptor to orchestrator sessions only, and records the role", async () => { diff --git a/tests/claude-draft-editor.test.mjs b/tests/claude-draft-editor.test.mjs new file mode 100644 index 000000000..81b77ae4d --- /dev/null +++ b/tests/claude-draft-editor.test.mjs @@ -0,0 +1,187 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { ClaudeDraftEditor, isClaudeDraftAction } from "../src/main/services/ClaudeDraftEditor.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; + +const helperPath = new URL("../src/agent-runtime/draft-editor.sh", import.meta.url).pathname; + +// Stands in for Claude Code: Ctrl+G hands the draft to $VISUAL and reloads what it leaves. +function fakeClaude(environment, draft) { + const state = { draft, writes: [], editorRuns: 0 }; + const scratch = mkdtempSync(join(tmpdir(), "claude-draft-test-")); + state.write = (data) => { + state.writes.push(data); + if (data !== "\u0007") return true; + state.editorRuns += 1; + const file = join(scratch, "prompt.md"); + writeFileSync(file, state.draft); + execFileSync("/bin/sh", ["-c", `${environment.VISUAL} "$1"`, "claude", file], { + env: { PATH: process.env.PATH, ...environment, CANVASTTY_DRAFT_EDITOR: "true" } + }); + state.draft = readFileSync(file, "utf8"); + return true; + }; + state.cleanup = () => rmSync(scratch, { recursive: true, force: true }); + return state; +} + +function editorFixture(timeoutMs) { + const root = mkdtempSync(join(tmpdir(), "claude-draft-root-")); + const clipboard = []; + const editor = new ClaudeDraftEditor({ root, helperPath, writeClipboard: (text) => clipboard.push(text), timeoutMs }); + return { root, clipboard, editor }; +} + +test("copy, cut and clear act on the whole Claude draft through the real helper", async () => { + const { root, clipboard, editor } = editorFixture(); + const environment = editor.environment("card-1", { VISUAL: "code --wait" }); + assert.deepEqual(environment, { + VISUAL: `/bin/sh ${helperPath}`, + CANVASTTY_DRAFT_DIR: join(root, "card-1"), + CANVASTTY_DRAFT_EDITOR: "code --wait" + }); + const draft = "Привет 👋\nвторая строка\n[вставка из 40 строк]"; + const claude = fakeClaude(environment, draft); + try { + assert.equal(await editor.run("card-1", "copy", "", claude.write), true); + assert.equal(claude.draft, draft, "copying leaves the draft alone"); + assert.equal(await editor.run("card-1", "cut", "", claude.write), true); + assert.equal(claude.draft, ""); + assert.deepEqual(clipboard, [draft, draft]); + claude.draft = "старое"; + assert.equal(await editor.run("card-1", "clear", "ж", claude.write), true); + assert.equal(claude.draft, ""); + assert.deepEqual(claude.writes.slice(-2), ["\u0007", "ж"], "the replacing key follows the round trip"); + assert.equal(clipboard.length, 2, "clearing never touches the clipboard"); + assert.equal(existsSync(join(root, "card-1", "request")), false, "no request outlives its action"); + } finally { + claude.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("input typed during the round trip is held and delivered after the replacing key, in order", async () => { + const { root, editor } = editorFixture(); + const writes = []; + let finishEditor; + const write = (data) => { + writes.push(data); + if (data === "\u0007") finishEditor = () => writeFileSync(join(root, "card-2", "done"), ""); + return true; + }; + const running = editor.run("card-2", "clear", "a", write); + assert.equal(editor.hold("card-2", "b"), true); + assert.equal(editor.hold("card-2", "c"), true); + assert.equal(editor.hold("other-card", "x"), false, "other cards are never held"); + assert.equal(await editor.run("card-2", "copy", "", write), false, "one action at a time"); + finishEditor(); + assert.equal(await running, true); + assert.deepEqual(writes, ["\u0007", "abc"]); + assert.equal(editor.hold("card-2", "d"), false); + rmSync(root, { recursive: true, force: true }); +}); + +test("a Ctrl+G Claude picks up late opens no editor and changes nothing, even after a newer action", async () => { + const { root, clipboard, editor } = editorFixture(60); + const environment = editor.environment("card-3", {}); + const claude = fakeClaude(environment, "черновик"); + const late = []; + const writes = []; + const delayed = (data) => { writes.push(data); if (data === "\u0007") late.push(() => claude.write(data)); return true; }; + try { + assert.equal(await editor.run("card-3", "cut", "ж", delayed), false, "the deadline passes"); + assert.deepEqual(writes, ["\u0007", "ж"], "what was typed still arrives"); + assert.equal(readFileSync(join(root, "card-3", "request"), "utf8"), "cancel\n"); + late.shift()(); + assert.equal(claude.draft, "черновик", "the late Ctrl+G is a no-op, not the person's editor"); + assert.equal(existsSync(join(root, "card-3", "request")), false); + assert.deepEqual(clipboard, []); + + assert.equal(await editor.run("card-3", "copy", "", delayed), false); + const running = editor.run("card-3", "clear", "", claude.write); + late.shift()(); + assert.equal(await running, true, "the newer action still completes behind the late one"); + assert.equal(claude.draft, ""); + assert.deepEqual(clipboard, []); + } finally { + claude.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a lost Ctrl+G costs at most the next one, and a new Claude launch forgets it", async () => { + const { root, editor } = editorFixture(30); + editor.environment("card-4", {}); + assert.equal(await editor.run("card-4", "cut", "", () => true), false); + assert.equal(readFileSync(join(root, "card-4", "request"), "utf8"), "cancel\n"); + editor.environment("card-4", {}); + assert.equal(existsSync(join(root, "card-4")), false); + assert.equal(await editor.run("card-4", "cut", "", () => false), false, "an unsent Ctrl+G is not queued"); + assert.equal(existsSync(join(root, "card-4", "request")), false); + rmSync(root, { recursive: true, force: true }); +}); + +test("without a request the helper opens the person's own editor, arguments included", () => { + const scratch = mkdtempSync(join(tmpdir(), "claude-draft-own-")); + const file = join(scratch, "prompt.md"); + writeFileSync(file, "draft"); + const ownEditor = join(scratch, "own-editor.sh"); + writeFileSync(ownEditor, 'printf "%s|" "$@" > "$(dirname "$0")/opened"\n'); + execFileSync("/bin/sh", [helperPath, file], { + env: { PATH: process.env.PATH, CANVASTTY_DRAFT_DIR: join(scratch, "none"), CANVASTTY_DRAFT_EDITOR: `/bin/sh ${ownEditor} --wait` } + }); + assert.equal(readFileSync(join(scratch, "opened"), "utf8"), `--wait|${file}|`); + assert.equal(readFileSync(file, "utf8"), "draft"); + rmSync(scratch, { recursive: true, force: true }); +}); + +test("the helper is only offered when it can be named safely, and actions are validated", () => { + const editor = new ClaudeDraftEditor({ root: "/tmp/x", helperPath: "/Applications/My App/draft-editor.sh", writeClipboard() {} }); + assert.deepEqual(editor.environment("card", {}), {}); + const safe = new ClaudeDraftEditor({ root: "/tmp/x", helperPath, writeClipboard() {} }); + assert.deepEqual(safe.environment("../escape", {}), {}); + assert.equal(safe.environment("card", { EDITOR: "nano" }).CANVASTTY_DRAFT_EDITOR, "nano"); + assert.equal(isClaudeDraftAction("cut"), true); + assert.equal(isClaudeDraftAction("rm"), false); +}); + +test("TerminalManager gives local macOS Claude cards the helper and holds their input during an action", { + skip: process.platform !== "darwin" && "the draft helper is macOS-only" +}, async () => { + const { root, clipboard, editor } = editorFixture(); + const calls = []; + const writes = []; + const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, + fakeSpawner(calls, { onWrite: (data, options) => { + writes.push(data); + if (data === "\u0007") { + writeFileSync(join(options.env.CANVASTTY_DRAFT_DIR, "draft.txt"), "весь черновик"); + writeFileSync(join(options.env.CANVASTTY_DRAFT_DIR, "done"), ""); + } + } })); + terminals.configureDraftEditor(editor); + try { + const base = { profile: "yolo", cwd: process.cwd(), position: { x: 0, y: 0 } }; + const claude = terminals.create({ ...base, provider: "claude" }); + const claudeEnv = calls.at(-1).options.env; + assert.equal(claudeEnv.VISUAL, `/bin/sh ${helperPath}`); + assert.equal(claudeEnv.CANVASTTY_DRAFT_DIR, join(root, claude.id)); + const codex = terminals.create({ ...base, provider: "codex" }); + assert.equal("CANVASTTY_DRAFT_DIR" in calls.at(-1).options.env, false, "only Claude cards get the helper"); + assert.equal(await terminals.draftAction(codex.id, "copy", ""), false); + + const running = terminals.draftAction(claude.id, "cut", ""); + terminals.input(claude.id, "после"); + assert.equal(await running, true); + assert.deepEqual(writes, ["\u0007", "после"], "typing during the round trip arrives after it"); + assert.deepEqual(clipboard, ["весь черновик"]); + } finally { + for (const session of terminals.list()) terminals.dispose(session.id); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 8ca7f9c16..9882c4125 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +import { stripTypeScriptTypes } from "node:module"; import test from "node:test"; import * as terminalShortcuts from "../src/renderer/src/features/terminal/terminalShortcuts.ts"; import { keyboardPresetShortcuts } from "../src/shared/contracts.ts"; @@ -13,17 +14,42 @@ const appStylesPath = new URL("../src/renderer/src/styles/app.css", import.meta. const terminalManagerPath = new URL("../src/main/services/TerminalManager.ts", import.meta.url); const contractsPath = new URL("../src/shared/contracts.ts", import.meta.url); -function terminalKeyHandler(body, scope) { +// Runs the card's actual keyboard setup (paste and Claude draft helpers plus the xterm key handler) against fakes. +// scope.screen, when given, is the fake .xterm-screen the Claude draft highlight layer is added to. +function terminalKeyHandler(source, scope) { + const setup = source.match(/^ const pasteClipboard[\s\S]*?^ terminal\.attachCustomKeyEventHandler\(\(event\) => \{[\s\S]*?^ \}\);/m)?.[0]; + assert.ok(setup, "the terminal's actual keyboard handler must be located"); + let handler; + const listeners = { write: [] }; + // The fake is extended in place: tests compare it with terminalRef.current. + const terminal = Object.assign(scope.terminal ?? {}, { + hasSelection: scope.terminal?.hasSelection ?? (() => false), + attachCustomKeyEventHandler: (attached) => { handler = attached; }, + onWriteParsed: (listener) => { listeners.write.push(listener); return { dispose() {} }; }, + onResize: () => ({ dispose() {} }), + element: { + addEventListener: (type, listener) => { if (type === "mousedown") scope.onMouseDown?.(listener); }, + removeEventListener() {} + } + }); const values = { ...terminalShortcuts, matchesShortcut, nativeEditorRef: { current: null }, shortcutsRef: { current: keyboardPresetShortcuts("macos") }, sessionExited: { current: false }, + sessionStartedAt: { current: 1 }, + terminalRef: { current: terminal }, terminalHost: { current: null }, - terminal: { hasSelection: () => false }, - ...scope + claudeDraftSelected: { current: false }, + document: { createElement: () => ({ className: "", hidden: false, style: {}, parentElement: null }) }, + screen: undefined, + ...scope, + terminal }; - return new Function(...Object.keys(values), `return (event) => {${body}}`)(...Object.values(values)); + delete values.onMouseDown; + new Function(...Object.keys(values), stripTypeScriptTypes(setup))(...Object.values(values)); + scope.repaint = () => listeners.write.forEach((listener) => listener()); + return handler; } test("actual Codex handler preserves Enter modifiers before xterm can collapse them", async () => { @@ -35,7 +61,7 @@ test("actual Codex handler preserves Enter modifiers before xterm can collapse t window: { isMacOS: true }, terminal: { input: (id, sequence) => writes.push([id, sequence]) } } }; - const handler = terminalKeyHandler(body, { window, session: { id: "draft", provider: "codex" } }); + const handler = terminalKeyHandler(source, { window, session: { id: "draft", provider: "codex" } }); for (const code of ["Enter", "NumpadEnter"]) { for (const [modifiers, sequence] of [ [{}, "\r"], [{ shiftKey: true }, "\u001b[13;2u"], [{ metaKey: true }, "\u001b[13;9u"], @@ -74,12 +100,13 @@ test("Command+A dispatches native Codex selection and leaves other providers and preventDefault: () => calls.push("prevent"), stopPropagation: () => calls.push("stop") }; - const codex = terminalKeyHandler(body, { window, session: { id: "codex-qa", provider: "codex" }, terminal }); + const codex = terminalKeyHandler(source, { window, session: { id: "codex-qa", provider: "codex" }, terminal }); assert.equal(codex(event), false); assert.deepEqual(calls, ["prevent", "stop", "clear", ["codex-qa", "\u001b[97;9u"]]); calls.length = 0; - for (const provider of ["terminal", "claude", "gemini"]) { - const handler = terminalKeyHandler(body, { window, session: { id: provider, provider }, terminal }); + // Claude's Command+A is CanvasTTY's own draft selection (its own test below). + for (const provider of ["terminal", "gemini"]) { + const handler = terminalKeyHandler(source, { window, session: { id: provider, provider }, terminal }); assert.equal(handler(event), true); } for (const change of [ @@ -90,6 +117,124 @@ test("Command+A dispatches native Codex selection and leaves other providers and assert.deepEqual(calls, []); }); +test("macOS Claude keeps Enter, Shift+Enter and Command+Enter distinct", async () => { + const source = await readFile(terminalCardPath, "utf8"); + const writes = []; + const window = { canvasTTY: { + window: { isMacOS: true }, + terminal: { input: (id, sequence) => writes.push([id, sequence]) } + } }; + const key = (handler, modifiers) => handler({ type: "keydown", key: "Enter", code: "Enter", ctrlKey: false, + altKey: false, shiftKey: false, metaKey: false, ...modifiers, preventDefault() {}, stopPropagation() {} }); + const claude = terminalKeyHandler(source, { window, session: { id: "claude", provider: "claude" } }); + assert.equal(key(claude, {}), true, "plain Enter stays xterm's carriage return"); + assert.equal(key(claude, { shiftKey: true }), false); + assert.equal(key(claude, { metaKey: true }), false); + assert.equal(key(claude, { metaKey: true, isComposing: true }), true); + assert.deepEqual(writes.splice(0), [["claude", "\u001b[13;2u"], ["claude", "\u001b[13;9u"]]); + const linux = terminalKeyHandler(source, { window: { canvasTTY: { ...window.canvasTTY, window: { isMacOS: false } } }, + session: { id: "claude", provider: "claude" } }); + assert.equal(key(linux, { metaKey: true }), true, "other platforms keep their own Command+Enter"); + const terminal = terminalKeyHandler(source, { window, session: { id: "shell", provider: "terminal" } }); + assert.equal(key(terminal, { metaKey: true }), true); + assert.deepEqual(writes, []); +}); + +test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and deletion act on as a whole", async () => { + const source = await readFile(terminalCardPath, "utf8"); + const calls = []; + const rule = "─".repeat(40); + let rows = ["history", rule, "❯ first line", " second line", rule, " footer"]; + const terminal = { + rows: rows.length, + buffer: { active: { viewportY: 10, getLine: (index) => ({ translateToString: () => rows[index - 10] ?? "" }) } }, + selectLines: () => calls.push("xterm selection"), + clearSelection: () => calls.push("xterm clear"), + paste: (text) => calls.push(["paste", text]) + }; + const screen = { clientHeight: 120, append(child) { child.parentElement = screen; screen.layer = child; } }; + let mouseDown; + const window = { canvasTTY: { + window: { isMacOS: true }, + clipboard: { hasImage: async () => false, readText: async () => "из буфера" }, + terminal: { + input: (id, data) => calls.push(["input", data]), + draftAction: async (id, action, followUp) => { calls.push(["draft", action, followUp ?? ""]); return true; } + } + } }; + const scope = { window, terminal, screen, session: { id: "claude-card", provider: "claude" }, + onMouseDown: (listener) => { mouseDown = listener; } }; + const handler = terminalKeyHandler(source, scope); + const key = (key, code, modifiers = {}) => handler({ type: "keydown", key, code, ctrlKey: false, shiftKey: false, + altKey: false, metaKey: false, repeat: false, ...modifiers, preventDefault() {}, stopPropagation() {} }); + const tick = () => new Promise((resolve) => setImmediate(resolve)); + const highlight = () => screen.layer.hidden ? null : [screen.layer.style.top, screen.layer.style.height]; + + assert.equal(screen.layer.className, "terminal-card__draft-highlight"); + assert.equal(highlight(), null); + assert.equal(key("ч", "KeyX", { metaKey: true }), false, "Command+X without a selection does nothing"); + assert.equal(key("ф", "KeyA", { metaKey: true }), false); + assert.deepEqual(highlight(), ["40px", "40px"], "the visible prompt rows show the selection"); + rows = ["history", "more", rule, "❯ first line", " second line", rule]; + scope.repaint(); + assert.deepEqual(highlight(), ["60px", "40px"], "the layer follows Claude's repaint"); + assert.equal(key("Shift", "ShiftLeft", { shiftKey: true }), true, "a modifier alone keeps the selection"); + assert.equal(key("с", "KeyC", { metaKey: true }), false); + assert.equal(key("с", "KeyC", { metaKey: true }), false, "copying keeps the selection"); + assert.equal(key("с", "KeyC", { metaKey: true, repeat: true }), false, "a held shortcut acts once"); + assert.notEqual(highlight(), null); + assert.equal(key("ж", "Semicolon"), false); + await tick(); + assert.deepEqual(calls.splice(0), [["draft", "copy", ""], ["draft", "copy", ""], ["draft", "clear", "ж"]], + "xterm's own selection is never used"); + assert.equal(highlight(), null); + assert.equal(key("я", "KeyZ"), true, "typing after the replacement is ordinary input"); + + for (const [press, expected] of [ + [() => key("ч", "KeyX", { metaKey: true }), [["draft", "cut", ""]]], + [() => key("Backspace", "Backspace"), [["draft", "clear", ""]]], + [() => key("Delete", "Delete"), [["draft", "clear", ""]]], + [() => key("м", "KeyV", { metaKey: true }), [["draft", "clear", ""], ["paste", "из буфера"]]] + ]) { + key("a", "KeyA", { metaKey: true }); + calls.length = 0; + assert.equal(press(), false); + for (let i = 0; i < 4; i += 1) await tick(); + assert.deepEqual(calls, expected); + calls.length = 0; + } + + key("a", "KeyA", { metaKey: true }); + assert.equal(key("a", "KeyA", { metaKey: true, repeat: true }), false); + assert.equal(key("ч", "KeyX", { metaKey: true, repeat: true }), false, "holding Command+X cannot cut"); + assert.deepEqual(calls.splice(0), []); + assert.equal(key("ArrowLeft", "ArrowLeft"), true, "other keys drop the selection and reach Claude"); + assert.equal(highlight(), null); + key("a", "KeyA", { metaKey: true }); + assert.equal(key("Enter", "Enter", { metaKey: true }), false); + assert.deepEqual(calls.splice(0), [["input", "\u001b[13;9u"]], "Command+Enter drops the selection, then sends"); + assert.equal(highlight(), null); + key("a", "KeyA", { metaKey: true }); + mouseDown(); + assert.equal(highlight(), null); + assert.equal(key("ж", "Semicolon"), true, "a click replaces the draft selection"); + assert.deepEqual(calls, []); +}); + +test("Claude prompt rows are found between the last two rules, and nothing is guessed without them", () => { + const rule = "─".repeat(30); + assert.deepEqual(terminalShortcuts.claudePromptRows(["a", rule, "❯ x", rule, "footer"]), { first: 2, last: 2 }); + assert.deepEqual(terminalShortcuts.claudePromptRows([rule, "old", rule, "❯ x", " y", " " + rule, ""]), { first: 3, last: 4 }); + assert.equal(terminalShortcuts.claudePromptRows(["❯ x", rule]), null); + assert.equal(terminalShortcuts.claudePromptRows([rule, rule]), null); + const commandA = { type: "keydown", key: "a", code: "KeyA", metaKey: true, ctrlKey: false, shiftKey: false, altKey: false }; + for (const provider of ["codex", "terminal"]) { + assert.equal(terminalShortcuts.claudeDraftKeyIntent(commandA, true, provider, false), null); + } + assert.equal(terminalShortcuts.claudeDraftKeyIntent(commandA, false, "claude", false), null, "macOS only"); + assert.equal(terminalShortcuts.claudeDraftKeyIntent({ ...commandA, isComposing: true }, true, "claude", false), null); +}); + test("clipboard replies cannot paste into a restarted or exited session while normal text and image paste stay intact", async () => { const source = await readFile(terminalCardPath, "utf8"); const body = source.match(/terminal\.attachCustomKeyEventHandler\(\(event\) => \{([\s\S]*?)^ \}\);/m)?.[1]; @@ -122,7 +267,7 @@ test("clipboard replies cannot paste into a restarted or exited session while no }, terminal: { input: (id, sequence) => calls.push([id, sequence]) } } }; - const handler = terminalKeyHandler(body, { window, session: { id: "same-card", provider: "codex" }, terminal, + const handler = terminalKeyHandler(source, { window, session: { id: "same-card", provider: "codex" }, terminal, terminalRef, sessionExited, sessionStartedAt }); return { handler, image, text, calls, reads, terminalRef, sessionExited, sessionStartedAt }; }; From ffa188fd593d29dec6de4bc5fa57574b5c85831c Mon Sep 17 00:00:00 2001 From: MrCertis Date: Tue, 13 Oct 2026 17:04:21 +0300 Subject: [PATCH 2/4] fix(mac): harden Claude draft actions after review - Requests are files req. claimed or cancelled by an atomic rename, so a Ctrl+G Claude never handles no longer blocks later actions and a request can't be both acted on and cancelled. - A session's actions run one after another; input typed meanwhile keeps its place, and held input still settles answered prompts. - Only a Claude launched with the helper (SessionMetadata.claudeDraft) gets the whole-draft keys; other Claude cards keep xterm's own Command+A. - Command+A selects only a prompt visible on screen; a dialog in its place or a restarted Claude drops the selection. - The helper keeps the draft when it cannot save a copy and ignores unknown requests; the helper path may not contain shell syntax. - A localeless macOS launch gets LANG set to the system's UTF-8 locale (en_US.UTF-8 when the system lacks it), a name ssh can pass on. Co-Authored-By: Claude Opus 5.5 --- src/agent-runtime/draft-editor.sh | 51 +++-- src/main/index.ts | 3 +- src/main/services/ClaudeDraftEditor.ts | 191 ++++++++++++------ src/main/services/TerminalManager.ts | 44 +++- .../src/features/terminal/TerminalCard.tsx | 29 ++- src/shared/contracts.ts | 2 + tests/agent-control-capabilities.test.mjs | 18 +- tests/claude-draft-editor.test.mjs | 157 ++++++++++---- tests/terminal-lifecycle.test.mjs | 32 ++- 9 files changed, 392 insertions(+), 135 deletions(-) diff --git a/src/agent-runtime/draft-editor.sh b/src/agent-runtime/draft-editor.sh index b4f182428..9e5b92f18 100644 --- a/src/agent-runtime/draft-editor.sh +++ b/src/agent-runtime/draft-editor.sh @@ -1,23 +1,42 @@ #!/bin/sh # $VISUAL for macOS Claude sessions. Claude Code exposes no select-all, so CanvasTTY -# acts on the whole prompt draft through Claude's external-editor round trip (Ctrl+G): -# it adds one line per Ctrl+G it sends to CANVASTTY_DRAFT_DIR/request. A "cancel" line -# stands for a Ctrl+G that Claude took too long to pick up: it changes nothing and opens -# no editor. Without a request this is an ordinary Ctrl+G and the person's own editor opens. +# acts on the whole prompt draft through Claude's external-editor round trip (Ctrl+G). +# Each action is a file req. in CANVASTTY_DRAFT_DIR, claimed here by renaming it. +# A cancel. stands for a Ctrl+G that Claude took too long to pick up: when no request +# is waiting, one is used up and nothing changes, so a late Ctrl+G opens no editor. +# With neither, this is an ordinary Ctrl+G and the person's own editor opens. file=$1 dir=$CANVASTTY_DRAFT_DIR -if [ -n "$dir" ] && [ -s "$dir/request" ]; then - action=$(head -n 1 "$dir/request") - tail -n +2 "$dir/request" > "$dir/request.next" - mv -f "$dir/request.next" "$dir/request" - [ -s "$dir/request" ] || rm -f "$dir/request" - [ "$action" = cancel ] && exit 0 - cp "$file" "$dir/draft.txt" - case "$action" in - cut|clear) : > "$file" ;; - esac - : > "$dir/done" - exit 0 +oldest() { + ls "$dir" 2>/dev/null | sed -n "s/^$1\.\([0-9][0-9]*\)\$/\1/p" | sort -n | head -n 1 +} +if [ -n "$dir" ] && [ -d "$dir" ]; then + while id=$(oldest req) && [ -n "$id" ]; do + # Renaming is atomic: CanvasTTY cancels a request the same way, so only one side gets it. + if ! mv "$dir/req.$id" "$dir/claimed.$id" 2>/dev/null; then + [ -e "$dir/req.$id" ] && exit 1 + continue + fi + action=$(head -n 1 "$dir/claimed.$id") + rm -f "$dir/claimed.$id" + case "$action" in + copy|cut|clear) ;; + *) exit 0 ;; + esac + rm -f "$dir/draft.$id" + # Without a saved copy the draft is left as it was. + cp "$file" "$dir/draft.$id" || exit 1 + case "$action" in + cut|clear) : > "$file" ;; + esac + : > "$dir/done.$id" + exit 0 + done + id=$(oldest cancel) + if [ -n "$id" ]; then + rm -f "$dir/cancel.$id" + exit 0 + fi fi if [ -n "$CANVASTTY_DRAFT_EDITOR" ]; then exec /bin/sh -c "$CANVASTTY_DRAFT_EDITOR \"\$1\"" canvastty-editor "$file" diff --git a/src/main/index.ts b/src/main/index.ts index 1ce2e5d62..ca5912f82 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -28,7 +28,7 @@ import { SettingsStore } from "./services/SettingsStore"; import { FileAccessService } from "./services/FileAccessService"; import { SkinRegistry } from "./services/SkinRegistry"; import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry"; -import { TerminalManager, reachesObservers, reachesRenderer } from "./services/TerminalManager"; +import { TerminalManager, reachesObservers, reachesRenderer, setTerminalSystemLocale } from "./services/TerminalManager"; import { ClaudeDraftEditor } from "./services/ClaudeDraftEditor"; import { TerminalRendererOutbox } from "./services/TerminalRendererOutbox"; import { AgentControlGateway } from "./services/agent-control/AgentControlGateway"; @@ -643,6 +643,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { terminalManager.configureYoloAcknowledgement((provider) => settings.get().acknowledgedDangerousProfiles.includes(provider as AgentProviderId)); // OpenCode's auto profile runs shell commands without asking only while base protection guards them. terminalManager.configureBaseProtection(() => settings.get().baseProtectionEnabled); + if (process.platform === "darwin") setTerminalSystemLocale(app.getSystemLocale()); terminalManager.configureDraftEditor(new ClaudeDraftEditor({ root: join(userDataPath, "claude-draft"), helperPath: app.isPackaged diff --git a/src/main/services/ClaudeDraftEditor.ts b/src/main/services/ClaudeDraftEditor.ts index 543bf96e3..956495f49 100644 --- a/src/main/services/ClaudeDraftEditor.ts +++ b/src/main/services/ClaudeDraftEditor.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { ClaudeDraftAction } from "../../shared/contracts.ts"; @@ -14,36 +14,57 @@ const POLL_MS = 15; export interface ClaudeDraftEditorOptions { /** Private folder for the per-session request/done files; created on demand. */ root: string; - /** draft-editor.sh; its path becomes part of $VISUAL, so it must not contain whitespace. */ + /** draft-editor.sh; its path becomes part of $VISUAL, so it must be a plain path. */ helperPath: string; writeClipboard: (text: string) => void; timeoutMs?: number; } +type Step = + | { data: string } + | { action: ClaudeDraftAction; followUp: string; resolve: (done: boolean) => void }; + +interface SessionQueue { + steps: Step[]; + write: (data: string) => boolean; +} + /** * Whole-draft copy/cut/clear for Claude Code on macOS. Claude has no select-all and * no draft API, but its Ctrl+G external-editor round trip hands the full draft (hidden * lines and pasted text included) to $VISUAL and reloads whatever $VISUAL leaves. - * Claude drops keystrokes that arrive while the editor runs, so input for the session - * is held until the helper reports done and then delivered in order. The request file - * has one line per Ctrl+G sent; a Ctrl+G Claude has not picked up by the deadline stays - * queued as "cancel", so arriving late it opens no editor and changes nothing. + * Claude drops keystrokes that arrive while the editor runs, so a session's actions run + * one at a time and its input is held until they finish, then delivered in order. + * + * Each action is a file req.. The helper claims the oldest one by renaming it, so a + * request is either claimed or cancelled, never both. A Ctrl+G Claude has not picked up + * by the deadline turns its request into cancel.: a Ctrl+G that finds no request uses + * up one cancel and changes nothing, so a late one opens no editor. */ export class ClaudeDraftEditor { - private readonly pending = new Map(); + private readonly queues = new Map(); + private readonly granted = new Set(); private readonly options: ClaudeDraftEditorOptions; private readonly timeoutMs: number; + private sequence = 0; constructor(options: ClaudeDraftEditorOptions) { this.options = options; this.timeoutMs = options.timeoutMs ?? DONE_TIMEOUT_MS; } - /** Launch variables for one Claude session, or none when the helper cannot be named safely. */ + /** Launch variables for one Claude session, or none when the helper cannot be used safely. */ environment(sessionId: string, source: Readonly>): Record { - if (/\s/.test(this.options.helperPath) || !isSafeId(sessionId)) return {}; + this.forget(sessionId); + // $VISUAL may go through a shell: no whitespace, quoting or shell syntax in the helper's path. + if (/[\s'"\\$`;&|<>(){}[\]*?!#~]/.test(this.options.helperPath) || !isSafeId(sessionId)) return {}; // A new Claude process has no Ctrl+G outstanding from an earlier one. - rmSync(this.directory(sessionId), { recursive: true, force: true }); + try { + rmSync(this.directory(sessionId), { recursive: true, force: true }); + } catch { + return {}; + } + this.granted.add(sessionId); return { VISUAL: `/bin/sh ${this.options.helperPath}`, CANVASTTY_DRAFT_DIR: this.directory(sessionId), @@ -51,78 +72,118 @@ export class ClaudeDraftEditor { }; } - /** Queues input written while an action runs; true when the caller must not write it now. */ + /** Whether this session's Claude was launched with the helper as its editor. */ + isGranted(sessionId: string): boolean { + return this.granted.has(sessionId); + } + + /** Queues input written while actions run; true when the caller must not write it now. */ hold(sessionId: string, data: string): boolean { - const queue = this.pending.get(sessionId); + const queue = this.queues.get(sessionId); if (!queue) return false; - queue.push(data); + queue.steps.push({ data }); return true; } /** - * Runs one action and then writes followUp (the key or paste that replaces a selected - * draft) and anything typed meanwhile. Resolves true when Claude's editor round trip ran. + * Queues one action; once earlier ones finish it runs, then writes followUp (the key or + * paste that replaces a selected draft). Resolves true when Claude's editor round trip ran. */ - async run(sessionId: string, action: ClaudeDraftAction, followUp: string, write: (data: string) => boolean): Promise { - if (!isSafeId(sessionId) || this.pending.has(sessionId)) return false; - const directory = this.directory(sessionId); - const request = join(directory, "request"); - this.pending.set(sessionId, []); - let done = false; - let sent = false; - try { - mkdirSync(directory, { recursive: true, mode: 0o700 }); - rmSync(join(directory, "done"), { force: true }); - rmSync(join(directory, "draft.txt"), { force: true }); - this.writeRequest(request, [...this.readRequest(request), action]); - sent = write(EXTERNAL_EDITOR_KEY); - if (!sent) return false; - const deadline = Date.now() + this.timeoutMs; - while (!(done = existsSync(join(directory, "done"))) && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, POLL_MS)); - } - if (done && action !== "clear") { - const text = readFileSync(join(directory, "draft.txt"), "utf8"); - if (text) this.options.writeClipboard(text); + run(sessionId: string, action: ClaudeDraftAction, followUp: string, write: (data: string) => boolean): Promise { + if (!isSafeId(sessionId) || !this.granted.has(sessionId)) return Promise.resolve(false); + return new Promise((resolve) => { + const step: Step = { action, followUp, resolve }; + const queue = this.queues.get(sessionId); + if (queue) { + queue.steps.push(step); + return; } - return done; + const started: SessionQueue = { steps: [step], write }; + this.queues.set(sessionId, started); + void this.drain(sessionId, started); + }); + } + + /** Drops the session's queue and files; queued actions resolve false and held input is discarded. */ + forget(sessionId: string): void { + this.granted.delete(sessionId); + const queue = this.queues.get(sessionId); + this.queues.delete(sessionId); + for (const step of queue?.steps.splice(0) ?? []) if ("action" in step) step.resolve(false); + if (!isSafeId(sessionId)) return; + try { + rmSync(this.directory(sessionId), { recursive: true, force: true }); } catch { - return false; - } finally { - try { - const queued = this.readRequest(request); - if (!sent) queued.pop(); - // Every line still queued is a Ctrl+G on its way; none of them may act any more. - this.writeRequest(request, done ? queued : queued.map(() => "cancel")); - rmSync(join(directory, "done"), { force: true }); - rmSync(join(directory, "draft.txt"), { force: true }); - } catch { - // The folder is private and rebuilt with the next action or launch. - } - const held = this.pending.get(sessionId) ?? []; - this.pending.delete(sessionId); - const rest = followUp + held.join(""); - if (rest) write(rest); + // Rebuilt with the next launch. } } - forget(sessionId: string): void { - this.pending.delete(sessionId); - if (isSafeId(sessionId)) rmSync(this.directory(sessionId), { recursive: true, force: true }); + private async drain(sessionId: string, queue: SessionQueue): Promise { + const live = () => this.queues.get(sessionId) === queue; + while (live() && queue.steps.length > 0) { + const step = queue.steps.shift()!; + if ("data" in step) { + queue.write(step.data); + continue; + } + const done = await this.roundTrip(sessionId, step.action, queue.write, live).catch(() => false); + if (live() && step.followUp) queue.write(step.followUp); + step.resolve(done); + } + if (live()) this.queues.delete(sessionId); } - private readRequest(request: string): string[] { - return existsSync(request) ? readFileSync(request, "utf8").split("\n").filter(Boolean) : []; + private async roundTrip( + sessionId: string, + action: ClaudeDraftAction, + write: (data: string) => boolean, + live: () => boolean + ): Promise { + const directory = this.directory(sessionId); + const id = String(++this.sequence); + const request = join(directory, `req.${id}`); + const done = join(directory, `done.${id}`); + const draft = join(directory, `draft.${id}`); + mkdirSync(directory, { recursive: true, mode: 0o700 }); + // Written aside and renamed in, so the helper never sees a half-written request. + const staged = join(directory, `staged.${id}`); + rmSync(staged, { force: true }); + writeFileSync(staged, `${action}\n`, { mode: 0o600, flag: "wx" }); + renameSync(staged, request); + try { + if (!write(EXTERNAL_EDITOR_KEY)) { + rmSync(request, { force: true }); + return false; + } + if (!(await this.waitFor(done, live))) { + try { + // Not claimed yet: the Ctrl+G still on its way must change nothing. + renameSync(request, join(directory, `cancel.${id}`)); + return false; + } catch { + // Claimed just now; the helper is finishing this very request. + if (!(await this.waitFor(done, live))) return false; + } + } + if (action !== "clear") { + const stat = lstatSync(draft, { throwIfNoEntry: false }); + const text = stat?.isFile() ? readFileSync(draft, "utf8") : ""; + if (text) this.options.writeClipboard(text); + } + return true; + } finally { + rmSync(done, { force: true }); + rmSync(draft, { force: true }); + } } - private writeRequest(request: string, lines: readonly string[]): void { - if (lines.length === 0) { - rmSync(request, { force: true }); - return; + private async waitFor(path: string, live: () => boolean): Promise { + const deadline = Date.now() + this.timeoutMs; + while (live() && !existsSync(path)) { + if (Date.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } - // Renamed into place so the helper never reads a half-written queue. - writeFileSync(`${request}.write`, `${lines.join("\n")}\n`, { mode: 0o600 }); - renameSync(`${request}.write`, request); + return live(); } private directory(sessionId: string): string { diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 8a7dad285..412809715 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { realpathSync, statSync } from "node:fs"; +import { existsSync, realpathSync, statSync } from "node:fs"; import { basename, dirname } from "node:path"; import { isPathInside } from "../../agent-runtime/path-inside.mjs"; import * as pty from "node-pty"; @@ -281,6 +281,7 @@ export class TerminalManager { private readonly startingOwners = new Map(); // What a card's first launch found in its CLI's own configuration, before the card exists. private readonly pendingConfiguredModes = new Map(); + private readonly pendingClaudeDrafts = new Set(); // Whether the person acknowledged YOLO for a CLI (Settings: acknowledgedDangerousProfiles); unset allows it. private yoloAcknowledged: (provider: ProviderId) => boolean = () => true; // Set and cleared around a single synchronous session emit (see emitSession): @@ -718,6 +719,7 @@ export class TerminalManager { const configured = this.pendingConfiguredModes.get(id); this.pendingConfiguredModes.delete(id); if (configured) metadata.configuredMode = configured; + if (this.pendingClaudeDrafts.delete(id)) metadata.claudeDraft = true; const session: ManagedSession = { metadata, @@ -968,10 +970,13 @@ export class TerminalManager { const session = this.sessions.get(id); const process = session?.process; if (!this.draftEditor || !session || !process || session.metadata.exitCode !== null - || session.metadata.provider !== "claude") return Promise.resolve(false); - return this.draftEditor.run(id, action, followUp, (data) => ( - this.sessions.get(id) === session && session.process === process && tryPtyOperation(() => process.write(data)) - )); + || session.metadata.provider !== "claude" || !session.metadata.claudeDraft) return Promise.resolve(false); + return this.draftEditor.run(id, action, followUp, (data) => { + const written = this.sessions.get(id) === session && session.process === process + && tryPtyOperation(() => process.write(data)); + if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); + return written; + }); } /** @@ -1182,6 +1187,7 @@ export class TerminalManager { this.redaction.clear(`session:${id}`); this.releaseIsolation(id); this.draftEditor?.forget(id); + this.pendingClaudeDrafts.delete(id); session.launchToken += 1; void session.launchCleanup?.().catch(() => undefined); session.launchCleanup = null; @@ -1586,6 +1592,7 @@ export class TerminalManager { return { ...none, failure: planned.failure }; } this.noteConfiguredMode(id, provider, profile, planned.env, planned.cwd); + this.noteClaudeDraft(id, planned.launchEnvironment); let spawn: { command: string; args: string[] | string; env: Record } = planned; if (decision.apply) { try { @@ -1626,6 +1633,19 @@ export class TerminalManager { } } + /** Only a Claude launched with the draft helper as its editor gets the whole-draft keys. */ + private noteClaudeDraft(id: string, launchEnvironment: Readonly>): void { + const granted = Boolean(launchEnvironment.CANVASTTY_DRAFT_DIR); + const target = this.sessions.get(id)?.metadata; + this.pendingClaudeDrafts.delete(id); + if (target) { + if (granted) target.claudeDraft = true; + else delete target.claudeDraft; + } else if (granted) { + this.pendingClaudeDrafts.add(id); + } + } + /** Delegated: a subagent, or a card a plugin started (not the person). */ private isDelegated(id: string, role: SessionRole): boolean { return role === "subagent" || Boolean(this.sessions.get(id)?.extras.ownerPluginId ?? this.startingOwners.get(id)); @@ -2089,6 +2109,8 @@ export class TerminalManager { return "failed"; } session.process = process; + if (planned.launchEnvironment.CANVASTTY_DRAFT_DIR) metadata.claudeDraft = true; + else delete metadata.claudeDraft; if (planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD) { metadata.nativeEditor = JSON.parse(planned.launchEnvironment.CANVASTTY_CODEX_KEYBOARD); } else { @@ -2352,11 +2374,21 @@ export function terminalEnvironment( ); // A Finder-launched app gets no locale, and macOS tools such as pbcopy then // read UTF-8 as the legacy region encoding (Cyrillic turns into mojibake). + // Like Terminal.app, set LANG to the system's own locale: a full name ssh can pass on. const hasLocale = ["LC_ALL", "LC_CTYPE", "LANG"].some((name) => environment[name]); - if (platform === "darwin" && !hasLocale) environment.LC_CTYPE = "UTF-8"; + if (platform === "darwin" && !hasLocale) environment.LANG = systemLocale; return { ...environment, TERM: "xterm-256color", COLORTERM: "truecolor" }; } +let systemLocale = "en_US.UTF-8"; + +/** The macOS system locale (such as "ru-RU") as a UTF-8 locale name, or en_US.UTF-8 when the system lacks it. */ +export function setTerminalSystemLocale(locale: string, exists: (path: string) => boolean = existsSync): void { + const match = /^([a-z]{2,3})[-_]([A-Z]{2})(?![A-Za-z])/.exec(locale); + const name = match ? `${match[1]}_${match[2]}.UTF-8` : ""; + systemLocale = name && exists(`/usr/share/locale/${name}`) ? name : "en_US.UTF-8"; +} + function defaultTitle(provider: ProviderId, cwd: string): string { const project = basename(cwd) || cwd; if (provider === "terminal") return `Terminal · ${project}`; diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 450e937c0..f370a6da8 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -216,6 +216,8 @@ function TerminalCardView({ const sessionStartedAt = useRef(session.startedAt); sessionStartedAt.current = session.startedAt; const claudeDraftSelected = useRef(false); + const claudeDraftRef = useRef(session.claudeDraft === true); + claudeDraftRef.current = session.claudeDraft === true; const restartAction = useRef<(resume?: boolean) => Promise>(async () => undefined); const invertTerminalWheelRef = useRef(invertTerminalWheel); invertTerminalWheelRef.current = invertTerminalWheel; @@ -582,13 +584,17 @@ function TerminalCardView({ draftHighlight.className = "terminal-card__draft-highlight"; draftHighlight.hidden = true; screen?.append(draftHighlight); - const placeDraftHighlight = (): void => { - // The visible prompt rows show the selection; the action always covers the whole draft. + const visiblePromptRows = () => { const buffer = terminal.buffer.active; - const lines = Array.from({ length: terminal.rows }, (_, row) => ( + return claudePromptRows(Array.from({ length: terminal.rows }, (_, row) => ( buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "" - )); - const rows = claudeDraftSelected.current ? claudePromptRows(lines) : null; + ))); + }; + // The launch the selection belongs to; a restarted Claude has a new draft. + let draftSelectedAt = 0; + const placeDraftHighlight = (): void => { + // The visible prompt rows show the selection; the action always covers the whole draft. + const rows = claudeDraftSelected.current ? visiblePromptRows() : null; const rowHeight = (draftHighlight.parentElement?.clientHeight ?? 0) / terminal.rows; draftHighlight.hidden = !rows || !rowHeight; if (!rows || !rowHeight) return; @@ -600,7 +606,10 @@ function TerminalCardView({ draftHighlight.hidden = true; }; const selectClaudeDraft = (): void => { + // Only a prompt on screen can be selected: no hidden selection behind a dialog or a startup screen. + if (!visiblePromptRows()) return; claudeDraftSelected.current = true; + draftSelectedAt = sessionStartedAt.current; placeDraftHighlight(); }; // Claude repaints the prompt as it streams or resizes; the layer follows it. @@ -614,9 +623,15 @@ function TerminalCardView({ const draftMouseDown = (): void => deselectClaudeDraft(); terminal.element?.addEventListener("mousedown", draftMouseDown, true); terminal.attachCustomKeyEventHandler((event) => { - const draftIntent = claudeDraftKeyIntent( - event, window.canvasTTY.window.isMacOS, session.provider, claudeDraftSelected.current + if (claudeDraftSelected.current && (draftSelectedAt !== sessionStartedAt.current || !claudeDraftRef.current)) { + deselectClaudeDraft(); + } + let draftIntent = claudeDraftKeyIntent( + event, window.canvasTTY.window.isMacOS && claudeDraftRef.current, session.provider, claudeDraftSelected.current ); + // The prompt gave way to a dialog: the key is the dialog's, not an edit of the draft. + if (draftIntent !== null && draftIntent !== "select" && draftIntent !== "swallow" + && claudeDraftSelected.current && !visiblePromptRows()) draftIntent = "deselect"; if (draftIntent === "deselect") deselectClaudeDraft(); else if (draftIntent !== null) { event.preventDefault(); diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 67718ed69..83cea8725 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -674,6 +674,8 @@ export interface SessionMetadata { provider: ProviderId; /** Bindings actually consumed by this separately launched Codex editor. */ nativeEditor?: { submit: string; submitAlternate: string; submitSuper: string; newline: string; selectAll: string }; + /** macOS Claude launched with CanvasTTY's draft helper: Command+A selects the whole draft. */ + claudeDraft?: boolean; /** Provider conversation id, when the session is attached to one. */ threadId?: string; profile: LaunchProfileId; diff --git a/tests/agent-control-capabilities.test.mjs b/tests/agent-control-capabilities.test.mjs index 144b10991..4d3762dac 100644 --- a/tests/agent-control-capabilities.test.mjs +++ b/tests/agent-control-capabilities.test.mjs @@ -11,7 +11,7 @@ import { isLaunchRole, launchRole } from "../src/main/services/agent-control/controlCapabilities.ts"; -import { TerminalManager, terminalEnvironment } from "../src/main/services/TerminalManager.ts"; +import { TerminalManager, setTerminalSystemLocale, terminalEnvironment } from "../src/main/services/TerminalManager.ts"; import { availableRegistry, fakeSpawner } from "./helpers/terminal.mjs"; const CONNECTION = { connectionPath: "/data/agent-control/connection.json", cliPath: "/app/scripts/canvastty-control.mjs" }; @@ -77,8 +77,18 @@ test("terminalEnvironment strips an inherited control grant like the other reser assert.equal(terminalEnvironment({ VISUAL: "code --wait" }).VISUAL, "code --wait", "a person's own editor is kept"); }); -test("terminalEnvironment gives a localeless macOS launch UTF-8 and keeps any explicit locale", () => { - assert.equal(terminalEnvironment({ PATH: "/usr/bin" }, "darwin").LC_CTYPE, "UTF-8"); +test("terminalEnvironment gives a localeless macOS launch the system's UTF-8 locale and keeps any explicit one", () => { + const installed = new Set(["/usr/share/locale/ru_RU.UTF-8", "/usr/share/locale/en_US.UTF-8"]); + const exists = (path) => installed.has(path); + for (const [system, expected] of [ + ["ru-RU", "ru_RU.UTF-8"], ["ru_RU", "ru_RU.UTF-8"], ["en-RU", "en_US.UTF-8"], ["en", "en_US.UTF-8"], ["", "en_US.UTF-8"] + ]) { + setTerminalSystemLocale(system, exists); + const environment = terminalEnvironment({ PATH: "/usr/bin" }, "darwin"); + assert.equal(environment.LANG, expected, system); + assert.equal("LC_CTYPE" in environment, false, "a full locale name, which ssh can pass to a server"); + } + setTerminalSystemLocale("en-US", exists); for (const locale of [{ LANG: "C" }, { LC_ALL: "en_US.UTF-8" }, { LC_CTYPE: "ru_RU.KOI8-R" }]) { const environment = terminalEnvironment({ PATH: "/usr/bin", ...locale }, "darwin"); assert.deepEqual( @@ -87,7 +97,7 @@ test("terminalEnvironment gives a localeless macOS launch UTF-8 and keeps any ex ); } for (const platform of ["linux", "win32"]) { - assert.equal("LC_CTYPE" in terminalEnvironment({ PATH: "/usr/bin" }, platform), false); + assert.equal("LANG" in terminalEnvironment({ PATH: "/usr/bin" }, platform), false); } }); diff --git a/tests/claude-draft-editor.test.mjs b/tests/claude-draft-editor.test.mjs index 81b77ae4d..a8b137a89 100644 --- a/tests/claude-draft-editor.test.mjs +++ b/tests/claude-draft-editor.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -30,6 +30,16 @@ function fakeClaude(environment, draft) { return state; } +// What the helper would do for the oldest waiting request: claim it, save the draft, report done. +function finishRequest(directory, draft = "") { + const id = readdirSync(directory).filter((name) => name.startsWith("req.")).map((name) => Number(name.slice(4))).sort((a, b) => a - b)[0]; + rmSync(join(directory, `req.${id}`)); + writeFileSync(join(directory, `draft.${id}`), draft); + writeFileSync(join(directory, `done.${id}`), ""); +} + +const files = (directory) => (existsSync(directory) ? readdirSync(directory).sort() : []); + function editorFixture(timeoutMs) { const root = mkdtempSync(join(tmpdir(), "claude-draft-root-")); const clipboard = []; @@ -58,30 +68,36 @@ test("copy, cut and clear act on the whole Claude draft through the real helper" assert.equal(claude.draft, ""); assert.deepEqual(claude.writes.slice(-2), ["\u0007", "ж"], "the replacing key follows the round trip"); assert.equal(clipboard.length, 2, "clearing never touches the clipboard"); - assert.equal(existsSync(join(root, "card-1", "request")), false, "no request outlives its action"); + assert.deepEqual(files(join(root, "card-1")), [], "no request or draft copy outlives its action"); } finally { claude.cleanup(); rmSync(root, { recursive: true, force: true }); } }); -test("input typed during the round trip is held and delivered after the replacing key, in order", async () => { - const { root, editor } = editorFixture(); +test("a session's actions run one after another, and input typed meanwhile keeps its place", async () => { + const { root, clipboard, editor } = editorFixture(); + editor.environment("card-2", {}); + const directory = join(root, "card-2"); const writes = []; - let finishEditor; - const write = (data) => { - writes.push(data); - if (data === "\u0007") finishEditor = () => writeFileSync(join(root, "card-2", "done"), ""); - return true; - }; - const running = editor.run("card-2", "clear", "a", write); + const write = (data) => { writes.push(data); return true; }; + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); + const copying = editor.run("card-2", "copy", "", write); assert.equal(editor.hold("card-2", "b"), true); + const clearing = editor.run("card-2", "clear", "ж", write); assert.equal(editor.hold("card-2", "c"), true); assert.equal(editor.hold("other-card", "x"), false, "other cards are never held"); - assert.equal(await editor.run("card-2", "copy", "", write), false, "one action at a time"); - finishEditor(); - assert.equal(await running, true); - assert.deepEqual(writes, ["\u0007", "abc"]); + await tick(); + assert.deepEqual(writes, ["\u0007"], "the second action waits for the first"); + finishRequest(directory, "весь черновик"); + assert.equal(await copying, true); + await tick(); + assert.deepEqual(writes, ["\u0007", "b", "\u0007"]); + finishRequest(directory); + assert.equal(await clearing, true); + await tick(); + assert.deepEqual(writes, ["\u0007", "b", "\u0007", "ж", "c"]); + assert.deepEqual(clipboard, ["весь черновик"]); assert.equal(editor.hold("card-2", "d"), false); rmSync(root, { recursive: true, force: true }); }); @@ -96,36 +112,97 @@ test("a Ctrl+G Claude picks up late opens no editor and changes nothing, even af try { assert.equal(await editor.run("card-3", "cut", "ж", delayed), false, "the deadline passes"); assert.deepEqual(writes, ["\u0007", "ж"], "what was typed still arrives"); - assert.equal(readFileSync(join(root, "card-3", "request"), "utf8"), "cancel\n"); + assert.deepEqual(files(join(root, "card-3")), ["cancel.1"]); late.shift()(); assert.equal(claude.draft, "черновик", "the late Ctrl+G is a no-op, not the person's editor"); - assert.equal(existsSync(join(root, "card-3", "request")), false); + assert.deepEqual(files(join(root, "card-3")), []); assert.deepEqual(clipboard, []); assert.equal(await editor.run("card-3", "copy", "", delayed), false); const running = editor.run("card-3", "clear", "", claude.write); + assert.equal(await running, true, "the newer action is done by its own Ctrl+G"); late.shift()(); - assert.equal(await running, true, "the newer action still completes behind the late one"); assert.equal(claude.draft, ""); assert.deepEqual(clipboard, []); + assert.deepEqual(files(join(root, "card-3")), []); } finally { claude.cleanup(); rmSync(root, { recursive: true, force: true }); } }); -test("a lost Ctrl+G costs at most the next one, and a new Claude launch forgets it", async () => { - const { root, editor } = editorFixture(30); - editor.environment("card-4", {}); - assert.equal(await editor.run("card-4", "cut", "", () => true), false); - assert.equal(readFileSync(join(root, "card-4", "request"), "utf8"), "cancel\n"); - editor.environment("card-4", {}); - assert.equal(existsSync(join(root, "card-4")), false); - assert.equal(await editor.run("card-4", "cut", "", () => false), false, "an unsent Ctrl+G is not queued"); - assert.equal(existsSync(join(root, "card-4", "request")), false); +test("a Ctrl+G Claude never handles costs one later Ctrl+G, never the following actions", async () => { + const { root, clipboard, editor } = editorFixture(40); + const environment = editor.environment("card-4", {}); + const claude = fakeClaude(environment, "черновик"); + try { + // A dialog swallowed this Ctrl+G: it will never reach the helper. + assert.equal(await editor.run("card-4", "cut", "", () => true), false); + for (let i = 0; i < 3; i += 1) assert.equal(await editor.run("card-4", "copy", "", claude.write), true); + assert.deepEqual(clipboard, ["черновик", "черновик", "черновик"]); + assert.deepEqual(files(join(root, "card-4")), ["cancel.1"]); + claude.write("\u0007"); + assert.deepEqual(files(join(root, "card-4")), [], "the next manual Ctrl+G uses it up"); + assert.equal(claude.draft, "черновик"); + editor.environment("card-4", {}); + assert.equal(existsSync(join(root, "card-4")), false, "a new Claude launch starts clean"); + assert.equal(await editor.run("card-4", "cut", "", () => false), false, "an unsent Ctrl+G is not queued"); + assert.deepEqual(files(join(root, "card-4")), []); + } finally { + claude.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a request is either claimed by the helper or cancelled, never both", async () => { + const { root, clipboard, editor } = editorFixture(40); + editor.environment("card-5", {}); + const directory = join(root, "card-5"); + let claim; + const write = () => { + // The helper claims the request just before the deadline and finishes just after it. + claim = setTimeout(() => { + rmSync(join(directory, "req.1")); + setTimeout(() => { + writeFileSync(join(directory, "draft.1"), "вырезано"); + writeFileSync(join(directory, "done.1"), ""); + }, 40); + }, 30); + return true; + }; + assert.equal(await editor.run("card-5", "cut", "", write), true, "a claimed request is waited for, not cancelled"); + clearTimeout(claim); + assert.deepEqual(clipboard, ["вырезано"], "cut text reaches the clipboard"); + assert.deepEqual(files(directory), []); rmSync(root, { recursive: true, force: true }); }); +test("the helper leaves the draft alone when it cannot save a copy, and ignores unknown requests", () => { + const scratch = mkdtempSync(join(tmpdir(), "claude-draft-helper-")); + const directory = join(scratch, "card"); + mkdirSync(directory); + const file = join(scratch, "prompt.md"); + writeFileSync(file, "draft"); + const run = () => { + try { + execFileSync("/bin/sh", [helperPath, file], { env: { PATH: process.env.PATH, CANVASTTY_DRAFT_DIR: directory, CANVASTTY_DRAFT_EDITOR: "false" } }); + return 0; + } catch (error) { + return error.status; + } + }; + writeFileSync(join(directory, "req.1"), "rm\n"); + assert.equal(run(), 0); + assert.equal(readFileSync(file, "utf8"), "draft"); + writeFileSync(join(directory, "req.2"), "cut\n"); + chmodSync(file, 0o200); + assert.notEqual(run(), 0); + chmodSync(file, 0o600); + assert.equal(readFileSync(file, "utf8"), "draft", "no copy, no cut"); + assert.equal(existsSync(join(directory, "done.2")), false); + rmSync(scratch, { recursive: true, force: true }); +}); + test("without a request the helper opens the person's own editor, arguments included", () => { const scratch = mkdtempSync(join(tmpdir(), "claude-draft-own-")); const file = join(scratch, "prompt.md"); @@ -140,14 +217,25 @@ test("without a request the helper opens the person's own editor, arguments incl rmSync(scratch, { recursive: true, force: true }); }); -test("the helper is only offered when it can be named safely, and actions are validated", () => { - const editor = new ClaudeDraftEditor({ root: "/tmp/x", helperPath: "/Applications/My App/draft-editor.sh", writeClipboard() {} }); - assert.deepEqual(editor.environment("card", {}), {}); - const safe = new ClaudeDraftEditor({ root: "/tmp/x", helperPath, writeClipboard() {} }); +test("the helper is only offered when it can be named safely, and actions are validated", async () => { + const root = mkdtempSync(join(tmpdir(), "claude-draft-names-")); + for (const unsafe of ["/Applications/My App/draft-editor.sh", "/opt/a$b/draft-editor.sh", "/x/'q'/draft-editor.sh"]) { + const editor = new ClaudeDraftEditor({ root, helperPath: unsafe, writeClipboard() {} }); + assert.deepEqual(editor.environment("card", {}), {}, unsafe); + assert.equal(editor.isGranted("card"), false); + assert.equal(await editor.run("card", "clear", "", () => assert.fail("no Ctrl+G without the helper")), false); + } + const cyrillic = new ClaudeDraftEditor({ root, helperPath: "/opt/Программы/draft-editor.sh", writeClipboard() {} }); + assert.equal(cyrillic.environment("card", {}).VISUAL, "/bin/sh /opt/Программы/draft-editor.sh"); + const safe = new ClaudeDraftEditor({ root, helperPath, writeClipboard() {} }); assert.deepEqual(safe.environment("../escape", {}), {}); assert.equal(safe.environment("card", { EDITOR: "nano" }).CANVASTTY_DRAFT_EDITOR, "nano"); + assert.equal(safe.isGranted("card"), true); + safe.forget("card"); + assert.equal(safe.isGranted("card"), false); assert.equal(isClaudeDraftAction("cut"), true); assert.equal(isClaudeDraftAction("rm"), false); + rmSync(root, { recursive: true, force: true }); }); test("TerminalManager gives local macOS Claude cards the helper and holds their input during an action", { @@ -159,10 +247,7 @@ test("TerminalManager gives local macOS Claude cards the helper and holds their const terminals = new TerminalManager(() => undefined, availableRegistry(), undefined, undefined, true, fakeSpawner(calls, { onWrite: (data, options) => { writes.push(data); - if (data === "\u0007") { - writeFileSync(join(options.env.CANVASTTY_DRAFT_DIR, "draft.txt"), "весь черновик"); - writeFileSync(join(options.env.CANVASTTY_DRAFT_DIR, "done"), ""); - } + if (data === "\u0007") finishRequest(options.env.CANVASTTY_DRAFT_DIR, "весь черновик"); } })); terminals.configureDraftEditor(editor); try { @@ -171,8 +256,10 @@ test("TerminalManager gives local macOS Claude cards the helper and holds their const claudeEnv = calls.at(-1).options.env; assert.equal(claudeEnv.VISUAL, `/bin/sh ${helperPath}`); assert.equal(claudeEnv.CANVASTTY_DRAFT_DIR, join(root, claude.id)); + assert.equal(terminals.listMetadata().find((s) => s.id === claude.id).claudeDraft, true); const codex = terminals.create({ ...base, provider: "codex" }); assert.equal("CANVASTTY_DRAFT_DIR" in calls.at(-1).options.env, false, "only Claude cards get the helper"); + assert.equal(terminals.listMetadata().find((s) => s.id === codex.id).claudeDraft, undefined); assert.equal(await terminals.draftAction(codex.id, "copy", ""), false); const running = terminals.draftAction(claude.id, "cut", ""); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 9882c4125..23fc9c258 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -41,6 +41,7 @@ function terminalKeyHandler(source, scope) { terminalRef: { current: terminal }, terminalHost: { current: null }, claudeDraftSelected: { current: false }, + claudeDraftRef: { current: scope.session?.claudeDraft === true }, document: { createElement: () => ({ className: "", hidden: false, style: {}, parentElement: null }) }, screen: undefined, ...scope, @@ -162,7 +163,8 @@ test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and d draftAction: async (id, action, followUp) => { calls.push(["draft", action, followUp ?? ""]); return true; } } } }; - const scope = { window, terminal, screen, session: { id: "claude-card", provider: "claude" }, + const scope = { window, terminal, screen, sessionStartedAt: { current: 1 }, + session: { id: "claude-card", provider: "claude", claudeDraft: true }, onMouseDown: (listener) => { mouseDown = listener; } }; const handler = terminalKeyHandler(source, scope); const key = (key, code, modifiers = {}) => handler({ type: "keydown", key, code, ctrlKey: false, shiftKey: false, @@ -219,6 +221,34 @@ test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and d assert.equal(highlight(), null); assert.equal(key("ж", "Semicolon"), true, "a click replaces the draft selection"); assert.deepEqual(calls, []); + + key("a", "KeyA", { metaKey: true }); + scope.sessionStartedAt.current = 2; + assert.equal(key("ж", "Semicolon"), true, "a restarted Claude has a new draft: no selection carries over"); + key("a", "KeyA", { metaKey: true }); + rows = ["Do you want to proceed?", "❯ 1. Yes", " 2. No"]; + scope.repaint(); + assert.equal(highlight(), null); + assert.equal(key("Backspace", "Backspace"), true, "with a dialog in place of the prompt the key is the dialog's"); + assert.equal(key("a", "KeyA", { metaKey: true }), false); + assert.equal(key("Backspace", "Backspace"), true, "Command+A selects nothing without a prompt on screen"); + assert.deepEqual(calls, []); +}); + +test("a Claude card launched without the draft helper keeps xterm's own Command+A and clipboard keys", async () => { + const source = await readFile(terminalCardPath, "utf8"); + const calls = []; + const window = { canvasTTY: { + window: { isMacOS: true }, + terminal: { input: (id, data) => calls.push(data), draftAction: async () => calls.push("draft") } + } }; + const handler = terminalKeyHandler(source, { window, session: { id: "plugin-claude", provider: "claude" } }); + const key = (key, code, modifiers = {}) => handler({ type: "keydown", key, code, ctrlKey: false, shiftKey: false, + altKey: false, metaKey: false, repeat: false, ...modifiers, preventDefault() {}, stopPropagation() {} }); + assert.equal(key("a", "KeyA", { metaKey: true }), true); + assert.equal(key("Backspace", "Backspace"), true); + assert.equal(key("x", "KeyX", { metaKey: true }), true); + assert.deepEqual(calls, [], "no Ctrl+G, so Claude never opens the person's editor"); }); test("Claude prompt rows are found between the last two rules, and nothing is guessed without them", () => { From ea45f86a8cae97ef36139eb13014e12a318a1e2d Mon Sep 17 00:00:00 2001 From: MrCertis Date: Tue, 13 Oct 2026 18:10:48 +0300 Subject: [PATCH 3/4] fix(mac): keep Claude draft keys in background sessions and isolation - Pass the draft helper and the locale through Claude's --settings env as well: a background session (/bg, /resume, agent view) runs under Claude's supervisor and gets this launch's --settings, not its environment. - The helper answers a request in another card's folder after its own, so a background session shown by another card still works; it acts only on Claude's own prompt file (claude-prompt-*.md), never on a file a program Claude runs opens (git commit, crontab -e). - A cancel the helper cannot remove is not used up: that Ctrl+G opens the editor. - Agent isolation: a Claude card's own draft folder is writable and readable again; the draft root is private app data, hidden from other launches. - Command+V over a selected draft reads the clipboard when its turn comes, so keys pressed meanwhile stay after the paste. - Every launch resets the card's draft queue; leftovers of an earlier run are removed at startup; Command+A over a dialog drops an earlier selection. Co-Authored-By: Claude Opus 5.5 --- src/agent-runtime/draft-editor.sh | 41 +++-- src/main/index.ts | 9 +- src/main/ipc/registerIpc.ts | 5 +- src/main/services/ClaudeDraftEditor.ts | 54 ++++++- src/main/services/TerminalManager.ts | 26 +++- src/main/services/isolation/AgentIsolation.ts | 3 + src/main/services/isolation/isolationPaths.ts | 8 +- src/preload/index.ts | 3 +- .../src/features/terminal/TerminalCard.tsx | 11 +- src/shared/contracts.ts | 7 +- tests/agent-isolation.test.mjs | 35 ++++- tests/claude-draft-editor.test.mjs | 142 ++++++++++++++++-- tests/terminal-lifecycle.test.mjs | 17 ++- 13 files changed, 305 insertions(+), 56 deletions(-) diff --git a/src/agent-runtime/draft-editor.sh b/src/agent-runtime/draft-editor.sh index 9e5b92f18..e2013dc3d 100644 --- a/src/agent-runtime/draft-editor.sh +++ b/src/agent-runtime/draft-editor.sh @@ -5,36 +5,49 @@ # A cancel. stands for a Ctrl+G that Claude took too long to pick up: when no request # is waiting, one is used up and nothing changes, so a late Ctrl+G opens no editor. # With neither, this is an ordinary Ctrl+G and the person's own editor opens. +# +# A background Claude session may be shown by another card than the one that started it, +# so after this card's own folder the other cards' folders are looked at too. file=$1 dir=$CANVASTTY_DRAFT_DIR -oldest() { - ls "$dir" 2>/dev/null | sed -n "s/^$1\.\([0-9][0-9]*\)\$/\1/p" | sort -n | head -n 1 +# Only Claude's own prompt file: a program Claude runs (git commit, crontab -e) gets the person's editor. +case "${file##*/}" in + claude-prompt-*.md) ;; + *) dir= ;; +esac +# Sets found (a folder) and id to the oldest ., this card's own folder first. +find_oldest() { + for found in "$dir" "${dir%/*}"/*; do + [ -d "$found" ] || continue + id=$(ls "$found" 2>/dev/null | sed -n "s/^$1\.\([0-9][0-9]*\)\$/\1/p" | sort -n | head -n 1) + [ -n "$id" ] && return 0 + done + return 1 } -if [ -n "$dir" ] && [ -d "$dir" ]; then - while id=$(oldest req) && [ -n "$id" ]; do +if [ -n "$dir" ] && [ -d "${dir%/*}" ]; then + while find_oldest req; do # Renaming is atomic: CanvasTTY cancels a request the same way, so only one side gets it. - if ! mv "$dir/req.$id" "$dir/claimed.$id" 2>/dev/null; then - [ -e "$dir/req.$id" ] && exit 1 + if ! mv "$found/req.$id" "$found/claimed.$id" 2>/dev/null; then + [ -e "$found/req.$id" ] && exit 1 continue fi - action=$(head -n 1 "$dir/claimed.$id") - rm -f "$dir/claimed.$id" + action=$(head -n 1 "$found/claimed.$id") + rm -f "$found/claimed.$id" case "$action" in copy|cut|clear) ;; *) exit 0 ;; esac - rm -f "$dir/draft.$id" + rm -f "$found/draft.$id" # Without a saved copy the draft is left as it was. - cp "$file" "$dir/draft.$id" || exit 1 + cp "$file" "$found/draft.$id" || exit 1 case "$action" in cut|clear) : > "$file" ;; esac - : > "$dir/done.$id" + : > "$found/done.$id" exit 0 done - id=$(oldest cancel) - if [ -n "$id" ]; then - rm -f "$dir/cancel.$id" + # A cancel that cannot be removed is not used up: this Ctrl+G opens the editor. + if find_oldest cancel && rm "$found/cancel.$id" 2>/dev/null; then exit 0 fi fi diff --git a/src/main/index.ts b/src/main/index.ts index ca5912f82..4234fd068 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -644,13 +644,16 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // OpenCode's auto profile runs shell commands without asking only while base protection guards them. terminalManager.configureBaseProtection(() => settings.get().baseProtectionEnabled); if (process.platform === "darwin") setTerminalSystemLocale(app.getSystemLocale()); - terminalManager.configureDraftEditor(new ClaudeDraftEditor({ + const draftEditor = new ClaudeDraftEditor({ root: join(userDataPath, "claude-draft"), helperPath: app.isPackaged ? join(process.resourcesPath, "agent-runtime", "draft-editor.sh") : join(app.getAppPath(), "src", "agent-runtime", "draft-editor.sh"), - writeClipboard: (text) => clipboard.writeText(text) - })); + writeClipboard: (text) => clipboard.writeText(text), + readClipboard: () => ({ image: !clipboard.readImage().isEmpty(), text: clipboard.readText() }) + }); + draftEditor.removeLeftovers(); + terminalManager.configureDraftEditor(draftEditor); const terminalSessionStore = new TerminalSessionStore(userDataPath); terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().sessionRestoreMode); agentChatHistory = new AgentChatHistoryService(settings, providerClis, terminalManager, hermesHomeDirectory); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 667099c5a..a50614d42 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -955,10 +955,11 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (!isMainRenderer(event, getMainWindow)) return; terminals.input(id, data); }); - ipcMain.handle(IPC.terminalDraftAction, (event, id: unknown, action: unknown, followUp: unknown) => { + ipcMain.handle(IPC.terminalDraftAction, (event, id: unknown, action: unknown, followUp: unknown, paste: unknown) => { assertMainRenderer(event, getMainWindow); if (typeof id !== "string" || !isClaudeDraftAction(action)) return false; - return terminals.draftAction(id, action, typeof followUp === "string" ? followUp : ""); + return terminals.draftAction(id, action, typeof followUp === "string" ? followUp : "", + paste === "plain" || paste === "bracketed" ? paste : null); }); ipcMain.on(IPC.terminalResize, (event, id: string, cols: number, rows: number) => { if (!isMainRenderer(event, getMainWindow) || typeof id !== "string") return; diff --git a/src/main/services/ClaudeDraftEditor.ts b/src/main/services/ClaudeDraftEditor.ts index 956495f49..9191b4848 100644 --- a/src/main/services/ClaudeDraftEditor.ts +++ b/src/main/services/ClaudeDraftEditor.ts @@ -17,12 +17,17 @@ export interface ClaudeDraftEditorOptions { /** draft-editor.sh; its path becomes part of $VISUAL, so it must be a plain path. */ helperPath: string; writeClipboard: (text: string) => void; + /** The clipboard at the moment a paste that replaces the draft is typed. */ + readClipboard: () => { image: boolean; text: string }; timeoutMs?: number; } +/** How a paste is typed: as xterm types it, bracketed when Claude turned bracketed paste on. */ +export type ClaudeDraftPaste = "plain" | "bracketed"; + type Step = | { data: string } - | { action: ClaudeDraftAction; followUp: string; resolve: (done: boolean) => void }; + | { action: ClaudeDraftAction; followUp: string; paste: ClaudeDraftPaste | null; resolve: (done: boolean) => void }; interface SessionQueue { steps: Step[]; @@ -53,7 +58,20 @@ export class ClaudeDraftEditor { this.timeoutMs = options.timeoutMs ?? DONE_TIMEOUT_MS; } - /** Launch variables for one Claude session, or none when the helper cannot be used safely. */ + /** Drops what an earlier run of CanvasTTY left: its Claude sessions are gone, and so are their requests. */ + removeLeftovers(): void { + try { + rmSync(this.options.root, { recursive: true, force: true }); + } catch { + // Each session's folder is cleared again at its launch. + } + } + + /** + * Launch variables for one Claude session, or none when the helper cannot be used safely. They are given to + * Claude twice: in its environment and in its --settings env, which is what a background session (/bg, + * /resume, agent view) gets: such a session runs under Claude's own supervisor, not under this process. + */ environment(sessionId: string, source: Readonly>): Record { this.forget(sessionId); // $VISUAL may go through a shell: no whitespace, quoting or shell syntax in the helper's path. @@ -77,6 +95,11 @@ export class ClaudeDraftEditor { return this.granted.has(sessionId); } + /** The folder the helper of a session launched with it answers in; null for any other session. */ + folderOf(sessionId: string): string | null { + return this.granted.has(sessionId) ? this.directory(sessionId) : null; + } + /** Queues input written while actions run; true when the caller must not write it now. */ hold(sessionId: string, data: string): boolean { const queue = this.queues.get(sessionId); @@ -89,10 +112,16 @@ export class ClaudeDraftEditor { * Queues one action; once earlier ones finish it runs, then writes followUp (the key or * paste that replaces a selected draft). Resolves true when Claude's editor round trip ran. */ - run(sessionId: string, action: ClaudeDraftAction, followUp: string, write: (data: string) => boolean): Promise { + run( + sessionId: string, + action: ClaudeDraftAction, + followUp: string, + write: (data: string) => boolean, + paste: ClaudeDraftPaste | null = null + ): Promise { if (!isSafeId(sessionId) || !this.granted.has(sessionId)) return Promise.resolve(false); return new Promise((resolve) => { - const step: Step = { action, followUp, resolve }; + const step: Step = { action, followUp, paste, resolve }; const queue = this.queues.get(sessionId); if (queue) { queue.steps.push(step); @@ -127,12 +156,27 @@ export class ClaudeDraftEditor { continue; } const done = await this.roundTrip(sessionId, step.action, queue.write, live).catch(() => false); - if (live() && step.followUp) queue.write(step.followUp); + // A paste reads the clipboard only now, so keys typed after it still come after it. + const followUp = step.paste ? this.pasteData(step.paste) : step.followUp; + if (live() && followUp) queue.write(followUp); step.resolve(done); } if (live()) this.queues.delete(sessionId); } + private pasteData(paste: ClaudeDraftPaste): string { + try { + const clipboard = this.options.readClipboard(); + // An image goes the way Claude reads it: Ctrl+V makes Claude read the clipboard itself. + if (clipboard.image) return "\u0016"; + if (!clipboard.text) return ""; + const text = clipboard.text.replace(/\r?\n/g, "\r"); + return paste === "bracketed" ? `\u001b[200~${text}\u001b[201~` : text; + } catch { + return ""; + } + } + private async roundTrip( sessionId: string, action: ClaudeDraftAction, diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 412809715..677318ca8 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -59,7 +59,7 @@ import { onDiskPath } from "./onDiskPath.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; import { launchEffortProblem, launchModelProblem, type ReasoningEffort } from "../../shared/launchModel.ts"; import { SecretRedactionRegistry } from "./safety/SecretRedaction.ts"; -import type { ClaudeDraftEditor } from "./ClaudeDraftEditor.ts"; +import type { ClaudeDraftEditor, ClaudeDraftPaste } from "./ClaudeDraftEditor.ts"; import type { DecisionSession } from "./DecisionHooks.ts"; import { tryPtyOperation } from "./ptySafety.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; @@ -966,7 +966,7 @@ export class TerminalManager { return written; } - draftAction(id: string, action: ClaudeDraftAction, followUp: string): Promise { + draftAction(id: string, action: ClaudeDraftAction, followUp: string, paste: ClaudeDraftPaste | null = null): Promise { const session = this.sessions.get(id); const process = session?.process; if (!this.draftEditor || !session || !process || session.metadata.exitCode !== null @@ -976,7 +976,7 @@ export class TerminalManager { && tryPtyOperation(() => process.write(data)); if (written && ANSWERS_PROMPT.test(data)) this.settleAnsweredPrompt(id, session); return written; - }); + }, paste); } /** @@ -1677,6 +1677,8 @@ export class TerminalManager { if (!this.isolation) throw new LaunchRefusal("agent isolation is not configured; the agent was not started without it."); if (typeof planned.args === "string") throw new LaunchRefusal("a Windows batch launcher cannot run inside agent isolation."); const grant = controlGrantFolder(planned.env); + // Claude's draft helper writes its answers in this card's own folder (never another card's). + const draftFolder = this.draftEditor?.folderOf(id) ?? null; const wrapped = this.isolation.wrap({ sessionId: id, provider, @@ -1685,7 +1687,8 @@ export class TerminalManager { args: planned.args, env: planned.env, profile, - ...(grant ? { grantedPrivate: [grant] } : {}) + ...(grant ? { grantedPrivate: [grant] } : {}), + ...(draftFolder ? { ownWritable: [draftFolder] } : {}) }); this.releaseIsolation(id); this.isolationCleanups.set(id, wrapped.cleanup); @@ -1792,10 +1795,23 @@ export class TerminalManager { if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); // A plugin or a remote environment that sets its own editor keeps it; the helper only exists locally. const contributesEditor = ["VISUAL", "EDITOR"].some((key) => key in (contribution?.env ?? {})); + let draftEnvironment: Record = {}; if (provider === "claude" && process.platform === "darwin" && !environmentWrapped && !contributesEditor && this.draftEditor) { - Object.assign(providerEnvironment, this.draftEditor.environment(id, baseEnvironment)); + draftEnvironment = this.draftEditor.environment(id, baseEnvironment); + } else { + // Whatever ran in this card before has no helper now. + this.draftEditor?.forget(id); } + Object.assign(providerEnvironment, draftEnvironment); const providerArgs = [...(agentRuntime?.args ?? []), ...(agentBrowser?.args ?? [])]; + // A background Claude session (/bg, /resume, agent view) runs under Claude's own supervisor and gets this + // launch's --settings, not its environment: the helper and the locale go there too. + if (Object.keys(draftEnvironment).length > 0) { + const launched = { ...baseEnvironment, ...providerEnvironment }; + const locale = Object.fromEntries(["LANG", "LC_ALL", "LC_CTYPE"] + .filter((key) => launched[key]).map((key) => [key, launched[key]!])); + providerArgs.push("--settings", JSON.stringify({ env: { ...draftEnvironment, ...locale } })); + } // Stable terminal observations for the CLI controller; leave ordinary launches unchanged. if (captureResult && provider === "codex") providerArgs.push("-c", "tui.animations=false"); // A Codex subagent in the person's folder is not asked to trust it again (this run only, never ~/.codex). diff --git a/src/main/services/isolation/AgentIsolation.ts b/src/main/services/isolation/AgentIsolation.ts index 5d648c290..e6bac0bba 100644 --- a/src/main/services/isolation/AgentIsolation.ts +++ b/src/main/services/isolation/AgentIsolation.ts @@ -75,6 +75,8 @@ export interface IsolationLaunch { env: Record; /** Folders under CanvasTTY's private data this launch was handed (its control grant, its account home). */ grantedPrivate?: readonly string[]; + /** Folders under CanvasTTY's private data this launch may also write (its Claude draft folder). */ + ownWritable?: readonly string[]; /** The launch profile: in plan the project is not writable. */ profile?: LaunchProfile; } @@ -193,6 +195,7 @@ export class AgentIsolation { userDataPath: this.options.userDataPath, sessionId: launch.sessionId, ...(launch.grantedPrivate ? { grantedPrivate: launch.grantedPrivate } : {}), + ...(launch.ownWritable ? { ownWritable: launch.ownWritable } : {}), ...(launch.profile === "plan" ? { readOnlyProject: true } : {}) }); // `git init` and `git clone` copy git's template, sample hooks included: an empty one writes no hooks. diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5e..92327d1ad 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -26,6 +26,8 @@ export interface IsolationPathInput { sessionId: string; /** Folders under CanvasTTY's private data this launch was handed (its own control grant, its account home). */ grantedPrivate?: readonly string[]; + /** Folders under CanvasTTY's private data this launch may also write (its Claude draft folder); read again too. */ + ownWritable?: readonly string[]; /** Extra socket folders this launch may connect to (the control grant's endpoint folder). */ socketFolders?: readonly string[]; /** Plan: the project is readable only (the CLI's own folders stay writable). */ @@ -144,7 +146,7 @@ function sensitiveHomeFolders(home: string, xdgConfig: string): string[] { * token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs"] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", "claude-draft"] .map((name) => join(userDataPath, name)); } @@ -212,7 +214,8 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { // variable pointing at HOME or above the project would hide them, so it is not followed. .filter((folder) => !ownFolders.includes(folder) && !hides(folder)); const privateData = privateAppData(input.userDataPath); - const grants = [...(input.grantedPrivate ?? []), join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; + const ownWritable = input.ownWritable ?? []; + const grants = [...(input.grantedPrivate ?? []), ...ownWritable, join(input.userDataPath, "launch-runs", safeSegment(input.sessionId))]; const project = input.cwd; const trustedOwn = providerFolders(input.provider, {}, hostHome); const trustedOthers = AGENT_PROVIDERS.filter((provider) => provider !== input.provider).flatMap((provider) => { @@ -259,6 +262,7 @@ export function isolationPaths(input: IsolationPathInput): IsolationPaths { ...(input.readOnlyProject ? [] : [project]), input.sessionTemp, ...ownFolders, + ...ownWritable, join(home, ".npm"), join(home, ".bun"), join(xdg.cache, "npm"), join(xdg.cache, "bun") ]), writableFiles: all(own.files), diff --git a/src/preload/index.ts b/src/preload/index.ts index 670268a3d..d6633cbf3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -313,7 +313,8 @@ const api: CanvasTTYApi = { create: (request: CreateSessionRequest) => ipcRenderer.invoke(IPC.terminalCreate, request), restart: (id: string, options?: { resume?: boolean }) => ipcRenderer.invoke(IPC.terminalRestart, id, options), input: (id: string, data: string) => ipcRenderer.send(IPC.terminalInput, id, data), - draftAction: (id: string, action: ClaudeDraftAction, followUp?: string) => ipcRenderer.invoke(IPC.terminalDraftAction, id, action, followUp ?? ""), + draftAction: (id: string, action: ClaudeDraftAction, followUp?: string, paste?: "plain" | "bracketed") => + ipcRenderer.invoke(IPC.terminalDraftAction, id, action, followUp ?? "", paste ?? null), resize: (id: string, cols: number, rows: number) => ipcRenderer.send(IPC.terminalResize, id, cols, rows), setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index f370a6da8..1c042bd96 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -607,7 +607,10 @@ function TerminalCardView({ }; const selectClaudeDraft = (): void => { // Only a prompt on screen can be selected: no hidden selection behind a dialog or a startup screen. - if (!visiblePromptRows()) return; + if (!visiblePromptRows()) { + deselectClaudeDraft(); + return; + } claudeDraftSelected.current = true; draftSelectedAt = sessionStartedAt.current; placeDraftHighlight(); @@ -642,9 +645,9 @@ function TerminalCardView({ deselectClaudeDraft(); const action = draftIntent === "cut" ? "cut" : "clear"; const followUp = draftIntent === "replace" ? event.key : ""; - void window.canvasTTY.terminal.draftAction(session.id, action, followUp).then(() => { - if (draftIntent === "paste") pasteClipboard(true); - }); + // The paste is typed in turn after the clear, so keys pressed meanwhile stay after it. + const paste = draftIntent !== "paste" ? undefined : terminal.modes.bracketedPasteMode ? "bracketed" : "plain"; + void window.canvasTTY.terminal.draftAction(session.id, action, followUp, paste); } return false; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 83cea8725..71d5d51c3 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -2065,8 +2065,11 @@ export interface CanvasTTYApi { /** `resume` continues the card's own conversation instead of starting a new one. */ restart(id: string, options?: { resume?: boolean }): Promise; input(id: string, data: string): void; - /** Copies, cuts or clears a macOS Claude card's whole draft, then types followUp; false when nothing ran. */ - draftAction(id: string, action: ClaudeDraftAction, followUp?: string): Promise; + /** + * Copies, cuts or clears a macOS Claude card's whole draft, then types followUp, or pastes the clipboard as it is + * then (bracketed when Claude turned bracketed paste on); false when nothing ran. + */ + draftAction(id: string, action: ClaudeDraftAction, followUp?: string, paste?: "plain" | "bracketed"): Promise; resize(id: string, cols: number, rows: number): void; setBounds(id: string, bounds: SessionBounds): void; rename(id: string, title: string): Promise; diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 89b2266aa..9fb740f4e 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -51,9 +51,9 @@ function isolation(w, options = {}) { } /** Runs `sh -c script` under the generated profile; returns stdout lines. */ -function run(w, script, { provider = "codex", env = {}, granted } = {}) { +function run(w, script, { provider = "codex", env = {}, granted, ownWritable } = {}) { const wrapped = isolation(w).wrap({ sessionId: "s1", provider, cwd: w.project.normalize("NFC"), command: "/bin/sh", args: ["-c", script], - env: { ...w.env, ...env }, ...(granted ? { grantedPrivate: granted } : {}) }); + env: { ...w.env, ...env }, ...(granted ? { grantedPrivate: granted } : {}), ...(ownWritable ? { ownWritable } : {}) }); try { const result = spawnSync(wrapped.command, wrapped.args, { cwd: w.project, env: wrapped.env, encoding: "utf8", timeout: 20_000 }); return { lines: result.stdout.split("\n").filter(Boolean), stderr: result.stderr, env: wrapped.env }; @@ -378,6 +378,37 @@ test("seatbelt, for real: the launch writes the account home it was handed, not assert.equal(await readFile(join(own, "auth.json"), "utf8"), "refreshed\n"); }); +test("the paths: a Claude card's own draft folder is writable and readable again; other cards' are hidden", async (t) => { + const w = await world(t); + const own = join(w.userData, "claude-draft", "s1"); + const paths = isolationPaths({ provider: "claude", cwd: w.project, sessionTemp: join(w.temp, "s"), env: w.env, + userDataPath: w.userData, sessionId: "s1", ownWritable: [own] }); + assert.ok(paths.writable.includes(own) && paths.readableAgain.includes(own)); + assert.ok(paths.unreadable.includes(join(w.userData, "claude-draft"))); + const without = isolationPaths({ provider: "claude", cwd: w.project, sessionTemp: join(w.temp, "s"), env: w.env, userDataPath: w.userData, sessionId: "s1" }); + assert.ok(!without.writable.includes(own) && !without.readableAgain.includes(own)); +}); + +test("seatbelt, for real: Claude's draft helper answers in its own card's folder and cannot reach another card's", onMac, async (t) => { + const w = await world(t); + const drafts = join(w.userData, "claude-draft"); + const own = join(drafts, "s1"); + const other = join(drafts, "s2"); + await mkdir(own, { recursive: true }); + await mkdir(other, { recursive: true }); + await writeFile(join(own, "req.1"), "copy\n"); + await writeFile(join(other, "draft.2"), "другая карточка"); + const { lines } = run(w, [ + `mv "${own}/req.1" "${own}/claimed.1" && : > "${own}/done.1" && echo own`, + `: > "${other}/req.9" 2>/dev/null && echo other-write`, + `cat "${other}/draft.2" 2>/dev/null && echo other-read`, + `ls "${drafts}" 2>/dev/null && echo listed` + ].join("; "), { provider: "claude", ownWritable: [own] }); + assert.deepEqual(lines, ["own"]); + assert.deepEqual((await readdir(own)).sort(), ["claimed.1", "done.1"]); + assert.deepEqual(await readdir(other), ["draft.2"]); +}); + test("seatbelt, for real: git init works, but no repository in the project gets a hook or attributes from the agent", onMac, async (t) => { const w = await world(t); const { lines, env } = run(w, [ diff --git a/tests/claude-draft-editor.test.mjs b/tests/claude-draft-editor.test.mjs index a8b137a89..526479458 100644 --- a/tests/claude-draft-editor.test.mjs +++ b/tests/claude-draft-editor.test.mjs @@ -18,7 +18,7 @@ function fakeClaude(environment, draft) { state.writes.push(data); if (data !== "\u0007") return true; state.editorRuns += 1; - const file = join(scratch, "prompt.md"); + const file = join(scratch, "claude-prompt-test.md"); writeFileSync(file, state.draft); execFileSync("/bin/sh", ["-c", `${environment.VISUAL} "$1"`, "claude", file], { env: { PATH: process.env.PATH, ...environment, CANVASTTY_DRAFT_EDITOR: "true" } @@ -43,8 +43,21 @@ const files = (directory) => (existsSync(directory) ? readdirSync(directory).sor function editorFixture(timeoutMs) { const root = mkdtempSync(join(tmpdir(), "claude-draft-root-")); const clipboard = []; - const editor = new ClaudeDraftEditor({ root, helperPath, writeClipboard: (text) => clipboard.push(text), timeoutMs }); - return { root, clipboard, editor }; + const source = { image: false, text: "" }; + const editor = new ClaudeDraftEditor({ + root, helperPath, writeClipboard: (text) => clipboard.push(text), readClipboard: () => ({ ...source }), timeoutMs + }); + return { root, clipboard, source, editor }; +} + +/** Runs the helper as Claude's $VISUAL on `file`; returns its exit status. */ +function runHelper(file, directory, ownEditor = "false") { + try { + execFileSync("/bin/sh", [helperPath, file], { env: { PATH: process.env.PATH, CANVASTTY_DRAFT_DIR: directory, CANVASTTY_DRAFT_EDITOR: ownEditor } }); + return 0; + } catch (error) { + return error.status; + } } test("copy, cut and clear act on the whole Claude draft through the real helper", async () => { @@ -181,16 +194,9 @@ test("the helper leaves the draft alone when it cannot save a copy, and ignores const scratch = mkdtempSync(join(tmpdir(), "claude-draft-helper-")); const directory = join(scratch, "card"); mkdirSync(directory); - const file = join(scratch, "prompt.md"); + const file = join(scratch, "claude-prompt-test.md"); writeFileSync(file, "draft"); - const run = () => { - try { - execFileSync("/bin/sh", [helperPath, file], { env: { PATH: process.env.PATH, CANVASTTY_DRAFT_DIR: directory, CANVASTTY_DRAFT_EDITOR: "false" } }); - return 0; - } catch (error) { - return error.status; - } - }; + const run = () => runHelper(file, directory); writeFileSync(join(directory, "req.1"), "rm\n"); assert.equal(run(), 0); assert.equal(readFileSync(file, "utf8"), "draft"); @@ -203,9 +209,111 @@ test("the helper leaves the draft alone when it cannot save a copy, and ignores rmSync(scratch, { recursive: true, force: true }); }); +test("only Claude's own prompt file is acted on: a program Claude runs gets the person's editor", () => { + const scratch = mkdtempSync(join(tmpdir(), "claude-draft-other-file-")); + const directory = join(scratch, "card"); + mkdirSync(directory); + const commitMessage = join(scratch, "COMMIT_EDITMSG"); + writeFileSync(commitMessage, "fix: keep me"); + writeFileSync(join(directory, "req.1"), "cut\n"); + writeFileSync(join(directory, "cancel.2"), ""); + const ownEditor = join(scratch, "own-editor.sh"); + writeFileSync(ownEditor, 'printf "%s" "$1" > "$(dirname "$0")/opened"\n'); + assert.equal(runHelper(commitMessage, directory, `/bin/sh ${ownEditor}`), 0); + assert.equal(readFileSync(join(scratch, "opened"), "utf8"), commitMessage, "git's editor is the person's"); + assert.equal(readFileSync(commitMessage, "utf8"), "fix: keep me"); + assert.deepEqual(files(directory), ["cancel.2", "req.1"], "the card's request and cancel stay for Claude's Ctrl+G"); + rmSync(scratch, { recursive: true, force: true }); +}); + +test("a background session shown by another card answers that card's request; its own come first", () => { + const scratch = mkdtempSync(join(tmpdir(), "claude-draft-cards-")); + const started = join(scratch, "card-a"); + const showing = join(scratch, "card-b"); + mkdirSync(started); + mkdirSync(showing); + const file = join(scratch, "claude-prompt-test.md"); + writeFileSync(file, "черновик"); + // card-b sent the Ctrl+G; the session's helper names card-a's folder, where it was started. + writeFileSync(join(showing, "req.7"), "copy\n"); + assert.equal(runHelper(file, started), 0); + assert.deepEqual(files(showing), ["done.7", "draft.7"]); + assert.equal(readFileSync(join(showing, "draft.7"), "utf8"), "черновик"); + rmSync(join(showing, "done.7")); + rmSync(join(showing, "draft.7")); + writeFileSync(join(showing, "req.3"), "cut\n"); + writeFileSync(join(started, "req.9"), "copy\n"); + assert.equal(runHelper(file, started), 0); + assert.deepEqual(files(started), ["done.9", "draft.9"], "its own card's request first"); + assert.deepEqual(files(showing), ["req.3"]); + assert.equal(readFileSync(file, "utf8"), "черновик"); + rmSync(scratch, { recursive: true, force: true }); +}); + +test("a cancel the helper cannot remove is not used up: that Ctrl+G opens the editor", { skip: process.getuid?.() === 0 && "root ignores permissions" }, () => { + const scratch = mkdtempSync(join(tmpdir(), "claude-draft-locked-")); + const directory = join(scratch, "card"); + mkdirSync(directory); + const file = join(scratch, "claude-prompt-test.md"); + writeFileSync(file, "draft"); + writeFileSync(join(directory, "cancel.1"), ""); + chmodSync(directory, 0o500); + try { + const ownEditor = join(scratch, "own-editor.sh"); + writeFileSync(ownEditor, ': > "$(dirname "$0")/opened"\n'); + assert.equal(runHelper(file, directory, `/bin/sh ${ownEditor}`), 0); + assert.equal(existsSync(join(scratch, "opened")), true); + assert.deepEqual(files(directory), ["cancel.1"]); + } finally { + chmodSync(directory, 0o700); + rmSync(scratch, { recursive: true, force: true }); + } +}); + +test("a paste that replaces the draft reads the clipboard in its turn, so later keys stay after it", async () => { + const { root, source, editor } = editorFixture(); + editor.environment("card-p", {}); + const directory = join(root, "card-p"); + const writes = []; + const write = (data) => { writes.push(data); return true; }; + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); + source.text = "старый буфер"; + const pasting = editor.run("card-p", "clear", "", write, "bracketed"); + assert.equal(editor.hold("card-p", "x"), true); + await tick(); + source.text = "строка 1\nстрока 2"; + finishRequest(directory); + assert.equal(await pasting, true); + await tick(); + assert.deepEqual(writes, ["\u0007", "\u001b[200~строка 1\rстрока 2\u001b[201~", "x"]); + source.image = true; + const image = editor.run("card-p", "clear", "", write, "plain"); + await tick(); + finishRequest(directory); + await image; + assert.equal(writes.at(-1), "\u0016", "an image is left for Claude to read itself"); + source.image = false; + source.text = "a\r\nb"; + const plain = editor.run("card-p", "clear", "", write, "plain"); + await tick(); + finishRequest(directory); + await plain; + assert.equal(writes.at(-1), "a\rb"); + rmSync(root, { recursive: true, force: true }); +}); + +test("leftovers of an earlier CanvasTTY run are removed before any card launches", () => { + const { root, editor } = editorFixture(); + mkdirSync(join(root, "old-card")); + writeFileSync(join(root, "old-card", "cancel.4"), ""); + editor.removeLeftovers(); + assert.equal(existsSync(root), false); + assert.equal(editor.environment("card", {}).CANVASTTY_DRAFT_DIR, join(root, "card")); +}); + test("without a request the helper opens the person's own editor, arguments included", () => { const scratch = mkdtempSync(join(tmpdir(), "claude-draft-own-")); - const file = join(scratch, "prompt.md"); + const file = join(scratch, "claude-prompt-test.md"); writeFileSync(file, "draft"); const ownEditor = join(scratch, "own-editor.sh"); writeFileSync(ownEditor, 'printf "%s|" "$@" > "$(dirname "$0")/opened"\n'); @@ -257,6 +365,14 @@ test("TerminalManager gives local macOS Claude cards the helper and holds their assert.equal(claudeEnv.VISUAL, `/bin/sh ${helperPath}`); assert.equal(claudeEnv.CANVASTTY_DRAFT_DIR, join(root, claude.id)); assert.equal(terminals.listMetadata().find((s) => s.id === claude.id).claudeDraft, true); + // A background session (/bg, /resume) gets Claude's --settings, not this environment. + const args = calls.at(-1).args; + const inline = args.filter((_, index) => args[index - 1] === "--settings").map((value) => JSON.parse(value)); + assert.equal(inline.length, 1, "one merged --settings, so the hooks are kept"); + assert.equal(inline[0].env.VISUAL, claudeEnv.VISUAL); + assert.equal(inline[0].env.CANVASTTY_DRAFT_DIR, claudeEnv.CANVASTTY_DRAFT_DIR); + assert.equal(inline[0].env.CANVASTTY_DRAFT_EDITOR, claudeEnv.CANVASTTY_DRAFT_EDITOR); + for (const key of ["LANG", "LC_ALL", "LC_CTYPE"]) assert.equal(inline[0].env[key], claudeEnv[key], key); const codex = terminals.create({ ...base, provider: "codex" }); assert.equal("CANVASTTY_DRAFT_DIR" in calls.at(-1).options.env, false, "only Claude cards get the helper"); assert.equal(terminals.listMetadata().find((s) => s.id === codex.id).claudeDraft, undefined); diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 23fc9c258..43c64ca2c 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -151,7 +151,8 @@ test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and d buffer: { active: { viewportY: 10, getLine: (index) => ({ translateToString: () => rows[index - 10] ?? "" }) } }, selectLines: () => calls.push("xterm selection"), clearSelection: () => calls.push("xterm clear"), - paste: (text) => calls.push(["paste", text]) + paste: (text) => calls.push(["paste", text]), + modes: { bracketedPasteMode: true } }; const screen = { clientHeight: 120, append(child) { child.parentElement = screen; screen.layer = child; } }; let mouseDown; @@ -160,7 +161,7 @@ test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and d clipboard: { hasImage: async () => false, readText: async () => "из буфера" }, terminal: { input: (id, data) => calls.push(["input", data]), - draftAction: async (id, action, followUp) => { calls.push(["draft", action, followUp ?? ""]); return true; } + draftAction: async (id, action, followUp, paste) => { calls.push(["draft", action, followUp ?? "", ...(paste ? [paste] : [])]); return true; } } } }; const scope = { window, terminal, screen, sessionStartedAt: { current: 1 }, @@ -196,7 +197,8 @@ test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and d [() => key("ч", "KeyX", { metaKey: true }), [["draft", "cut", ""]]], [() => key("Backspace", "Backspace"), [["draft", "clear", ""]]], [() => key("Delete", "Delete"), [["draft", "clear", ""]]], - [() => key("м", "KeyV", { metaKey: true }), [["draft", "clear", ""], ["paste", "из буфера"]]] + // The main process types the clipboard right after the clear, in order with later keys. + [() => key("м", "KeyV", { metaKey: true }), [["draft", "clear", "", "bracketed"]]] ]) { key("a", "KeyA", { metaKey: true }); calls.length = 0; @@ -232,6 +234,15 @@ test("Command+A selects a macOS Claude draft that copy, cut, typing, paste and d assert.equal(key("Backspace", "Backspace"), true, "with a dialog in place of the prompt the key is the dialog's"); assert.equal(key("a", "KeyA", { metaKey: true }), false); assert.equal(key("Backspace", "Backspace"), true, "Command+A selects nothing without a prompt on screen"); + rows = ["history", rule, "❯ draft", rule]; + scope.repaint(); + key("a", "KeyA", { metaKey: true }); + rows = ["Do you want to proceed?", "❯ 1. Yes", " 2. No"]; + key("a", "KeyA", { metaKey: true }); + rows = ["history", rule, "❯ draft", rule]; + scope.repaint(); + assert.equal(highlight(), null, "Command+A over a dialog drops an earlier selection instead of keeping it hidden"); + assert.equal(key("Backspace", "Backspace"), true); assert.deepEqual(calls, []); }); From c12a6c44d063ab8af95be8bd5492d3a203da1e4d Mon Sep 17 00:00:00 2001 From: MrCertis Date: Tue, 13 Oct 2026 18:21:33 +0300 Subject: [PATCH 4/4] fix(mac): Claude draft helper inside agent isolation; keep plugin editor and locale - Inside agent isolation the shared draft root is hidden, so the helper no longer requires it: it looks in its own card's folder, then in whichever other card folders it can see. The seatbelt test now runs the real helper. - A cancel is used up only from the card's own folder: another card's timed-out action never swallows an ordinary Ctrl+G here. - A plugin that sets VISUAL or EDITOR in its own --settings env keeps Ctrl+G (no helper), and a locale a plugin sets is not replaced in the --settings env that background sessions get. - A request that cannot be claimed but is still there (also a dangling link) stops the helper instead of looping. Co-Authored-By: Claude Opus 5.5 --- src/agent-runtime/draft-editor.sh | 20 ++++++++++++-------- src/main/services/TerminalManager.ts | 21 +++++++++++++++++---- tests/agent-isolation.test.mjs | 24 ++++++++++++++++-------- tests/claude-draft-editor.test.mjs | 14 ++++++++++++++ 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/src/agent-runtime/draft-editor.sh b/src/agent-runtime/draft-editor.sh index e2013dc3d..b4cbc83a7 100644 --- a/src/agent-runtime/draft-editor.sh +++ b/src/agent-runtime/draft-editor.sh @@ -7,7 +7,9 @@ # With neither, this is an ordinary Ctrl+G and the person's own editor opens. # # A background Claude session may be shown by another card than the one that started it, -# so after this card's own folder the other cards' folders are looked at too. +# so after this card's own folder the other cards' folders are looked at for a request +# (inside agent isolation they are hidden, and only the own folder is seen). A cancel is +# only ever this card's own: another card's never costs a Ctrl+G here. file=$1 dir=$CANVASTTY_DRAFT_DIR # Only Claude's own prompt file: a program Claude runs (git commit, crontab -e) gets the person's editor. @@ -15,20 +17,22 @@ case "${file##*/}" in claude-prompt-*.md) ;; *) dir= ;; esac -# Sets found (a folder) and id to the oldest ., this card's own folder first. +# Sets found (a folder) and id to the oldest . in the given folders, in order. find_oldest() { - for found in "$dir" "${dir%/*}"/*; do + kind=$1 + shift + for found in "$@"; do [ -d "$found" ] || continue - id=$(ls "$found" 2>/dev/null | sed -n "s/^$1\.\([0-9][0-9]*\)\$/\1/p" | sort -n | head -n 1) + id=$(ls "$found" 2>/dev/null | sed -n "s/^$kind\.\([0-9][0-9]*\)\$/\1/p" | sort -n | head -n 1) [ -n "$id" ] && return 0 done return 1 } -if [ -n "$dir" ] && [ -d "${dir%/*}" ]; then - while find_oldest req; do +if [ -n "$dir" ]; then + while find_oldest req "$dir" "${dir%/*}"/*; do # Renaming is atomic: CanvasTTY cancels a request the same way, so only one side gets it. if ! mv "$found/req.$id" "$found/claimed.$id" 2>/dev/null; then - [ -e "$found/req.$id" ] && exit 1 + { [ -e "$found/req.$id" ] || [ -L "$found/req.$id" ]; } && exit 1 continue fi action=$(head -n 1 "$found/claimed.$id") @@ -47,7 +51,7 @@ if [ -n "$dir" ] && [ -d "${dir%/*}" ]; then exit 0 done # A cancel that cannot be removed is not used up: this Ctrl+G opens the editor. - if find_oldest cancel && rm "$found/cancel.$id" 2>/dev/null; then + if find_oldest cancel "$dir" && rm "$found/cancel.$id" 2>/dev/null; then exit 0 fi fi diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 677318ca8..bdb9713b3 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -63,7 +63,7 @@ import type { ClaudeDraftEditor, ClaudeDraftPaste } from "./ClaudeDraftEditor.ts import type { DecisionSession } from "./DecisionHooks.ts"; import { tryPtyOperation } from "./ptySafety.ts"; import { terminalFailureDetails } from "./terminalFailureDetails.ts"; -import { canResumeThreadById, resolveTerminalLaunch } from "./terminalLaunch.ts"; +import { canResumeThreadById, parseInlineSettings, resolveTerminalLaunch } from "./terminalLaunch.ts"; import { isLaunchProfile, PROFILE_RANK, profileAvailable, profileCeiling, type LaunchProfile } from "../../shared/autoMode.ts"; import type { AgentIsolation, IsolationDecision } from "./isolation/AgentIsolation.ts"; import { controlGrantFolder } from "./isolation/AgentIsolation.ts"; @@ -1793,8 +1793,10 @@ export class TerminalManager { }; // OpenCode: the project folder in its other Unicode spelling is still this folder, not an external one. if (provider === "opencode") Object.assign(providerEnvironment, openCodeProjectFolderEnvironment({ ...baseEnvironment, ...providerEnvironment }, cwd)); - // A plugin or a remote environment that sets its own editor keeps it; the helper only exists locally. - const contributesEditor = ["VISUAL", "EDITOR"].some((key) => key in (contribution?.env ?? {})); + // A plugin or a remote environment that sets its own editor (in the environment or in its --settings env) keeps + // it; the helper only exists locally. + const contributedNames = new Set([...Object.keys(contribution?.env ?? {}), ...inlineSettingsEnvironmentNames(contribution?.args ?? [])]); + const contributesEditor = ["VISUAL", "EDITOR"].some((key) => contributedNames.has(key)); let draftEnvironment: Record = {}; if (provider === "claude" && process.platform === "darwin" && !environmentWrapped && !contributesEditor && this.draftEditor) { draftEnvironment = this.draftEditor.environment(id, baseEnvironment); @@ -1808,8 +1810,9 @@ export class TerminalManager { // launch's --settings, not its environment: the helper and the locale go there too. if (Object.keys(draftEnvironment).length > 0) { const launched = { ...baseEnvironment, ...providerEnvironment }; + // A locale a plugin or a remote environment sets is its own. const locale = Object.fromEntries(["LANG", "LC_ALL", "LC_CTYPE"] - .filter((key) => launched[key]).map((key) => [key, launched[key]!])); + .filter((key) => launched[key] && !contributedNames.has(key)).map((key) => [key, launched[key]!])); providerArgs.push("--settings", JSON.stringify({ env: { ...draftEnvironment, ...locale } })); } // Stable terminal observations for the CLI controller; leave ordinary launches unchanged. @@ -2396,6 +2399,16 @@ export function terminalEnvironment( return { ...environment, TERM: "xterm-256color", COLORTERM: "truecolor" }; } +/** The names a launch's inline --settings set in Claude's env (`--settings ` or `--settings=`). */ +function inlineSettingsEnvironmentNames(args: readonly string[]): string[] { + return args.flatMap((argument, index) => { + const value = argument.startsWith("--settings=") ? argument.slice("--settings=".length) + : args[index - 1] === "--settings" ? argument : null; + const env = value === null ? null : parseInlineSettings(value)?.env; + return env && typeof env === "object" && !Array.isArray(env) ? Object.keys(env) : []; + }); +} + let systemLocale = "en_US.UTF-8"; /** The macOS system locale (such as "ru-RU") as a UTF-8 locale name, or en_US.UTF-8 when the system lacks it. */ diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 9fb740f4e..a3a28f5ab 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -394,19 +394,27 @@ test("seatbelt, for real: Claude's draft helper answers in its own card's folder const drafts = join(w.userData, "claude-draft"); const own = join(drafts, "s1"); const other = join(drafts, "s2"); + const helper = new URL("../src/agent-runtime/draft-editor.sh", import.meta.url).pathname; await mkdir(own, { recursive: true }); await mkdir(other, { recursive: true }); - await writeFile(join(own, "req.1"), "copy\n"); - await writeFile(join(other, "draft.2"), "другая карточка"); + const prompt = join(w.project, "claude-prompt-t.md"); + await writeFile(prompt, "черновик"); + await writeFile(join(own, "req.1"), "cut\n"); + await writeFile(join(other, "req.2"), "copy\n"); + await writeFile(join(other, "draft.3"), "другая карточка"); const { lines } = run(w, [ - `mv "${own}/req.1" "${own}/claimed.1" && : > "${own}/done.1" && echo own`, + `/bin/sh "${helper}" "${prompt}" && echo own`, + // Another card's request is hidden: with none of its own, this Ctrl+G opens the person's editor. + `/bin/sh "${helper}" "${prompt}"`, `: > "${other}/req.9" 2>/dev/null && echo other-write`, - `cat "${other}/draft.2" 2>/dev/null && echo other-read`, + `cat "${other}/draft.3" 2>/dev/null && echo other-read`, `ls "${drafts}" 2>/dev/null && echo listed` - ].join("; "), { provider: "claude", ownWritable: [own] }); - assert.deepEqual(lines, ["own"]); - assert.deepEqual((await readdir(own)).sort(), ["claimed.1", "done.1"]); - assert.deepEqual(await readdir(other), ["draft.2"]); + ].join("; "), { provider: "claude", ownWritable: [own], env: { CANVASTTY_DRAFT_DIR: own, CANVASTTY_DRAFT_EDITOR: "echo editor" } }); + assert.deepEqual(lines, ["own", `editor ${prompt}`]); + assert.deepEqual((await readdir(own)).sort(), ["done.1", "draft.1"]); + assert.equal(await readFile(join(own, "draft.1"), "utf8"), "черновик"); + assert.equal(await readFile(prompt, "utf8"), "", "cut empties Claude's prompt file"); + assert.deepEqual((await readdir(other)).sort(), ["draft.3", "req.2"]); }); test("seatbelt, for real: git init works, but no repository in the project gets a hook or attributes from the agent", onMac, async (t) => { diff --git a/tests/claude-draft-editor.test.mjs b/tests/claude-draft-editor.test.mjs index 526479458..dafbb22f6 100644 --- a/tests/claude-draft-editor.test.mjs +++ b/tests/claude-draft-editor.test.mjs @@ -247,6 +247,10 @@ test("a background session shown by another card answers that card's request; it assert.deepEqual(files(started), ["done.9", "draft.9"], "its own card's request first"); assert.deepEqual(files(showing), ["req.3"]); assert.equal(readFileSync(file, "utf8"), "черновик"); + rmSync(join(showing, "req.3")); + writeFileSync(join(showing, "cancel.4"), ""); + assert.equal(runHelper(file, started), 1, "another card's cancel does not cost this Ctrl+G: the editor opens"); + assert.deepEqual(files(showing), ["cancel.4"]); rmSync(scratch, { recursive: true, force: true }); }); @@ -373,6 +377,16 @@ test("TerminalManager gives local macOS Claude cards the helper and holds their assert.equal(inline[0].env.CANVASTTY_DRAFT_DIR, claudeEnv.CANVASTTY_DRAFT_DIR); assert.equal(inline[0].env.CANVASTTY_DRAFT_EDITOR, claudeEnv.CANVASTTY_DRAFT_EDITOR); for (const key of ["LANG", "LC_ALL", "LC_CTYPE"]) assert.equal(inline[0].env[key], claudeEnv[key], key); + // A plugin's own editor (even only in its --settings env) and its own locale are kept. + const plugin = (env, args) => ({ ok: true, env, secretEnv: {}, args, files: [], envSources: Object.fromEntries(Object.keys(env).map((key) => [key, "Plugin p"])) }); + terminals.spawnProcess("plugin-editor", "claude", "yolo", process.cwd(), 80, 24, null, false, "agent", undefined, + plugin({}, ["--settings", JSON.stringify({ env: { VISUAL: "plugin-editor" } })])); + assert.equal("CANVASTTY_DRAFT_DIR" in calls.at(-1).options.env, false, "a plugin's --settings editor keeps Ctrl+G"); + terminals.spawnProcess("plugin-locale", "claude", "yolo", process.cwd(), 80, 24, null, false, "agent", undefined, plugin({ LC_ALL: "C" }, [])); + const localeArgs = calls.at(-1).args; + const localeSettings = localeArgs.filter((_, index) => localeArgs[index - 1] === "--settings").map((value) => JSON.parse(value)); + assert.equal(localeSettings[0].env.CANVASTTY_DRAFT_DIR, join(root, "plugin-locale")); + assert.equal("LC_ALL" in localeSettings[0].env, false, "the plugin's locale is not replaced in background sessions"); const codex = terminals.create({ ...base, provider: "codex" }); assert.equal("CANVASTTY_DRAFT_DIR" in calls.at(-1).options.env, false, "only Claude cards get the helper"); assert.equal(terminals.listMetadata().find((s) => s.id === codex.id).claudeDraft, undefined);