From be052b27fa5dc140663e88226b09cf3828ac59b0 Mon Sep 17 00:00:00 2001 From: SHEM Date: Mon, 14 Sep 2026 20:43:10 +0500 Subject: [PATCH 01/35] feat(series): app reliability Source commits: 13c9e30bf1d0d3a4b6d1d431cb9252327052859a 6511a330ec2bb6a7f50938f6187a85e5682dc62c 8f4cefadd71c82f28e985214b2dc7c2fc4294cbe 1b8ce72c7f11af0b23f35edfa1354e6ddd447ecd 554d75747e17114d0f5c0855b51902dc62cb84b9 995daf02c1e2247714af987b8605a25a5cc7862a --- .github/workflows/release.yml | 2 + electron-builder.yml | 34 +++ package-lock.json | 75 +++++-- package.json | 3 + scripts/audit-secrets.mjs | 77 ++++++- src/main/index.ts | 193 +++++++++++++++++- src/main/ipc/registerIpc.ts | 26 ++- src/main/services/SettingsStore.ts | 8 +- src/main/services/TerminalManager.ts | 90 +++++++- src/preload/index.ts | 18 +- src/renderer/src/App.tsx | 3 +- .../src/features/settings/SettingsPanel.tsx | 71 +++++++ src/renderer/src/lib/i18n.ts | 52 ++++- src/renderer/src/styles/app.css | 36 ++++ src/shared/contracts.ts | 27 +++ tests/attention-notifications.test.mjs | 185 +++++++++++++++++ tests/repository-security.test.mjs | 72 ++++++- tests/terminal-lifecycle.test.mjs | 5 +- tests/terminal-visibility.test.mjs | 130 ++++++++++++ 19 files changed, 1068 insertions(+), 39 deletions(-) create mode 100644 tests/attention-notifications.test.mjs create mode 100644 tests/terminal-visibility.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10f2ac27..2f1a8ca4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,8 @@ jobs: run: npm test - name: Build installers run: npm run ${{ matrix.script }} + - name: Audit built bundle secrets + run: npm run audit:secrets - name: Verify macOS app signature if: runner.os == 'macOS' run: | diff --git a/electron-builder.yml b/electron-builder.yml index 11cc5c7e..61f2a5d0 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -16,6 +16,40 @@ files: asar: true asarUnpack: - node_modules/node-pty/**/* +# Close the remaining LoTL vectors in the packaged binary: ignore NODE_OPTIONS / +# NODE_EXTRA_CA_CERTS and --inspect from the environment, and validate the +# embedded asar when app.asar is loaded. Dev builds are unaffected (fuses are +# applied at package time). Electron implements asar integrity validation only on +# macOS (>= 16) and Windows (>= 30), so the Linux AppImage and deb targets flip +# the bit without receiving validation from it. +# +# runAsNode is deliberately left ENABLED: provider CLIs and the agent runtime +# spawn the bundled helpers as +# { command: process.execPath, args: [helper], env: { ELECTRON_RUN_AS_NODE: "1" } } +# (src/main/index.ts for the browser, agent-runtime, and plugin-hook helpers, +# src/agent-runtime/*.mjs, plus the generated lifecycle hook commands). Setting +# runAsNode: false would break the agent runtime in packaged builds. +# +# onlyLoadAppFromAsar is left at its default (off). It does not govern the helper +# .mjs files: the fuse only narrows Electron's own application-code search order +# (app.asar -> app -> default_app.asar) to app.asar alone, and its documented +# value is that it stops asar-integrity validation from being bypassed through +# that search path. The helpers are not application code — they are spawned as +# child processes with ELECTRON_RUN_AS_NODE=1, and in that mode Electron loads no +# app bundle at all — so enabling the fuse would not break them. It stays off +# because no packaged build on any target has exercised the flip, and a wrong +# fuse choice is a release-wide defect rather than a runtime one; verify it with +# a packaged run before enabling it. +# +# enableCookieEncryption is intentionally omitted rather than set to false: the +# fuse is a one-way transition, so a release that enabled it would make every +# later build without the key read the encrypted store as plaintext and corrupt +# the profile's cookies. The upstream reference comment claimed this key kept +# cookies encrypted, which is the opposite of what false does. +electronFuses: + enableNodeOptionsEnvironmentVariable: false + enableNodeCliInspectArguments: false + enableEmbeddedAsarIntegrityValidation: true extraResources: - from: src/agent-browser to: agent-browser diff --git a/package-lock.json b/package-lock.json index 6125f45b..3cc8e7f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,15 @@ "": { "name": "canvastty", "version": "1.5.1", - "license": "MIT", "hasInstallScript": true, + "license": "MIT", "dependencies": { "@xterm/addon-fit": "0.11.0", + "@xterm/addon-search": "0.16.0", "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", + "electron-updater": "6.8.9", "node-pty": "1.1.0", "react": "19.2.8", "react-dom": "19.2.8", @@ -2240,12 +2243,24 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, "node_modules/@xterm/addon-web-links": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", "license": "MIT" }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", @@ -2511,7 +2526,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/asn1js": { @@ -2714,7 +2728,6 @@ "version": "9.7.0", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -2995,7 +3008,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -3312,6 +3324,34 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-vite": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", @@ -3689,7 +3729,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -3954,7 +3993,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -4200,7 +4238,6 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, "funding": [ { "type": "github", @@ -4271,7 +4308,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -4294,7 +4330,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -4304,6 +4339,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -4471,7 +4519,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -5221,7 +5268,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -5510,6 +5556,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5635,7 +5687,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 234cd6e6..0f5a7ece 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,11 @@ }, "dependencies": { "@xterm/addon-fit": "0.11.0", + "@xterm/addon-search": "0.16.0", "@xterm/addon-web-links": "0.12.0", + "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", + "electron-updater": "6.8.9", "node-pty": "1.1.0", "react": "19.2.8", "react-dom": "19.2.8", diff --git a/scripts/audit-secrets.mjs b/scripts/audit-secrets.mjs index a5d4308b..8e0bbe04 100644 --- a/scripts/audit-secrets.mjs +++ b/scripts/audit-secrets.mjs @@ -15,15 +15,27 @@ const IGNORED_ENTRY_NAMES = new Set([ "release", "artifacts" ]); +// Generated native build output, gitignored exactly like out/ and dist/. node-gyp +// writes the builder's absolute home path into the generated project files and +// objects, which is build-environment noise rather than publishable content. +// `build/` itself is not ignored: it also holds tracked icons and resources. +const IGNORED_RELATIVE_PATHS = new Set([ + "build/windows-agent-pipe-host", + "native/windows-agent-pipe-host/build" +]); +const BUILD_OUTPUT_DIRECTORY = "out"; const BINARY_EXTENSIONS = new Set([ ".gif", ".icns", ".ico", ".jpeg", ".jpg", ".pdf", ".png", ".webp", ".zip" ]); const MAX_TEXT_FILE_BYTES = 2 * 1024 * 1024; -const SECRET_PATTERNS = [ +export const SECRET_PATTERNS = [ ["private key", /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/g], - ["Anthropic token", /sk-ant-[A-Za-z0-9_-]{16,}/g], - ["OpenAI-style token", /sk-[A-Za-z0-9_-]{20,}/g], + // The negative lookbehind keeps identifier-like text that merely contains a + // key prefix (`disk-…`, `task-…`) out of the report; a real key never follows + // an alphanumeric character. + ["Anthropic token", /(? 0) { - console.error("Repository secret audit failed:"); + console.error("Secret audit failed:"); for (const issue of issues) console.error(`- ${issue.path}: ${issue.rule}`); process.exitCode = 1; + } else if (artifactIssues === null) { + console.log( + `Repository secret audit passed: no high-confidence secrets or private paths found in the repository source tree. ` + + `No built application bundle exists (${BUILD_OUTPUT_DIRECTORY}/), so the packaged output was not scanned; ` + + "run the audit after `npm run build` to gate the artifact too." + ); } else { - console.log("Repository secret audit passed: no high-confidence secrets or private paths found."); + console.log( + "Secret audit passed: no high-confidence secrets or private paths found in the repository source tree " + + `or the built ${BUILD_OUTPUT_DIRECTORY}/ application bundle.` + ); } } diff --git a/src/main/index.ts b/src/main/index.ts index 64d2892d..d314972a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,14 @@ import { join } from "node:path"; -import { app, BrowserWindow, dialog, net, protocol, safeStorage } from "electron"; -import { IPC, type PluginCanvasRequest } from "../shared/contracts"; +import { app, BrowserWindow, dialog, net, Notification, protocol, safeStorage, session } from "electron"; +import electronUpdater from "electron-updater"; +import { + IPC, + type LocaleId, + type PluginCanvasRequest, + type SessionStatus, + type UpdaterState, + type UpdaterStateEvent +} from "../shared/contracts"; import { registerIpc } from "./ipc/registerIpc"; import { SettingsStore } from "./services/SettingsStore"; import { TerminalManager } from "./services/TerminalManager"; @@ -47,6 +55,9 @@ import { import { startupPageUrl } from "./startupPage"; import { mainWindowChromeOptions } from "./windowChrome"; +// electron-updater is CommonJS; a default import is the only ESM-safe form. +const { autoUpdater } = electronUpdater; + protocol.registerSchemesAsPrivileged([ { scheme: "canvastty-plugin", @@ -92,6 +103,13 @@ let servicesReady = false; let startupRunning = false; let shutdownRunning = false; let shutdownComplete = false; +// Deduplicates attention notifications: the last status already announced per +// session, so a burst of snapshots notifies once per transition. Cleared when +// the session is removed (its removal event), never used as a status source. +const notifiedAttentionStatus = new Map(); +// Self-update state; advanced by autoUpdater events and pushed to the renderer. +let updaterState: UpdaterState = { status: "idle" }; +let updaterInitialized = false; const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) app.quit(); @@ -120,6 +138,25 @@ async function createWindow(): Promise { if (currentUrl && url !== currentUrl) event.preventDefault(); }); canvasNavigationInput?.attach(window.webContents, { preventMouseBindings: false }); + // Crash recovery: a dead renderer must never leave the user staring at a + // blank window. The application surface is reloaded in place — the same entry + // startup loads — so services, sessions and their scrollback stay untouched + // and the user lands back in the app. The startup page is not a recovery + // surface: it is static HTML with no script that could re-enter the app, so + // loading it here would strand the user on a spinner forever. + // "clean-exit" is the normal teardown path and must not trigger a reload. + window.webContents.on("render-process-gone", (_event, details) => { + if (details.reason === "clean-exit") return; + console.warn( + `CanvasTTY renderer is gone (reason=${details.reason}, exitCode=${details.exitCode}). Reloading the application.` + ); + if (window.isDestroyed() || window.webContents.isDestroyed()) return; + void loadApplicationSurface(window) + .catch((error) => console.warn("CanvasTTY could not reload the application after a renderer crash.", error)); + }); + // Seed the renderer's view of the updater on every (re)load, including the + // crash-recovery reload above. + window.webContents.on("did-finish-load", broadcastUpdaterState); window.on("blur", () => { canvasNavigationInput?.reset(); browserService?.cancelCanvasNavigationGesture(); @@ -134,6 +171,14 @@ async function createWindow(): Promise { } async function initializeServices(): Promise { + // Deny-by-default browser permissions on the default session: neither the + // shell window nor plugin windows ever request camera, microphone, location, + // notifications or device access, so nothing is granted silently. The + // browser partition keeps its own, deliberately more permissive policy in + // BrowserService; exceptions belong there, not here. + session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false)); + session.defaultSession.setPermissionCheckHandler(() => false); + session.defaultSession.setDevicePermissionHandler(() => false); providerClis = buildProviderCliRegistry(); // Recovery is independent of gateway availability: interrupted provider config // overlays must be restored before any new terminal can launch, including on Windows. @@ -258,6 +303,31 @@ async function initializeServices(): Promise { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(channel, payload); } + // Attention notifications ride the session-status stream, never the output + // stream: a transition into needs_approval/failed notifies once, and the + // removal event clears the dedup entry so a later session (or restart) can + // notify again. Failures are not all the same event, though, and status + // equality cannot tell them apart: the manager states whether a failure is + // re-derived state (a restored session, already on screen) or the outcome + // of a launch the user just asked for, which is news either way. + if (channel === IPC.terminalSession && "session" in payload) { + const { id, status, title, provider } = payload.session; + const previousStatus = notifiedAttentionStatus.get(id); + notifiedAttentionStatus.set(id, status); + const failureOrigin = status === "failed" ? terminalManager?.consumeFailureOrigin() ?? null : null; + if ((status === "needs_approval" || status === "failed") + && failureOrigin !== "restore" + && (failureOrigin === "user" || previousStatus !== status) + && settings.get().attentionNotifications + && Notification.isSupported()) { + new Notification({ + title: title || provider, + body: attentionStatusLabel(status, settings.get().locale) + }).show(); + } + } else if (channel === IPC.terminalRemoved && "id" in payload) { + notifiedAttentionStatus.delete(payload.id); + } }, providerClis, agentBrowserBridge ?? undefined, agentRuntimeBridge ?? undefined, settings.get().agentLifecycleHooksEnabled); const terminalSessionStore = new TerminalSessionStore(userDataPath); terminalManager.configureSessionPersistence(terminalSessionStore, settings.get().restoreTerminalSessions); @@ -320,17 +390,30 @@ async function initializeServices(): Promise { closePluginWindows, requestPluginLauncher, requestPluginCanvas, - broadcastPluginStorageChange + broadcastPluginStorageChange, + updater: { + check: requestUpdaterCheck, + install: installUpdaterUpdate + } }); servicesReady = true; } -async function loadApplication(window: BrowserWindow): Promise { +/** + * Loads the application entry into a window: the dev server when one is + * configured, the packaged renderer bundle otherwise. Startup and renderer + * crash recovery both go through here, so they can never drift apart. + */ +async function loadApplicationSurface(window: BrowserWindow): Promise { if (process.env.ELECTRON_RENDERER_URL) { await window.loadURL(process.env.ELECTRON_RENDERER_URL); } else { await window.loadFile(join(__dirname, "../renderer/index.html")); } +} + +async function loadApplication(window: BrowserWindow): Promise { + await loadApplicationSurface(window); if (process.env.CANVASTTY_SMOKE_TEST === "1") { await window.webContents.executeJavaScript( @@ -386,6 +469,7 @@ async function startApplication(): Promise { return; } if (!servicesReady) await initializeServices(); + initializeUpdater(); await loadApplication(window); } catch (error) { if (window) await showStartupFailure(window, error); @@ -449,6 +533,99 @@ async function showStartupFailure(window: BrowserWindow, error: unknown): Promis } } +/** Notification body for the two statuses that deserve the user's attention. */ +function attentionStatusLabel(status: "needs_approval" | "failed", locale: LocaleId): string { + if (status === "needs_approval") return locale === "ru" ? "Требуется подтверждение" : "Needs approval"; + return locale === "ru" ? "Сессия завершилась с ошибкой" : "Session failed"; +} + +// Self-update. electron-updater is packaged-only: in dev, or when the feed +// cannot be reached, the honest state is `unavailable` — never an exception. + +/** Pushes the current updater state; also runs on every renderer load. */ +function broadcastUpdaterState(): void { + const event: UpdaterStateEvent = { state: updaterState }; + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC.updaterState, event); + } +} + +function publishUpdaterState(state: UpdaterState): void { + updaterState = state; + broadcastUpdaterState(); +} + +function initializeUpdater(): void { + if (updaterInitialized) return; + updaterInitialized = true; + if (!app.isPackaged) { + publishUpdaterState({ status: "unavailable", reason: "dev" }); + return; + } + + // The user decides when to download (the settings row), while an update that + // is already on disk installs itself on quit. + autoUpdater.autoDownload = false; + autoUpdater.autoInstallOnAppQuit = true; + let availableVersion = ""; + autoUpdater.on("checking-for-update", () => publishUpdaterState({ status: "checking" })); + autoUpdater.on("update-available", (info) => { + availableVersion = info.version; + publishUpdaterState({ status: "available", version: info.version }); + }); + autoUpdater.on("update-not-available", () => publishUpdaterState({ status: "idle" })); + autoUpdater.on("download-progress", (progress) => { + publishUpdaterState({ + status: "downloading", + version: availableVersion || app.getVersion(), + percent: Number.isFinite(progress.percent) ? Math.round(progress.percent) : null + }); + }); + autoUpdater.on("update-downloaded", (info) => publishUpdaterState({ status: "downloaded", version: info.version })); + // Without a listener EventEmitter would rethrow an updater error. + autoUpdater.on("error", (error) => { + publishUpdaterState({ status: "unavailable", reason: updaterFailureReason(error) }); + }); + void requestUpdaterCheck(); +} + +/** + * Renderer "check for updates" intent. A release that was already found is + * what the row's Download action fetches: autoDownload is off, so only this + * second request actually pulls the update down. + */ +async function requestUpdaterCheck(): Promise { + if (!app.isPackaged) { + publishUpdaterState({ status: "unavailable", reason: "dev" }); + return; + } + if (updaterState.status === "downloading" || updaterState.status === "downloaded") return; + try { + if (updaterState.status === "available") await autoUpdater.downloadUpdate(); + else { + publishUpdaterState({ status: "checking" }); + await autoUpdater.checkForUpdates(); + } + } catch (error) { + publishUpdaterState({ status: "unavailable", reason: updaterFailureReason(error) }); + } +} + +/** Renderer "install" intent; only meaningful once a download finished. */ +function installUpdaterUpdate(): void { + if (updaterState.status !== "downloaded") return; + autoUpdater.quitAndInstall(); +} + +function updaterFailureReason(error: unknown): "offline" | "error" { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + const message = error instanceof Error ? error.message : String(error); + return /ENOTFOUND|EAI_AGAIN|ETIMEDOUT|ECONNREFUSED|ENETUNREACH|ERR_INTERNET_DISCONNECTED|getaddrinfo/i + .test(`${code} ${message}`) + ? "offline" + : "error"; +} + if (hasSingleInstanceLock) { void app.whenReady() .then(startApplication) @@ -477,6 +654,14 @@ app.on("before-quit", (event) => { app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); +// Crash diagnostics: a lost GPU or utility child is logged with its reason; the +// window itself recovers through render-process-gone in createWindow. +app.on("child-process-gone", (_event, details) => { + const service = details.serviceName ? `, service=${details.serviceName}` : ""; + console.warn( + `CanvasTTY child process exited: type=${details.type}, reason=${details.reason}, exitCode=${details.exitCode}${service}.` + ); +}); // Keep shared event names in the main bundle so accidental channel drift fails at build time. void IPC.terminalData; diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index f777a05a..46914d38 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -56,6 +56,14 @@ interface Dependencies { requestPluginLauncher(provider: ProviderId): void; requestPluginCanvas(request: PluginCanvasRequest): void; broadcastPluginStorageChange(pluginId: string, key: string, value: unknown): void; + /** + * Self-update actions owned by the main entry point (it holds the updater + * state machine); the IPC layer only forwards renderer intent. + */ + updater: { + check(): Promise; + install(): void; + }; } export function registerIpc({ @@ -76,7 +84,8 @@ export function registerIpc({ closePluginWindows, requestPluginLauncher, requestPluginCanvas, - broadcastPluginStorageChange + broadcastPluginStorageChange, + updater }: Dependencies): void { const pluginBrowserOpenBroker = new PluginBrowserOpenBroker(getMainWindow); const requestPluginBrowserOpen = async (pluginId: string, value: unknown): Promise => { @@ -591,6 +600,12 @@ export function registerIpc({ ipcMain.on(IPC.terminalBounds, (_event, id: string, bounds: SessionBounds) => terminals.setBounds(id, bounds)); ipcMain.handle(IPC.terminalRename, (_event, id: string, title: string) => terminals.rename(id, title)); ipcMain.handle(IPC.terminalDispose, (_event, id: string) => terminals.dispose(id)); + // Fire-and-forget, like the other stream-reporting channels: a malformed + // report is ignored rather than rejecting into the renderer. + ipcMain.on(IPC.terminalSetVisible, (_event, id: unknown, visible: unknown) => { + if (typeof id !== "string" || typeof visible !== "boolean") return; + terminals.setVisible(id, visible); + }); const publishWindowState = (window: BrowserWindow): void => { if (!window.isDestroyed()) window.webContents.send(IPC.windowState, readWindowState(window)); @@ -608,6 +623,15 @@ export function registerIpc({ }); ipcMain.on(IPC.windowClose, (event) => BrowserWindow.fromWebContents(event.sender)?.close()); ipcMain.handle(IPC.windowGetState, (event) => readWindowState(BrowserWindow.fromWebContents(event.sender))); + + ipcMain.handle(IPC.updaterCheck, (event) => { + assertMainRenderer(event, getMainWindow); + return updater.check(); + }); + ipcMain.on(IPC.updaterInstall, (event) => { + assertMainRenderer(event, getMainWindow); + updater.install(); + }); } function isCanvasNavigationPointerBindingInput( diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index 0c83058a..136939d9 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -289,7 +289,8 @@ function createDefaults(systemLocale: string, platform: CanvasNavigationPlatform browserCanvas: null, browserAgentAccess: true, browserShowAgentPresence: true, - browserRestoreTabs: true + browserRestoreTabs: true, + attentionNotifications: true }; } @@ -462,7 +463,10 @@ export function normalizeSettings( : fallback.browserShowAgentPresence, browserRestoreTabs: typeof source.browserRestoreTabs === "boolean" ? source.browserRestoreTabs - : fallback.browserRestoreTabs + : fallback.browserRestoreTabs, + attentionNotifications: typeof source.attentionNotifications === "boolean" + ? source.attentionNotifications + : fallback.attentionNotifications }; } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 62f9da3f..7821ade0 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -76,6 +76,13 @@ export interface ProviderLifecycleSignal { requestId?: string; } +/** + * Why a snapshot reports a failing status, when that reason is not an ordinary + * transition into failure: "restore" re-derived a persisted session's status + * at launch, "user" is the outcome of a launch the user asked for in the UI. + */ +export type FailureOrigin = "restore" | "user"; + type Emit = ( channel: typeof IPC.terminalData | typeof IPC.terminalSession | typeof IPC.terminalRemoved, payload: TerminalDataEvent | SessionEvent | SessionRemovedEvent @@ -88,10 +95,19 @@ export class TerminalManager { private readonly agentBrowser?: AgentBrowserLaunchCoordinator; private readonly agentRuntime?: AgentRuntimeLaunchCoordinator; private readonly spawnPty: typeof pty.spawn; + // Renderer-reported card visibility, keyed by session and holding the + // outputOffset at the moment it was hidden: the last offset the card saw. + // A hidden session's batch queue is always empty (see setVisible/queueOutput), + // so no output can be stranded there. + private readonly hiddenSinceOffset = new Map(); private lifecycleHooksEnabled: boolean; private sessionStore: TerminalSessionStore | null = null; private sessionPersistenceEnabled = false; private suppressPersistence = false; + // Set and cleared around a single synchronous session emit (see emitSession): + // the main process reads it from its emit callback to tell a failure that is + // merely re-derived state from one the user just caused. + private emittingFailureOrigin: FailureOrigin | null = null; constructor( emit: Emit, @@ -148,6 +164,17 @@ export class TerminalManager { return [...this.sessions.values()].map((session) => snapshot(session)); } + /** + * Takes the failure origin of the session event being emitted right now, or + * null for an ordinary snapshot. Only meaningful inside the emit callback: + * the value is one-shot, so one failure can never be announced twice. + */ + consumeFailureOrigin(): FailureOrigin | null { + const origin = this.emittingFailureOrigin; + this.emittingFailureOrigin = null; + return origin; + } + readBuffer(id: string): TerminalBufferSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); @@ -253,8 +280,12 @@ export class TerminalManager { ? createProviderLifecycleParser(session.metadata.provider, session.metadata.cwd) : null; session.metadata.startedAt = Date.now(); + // A restart is a launch the user asked for, so its failure is news even + // though the card already showed "failed" before they clicked. + let failureOrigin: FailureOrigin | null = null; if (launched.failure) { applyLaunchFailure(session.metadata, launched.failure); + failureOrigin = "user"; } else { session.metadata.status = initialSessionStatus(session.metadata.provider); session.metadata.exitCode = null; @@ -263,7 +294,7 @@ export class TerminalManager { const runtimeStatus = this.agentRuntime?.currentStatus(id); if (runtimeStatus) session.metadata.status = runtimeStatus; } - this.emitSession(session.metadata); + this.emitSession(session.metadata, failureOrigin); return snapshot(session); } @@ -348,12 +379,55 @@ export class TerminalManager { } } + /** + * Reports whether the session's card renders live output. A hidden session + * keeps appending to its scrollback and advancing outputOffset, so history + * stays canonical; only the renderer terminalData stream is gated. + */ + setVisible(id: string, visible: boolean): void { + if (typeof id !== "string" || typeof visible !== "boolean") return; + const session = this.sessions.get(id); + if (!session) return; + const hiddenSince = this.hiddenSinceOffset.get(id); + if (visible === (hiddenSince === undefined)) return; + + if (!visible) { + // Visible -> hidden: flush the batch queued while the card was still + // live instead of dropping it. From here on queueOutput stops batching, + // so this is the last batch that can exist while hidden — nothing is + // lost, and nothing is duplicated because the renderer dedups by + // absolute offset. + this.flushOutput(id, session); + this.hiddenSinceOffset.set(id, session.outputOffset); + return; + } + + // Hidden -> visible: replay the retained scrollback ending at the current + // outputOffset. The card drops everything it already wrote (its offset is + // absolute; features/terminal/terminalOutput.ts), so the missed suffix + // arrives — once. + // + // The window is bounded by MAX_SCROLLBACK_CHARS: when the hidden stretch + // was longer than the ring, the buffer no longer reaches back to + // hiddenSince and the head of that stretch is gone for good. There is no + // field on TerminalDataEvent to say so, so the consumer derives the hole + // from the offset arithmetic (the event starts after the offset it already + // wrote) and marks it in the card instead of stitching it as continuous + // output. Never widen the ring to hide this: the truncation must stay + // visible. + this.hiddenSinceOffset.delete(id); + if (hiddenSince === undefined || session.outputOffset === hiddenSince) return; + const data = session.bufferChunks.slice(session.bufferStart).join(""); + if (data.length > 0) this.emit(IPC.terminalData, { id, data, outputOffset: session.outputOffset }); + } + dispose(id: string): void { const session = this.sessions.get(id); if (!session) return; this.flushOutput(id, session); this.sessions.delete(id); + this.hiddenSinceOffset.delete(id); session.agentBrowser?.cleanup(); session.agentRuntime?.cleanup(); if (session.process) { @@ -452,7 +526,11 @@ export class TerminalManager { if (process) this.bindProcess(descriptor.id, session, process); const runtimeStatus = this.agentRuntime?.currentStatus(descriptor.id); if (runtimeStatus) session.metadata.status = runtimeStatus; - this.emitSession(metadata); + // Restoring re-derives a persisted session's status, so a failure here is + // state this launch found (a folder that vanished between runs), not + // something that happened under the user — announcing it every launch + // would notify about the same silent state again and again. + this.emitSession(metadata, metadata.status === "failed" ? "restore" : null); } private persistSessions(): Promise { @@ -470,9 +548,12 @@ export class TerminalManager { }); } - private emitSession(metadata: SessionMetadata): void { + private emitSession(metadata: SessionMetadata, failureOrigin: FailureOrigin | null = null): void { metadata.revision += 1; + this.emittingFailureOrigin = failureOrigin; this.emit(IPC.terminalSession, { session: structuredClone(metadata) }); + // The emit callback is the only legitimate reader and has already run. + this.emittingFailureOrigin = null; } private launchAwaitingSession(id: string, session: ManagedSession): void { @@ -601,6 +682,9 @@ export class TerminalManager { } private queueOutput(id: string, session: ManagedSession, data: string): void { + // The card is hidden: the scrollback already got the chunk in bindProcess, + // so don't accumulate a renderer batch that would be stale by flush time. + if (this.hiddenSinceOffset.has(id)) return; session.pendingOutput.push(data); if (session.outputTimer !== null) return; // Keep a TUI's clear-and-redraw sequence in one renderer update whenever possible. diff --git a/src/preload/index.ts b/src/preload/index.ts index 8610c2a1..8aa109d0 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,7 +22,9 @@ import type { SessionBounds, SessionEvent, SessionRemovedEvent, - TerminalDataEvent + TerminalDataEvent, + UpdaterState, + UpdaterStateEvent } from "../shared/contracts"; import { IPC } from "../shared/contracts"; import { terminalFileDropText } from "../shared/terminalFileDrop"; @@ -33,6 +35,13 @@ function subscribe(channel: string, listener: (event: T) => void): () => void return () => ipcRenderer.removeListener(channel, wrapped); } +// Main pushes the updater state on every transition and on each renderer load, +// so `state()` can answer from this cache instead of asking over IPC. +let latestUpdaterState: UpdaterState = { status: "idle" }; +ipcRenderer.on(IPC.updaterState, (_event: Electron.IpcRendererEvent, payload: UpdaterStateEvent) => { + latestUpdaterState = payload.state; +}); + const api: CanvasTTYApi = { appVersion: () => ipcRenderer.invoke(IPC.appVersion), clipboard: { @@ -170,10 +179,17 @@ const api: CanvasTTYApi = { setBounds: (id: string, bounds: SessionBounds) => ipcRenderer.send(IPC.terminalBounds, id, bounds), rename: (id: string, title: string) => ipcRenderer.invoke(IPC.terminalRename, id, title), dispose: (id: string) => ipcRenderer.invoke(IPC.terminalDispose, id), + setVisible: (id: string, visible: boolean) => ipcRenderer.send(IPC.terminalSetVisible, id, visible), onData: (listener: (event: TerminalDataEvent) => void) => subscribe(IPC.terminalData, listener), onSession: (listener: (event: SessionEvent) => void) => subscribe(IPC.terminalSession, listener), onRemoved: (listener: (event: SessionRemovedEvent) => void) => subscribe(IPC.terminalRemoved, listener) }, + updater: { + state: () => Promise.resolve(latestUpdaterState), + check: () => ipcRenderer.invoke(IPC.updaterCheck), + install: () => ipcRenderer.send(IPC.updaterInstall), + onState: (listener: (event: UpdaterStateEvent) => void) => subscribe(IPC.updaterState, listener) + }, window: { isMacOS: process.platform === "darwin", minimize: () => ipcRenderer.send(IPC.windowMinimize), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cc9bea2b..dcf47de5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -114,7 +114,8 @@ const FALLBACK_SETTINGS: AppSettings = { browserCanvas: null, browserAgentAccess: true, browserShowAgentPresence: true, - browserRestoreTabs: true + browserRestoreTabs: true, + attentionNotifications: true }; const EMPTY_BROWSER_SNAPSHOT: BrowserSnapshot = { diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx index b0dbc8f0..71f73655 100644 --- a/src/renderer/src/features/settings/SettingsPanel.tsx +++ b/src/renderer/src/features/settings/SettingsPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import appManifest from "../../../../../package.json"; import type { AppSettings, BrowserActivityEvent, @@ -28,6 +29,7 @@ import type { RadialLauncherItemId, SessionRowColorMode, ShortcutAction, + UpdaterState, ZoomSensitivity } from "../../../../shared/contracts"; import { @@ -164,6 +166,13 @@ export function SettingsPanel({ const [clearConfirm, setClearConfirm] = useState(false); const [clearingBrowserData, setClearingBrowserData] = useState(false); const [browserDataMessage, setBrowserDataMessage] = useState(null); + const [updaterState, setUpdaterState] = useState({ status: "idle" }); + + useEffect(() => { + const unsubscribe = window.canvasTTY.updater.onState(({ state }) => setUpdaterState(state)); + void window.canvasTTY.updater.state().then(setUpdaterState); + return unsubscribe; + }, []); useEffect(() => { if (!open) { @@ -350,6 +359,17 @@ export function SettingsPanel({ onChange={(value) => void onChange({ locale: value as LocaleId })} /> + + void onChange({ attentionNotifications: value === "on" })} + /> + + { + void window.canvasTTY.updater.check(); + }; + switch (state.status) { + case "checking": + text = t(locale, "checkForUpdates"); + label = t(locale, "checkForUpdates"); + disabled = true; + break; + case "available": + text = `${t(locale, "updateAvailable")} · v${state.version}`; + label = t(locale, "updateDownload"); + break; + case "downloading": + text = `${t(locale, "updateDownloading")}${percent}`; + label = t(locale, "updateDownloading"); + disabled = true; + break; + case "downloaded": + text = `${t(locale, "updateDownloaded")} · v${state.version}`; + label = t(locale, "updateInstall"); + run = () => window.canvasTTY.updater.install(); + break; + case "unavailable": + text = t(locale, "updateUnavailable"); + label = t(locale, "checkForUpdates"); + break; + default: + text = `CanvasTTY v${appManifest.version}`; + label = t(locale, "checkForUpdates"); + } + + return ( +
+ {text} + +
+ ); +} + function ShortcutRow({ label, value, diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 6b27acb7..0422769c 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -429,7 +429,31 @@ const ru = { copyErrorDetails: "Скопировать детали ошибки", failureOutputUnavailable: "Терминал не передал вывод. Код завершения: ", moveHint: "Тяните пустой канвас · Ctrl/Command + скролл масштабирует", - loading: "Готовим рабочее пространство…" + loading: "Готовим рабочее пространство…", + terminalSearch: "Поиск по выводу", + terminalSearchPlaceholder: "Найти в выводе", + terminalSearchNext: "Следующее совпадение", + terminalSearchPrevious: "Предыдущее совпадение", + terminalSearchClose: "Закрыть поиск", + fitCanvas: "Показать всё", + focusWindowHint: "Фокус на соседнее окно", + marqueeSelectionHint: "Shift + тянуть — выделить группу", + attentionNotifications: "Уведомлять о внимании", + attentionNotificationsDescription: "Системное уведомление, когда сессия ждёт ответа или завершилась с ошибкой", + needsAttention: "Требуют внимания", + needsAttentionEmpty: "Ничего не требует внимания", + browserInspect: "Разобрать элемент", + browserInspectTitle: "Наблюдаемые элементы", + browserInspectSend: "Отправить агенту", + browserInspectEmpty: "Элементы не найдены", + browserInspectNoAgent: "Нет активной агентской сессии", + checkForUpdates: "Проверить обновления", + updateAvailable: "Доступно обновление", + updateDownloading: "Скачиваем обновление", + updateDownloaded: "Обновление готово", + updateDownload: "Скачать", + updateInstall: "Установить и перезапустить", + updateUnavailable: "Обновления недоступны" } as const; const en: Record = { @@ -861,7 +885,31 @@ const en: Record = { copyErrorDetails: "Copy error details", failureOutputUnavailable: "The terminal produced no output. Exit code: ", moveHint: "Drag the empty canvas · Ctrl/Command + scroll to zoom", - loading: "Preparing your workspace…" + loading: "Preparing your workspace…", + terminalSearch: "Search output", + terminalSearchPlaceholder: "Find in output", + terminalSearchNext: "Next match", + terminalSearchPrevious: "Previous match", + terminalSearchClose: "Close search", + fitCanvas: "Fit to content", + focusWindowHint: "Focus the neighbouring window", + marqueeSelectionHint: "Shift + drag selects a group", + attentionNotifications: "Notify when attention is needed", + attentionNotificationsDescription: "System notification when a session needs input or fails", + needsAttention: "Needs attention", + needsAttentionEmpty: "Nothing needs attention", + browserInspect: "Inspect element", + browserInspectTitle: "Observed elements", + browserInspectSend: "Send to agent", + browserInspectEmpty: "No elements found", + browserInspectNoAgent: "No active agent session", + checkForUpdates: "Check for updates", + updateAvailable: "Update available", + updateDownloading: "Downloading update", + updateDownloaded: "Update ready", + updateDownload: "Download", + updateInstall: "Install and restart", + updateUnavailable: "Updates unavailable" }; export type TranslationKey = keyof typeof ru; diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 45b4b19f..e7bd537a 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1003,3 +1003,39 @@ button { border: 0; } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; } } + +/* --- Ported v1.2.1 features: attention ring, terminal search, marquee, inspect, updater --- */ +.terminal-card--attention { box-shadow: 0 0 0 3px var(--warning, #d8a24a), var(--shadow-lg); } +.terminal-card--attention:focus-within, .terminal-card--attention.terminal-card--selected { box-shadow: 0 0 0 3px var(--warning, #d8a24a), 0 0 0 6px color-mix(in srgb, var(--primary) 42%, transparent), var(--shadow-lg); } + +.terminal-card__search { position: absolute; z-index: 18; top: calc(var(--card-header-height) + 6px); right: 10px; display: flex; align-items: center; gap: 6px; padding: 5px 6px; border: 1px solid rgba(255,255,255,.14); border-radius: 9px; color: white; background: rgba(28,30,40,.96); box-shadow: var(--shadow-md); } +.terminal-card__search input { width: 190px; height: 26px; padding: 0 7px; border: 1px solid rgba(255,255,255,.16); outline: 0; border-radius: 6px; color: white; background: rgba(15,17,24,.72); font: 700 12px var(--font-mono); } +.terminal-card__search input:focus { border-color: var(--primary); } +.terminal-card__search button { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 6px; color: rgba(255,255,255,.78); background: rgba(255,255,255,.07); cursor: pointer; } +.terminal-card__search button:hover { color: white; background: rgba(255,255,255,.16); } +.terminal-card__search-count { min-width: 46px; color: rgba(255,255,255,.62); font: 750 11px var(--font-mono); text-align: center; } + +.canvas-marquee { position: absolute; z-index: 40; border: 1px solid color-mix(in srgb, var(--primary) 78%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--primary) 18%, transparent); pointer-events: none; } + +.attention-queue { display: grid; gap: 4px; min-width: 224px; padding: 9px 11px; border-radius: 12px; color: white; background: rgba(40,41,52,.9); box-shadow: var(--shadow-sm); pointer-events: auto; } +.attention-queue__title { color: rgba(255,255,255,.66); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; } +.attention-queue__item { display: flex; align-items: center; gap: 7px; width: 100%; padding: 5px 6px; border-radius: 7px; color: white; background: transparent; cursor: pointer; text-align: left; } +.attention-queue__item:hover { background: rgba(255,255,255,.12); } +.attention-queue__item span { overflow: hidden; font-size: 12px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } +.attention-queue__empty { color: rgba(255,255,255,.5); font-size: 11px; font-weight: 700; } + +.browser-inspect { position: absolute; z-index: 24; top: calc(var(--card-header-height) + 6px); right: 10px; width: 296px; max-height: 320px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 6px; padding: 8px; border: 1px solid rgba(255,255,255,.14); border-radius: 10px; color: white; background: rgba(28,30,40,.97); box-shadow: var(--shadow-md); } +.browser-inspect__title { color: rgba(255,255,255,.62); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; } +.browser-inspect__list { display: grid; gap: 3px; overflow-y: auto; } +.browser-inspect__item { display: grid; gap: 2px; padding: 5px 6px; border-radius: 7px; color: white; background: rgba(255,255,255,.05); cursor: pointer; text-align: left; } +.browser-inspect__item[aria-selected="true"] { background: color-mix(in srgb, var(--primary) 42%, transparent); } +.browser-inspect__item strong { overflow: hidden; font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; } +.browser-inspect__item span { color: rgba(255,255,255,.5); font: 700 10px var(--font-mono); } +.browser-inspect__empty { padding: 8px 4px; color: rgba(255,255,255,.5); font-size: 11px; font-weight: 700; } +.browser-inspect__send { height: 28px; border-radius: 7px; color: white; background: var(--primary); cursor: pointer; font-size: 12px; font-weight: 800; } +.browser-inspect__send:disabled { opacity: .5; cursor: not-allowed; } + +.settings-update-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-radius: 9px; background: var(--surface-soft); } +.settings-update-row span { font-size: 12px; font-weight: 750; } +.settings-update-row button { min-height: 28px; padding: 0 10px; border-radius: 7px; color: white; background: var(--primary); cursor: pointer; font-size: 12px; font-weight: 800; } +.settings-update-row button:disabled { opacity: .5; cursor: not-allowed; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 997a06c9..c1f47179 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -224,6 +224,8 @@ export interface AppSettings { browserAgentAccess: boolean; browserShowAgentPresence: boolean; browserRestoreTabs: boolean; + /** Show an OS notification when a session needs approval or fails. */ + attentionNotifications: boolean; } export interface CreateSessionRequest { @@ -888,6 +890,19 @@ export interface LimitsSnapshot { providers: ProviderLimitsSnapshot[]; } +/** Self-update lifecycle; `unavailable` is the honest state for dev and offline runs. */ +export type UpdaterState = + | { status: "idle" } + | { status: "checking" } + | { status: "available"; version: string } + | { status: "downloading"; version: string; percent: number | null } + | { status: "downloaded"; version: string } + | { status: "unavailable"; reason: "dev" | "offline" | "error" }; + +export interface UpdaterStateEvent { + state: UpdaterState; +} + export interface CanvasTTYApi { appVersion(): Promise; clipboard: { @@ -1000,10 +1015,18 @@ export interface CanvasTTYApi { setBounds(id: string, bounds: SessionBounds): void; rename(id: string, title: string): Promise; dispose(id: string): Promise; + /** Report whether the card renders live output; hidden cards keep history but skip streaming. */ + setVisible(id: string, visible: boolean): void; onData(listener: (event: TerminalDataEvent) => void): () => void; onSession(listener: (event: SessionEvent) => void): () => void; onRemoved(listener: (event: SessionRemovedEvent) => void): () => void; }; + updater: { + state(): Promise; + check(): Promise; + install(): void; + onState(listener: (event: UpdaterStateEvent) => void): () => void; + }; window: { isMacOS: boolean; minimize(): void; @@ -1018,6 +1041,10 @@ export const IPC = { clipboardRead: "clipboard:read", clipboardWrite: "clipboard:write", externalOpenUrl: "external:open-url", + terminalSetVisible: "terminal:set-visible", + updaterState: "updater:state", + updaterCheck: "updater:check", + updaterInstall: "updater:install", settingsGet: "settings:get", settingsUpdate: "settings:update", dialogPickDirectory: "dialog:pick-directory", diff --git a/tests/attention-notifications.test.mjs b/tests/attention-notifications.test.mjs new file mode 100644 index 00000000..f85222df --- /dev/null +++ b/tests/attention-notifications.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { SettingsStore } from "../src/main/services/SettingsStore.ts"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { TerminalSessionStore } from "../src/main/services/TerminalSessionStore.ts"; +import { IPC } from "../src/shared/contracts.ts"; + +test("fresh installs enable attention notifications", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-attention-")); + try { + const store = new SettingsStore(dir, "en-US"); + await store.load(); + assert.equal(store.get().attentionNotifications, true); + assert.equal(JSON.parse(await readFile(join(dir, "settings.json"), "utf8")).attentionNotifications, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("an explicit opt-out survives a reload", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-attention-")); + try { + const store = new SettingsStore(dir, "en-US"); + await store.load(); + await store.update({ attentionNotifications: false }); + assert.equal(store.get().attentionNotifications, false); + + const reloaded = new SettingsStore(dir, "en-US"); + await reloaded.load(); + assert.equal(reloaded.get().attentionNotifications, false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a malformed value falls back instead of leaking into settings", async () => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-attention-")); + try { + await writeFile(join(dir, "settings.json"), JSON.stringify({ attentionNotifications: "yes" }), "utf8"); + const store = new SettingsStore(dir, "en-US"); + await store.load(); + assert.equal(store.get().attentionNotifications, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +// Not every failure is the same event. Notification policy is decided in the +// main process, but "was this failure caused by the user or is it state we +// re-derived?" is knowledge only the terminal manager has, so it announces it +// with the snapshot. These tests pin that signal; the policy itself lives in +// src/main/index.ts, which imports electron and has no seam to test directly. + +const PERSISTED_SESSION = { + id: "restored-session", + provider: "codex", + profile: "normal", + title: "Restored · Codex", + titleCustomized: false, + position: { x: 0, y: 0 }, + size: { width: 700, height: 430 } +}; + +/** CLI registry whose availability can flip mid-test, like a CLI removed under a card. */ +function cliRegistry(availability) { + return { + get: (provider) => ({ + state: availability.state, + provider, + executable: "/resolved/codex", + launcher: "native", + environment: {}, + checked: [], + diagnostic: "codex was not found on PATH." + }) + }; +} + +function createManager(t) { + const availability = { state: "available" }; + const announcements = []; + const exits = []; + let manager; + manager = new TerminalManager((channel, payload) => { + if (channel !== IPC.terminalSession) return; + // Read inside the emit, the way the main-process callback does. + announcements.push({ ...payload.session, failureOrigin: manager.consumeFailureOrigin() }); + }, cliRegistry(availability), undefined, undefined, true, () => ({ + pid: 10000, + process: "codex", + kill() {}, + write() {}, + resize() {}, + onData() { return { dispose() {} }; }, + onExit(listener) { exits.push(listener); return { dispose() {} }; } + })); + t.after(() => manager.disposeAll()); + return { manager, announcements, exits, availability }; +} + +async function persistedStore(directory, cwd) { + const store = new TerminalSessionStore(directory); + await store.replace([{ ...PERSISTED_SESSION, cwd }]); + return store; +} + +test("a restored session whose folder vanished announces its failure as restore-derived", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-attention-")); + try { + const store = await persistedStore(dir, join(dir, "deleted-folder")); + const { manager, announcements } = createManager(t); + manager.configureSessionPersistence(store, true); + + await manager.restorePersistedSessions(); + + assert.equal(announcements.length, 1, "a restored session announces once"); + assert.equal(announcements[0].status, "failed"); + assert.equal(announcements[0].failureOrigin, "restore", + "state re-derived at launch must not be announced as a fresh failure again and again"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a restored session whose CLI is gone announces its failure as restore-derived too", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-attention-")); + try { + const store = await persistedStore(dir, process.cwd()); + const { manager, announcements, availability } = createManager(t); + availability.state = "unavailable"; + manager.configureSessionPersistence(store, true); + + await manager.restorePersistedSessions(); + + assert.equal(announcements[0].status, "failed"); + assert.equal(announcements[0].failureOrigin, "restore"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a restored session that still launches announces no failure origin", async (t) => { + const dir = await mkdtemp(join(tmpdir(), "canvastty-attention-")); + try { + const store = await persistedStore(dir, process.cwd()); + const { manager, announcements } = createManager(t); + manager.configureSessionPersistence(store, true); + + await manager.restorePersistedSessions(); + + assert.notEqual(announcements[0].status, "failed"); + assert.equal(announcements[0].failureOrigin, null, + "a launch that worked must not mark a later failure of the same session as restore-derived"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("a failure from a restart the user asked for is announced as user-caused", async (t) => { + const { manager, announcements, exits, availability } = createManager(t); + const { id } = manager.create({ + provider: "codex", + cwd: process.cwd(), + profile: "normal", + position: { x: 0, y: 0 } + }); + + // The session ran and exited non-zero: an ordinary transition into failure, + // which the policy notices by the status change alone. + exits[0]({ exitCode: 1, signal: 0 }); + assert.equal(announcements.at(-1).status, "failed"); + assert.equal(announcements.at(-1).failureOrigin, null, "an exit nobody caused carries no origin"); + + // The CLI is gone by the time the user clicks Restart, so the announcement + // repeats a status the card already showed. + availability.state = "unavailable"; + manager.restart(id); + assert.equal(announcements.at(-1).status, "failed"); + assert.equal(announcements.at(-1).failureOrigin, "user", + "a failure the user just caused is news even though the status did not change"); + assert.equal(manager.consumeFailureOrigin(), null, "the origin does not outlive the emit it belongs to"); +}); diff --git a/tests/repository-security.test.mjs b/tests/repository-security.test.mjs index 472a3e4d..9858b093 100644 --- a/tests/repository-security.test.mjs +++ b/tests/repository-security.test.mjs @@ -1,10 +1,10 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; -import { collectRepositoryIssues } from "../scripts/audit-secrets.mjs"; +import { SECRET_PATTERNS, collectArtifactIssues, collectRepositoryIssues } from "../scripts/audit-secrets.mjs"; test("publishable repository files contain no high-confidence secrets or personal paths", async () => { assert.deepEqual(await collectRepositoryIssues(), []); @@ -33,6 +33,74 @@ test("secret audit still reports personal paths in publishable files", async (t) ]); }); +test("secret audit ignores key prefixes embedded in identifiers", () => { + const patterns = new Map(SECRET_PATTERNS); + const matches = (rule, sample) => [...sample.matchAll(patterns.get(rule))].map((match) => match[0]); + const longTail = "x".repeat(28); + + for (const [rule, [standalonePrefix, ...identifierPrefixes]] of Object.entries({ + "Anthropic token": ["sk-ant-", "disk-ant-", "task-ant-"], + "OpenAI-style token": ["sk-", "disk-", "task-"] + })) { + const token = `${standalonePrefix}${longTail}`; + assert.deepEqual(matches(rule, `${token}\n`), [token], `${rule} must report a standalone token`); + + for (const prefix of identifierPrefixes) { + for (const identifier of ["abc", "def", longTail, `${longTail}-suffix`]) { + const sample = `${prefix}${identifier}`; + assert.deepEqual(matches(rule, `${sample}\n`), [], `${rule} must ignore ${sample}`); + } + } + } +}); + +test("secret audit scans the built bundle in addition to the source tree", async (t) => { + const root = await mkdtemp(join(tmpdir(), "canvastty-secret-audit-")); + t.after(() => rm(root, { recursive: true, force: true })); + + const builtDirectory = join(root, "out", "main"); + await mkdir(builtDirectory, { recursive: true }); + const injectedToken = `sk-ant-${"a".repeat(24)}`; + await writeFile(join(builtDirectory, "index.js"), `const injected = "${injectedToken}";\n`, "utf8"); + + assert.deepEqual(await collectRepositoryIssues(root), []); + assert.deepEqual(await collectArtifactIssues(root), [ + { path: "out/main/index.js", rule: "Anthropic token" }, + { path: "out/main/index.js", rule: "OpenAI-style token" } + ]); +}); + +test("secret audit reports a missing built bundle instead of implying coverage", async (t) => { + const root = await mkdtemp(join(tmpdir(), "canvastty-secret-audit-")); + t.after(() => rm(root, { recursive: true, force: true })); + + await writeFile(join(root, "README.md"), "publishable content\n", "utf8"); + + assert.equal(await collectArtifactIssues(root), null); +}); + +test("secret audit catches Windows personal paths but not system, generic, or relative paths", async (t) => { + const root = await mkdtemp(join(tmpdir(), "canvastty-secret-audit-")); + t.after(() => rm(root, { recursive: true, force: true })); + const notes = join(root, "notes.md"); + + const personalPath = ["C:", "Users", "operator", "project", ""].join("\\"); + await writeFile(notes, `Local path: ${personalPath}\n`, "utf8"); + assert.deepEqual(await collectRepositoryIssues(root), [ + { path: "notes.md", rule: "personal home path" } + ]); + + const nonPersonalPaths = [ + ["C:", "Windows", "System32", "cmd.exe"].join("\\"), + ["C:", "Program Files", "CanvasTTY", "app.asar"].join("\\"), + ["C:", "Users", "runner", "work", ""].join("\\"), + "%USERPROFILE%\\project", + ["src", "main", "index.ts"].join("\\") + ]; + await writeFile(notes, `Samples:\n${nonPersonalPaths.join("\n")}\n`, "utf8"); + assert.deepEqual(await collectRepositoryIssues(root), []); +}); + test("gitignore excludes local credentials, logs, builds, and agent context", async () => { const gitignore = normalizeLineEndings( await readFile(new URL("../.gitignore", import.meta.url), "utf8") diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 5e64c53e..c790ecac 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -101,7 +101,10 @@ test("session metadata revisions advance before lifecycle events cross IPC", asy const source = await readFile(terminalManagerPath, "utf8"); assert.match(source, /revision: 0/); - assert.match(source, /metadata\.revision \+= 1;\s*this\.emit\(IPC\.terminalSession/); + // The revision must advance before the session event is emitted, with only the + // emit-scoped bookkeeping in between; a bounded gap keeps a reordering that + // moves the emit away from the bump failing here. + assert.match(source, /metadata\.revision \+= 1;[\s\S]{0,120}?this\.emit\(IPC\.terminalSession/); }); test("revoking lifecycle hooks makes live agent status unavailable until a restarted session gets a new parser", async () => { diff --git a/tests/terminal-visibility.test.mjs b/tests/terminal-visibility.test.mjs new file mode 100644 index 00000000..5a0e54b4 --- /dev/null +++ b/tests/terminal-visibility.test.mjs @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { TerminalManager } from "../src/main/services/TerminalManager.ts"; +import { IPC } from "../src/shared/contracts.ts"; +import { attachTerminalOutput } from "../src/renderer/src/features/terminal/terminalOutput.ts"; + +const availableRegistry = { + get: (provider) => ({ state: "available", provider, executable: "/resolved/codex", launcher: "native", environment: {}, checked: [] }) +}; + +function createManager(t) { + const emitted = []; + let emitData; + let exit; + const manager = new TerminalManager((channel, event) => { + if (channel === IPC.terminalData) emitted.push(event); + }, availableRegistry, undefined, undefined, true, () => ({ + pid: 10000, process: "codex", kill() {}, write() {}, resize() {}, + onData(listener) { emitData = listener; return { dispose() {} }; }, + onExit(listener) { exit = listener; return { dispose() {} }; } + })); + t.after(() => manager.disposeAll()); + const { id } = manager.create({ provider: "codex", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } }); + const flush = () => manager.flushOutput(id, manager.sessions.get(id)); + return { manager, id, emitted, data: (chunk) => emitData(chunk), exit, flush }; +} + +test("a hidden session keeps history but stops streaming, then replays exactly the missed suffix", (t) => { + const { manager, id, emitted, data, flush } = createManager(t); + + data("visible\r\n"); + flush(); + assert.deepEqual(emitted.map((event) => event.data), ["visible\r\n"]); + const visibleOffset = emitted[0].outputOffset; + assert.equal(visibleOffset, "visible\r\n".length); + + manager.setVisible(id, false); + assert.equal(emitted.length, 1, "hiding must not emit on its own"); + + data("hidden\r\n"); + flush(); + assert.equal(emitted.length, 1, "hidden output must not reach the renderer"); + const snapshot = manager.readBuffer(id); + assert.equal(snapshot.buffer, "visible\r\nhidden\r\n", "history stays canonical while hidden"); + assert.equal(snapshot.outputOffset, visibleOffset + "hidden\r\n".length); + + manager.setVisible(id, true); + assert.equal(emitted.length, 2, "becoming visible replays the current buffer once"); + const replay = emitted[1]; + assert.equal(replay.data, "visible\r\nhidden\r\n"); + assert.equal(replay.outputOffset, snapshot.outputOffset, "the replay carries the current absolute offset"); + // The renderer slices from its own offset, so the event must cover the whole + // hidden stretch and start at or before everything the card already wrote. + assert.ok(replay.outputOffset - replay.data.length <= visibleOffset); +}); + +test("hiding a session flushes the pending batch instead of dropping it", (t) => { + const { manager, id, emitted, data, flush } = createManager(t); + + // queueOutput batches for OUTPUT_BATCH_MS; this chunk is queued and unflushed + // when visibility flips. + data("queued\r\n"); + assert.equal(emitted.length, 0); + + manager.setVisible(id, false); + assert.deepEqual(emitted.map((event) => event.data), ["queued\r\n"], "the pending batch is delivered, not stranded"); + assert.equal(emitted[0].outputOffset, "queued\r\n".length); + + // The timer that would have flushed the same batch must not fire a duplicate. + flush(); + assert.equal(emitted.length, 1); +}); + +test("becoming visible with no new output emits nothing", (t) => { + const { manager, id, emitted, data, flush } = createManager(t); + + data("seen\r\n"); + flush(); + manager.setVisible(id, false); + manager.setVisible(id, true); + assert.equal(emitted.length, 1); + + // Repeated reports are idempotent, and a removal clears the visibility. + manager.setVisible(id, true); + assert.equal(emitted.length, 1); + manager.dispose(id); + manager.setVisible(id, true); + assert.equal(emitted.length, 1, "a disposed session cannot be replayed"); +}); + +test("an exit while hidden does not emit terminalData for the hidden stretch", (t) => { + const { manager, id, emitted, data, exit } = createManager(t); + + manager.setVisible(id, false); + data("last words\r\n"); + exit({ exitCode: 0, signal: 0 }); + assert.equal(emitted.length, 0); + assert.equal(manager.readBuffer(id).buffer, "last words\r\n"); +}); + +test("the real renderer dedup writes the hidden stretch exactly once", async (t) => { + const listeners = new Set(); + let emitData; + const manager = new TerminalManager((channel, event) => { + if (channel === IPC.terminalData) for (const listener of listeners) listener(event); + }, availableRegistry, undefined, undefined, true, () => ({ + pid: 10000, process: "codex", kill() {}, write() {}, resize() {}, + onData(listener) { emitData = listener; return { dispose() {} }; }, + onExit() { return { dispose() {} }; } + })); + t.after(() => manager.disposeAll()); + const { id } = manager.create({ provider: "codex", cwd: process.cwd(), profile: "normal", position: { x: 0, y: 0 } }); + const flush = () => manager.flushOutput(id, manager.sessions.get(id)); + const written = []; + const detach = attachTerminalOutput({ + onData(listener) { listeners.add(listener); return () => listeners.delete(listener); }, + readBuffer() { return Promise.resolve(manager.readBuffer(id)); } + }, id, (chunk) => written.push(chunk), assert.fail); + t.after(detach); + + emitData("first\r\n"); + flush(); + manager.setVisible(id, false); + emitData("hidden one\r\n"); + flush(); + manager.setVisible(id, true); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(written.join(""), "first\r\nhidden one\r\n", "no gap and no duplicate across the hidden window"); +}); From 83b64fe1338b513c04b2663cf740b9c2b14ddca1 Mon Sep 17 00:00:00 2001 From: SHEM Date: Mon, 14 Sep 2026 20:43:11 +0500 Subject: [PATCH 02/35] feat(series): workspace tools Source commits: 9876cf1891d924f271e48c7f019a7144cc469971 50bc6bb22eab07ddaf72a736f84b82b40ff17ab3 68eaaa7b12b1f86c1a4e99a3b0bc13ccc510f401 c014e5a1086378b52366df3dfc5df3dabf96fd7b 7a01529239182318aaf207156f8f8528cbe1dcd8 de5c21003c59082ba80ec9279528faa91ea9f34f --- .../src/features/browser/BrowserCard.tsx | 139 ++++++++++- .../src/features/browser/inspectToAgent.ts | 110 +++++++++ .../src/features/terminal/TerminalCard.tsx | 226 +++++++++++++++++- .../src/features/terminal/terminalOutput.ts | 19 +- .../features/terminal/terminalShortcuts.ts | 9 + .../workspace/CanvasCommandPalette.tsx | 15 +- .../features/workspace/WorkspaceCanvas.tsx | 154 +++++++++++- .../workspace/canvasCameraGeometry.ts | 49 ++++ .../features/workspace/canvasWidgetFocus.ts | 54 +++++ .../workspace/useCanvasPointerNavigation.ts | 190 ++++++++++++++- src/renderer/src/lib/i18n.ts | 4 + src/renderer/src/styles/app.css | 11 +- tests/browser-inspect-agent.test.mjs | 167 +++++++++++++ tests/canvas-camera-geometry.test.mjs | 57 +++++ tests/canvas-widget-focus.test.mjs | 33 +++ tests/terminal-lifecycle.test.mjs | 2 +- tests/terminal-search-overlay.test.mjs | 56 +++++ tests/terminal-visibility.test.mjs | 67 +++++- 18 files changed, 1334 insertions(+), 28 deletions(-) create mode 100644 src/renderer/src/features/browser/inspectToAgent.ts create mode 100644 src/renderer/src/features/workspace/canvasCameraGeometry.ts create mode 100644 tests/browser-inspect-agent.test.mjs create mode 100644 tests/canvas-camera-geometry.test.mjs create mode 100644 tests/terminal-search-overlay.test.mjs diff --git a/src/renderer/src/features/browser/BrowserCard.tsx b/src/renderer/src/features/browser/BrowserCard.tsx index 6c722f22..d035934d 100644 --- a/src/renderer/src/features/browser/BrowserCard.tsx +++ b/src/renderer/src/features/browser/BrowserCard.tsx @@ -4,6 +4,10 @@ import type { BrowserCanvasFreezeFrameEvent, BrowserCanvasState, BrowserDownloadSnapshot, + BrowserElementRef, + BrowserObservation, + BrowserObservedElement, + BrowserResult, BrowserSnapshot, BrowserTabSnapshot, BrowserViewportSurface, @@ -11,7 +15,8 @@ import type { FocusActivation, LocaleId, Point, - SessionBounds + SessionBounds, + SessionSnapshot } from "../../../../shared/contracts"; import { BROWSER_PROVIDER_COLORS } from "../../../../shared/contracts"; import { UiIcon } from "../../components/UiIcon"; @@ -19,11 +24,21 @@ import { t } from "../../lib/i18n"; import { shouldActivateCanvasFromClick } from "../workspace/focus"; import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; import { browserCanvasWidgetId } from "../workspace/canvasWidgetFocus"; +import { + INSPECT_ELEMENT_LIMIT, + inspectAgentLine, + inspectAgentAwaitsApproval, + inspectAgentSessionId, + inspectPayloadFor, + inspectRefIsStale +} from "./inspectToAgent"; interface BrowserCardProps { browser: BrowserSnapshot; bounds: BrowserCanvasState; locale: LocaleId; + /** Agent sessions that can receive an inspected element. */ + sessions: readonly SessionSnapshot[]; zoom: number; camera: CameraState; visible: boolean; @@ -54,7 +69,7 @@ interface ResizeState extends DragState { direction: ResizeDirection; } -type BrowserPanel = "downloads" | "close-all" | null; +type BrowserPanel = "downloads" | "close-all" | "inspect" | null; const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]; @@ -62,6 +77,7 @@ export function BrowserCard({ browser, bounds, locale, + sessions, zoom, camera, visible, @@ -91,6 +107,9 @@ export function BrowserCard({ const activeTab = browser.tabs.find((tab) => tab.id === browser.activeTabId) ?? null; const [address, setAddress] = useState(activeTab?.url ?? ""); const [panel, setPanel] = useState(null); + const [observed, setObserved] = useState(null); + const [observedElement, setObservedElement] = useState(null); + const [inspectError, setInspectError] = useState(null); const [dialogPrompt, setDialogPrompt] = useState(""); const [freezeFrame, setFreezeFrame] = useState(null); const summaryMode = zoom < 0.5; @@ -119,6 +138,12 @@ export function BrowserCard({ const freezeFrameDataUrl = freezeFrame && freezeFrame.tabId === activeTab?.id ? freezeFrame.dataUrl : null; + /** The exact line, CR included, that Send to agent will type into the PTY. */ + const inspectPreview = useMemo(() => ( + observed === null || observedElement === null + ? null + : inspectAgentLine(inspectPayloadFor(observedElement, observed.url)) + ), [observed, observedElement]); useEffect(() => { liveBounds.current = bounds; @@ -330,6 +355,66 @@ export function BrowserCard({ }); }; + /** Observes the page once and lets the user hand a single element to an agent session. */ + const toggleInspect = (): void => { + if (panel === "inspect") { + setPanel(null); + setObserved(null); + setObservedElement(null); + setInspectError(null); + return; + } + setPanel("inspect"); + setObserved(null); + setObservedElement(null); + setInspectError(null); + if (!activeTab) { + setInspectError(t(locale, "browserInspectEmpty")); + return; + } + run(async () => { + try { + const result: BrowserResult = await window.canvasTTY.browser.execute({ + type: "browser_observe", + requestId: crypto.randomUUID(), + tabId: activeTab.id, + limit: INSPECT_ELEMENT_LIMIT + }); + if (!result.ok) throw new Error(result.error?.message ?? t(locale, "browserActionFailed")); + const observation = result.data as BrowserObservation; + setObserved(observation); + setObservedElement(observation.elements[0] ?? null); + } catch (error: unknown) { + setInspectError(error instanceof Error ? error.message : t(locale, "browserActionFailed")); + } + }); + }; + + const sendInspectedElement = (): void => { + if (!observedElement || !observed) return; + const sessionId = inspectAgentSessionId(sessions); + if (sessionId === null) { + setInspectError(t(locale, inspectAgentAwaitsApproval(sessions) + ? "browserInspectAwaitingApproval" + : "browserInspectNoAgent")); + return; + } + const payload = inspectPayloadFor(observedElement, observed.url); + const live = activeTab === null + ? null + : { tabId: activeTab.id, documentRevision: activeTab.documentRevision }; + if (inspectRefIsStale(payload, live)) { + // The page moved on; sending the old node would target the wrong element. + setInspectError(t(locale, "browserActionFailed")); + return; + } + window.canvasTTY.terminal.input(sessionId, inspectAgentLine(payload)); + setPanel(null); + setObserved(null); + setObservedElement(null); + setInspectError(null); + }; + const answerDialog = (accept: boolean): void => { const dialog = browser.pendingDialog; if (!dialog) return; @@ -486,6 +571,16 @@ export function BrowserCard({ /> {showAgentPresence && } + + + ))} + + )} + {inspectPreview !== null && ( + // The page-authored label is part of what the user approves: show the whole line, untruncated. +

{inspectPreview}

+ )} + + + )} + {panel === "close-all" && (
{t(locale, "closeAllTabsQuestion")} diff --git a/src/renderer/src/features/browser/inspectToAgent.ts b/src/renderer/src/features/browser/inspectToAgent.ts new file mode 100644 index 00000000..fec2b1eb --- /dev/null +++ b/src/renderer/src/features/browser/inspectToAgent.ts @@ -0,0 +1,110 @@ +import type { + BrowserElementBounds, + BrowserElementRef, + BrowserObservedElement, + SessionSnapshot +} from "../../../../shared/contracts"; + +/** + * The only bound on observed elements: the panel requests this many and lists + * every element it gets back, so nothing is fetched and then unreachable. + */ +export const INSPECT_ELEMENT_LIMIT = 20; + +export interface InspectPayload { + url: string; + label: string; + ref: BrowserElementRef; + bounds: BrowserElementBounds | null; +} + +export interface InspectLiveTarget { + tabId: string; + documentRevision: number; +} + +export function inspectPayloadFor(element: BrowserObservedElement, url: string): InspectPayload { + return { + url, + label: element.name || element.role || element.ref.ref, + ref: element.ref, + bounds: element.bounds + }; +} + +/** + * Newest session that can actually receive a typed line. Terminals have no + * prompt to submit into, an exited session has nothing listening, and a session + * asking the user for a decision is showing a prompt whose highlighted answer + * the line's carriage return would activate on the user's behalf. + */ +export function inspectAgentSessionId(sessions: readonly SessionSnapshot[]): string | null { + for (let index = sessions.length - 1; index >= 0; index -= 1) { + const session = sessions[index]; + if ( + session.provider !== "terminal" + && session.exitCode === null + && session.status !== "needs_approval" + ) return session.id; + } + return null; +} + +/** + * True when the only reason no session is eligible is that a running agent session + * is waiting for a decision. The panel reports that distinctly from "no session at + * all", because the user has to answer the prompt before a handoff is possible. + */ +export function inspectAgentAwaitsApproval(sessions: readonly SessionSnapshot[]): boolean { + return inspectAgentSessionId(sessions) === null + && sessions.some((session) => session.provider !== "terminal" + && session.exitCode === null + && session.status === "needs_approval"); +} + +/** + * An element reference only stays valid while its tab keeps the observed document + * revision; after an in-page mutation or navigation the node may belong to another element. + */ +export function inspectRefIsStale(payload: InspectPayload, live: InspectLiveTarget | null): boolean { + if (live === null) return true; + return payload.ref.tabId !== live.tabId || payload.ref.documentRevision !== live.documentRevision; +} + +/** + * The same policy the main process applies to agent-facing results + * (safeAgentUrl in src/main/services/browser/BrowserCore.ts): credentials, query + * and fragment never reach an agent, because provider-specific query names carry + * signed URLs, SAML responses and tickets. Anything that is not http(s), or does + * not parse, becomes "" instead of leaking. + */ +export function inspectSafeUrl(value: string): string { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return ""; + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return ""; + } +} + +/** + * One structured line for the agent session. The PTY only submits on a carriage + * return. The line opens with CanvasTTY's own header and then names the + * page-authored label: it is flattened and JSON-quoted, so page text cannot + * close its field, append a forged tail that reads as this header, or start a + * second line. + */ +export function inspectAgentLine(payload: InspectPayload): string { + const bounds = payload.bounds + ? `${Math.round(payload.bounds.x)},${Math.round(payload.bounds.y)} ${Math.round(payload.bounds.width)}x${Math.round(payload.bounds.height)}` + : "unknown"; + const url = inspectSafeUrl(payload.url); + const label = JSON.stringify(payload.label.replace(/[\r\n]+/g, " ").trim()); + const line = `[Browser inspect] ${url === "" ? "url=none" : url} untrustedWebContent=true label=${label} ref=${payload.ref.ref} bounds=${bounds} documentRevision=${payload.ref.documentRevision}`; + return `${line.replace(/[\r\n]+/g, " ").trim()}\r`; +} diff --git a/src/renderer/src/features/terminal/TerminalCard.tsx b/src/renderer/src/features/terminal/TerminalCard.tsx index 198bab93..51b7fee6 100644 --- a/src/renderer/src/features/terminal/TerminalCard.tsx +++ b/src/renderer/src/features/terminal/TerminalCard.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { FitAddon } from "@xterm/addon-fit"; +import { SearchAddon } from "@xterm/addon-search"; import { WebLinksAddon } from "@xterm/addon-web-links"; +import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import { INITIAL_TERMINAL_COLS, @@ -25,6 +27,7 @@ import { shouldPasteTerminalClipboard, shouldRestartExitedTerminal, shouldScrollTerminalPage, + shouldSearchTerminalOutput, shouldSendTerminalLineBreak } from "./terminalShortcuts"; import { fitTerminalPreservingViewport } from "./terminalViewport"; @@ -51,6 +54,8 @@ interface TerminalCardProps { focused: boolean; focusChangeSource: "explicit" | "hover"; selected: boolean; + /** Multi-select group member: gets the selected outline without focus/WebGL side effects. */ + groupSelected?: boolean; renaming: boolean; snapTargets: readonly SessionBounds[]; onActivate(session: SessionSnapshot): void; @@ -77,6 +82,15 @@ const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", " const TERMINAL_FOCUS_IN = "\u001b[I"; const TERMINAL_FOCUS_OUT = "\u001b[O"; +const SEARCH_DECORATIONS = { + matchBackground: "#7b7899", + matchBorder: "#7b7899", + matchOverviewRuler: "#7b7899", + activeMatchBackground: "#9a96c2", + activeMatchBorder: "#9a96c2", + activeMatchColorOverviewRuler: "#9a96c2" +} as const; + export function TerminalCard({ session, locale, @@ -90,6 +104,7 @@ export function TerminalCard({ focused, focusChangeSource, selected, + groupSelected, renaming, snapTargets, onActivate, @@ -124,6 +139,15 @@ export function TerminalCard({ const summaryMode = zoom < 0.5; const summaryScale = summaryMode ? Math.min(2.5, Math.max(1, 0.5 / zoom)) : 1; const terminalBackground = terminalTheme(palette).background; + const searchAddonRef = useRef(null); + const webglAddonRef = useRef(null); + const searchInputRef = useRef(null); + const searchOpenRef = useRef(false); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchMatches, setSearchMatches] = useState<{ current: number; total: number }>({ current: 0, total: 0 }); + // Last OSC 0/2 title the shell reported; display-only, never persisted. + const [oscTitle, setOscTitle] = useState(null); restartAction.current = async () => { if (restarting || !sessionExited.current) return; @@ -158,6 +182,10 @@ export function TerminalCard({ lineHeight: 1.2, scrollback: 5_000, allowTransparency: true, + // Search decorations (highlighting every match and reporting the match + // count) are proposed API in xterm; without this flag findNext throws and + // the counter never leaves 0/0. The flag only unlocks that surface. + allowProposedApi: true, theme: terminalTheme(palette), // OSC 8 hyperlinks are handled by xterm itself rather than WebLinksAddon. // Without an explicit handler, xterm shows its own confirm() prompt and @@ -171,14 +199,18 @@ export function TerminalCard({ } }); const fitAddon = new FitAddon(); + const searchAddon = new SearchAddon(); const webLinksAddon = new WebLinksAddon((event, uri) => { event.preventDefault(); event.stopPropagation(); onOpenUrlRef.current(uri); }); terminal.loadAddon(fitAddon); + terminal.loadAddon(searchAddon); + searchAddonRef.current = searchAddon; terminal.loadAddon(webLinksAddon); terminal.open(host); + setOscTitle(null); let lastReportedGrid = ""; const reportGrid = (cols: number, rows: number): void => { const grid = `${cols}x${rows}`; @@ -194,7 +226,9 @@ export function TerminalCard({ (error) => { console.error("CanvasTTY could not load terminal history.", error); terminal.write(`\r\n[CanvasTTY] ${t(locale, "terminalHistoryFailed")}\r\n`); - } + }, + // Same locale capture as the notice above: this effect is scoped to the session. + (missing) => t(locale, "terminalReplayTrimmed").replace("{count}", String(missing)) ); const fit = (): void => { try { @@ -205,6 +239,18 @@ export function TerminalCard({ } }; terminal.attachCustomKeyEventHandler((event) => { + if (shouldSearchTerminalOutput(event)) { + // Ctrl+Shift+F belongs to the card's scrollback search, never the shell. + event.preventDefault(); + event.stopPropagation(); + if (searchOpenRef.current) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } else { + setSearchOpen(true); + } + return false; + } if (shouldRestartExitedTerminal(event, sessionExited.current)) { event.preventDefault(); event.stopPropagation(); @@ -264,6 +310,13 @@ export function TerminalCard({ if (suppressFocusReport.current && (data === TERMINAL_FOCUS_IN || data === TERMINAL_FOCUS_OUT)) return; window.canvasTTY.terminal.input(session.id, data); }); + const titleChange = terminal.onTitleChange((title) => setOscTitle(title.trim() ? title : null)); + const searchResults = searchAddon.onDidChangeResults(({ resultIndex, resultCount }) => { + setSearchMatches({ + current: resultCount > 0 && resultIndex >= 0 ? resultIndex + 1 : 0, + total: resultCount + }); + }); return () => { cancelAnimationFrame(frame); detachMouseCoordinateAdapter(); @@ -271,6 +324,10 @@ export function TerminalCard({ unsubscribe(); resizeObserver.disconnect(); input.dispose(); + titleChange.dispose(); + searchResults.dispose(); + searchAddonRef.current = null; + webglAddonRef.current = null; resize.dispose(); if (terminalRef.current === terminal) terminalRef.current = null; terminal.dispose(); @@ -282,6 +339,50 @@ export function TerminalCard({ if (terminal) terminal.options.theme = terminalTheme(palette); }, [palette]); + const enableWebgl = (): void => { + const terminal = terminalRef.current; + if (!terminal || webglAddonRef.current) return; + // WebglAddon takes no transparency argument in 0.19.0: it reads the stored + // terminal options, and this terminal is constructed with allowTransparency, + // so cell backgrounds stay transparent and the card's palette background + // keeps showing through the canvas exactly as it does in the DOM renderer. + const webgl = new WebglAddon(); + webgl.onContextLoss(() => { + // GPU context gone: drop the renderer, xterm falls back to the DOM renderer. + webgl.dispose(); + if (webglAddonRef.current === webgl) webglAddonRef.current = null; + }); + try { + terminal.loadAddon(webgl); + webglAddonRef.current = webgl; + } catch { + // WebGL2 unavailable — stay on the DOM renderer. + webgl.dispose(); + } + }; + + const disableWebgl = (): void => { + const webgl = webglAddonRef.current; + if (!webgl) return; + webgl.dispose(); + webglAddonRef.current = null; + }; + + useEffect(() => { + // One WebGL context per card: only the focused/frontmost terminal owns one, + // every other card keeps the DOM renderer. + if (focused && !summaryMode) enableWebgl(); + else disableWebgl(); + }, [focused, summaryMode]); + + useEffect(() => { + // Gate the main-process output stream: in summary mode the card is a cheap + // thumbnail, so the renderer skips terminalData (scrollback stays + // authoritative and the missing suffix is replayed when it turns visible). + window.canvasTTY.terminal.setVisible(session.id, !summaryMode); + return () => window.canvasTTY.terminal.setVisible(session.id, false); + }, [session.id, summaryMode]); + useEffect(() => { const terminal = terminalRef.current; if (!terminal) return; @@ -294,6 +395,23 @@ export function TerminalCard({ suppressFocusReport.current = false; }, [focusChangeSource, focused, renaming, summaryMode]); + useEffect(() => { + searchOpenRef.current = searchOpen; + }, [searchOpen]); + + useEffect(() => { + // The overlay is the only thing receiving keystrokes while it is open. + if (searchOpen) { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + } + }, [searchOpen]); + + useEffect(() => { + // Semantic zoom replaces the surface with a thumbnail and unmounts the overlay. + if (summaryMode) closeSearch(); + }, [summaryMode]); + const bindRenameInput = useCallback((input: HTMLInputElement | null): void => { renameInput.current = input; if (!input) return; @@ -419,15 +537,46 @@ export function TerminalCard({ } }; + const runSearch = (query: string, direction: "next" | "previous", incremental: boolean): void => { + const addon = searchAddonRef.current; + setSearchQuery(query); + if (!addon) return; + if (!query) { + addon.clearDecorations(); + setSearchMatches({ current: 0, total: 0 }); + return; + } + const options = { incremental, decorations: SEARCH_DECORATIONS }; + if (direction === "next") addon.findNext(query, options); + else addon.findPrevious(query, options); + }; + + const closeSearch = (): void => { + setSearchOpen(false); + setSearchQuery(""); + setSearchMatches({ current: 0, total: 0 }); + searchAddonRef.current?.clearDecorations(); + // Hand the keyboard back to the terminal so the next keystrokes reach the PTY. + if (!renaming && !summaryMode) terminalRef.current?.focus(); + }; + + const toggleSearch = (): void => { + if (summaryMode) return; + if (searchOpen) closeSearch(); + else setSearchOpen(true); + }; + + const searchCount = `${searchMatches.current}/${searchMatches.total}`; return (
{ onSelect(session.id); @@ -435,6 +584,14 @@ export function TerminalCard({ terminalRef.current?.focus(); } }} + onKeyDown={(event) => { + // Fallback for focus parked on the card itself; the terminal textarea is + // handled by attachCustomKeyEventHandler, which stops propagation first. + if (summaryMode || !shouldSearchTerminalOutput(event)) return; + event.preventDefault(); + event.stopPropagation(); + toggleSearch(); + }} onClick={activateCard} onDoubleClick={activateCardDouble} onDragOver={(event) => { @@ -500,12 +657,23 @@ export function TerminalCard({ }} /> ) : ( - - {session.titleCustomized ? session.title : compactPath(session.cwd)} + + {session.titleCustomized ? session.title : oscTitle ?? compactPath(session.cwd)} )}
+ {!summaryMode && ( + + )} {session.exitCode !== null && ( + + +
+ )} + @@ -858,6 +988,8 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element