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..b4cbc83a7 --- /dev/null +++ b/src/agent-runtime/draft-editor.sh @@ -0,0 +1,61 @@ +#!/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). +# 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. +# +# 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 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. +case "${file##*/}" in + claude-prompt-*.md) ;; + *) dir= ;; +esac +# Sets found (a folder) and id to the oldest . in the given folders, in order. +find_oldest() { + kind=$1 + shift + for found in "$@"; do + [ -d "$found" ] || continue + 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" ]; 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" ] || [ -L "$found/req.$id" ]; } && exit 1 + continue + fi + action=$(head -n 1 "$found/claimed.$id") + rm -f "$found/claimed.$id" + case "$action" in + copy|cut|clear) ;; + *) exit 0 ;; + esac + rm -f "$found/draft.$id" + # Without a saved copy the draft is left as it was. + cp "$file" "$found/draft.$id" || exit 1 + case "$action" in + cut|clear) : > "$file" ;; + esac + : > "$found/done.$id" + exit 0 + done + # A cancel that cannot be removed is not used up: this Ctrl+G opens the editor. + if find_oldest cancel "$dir" && rm "$found/cancel.$id" 2>/dev/null; then + exit 0 + fi +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..4234fd068 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, @@ -28,7 +28,8 @@ 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"; import { TerminalSessionStore } from "./services/TerminalSessionStore"; @@ -642,6 +643,17 @@ 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()); + 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), + 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 00d3feb6c..a50614d42 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,12 @@ 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, paste: unknown) => { + assertMainRenderer(event, getMainWindow); + if (typeof id !== "string" || !isClaudeDraftAction(action)) return false; + 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; 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..9191b4848 --- /dev/null +++ b/src/main/services/ClaudeDraftEditor.ts @@ -0,0 +1,240 @@ +import { existsSync, lstatSync, 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 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; paste: ClaudeDraftPaste | null; 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 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 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; + } + + /** 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. + if (/[\s'"\\$`;&|<>(){}[\]*?!#~]/.test(this.options.helperPath) || !isSafeId(sessionId)) return {}; + // A new Claude process has no Ctrl+G outstanding from an earlier one. + 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), + CANVASTTY_DRAFT_EDITOR: source.VISUAL || source.EDITOR || "" + }; + } + + /** Whether this session's Claude was launched with the helper as its editor. */ + isGranted(sessionId: string): boolean { + 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); + if (!queue) return false; + queue.steps.push({ data }); + return true; + } + + /** + * 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, + 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, paste, resolve }; + const queue = this.queues.get(sessionId); + if (queue) { + queue.steps.push(step); + return; + } + 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 { + // Rebuilt with the next launch. + } + } + + 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); + // 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, + 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 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)); + } + return live(); + } + + 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..bdb9713b3 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -1,10 +1,11 @@ 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"; import type { IPty } from "node-pty"; import type { + ClaudeDraftAction, CreateSessionRequest, GitRiskReport, Point, @@ -58,10 +59,11 @@ 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, 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"; @@ -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; @@ -277,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): @@ -323,6 +328,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 ?? (() => []); @@ -710,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, @@ -949,12 +959,26 @@ 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, paste: ClaudeDraftPaste | null = null): Promise { + const session = this.sessions.get(id); + const process = session?.process; + if (!this.draftEditor || !session || !process || session.metadata.exitCode !== null + || 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; + }, paste); + } + /** * 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 +1186,8 @@ export class TerminalManager { this.initialCodexInputs.delete(id); 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; @@ -1566,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 { @@ -1606,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)); @@ -1637,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, @@ -1645,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); @@ -1750,7 +1793,28 @@ 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 (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); + } 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 }; + // 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] && !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. 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). @@ -2064,6 +2128,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 { @@ -2300,7 +2366,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,13 +2384,40 @@ 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). + // 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.LANG = systemLocale; 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. */ +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/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 32f38cf65..d6633cbf3 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,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, 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 b32ada7a6..1c042bd96 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,9 @@ function TerminalCardView({ sessionExited.current = session.exitCode !== null; 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; @@ -555,7 +561,96 @@ 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 visiblePromptRows = () => { + const buffer = terminal.buffer.active; + return claudePromptRows(Array.from({ length: terminal.rows }, (_, row) => ( + buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "" + ))); + }; + // 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; + 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 => { + // Only a prompt on screen can be selected: no hidden selection behind a dialog or a startup screen. + if (!visiblePromptRows()) { + deselectClaudeDraft(); + return; + } + claudeDraftSelected.current = true; + draftSelectedAt = sessionStartedAt.current; + 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) => { + 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(); + 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 : ""; + // 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; + } const editor = nativeEditorRef.current; if (editor && Object.values(editor).some((binding) => matchesShortcut(event, binding))) { const sequence = codexShortcutSequence(event); @@ -574,6 +669,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 +735,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 +812,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..71d5d51c3 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; @@ -671,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; @@ -2060,6 +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, 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; @@ -2256,6 +2266,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..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" }; @@ -64,12 +64,41 @@ 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 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( + ["LANG", "LC_ALL", "LC_CTYPE"].filter((name) => name in environment), + Object.keys(locale) + ); + } + for (const platform of ["linux", "win32"]) { + assert.equal("LANG" 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/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 89b2266aa..a3a28f5ab 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,45 @@ 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"); + const helper = new URL("../src/agent-runtime/draft-editor.sh", import.meta.url).pathname; + await mkdir(own, { recursive: true }); + await mkdir(other, { recursive: true }); + 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, [ + `/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.3" 2>/dev/null && echo other-read`, + `ls "${drafts}" 2>/dev/null && echo listed` + ].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) => { 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 new file mode 100644 index 000000000..dafbb22f6 --- /dev/null +++ b/tests/claude-draft-editor.test.mjs @@ -0,0 +1,404 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +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"; +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, "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" } + }); + state.draft = readFileSync(file, "utf8"); + return true; + }; + state.cleanup = () => rmSync(scratch, { recursive: true, force: true }); + 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 = []; + 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 () => { + 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.deepEqual(files(join(root, "card-1")), [], "no request or draft copy outlives its action"); + } finally { + claude.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +}); + +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 = []; + 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"); + 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 }); +}); + +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.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.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(claude.draft, ""); + assert.deepEqual(clipboard, []); + assert.deepEqual(files(join(root, "card-3")), []); + } finally { + claude.cleanup(); + rmSync(root, { recursive: true, force: true }); + } +}); + +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, "claude-prompt-test.md"); + writeFileSync(file, "draft"); + const run = () => runHelper(file, directory); + 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("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(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 }); +}); + +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, "claude-prompt-test.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", 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", { + 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") finishRequest(options.env.CANVASTTY_DRAFT_DIR, "весь черновик"); + } })); + 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)); + 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); + // 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); + 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..43c64ca2c 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,43 @@ 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 }, + claudeDraftRef: { current: scope.session?.claudeDraft === true }, + 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 +62,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 +101,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 +118,164 @@ 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]), + modes: { bracketedPasteMode: true } + }; + 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, paste) => { calls.push(["draft", action, followUp ?? "", ...(paste ? [paste] : [])]); return true; } + } + } }; + 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, + 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", ""]]], + // 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; + 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, []); + + 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"); + 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, []); +}); + +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", () => { + 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 +308,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 }; };