Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"locale": "auto",
"theme": "auto",
"tuiMode": "regular",
"copyOnSelect": true,
"notifications": {
"method": "auto",
"condition": "unfocused"
Expand Down
18 changes: 18 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/zcode-tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions packages/zcode-tui/src/copy-on-select.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { readUserConfig, updateUserConfig } from "../../../src/model-access.ts";

function record(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: 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<boolean> {
const config = await readUserConfig(env);
return configuredCopyOnSelect(config) ?? true;
}

export async function writeCopyOnSelect(
enabled: boolean,
env: NodeJS.ProcessEnv = process.env
): Promise<string> {
return await updateUserConfig((config) => {
const ui = record(config.ui) ?? {};
ui.copyOnSelect = enabled;
config.ui = ui;
}, env);
}
83 changes: 80 additions & 3 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ import {
resolveTuiMode,
writeTuiMode
} from "./tui-mode.ts";
import {
readCopyOnSelect,
writeCopyOnSelect
} from "./copy-on-select.ts";
import {
effortPicker,
explicitModelRequest,
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
});
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -1429,7 +1455,7 @@ class ZCodeTui {
return;
}
if (input === "/copy") {
await this.copyLastResponse();
await this.copySelectionOrLastResponse();
return;
}
if (input === "/paste-image") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4844,7 +4915,13 @@ class ZCodeTui {
}
}

private async copyLastResponse(): Promise<void> {
private async copySelectionOrLastResponse(): Promise<void> {
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");
Expand Down
Loading