diff --git a/bun.lock b/bun.lock index 68edc00..3ea5d48 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "zcode-app-cli", "dependencies": { - "@earendil-works/pi-tui": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.4", "playwright-core": "1.59.1", }, "devDependencies": { @@ -20,7 +20,7 @@ }, }, "packages": { - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.84.3", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-fS6OEQKEEALnKa6Uw8LcgZZ+9CWck7f3MQSCETQp6leUgIFwMEDtKmOUnL9nsYm+RIPmy7OmplVxYRbV6hiaFg=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.84.4", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-nPUnwDkLtupPXnZQYrCwPFcuTydCDqTY6ZbFqhsL4S4kVq0AT418kPa/6uXwtaCD+MjBNBltb7ScTYX65yeE1w=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], diff --git a/config.example.json b/config.example.json index 89ce546..e6e0ce4 100644 --- a/config.example.json +++ b/config.example.json @@ -102,6 +102,7 @@ "locale": "auto", "theme": "auto", "tuiMode": "regular", + "copyOnSelect": true, "notifications": { "method": "auto", "condition": "unfocused" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f514415..7e5b53b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -361,6 +361,24 @@ Fullscreen mode is restored on normal exit and on handled `SIGINT`, `SIGTERM`, or `SIGHUP` shutdowns. A hard `SIGKILL` cannot be intercepted by any terminal application. +### Copy on select + +Releasing a mouse selection in fullscreen mode copies the selected text to the +system clipboard. Set `ui.copyOnSelect` to `false` to keep copying manual: +drags then only highlight, and the terminal's native selection (hold Shift or +the modifier your emulator documents while dragging) still works. The setting +only affects fullscreen mode; regular scrollback mode has no mouse selection. +The same toggle is available in `/settings` under **Fullscreen copy on +select**. + +```json +{ + "ui": { + "copyOnSelect": false + } +} +``` + ## Theme Set `ui.theme` to `"auto"` (terminal detection), `"dark"`, or `"light"` in the diff --git a/package.json b/package.json index 7dce3d8..1f3f162 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "provenance": true }, "dependencies": { - "@earendil-works/pi-tui": "^0.84.3", + "@earendil-works/pi-tui": "^0.84.4", "playwright-core": "1.59.1" }, "devDependencies": { diff --git a/packages/zcode-tui/package.json b/packages/zcode-tui/package.json index e168c40..5fe5977 100644 --- a/packages/zcode-tui/package.json +++ b/packages/zcode-tui/package.json @@ -7,7 +7,7 @@ ".": "./dist/index.js" }, "dependencies": { - "@earendil-works/pi-tui": "^0.84.3" + "@earendil-works/pi-tui": "^0.84.4" }, "license": "MIT", "private": true diff --git a/packages/zcode-tui/src/copy-on-select.ts b/packages/zcode-tui/src/copy-on-select.ts new file mode 100644 index 0000000..1647338 --- /dev/null +++ b/packages/zcode-tui/src/copy-on-select.ts @@ -0,0 +1,30 @@ +import { readUserConfig, updateUserConfig } from "../../../src/model-access.ts"; + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function configuredCopyOnSelect(config: unknown): boolean | undefined { + const value = record(record(config)?.ui)?.copyOnSelect; + return typeof value === "boolean" ? value : undefined; +} + +export async function readCopyOnSelect( + env: NodeJS.ProcessEnv = process.env +): Promise { + const config = await readUserConfig(env); + return configuredCopyOnSelect(config) ?? true; +} + +export async function writeCopyOnSelect( + enabled: boolean, + env: NodeJS.ProcessEnv = process.env +): Promise { + return await updateUserConfig((config) => { + const ui = record(config.ui) ?? {}; + ui.copyOnSelect = enabled; + config.ui = ui; + }, env); +} diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 5f830a9..09dbd36 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -139,6 +139,10 @@ import { resolveTuiMode, writeTuiMode } from "./tui-mode.ts"; +import { + readCopyOnSelect, + writeCopyOnSelect +} from "./copy-on-select.ts"; import { effortPicker, explicitModelRequest, @@ -585,6 +589,7 @@ class ZCodeTui { private mode: Mode; private model: string; private tuiMode: TuiMode; + private copyOnSelect = true; private thoughtLevel?: string; private modelOptions: unknown[]; private effortOptions: unknown[]; @@ -723,6 +728,13 @@ class ZCodeTui { } catch (error) { notificationConfigError = error instanceof Error ? error.message : String(error); } + // The constructor built the TUI before user config was readable; reconcile + // copy-on-select here so a tuiMode rebuild below also carries the value. + try { + this.setCopyOnSelect(await readCopyOnSelect()); + } catch { + // Config unreadable — keep the constructor's default (enabled). + } // Resolve the effective TUI mode from env > options > config. The vendor // runtime does not forward initialTuiMode, so read the persisted config // here and rebuild the TUI instance before start() when it differs from @@ -838,6 +850,16 @@ class ZCodeTui { } } + /** The live fullscreen alt screen, when the active TUI is the fullscreen one. */ + private get fullscreenAltScreen(): ZCodeAltScreen | undefined { + return this.ui instanceof ZCodeAltScreen ? this.ui : undefined; + } + + private setCopyOnSelect(enabled: boolean): void { + this.copyOnSelect = enabled; + this.fullscreenAltScreen?.setCopyOnSelect(enabled); + } + private createTui(mode: TuiMode): TUI { let fullscreenTui: ZCodeAltScreen | undefined; const terminal = new NotifyingProcessTerminal((data) => { @@ -857,6 +879,10 @@ class ZCodeTui { return false; } }, + // pi-tui copies the selection on mouse release by default; ui.copyOnSelect + // opts out so a drag only highlights and copying stays manual (/copy, + // native terminal selection). + copyOnSelect: this.copyOnSelect, mouse: true, wheelScrollLines: 3 }); @@ -1216,7 +1242,7 @@ class ZCodeTui { } for (const command of [ { name: "cls", description: "Clear the visible transcript (the runtime's /clear starts a new session)" }, - { name: "copy", description: "Copy the latest assistant response" }, + { name: "copy", description: "Copy the active fullscreen selection or latest assistant response" }, { name: "paste-image", description: "Attach an image from the system clipboard" }, { name: "attachments", description: "Manage or clear pending attachments", argumentHint: "[clear]" }, { name: "activity", description: "Inspect every active tool and open task" }, @@ -1429,7 +1455,7 @@ class ZCodeTui { return; } if (input === "/copy") { - await this.copyLastResponse(); + await this.copySelectionOrLastResponse(); return; } if (input === "/paste-image") { @@ -3715,6 +3741,11 @@ class ZCodeTui { description: tuiModeOverride === "fullscreen" || tuiModeOverride === "regular" ? `Current: ${this.tuiMode === "fullscreen" ? "Fullscreen" : "Regular"} (environment override)` : `Current: ${this.tuiMode === "fullscreen" ? "Fullscreen" : "Regular"}` + }, + { + value: "copy-on-select", + label: "Fullscreen copy on select", + description: `Current: ${this.copyOnSelect ? "Enabled" : "Disabled"}` } ], selectedIndex: selectedSettingIndex @@ -3765,6 +3796,46 @@ class ZCodeTui { continue; } + if (setting.value === "copy-on-select") { + selectedSettingIndex = 4; + const selected = await this.showChoice({ + title: "Fullscreen copy on select", + prompt: "Only applies to fullscreen mode. Releasing a mouse selection copies it to the clipboard.", + help: "Up/Down choose · Enter save · Esc back", + items: [ + { + value: "enabled", + label: "Enabled", + description: "Release copies the selection (default)" + }, + { + value: "disabled", + label: "Disabled", + description: "Selection only highlights; copy manually via /copy or the terminal" + } + ], + selectedIndex: this.copyOnSelect ? 0 : 1 + }); + if (!selected) { + feedback = "No changes · Esc closes settings"; + continue; + } + const next = selected.value === "enabled"; + if (next === this.copyOnSelect) { + feedback = "Copy on select unchanged"; + continue; + } + try { + await writeCopyOnSelect(next); + this.setCopyOnSelect(next); + feedback = `Copy on select ${next ? "enabled" : "disabled"} · saved`; + } catch (error) { + this.addNotice(error instanceof Error ? error.message : String(error), "error"); + feedback = "Could not save the setting · select it to retry"; + } + continue; + } + selectedSettingIndex = setting.value === "notification-condition" ? 2 : setting.value === "notification-method" ? 1 : 0; let next = stored; let changedLabel: string; @@ -4844,7 +4915,13 @@ class ZCodeTui { } } - private async copyLastResponse(): Promise { + private async copySelectionOrLastResponse(): Promise { + const fullscreen = this.fullscreenAltScreen; + if (fullscreen?.hasActiveSelection()) { + await fullscreen.copyActiveSelectionToClipboard(); + return; + } + const text = this.transcript.selectedText() ?? this.lastAssistantText; if (!text) { this.addNotice("There is no assistant response to copy.", "muted"); diff --git a/scripts/smoke-tui-fullscreen-layout.ts b/scripts/smoke-tui-fullscreen-layout.ts index 170a1c2..1739b65 100644 --- a/scripts/smoke-tui-fullscreen-layout.ts +++ b/scripts/smoke-tui-fullscreen-layout.ts @@ -1,145 +1,184 @@ #!/usr/bin/env bun -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; const root = join(import.meta.dir, ".."); const fixture = join(root, "test", "fixtures", "tui-fullscreen-layout.ts"); -const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-layout-")); -const clipboardPath = join(temporaryHome, "clipboard.txt"); -const decoder = new TextDecoder(); -let output = ""; -const terminal = new Bun.Terminal({ - cols: 80, - rows: 24, - name: "xterm-256color", - data(_terminal, data) { - output += decoder.decode(data, { stream: true }); + +async function runPhase(options: { copyOnSelect: boolean }): Promise { + const temporaryHome = await mkdtemp(join(tmpdir(), "zcode-tui-layout-")); + const clipboardPath = join(temporaryHome, "clipboard.txt"); + const decoder = new TextDecoder(); + // ui.copyOnSelect defaults to enabled; seed the user config to exercise the opt-out. + if (!options.copyOnSelect) { + const configDirectory = join(temporaryHome, ".zcode", "cli"); + await mkdir(configDirectory, { recursive: true, mode: 0o700 }); + await writeFile( + join(configDirectory, "config.json"), + `${JSON.stringify({ ui: { copyOnSelect: false } })}\n` + ); } -}); -const child = Bun.spawn([process.execPath, fixture], { - cwd: root, - env: { - ...process.env, - CI: "1", - HOME: temporaryHome, - USERPROFILE: temporaryHome, - TERM: "xterm-256color", - ZCODE_TUI_MODE: "fullscreen", - ZCODE_TUI_NOTIFICATION_METHOD: "off", - ZCODE_TUI_TEST_CLIPBOARD_PATH: clipboardPath - }, - terminal -}); + let output = ""; + const terminal = new Bun.Terminal({ + cols: 80, + rows: 24, + name: "xterm-256color", + data(_terminal, data) { + output += decoder.decode(data, { stream: true }); + } + }); -function plain(value: string): string { - return value - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\x1b_p[^\x07]*\x07/g, "") - .replace(/\r/g, ""); -} + const child = Bun.spawn([process.execPath, fixture], { + cwd: root, + env: { + ...process.env, + CI: "1", + HOME: temporaryHome, + USERPROFILE: temporaryHome, + TERM: "xterm-256color", + ZCODE_TUI_MODE: "fullscreen", + ZCODE_TUI_NOTIFICATION_METHOD: "off", + ZCODE_TUI_TEST_CLIPBOARD_PATH: clipboardPath + }, + terminal + }); -async function waitFor(pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { - const startedAt = Date.now(); - while (!pattern.test(plain(output.slice(start))) && child.exitCode === null && Date.now() - startedAt < timeoutMs) { - await Bun.sleep(20); - } - if (!pattern.test(plain(output.slice(start)))) { - throw new Error(`Timed out waiting for ${pattern}.\n${plain(output).slice(-4_000)}`); + function plain(value: string): string { + return value + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1bP[^\x07]*(?:\x07|\x1b\\)/g, "") + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") + .replace(/\x1b_p[^\x07]*\x07/g, "") + .replace(/\r/g, ""); } -} - -function screenRows(): string[] { - const rows: string[] = []; - const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); - for (const match of writes) rows[Number(match[1]) - 1] = plain(match[2] ?? "").trimEnd(); - return rows; -} -const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); -let failure: unknown; -try { - await waitFor(/alpha\/model/i); - const startupRows = screenRows(); - if (startupRows.some((row) => row.includes("SYSTEM INITIATED"))) { - throw new Error(`Fullscreen header used the wide banner unexpectedly.\n${startupRows.join("\n")}`); - } - if (!startupRows.some((row) => /^── ◆ ZCODE/u.test(row))) { - throw new Error(`Fullscreen header rail did not render its separator.\n${startupRows.join("\n")}`); - } - if (!startupRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { - throw new Error(`Fullscreen welcome card did not render its frame.\n${startupRows.join("\n")}`); - } - if (!startupRows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Fullscreen welcome surface was not rendered.\n${startupRows.join("\n")}`); - } - const copyRow = startupRows.findIndex((row) => row.includes("Ask a task about this workspace")); - const copyColumn = startupRows[copyRow]?.indexOf("Ask") ?? -1; - if (copyRow < 0 || copyColumn < 0) { - throw new Error(`Could not locate fullscreen text for mouse-copy verification.\n${startupRows.join("\n")}`); + async function waitFor(pattern: RegExp, start = 0, timeoutMs = 8_000): Promise { + const startedAt = Date.now(); + while (!pattern.test(plain(output.slice(start))) && child.exitCode === null && Date.now() - startedAt < timeoutMs) { + await Bun.sleep(20); + } + if (!pattern.test(plain(output.slice(start)))) { + throw new Error(`Timed out waiting for ${pattern}.\n${plain(output).slice(-4_000)}`); + } } - terminal.write(`\x1b[<0;${copyColumn + 1};${copyRow + 1}M`); - terminal.write(`\x1b[<32;${copyColumn + 4};${copyRow + 1}M`); - terminal.write(`\x1b[<0;${copyColumn + 4};${copyRow + 1}m`); - await waitFor(/Copied!/i); - const copiedText = await Bun.file(clipboardPath).text(); - if (!copiedText.startsWith("Ask")) { - throw new Error(`Fullscreen selection did not reach the system clipboard writer: ${JSON.stringify(copiedText)}`); - } - const turnStart = output.length; - terminal.write("long transcript\r"); - await waitFor(/transcript line 80/i, turnStart); - const beforeRows = screenRows(); - terminal.write("\x1b[5~"); - await Bun.sleep(100); - const rows = screenRows(); - const firstTranscript = (lines: string[]): number => { - const line = lines.find((value) => /^ transcript line \d+$/u.test(value)); - return line ? Number(line.match(/\d+/u)?.[0] ?? 0) : 0; - }; - const firstTranscriptRow = rows.findIndex((row) => /^ transcript line \d+$/u.test(row)); - if (firstTranscriptRow < 1 || firstTranscriptRow > 3) { - throw new Error(`Fullscreen header consumed too much space. transcriptRow=${firstTranscriptRow}\n${rows.join("\n")}`); - } - if (!rows.some((row) => row.includes("◆ ZCODE"))) { - throw new Error(`Fullscreen context rail was not rendered after the first turn.\n${rows.join("\n")}`); - } - if (rows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Fullscreen welcome surface did not collapse after the first turn.\n${rows.join("\n")}`); - } - if (firstTranscript(rows) >= firstTranscript(beforeRows)) { - throw new Error(`Fullscreen transcript did not scroll independently. before=${firstTranscript(beforeRows)} after=${firstTranscript(rows)}`); - } - const statusRow = rows.findIndex((row) => row.includes("◈ alpha/model")); - if (statusRow < 0 || statusRow < 18) { - throw new Error(`Fullscreen composer was not fixed near the bottom. statusRow=${statusRow}\n${rows.join("\n")}`); - } - const resetStart = output.length; - terminal.write("/cls\r"); - await waitFor(/Ask a task about this workspace/i, resetStart); - const resetRows = screenRows(); - if (!resetRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { - throw new Error(`Fullscreen /cls did not restore the welcome frame.\n${resetRows.join("\n")}`); + + function screenRows(): string[] { + const rows: string[] = []; + const writes = output.matchAll(/\x1b\[(\d+);1H\x1b\[2K([\s\S]*?)(?=\x1b\[\d+;1H\x1b\[2K|$)/g); + for (const match of writes) rows[Number(match[1]) - 1] = plain(match[2] ?? "").trimEnd(); + return rows; } - if (!resetRows.some((row) => row.includes("Ask a task about this workspace"))) { - throw new Error(`Fullscreen /cls did not restore the welcome content.\n${resetRows.join("\n")}`); + + const timeout = setTimeout(() => child.kill("SIGKILL"), 20_000); + let failure: unknown; + try { + await waitFor(/alpha\/model/i); + const startupRows = screenRows(); + if (startupRows.some((row) => row.includes("SYSTEM INITIATED"))) { + throw new Error(`Fullscreen header used the wide banner unexpectedly.\n${startupRows.join("\n")}`); + } + if (!startupRows.some((row) => /^── ◆ ZCODE/u.test(row))) { + throw new Error(`Fullscreen header rail did not render its separator.\n${startupRows.join("\n")}`); + } + if (!startupRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { + throw new Error(`Fullscreen welcome card did not render its frame.\n${startupRows.join("\n")}`); + } + if (!startupRows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Fullscreen welcome surface was not rendered.\n${startupRows.join("\n")}`); + } + const copyRow = startupRows.findIndex((row) => row.includes("Ask a task about this workspace")); + const copyColumn = startupRows[copyRow]?.indexOf("Ask") ?? -1; + if (copyRow < 0 || copyColumn < 0) { + throw new Error(`Could not locate fullscreen text for mouse-copy verification.\n${startupRows.join("\n")}`); + } + terminal.write(`\x1b[<0;${copyColumn + 1};${copyRow + 1}M`); + terminal.write(`\x1b[<32;${copyColumn + 4};${copyRow + 1}M`); + terminal.write(`\x1b[<0;${copyColumn + 4};${copyRow + 1}m`); + if (options.copyOnSelect) { + await waitFor(/Copied!/i); + const copiedText = await Bun.file(clipboardPath).text(); + if (!copiedText.startsWith("Ask")) { + throw new Error(`Fullscreen selection did not reach the system clipboard writer: ${JSON.stringify(copiedText)}`); + } + } else { + // Copy-on-select is disabled: a drag must highlight only and leave the + // system clipboard untouched for the user to copy manually. + await Bun.sleep(250); + if (/Copied!/i.test(plain(output))) { + throw new Error("Fullscreen selection flashed a clipboard copy despite ui.copyOnSelect being false."); + } + if (await Bun.file(clipboardPath).exists()) { + throw new Error(`Fullscreen selection reached the system clipboard writer: ${JSON.stringify(await Bun.file(clipboardPath).text())}`); + } + const manualCopyStart = output.length; + terminal.write("/copy\r"); + await waitFor(/Copied!/i, manualCopyStart); + const copiedText = await Bun.file(clipboardPath).text(); + if (!copiedText.startsWith("Ask")) { + throw new Error(`Fullscreen /copy did not copy the active mouse selection: ${JSON.stringify(copiedText)}`); + } + } + if (options.copyOnSelect) { + const turnStart = output.length; + terminal.write("long transcript\r"); + await waitFor(/transcript line 80/i, turnStart); + const beforeRows = screenRows(); + terminal.write("\x1b[5~"); + await Bun.sleep(100); + const rows = screenRows(); + const firstTranscript = (lines: string[]): number => { + const line = lines.find((value) => /^ transcript line \d+$/u.test(value)); + return line ? Number(line.match(/\d+/u)?.[0] ?? 0) : 0; + }; + const firstTranscriptRow = rows.findIndex((row) => /^ transcript line \d+$/u.test(row)); + if (firstTranscriptRow < 1 || firstTranscriptRow > 3) { + throw new Error(`Fullscreen header consumed too much space. transcriptRow=${firstTranscriptRow}\n${rows.join("\n")}`); + } + if (!rows.some((row) => row.includes("◆ ZCODE"))) { + throw new Error(`Fullscreen context rail was not rendered after the first turn.\n${rows.join("\n")}`); + } + if (rows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Fullscreen welcome surface did not collapse after the first turn.\n${rows.join("\n")}`); + } + if (firstTranscript(rows) >= firstTranscript(beforeRows)) { + throw new Error(`Fullscreen transcript did not scroll independently. before=${firstTranscript(beforeRows)} after=${firstTranscript(rows)}`); + } + const statusRow = rows.findIndex((row) => row.includes("◈ alpha/model")); + if (statusRow < 0 || statusRow < 18) { + throw new Error(`Fullscreen composer was not fixed near the bottom. statusRow=${statusRow}\n${rows.join("\n")}`); + } + const resetStart = output.length; + terminal.write("/cls\r"); + await waitFor(/Ask a task about this workspace/i, resetStart); + const resetRows = screenRows(); + if (!resetRows.some((row) => /^╭─ Workspace .*─╮$/u.test(row))) { + throw new Error(`Fullscreen /cls did not restore the welcome frame.\n${resetRows.join("\n")}`); + } + if (!resetRows.some((row) => row.includes("Ask a task about this workspace"))) { + throw new Error(`Fullscreen /cls did not restore the welcome content.\n${resetRows.join("\n")}`); + } + } + terminal.write("\x03"); + await Bun.sleep(40); + terminal.write("\x03"); + } catch (error) { + failure = error; + child.kill("SIGKILL"); } - terminal.write("\x03"); - await Bun.sleep(40); - terminal.write("\x03"); -} catch (error) { - failure = error; - child.kill("SIGKILL"); + + const code = await child.exited; + clearTimeout(timeout); + if (!terminal.closed) terminal.close(); + await rm(temporaryHome, { recursive: true, force: true }); + if (failure) throw failure; + if (code !== 0) throw new Error(`Fullscreen layout smoke exited with ${code}.`); + console.log(options.copyOnSelect + ? "Fullscreen fixed-composer layout smoke passed." + : "Fullscreen copy-on-select opt-out smoke passed."); } -const code = await child.exited; -clearTimeout(timeout); -if (!terminal.closed) terminal.close(); -await rm(temporaryHome, { recursive: true, force: true }); -if (failure) throw failure; -if (code !== 0) throw new Error(`Fullscreen layout smoke exited with ${code}.`); -console.log("Fullscreen fixed-composer layout smoke passed."); +await runPhase({ copyOnSelect: true }); +await runPhase({ copyOnSelect: false }); diff --git a/test/config-template.test.ts b/test/config-template.test.ts index c4cad05..2d1565c 100644 --- a/test/config-template.test.ts +++ b/test/config-template.test.ts @@ -24,6 +24,7 @@ interface ConfigTemplate { }; ui: { theme: string; + copyOnSelect: boolean; notifications: { method: string; condition: string; @@ -47,6 +48,7 @@ test("custom-provider config template is internally consistent", async () => { expect(config.modelStream.idleTimeoutMs).toBe(60_000); expect(config.subagents.autoBackgroundMs).toBe(1_000); expect(config.ui.theme).toBe("auto"); + expect(config.ui.copyOnSelect).toBe(true); expect(config.ui.notifications).toEqual({ method: "auto", condition: "unfocused" }); // The runtime's user-config model schema is strict: only main/lite are accepted // (model.available is a runtime-internal key injected by the app host, not user config). diff --git a/test/copy-on-select.test.ts b/test/copy-on-select.test.ts new file mode 100644 index 0000000..feb6a56 --- /dev/null +++ b/test/copy-on-select.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + readCopyOnSelect, + writeCopyOnSelect +} from "../packages/zcode-tui/src/copy-on-select.ts"; + +describe("fullscreen copy on select", () => { + test("defaults to enabled and ignores unsupported values", async () => { + const home = await mkdtemp(join(tmpdir(), "zcode-copy-on-select-default-")); + const env = { HOME: home, USERPROFILE: home }; + try { + expect(await readCopyOnSelect(env)).toBe(true); + await Bun.write( + join(home, ".zcode", "cli", "config.json"), + JSON.stringify({ ui: { copyOnSelect: "disabled" } }) + ); + expect(await readCopyOnSelect(env)).toBe(true); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + + test("reads and writes the setting without replacing other config", async () => { + const home = await mkdtemp(join(tmpdir(), "zcode-copy-on-select-test-")); + const configDirectory = join(home, ".zcode", "cli"); + const configPath = join(configDirectory, "config.json"); + const env = { HOME: home, USERPROFILE: home }; + try { + await mkdir(configDirectory, { recursive: true }); + await Bun.write(configPath, JSON.stringify({ + model: { main: "zai/glm-5.2" }, + ui: { tuiMode: "fullscreen", copyOnSelect: false } + })); + + expect(await readCopyOnSelect(env)).toBe(false); + await writeCopyOnSelect(true, env); + expect(await Bun.file(configPath).json()).toEqual({ + model: { main: "zai/glm-5.2" }, + ui: { tuiMode: "fullscreen", copyOnSelect: true } + }); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); +});