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
1 change: 1 addition & 0 deletions docs/UI_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ This contract preserves the approved MVP concept and prevents feature ownership
- In semantic summary mode, clicking a card still selects it with a visible outline and camera focus follows only the configured Off/Single click/Double click mode. Renderer, xterm, and plugin cards follow the same wheel focus matrix as normal scale: only a focused input-bearing card can keep wheel in Off or released-Key mode; On, an active Key binding, or the held full navigation override gives it to the canvas. A Browser summary or other non-native placeholder is always canvas-owned for wheel/pinch.
- Every terminal edge and corner is a resize target. The minimum card size is `420 × 260`; resizing updates the xterm viewport and preserves the opposite edge.
- Live terminal selection follows the visible pointer position at every canvas zoom. Copy and paste use the selected preset's editable terminal bindings: Linux defaults to `Ctrl+C`/`Ctrl+Shift+V`, Windows to `Ctrl+Shift+C`/`Ctrl+Shift+V`, and macOS to `Cmd+C`/`Cmd+V`. Plain `Ctrl+C` without a selection retains the PTY interrupt. Presets use Enter to submit and Shift+Enter for a newline in the optional Codex editor; other CLIs retain their editor policies.
- Controls includes an off-by-default **Copy on selection** setting, persisted and applied immediately to every agent and terminal, including fullscreen cards. Completing a non-empty mouse selection copies its text to the system clipboard without clearing the highlight; search results and cleared selections do not copy. With the setting enabled, Shift forces text selection in mouse-reporting TUIs on all desktop platforms; on macOS the terminal mouse adapter translates it to xterm's Option selection without taking the canvas's Option navigation binding.
- Canvas plugin apps use the same movable card grammar, `54px` header, resize/snap behavior, and semantic summary below `0.5×`. A `window` contribution opens a separate CanvasTTY-owned sandboxed window; arbitrary native window embedding is not part of the contract.
- The built-in Browser is one movable, resizable core canvas card, not a plugin contribution. It uses the same `54px` outer window header as other canvas cards, with its identity and hide-card action isolated from the internal tab strip below. Its trusted DOM chrome owns compact tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs, and explicit Close tab/Close all. Hiding the card keeps its tabs and shared authenticated Chromium profile alive. Below `0.5×`, during Edit HOME, behind trusted dialogs/popovers, and while overlapped by a higher renderer-owned canvas window, the native page is hidden and replaced with a stable semantic surface. During card or camera motion it remains live and follows frame-coalesced viewport geometry.
- An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself.
Expand Down
1 change: 1 addition & 0 deletions docs/UI_CONTRACT.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- В semantic summary нажатие по карточке по-прежнему выбирает её с видимой рамкой, а camera focus подчиняется только Off/Single click/Double click. Renderer-, xterm- и plugin-карточки используют ту же wheel focus-матрицу, что и при нормальном масштабе: только focused input-карточка может оставить wheel себе в Off или при отпущенном Key; On, активный Key binding или полный navigation override передаёт его канвасу. Browser summary и другие non-native placeholder-поверхности всегда передают wheel/pinch канвасу.
- Любая грань/угол terminal — resize target. Минимальный размер `420 × 260`; resize обновляет viewport xterm и сохраняет противоположную грань.
- Selection живого терминала следует за видимой позицией указателя при любом canvas zoom. При непустом выделении `Ctrl+C`/`Ctrl+Shift+C` или `Cmd+C` копирует; `Ctrl+Shift+V`/`Cmd+V` и `Shift+Insert` вставляют из system clipboard. Обычный `Ctrl+C` без выделения остаётся PTY interrupt. `Shift+Enter` отправляет line-break sequence (`ESC [ 13 ; 2 u`) в PTY вместо отправки строки.
- В «Управлении» есть выключенная по умолчанию настройка **Копировать при выделении**. Она сохраняется и сразу применяется ко всем агентам и терминалам, включая полноэкранные карточки. После завершения непустого выделения мышью текст копируется в системный буфер, подсветка сохраняется. Поиск и снятие выделения не запускают копирование. При включённой настройке Shift позволяет выделять текст в TUI, перехватывающих мышь, на всех desktop-платформах; на macOS адаптер мыши терминала переводит Shift в Option для xterm, сохраняя навигацию канвасом через Option.
- Canvas plugin apps используют ту же movable card grammar, header `54px`, resize/snap и semantic summary ниже `0.5×`. Contribution `window` открывает отдельное sandboxed окно под управлением CanvasTTY; произвольные native windows не встраиваются.
- Built-in Browser — одна movable/resizable core canvas card, а не plugin contribution. Она использует тот же внешний header `54px`, что и другие canvas cards: identity и hide-card action отделены от внутренней полосы вкладок ниже. Trusted DOM chrome владеет tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs и явными Close tab/Close all. Скрытие карточки сохраняет вкладки и общий аутентифицированный Chromium profile. Ниже `0.5×`, в Edit HOME и за trusted dialogs/popovers native page скрывается и заменяется стабильной semantic surface. Во время движения карточки или камеры он остаётся live и следует за frame-coalesced viewport geometry.
- Само authenticated connection или heartbeat не показывает agent presence. Branded badge появляется только после реальной browser-команды агента, а cursor — только после появления настоящей pointer position. Claude использует `#D97757`, Codex — `#10A37F`, Qwen — `#6D44E8`, Kimi — `#7C5CFC`, OpenCode — `#5A5858`, Hermes — `#D6A700`, unknown provider — `#7A8291`. Browser Settings отдельно управляет видимостью индикаторов, не отзывая доступ; kill switch доступа агентов отзывает само подключение.
Expand Down
1 change: 1 addition & 0 deletions docs/UI_CONTRACT.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
- Semantic summary 模式中,点击卡片仍会选中并显示 outline,camera focus 只遵循 Off/Single click/Double click。Renderer、xterm 与 plugin 卡片使用和正常 scale 相同的 wheel focus 矩阵:只有 focused 且具备 input 的卡片能在 Off 或 Key 未按下时保留 wheel;On、激活的 Key binding 或完整 navigation override 会把它交给 canvas。Browser summary 与其他 non-native placeholder surface 的 wheel/pinch 始终归 canvas。
- Terminal 任意 edge/corner 都是 resize target。最小 card size 为 `420 × 260`;resize 更新 xterm viewport 并保持对边不动。
- 在任意 canvas zoom 下,实时终端 selection 都跟随可见指针位置。有文字选择时,`Ctrl+C`/`Ctrl+Shift+C` 或 `Cmd+C` 复制;`Ctrl+Shift+V`/`Cmd+V` 与 `Shift+Insert` 从系统剪贴板粘贴。没有选择时,普通 `Ctrl+C` 仍是 PTY interrupt。`Shift+Enter` 向 PTY 发送换行 sequence(`ESC [ 13 ; 2 u`),而不是提交当前行。
- Controls 提供默认关闭的 **Copy on selection** 设置,保存后立即应用于所有智能体和终端,包括全屏卡片。鼠标完成非空文本选择后复制到系统剪贴板,保留高亮;搜索结果和清除选择不会复制。启用后,所有桌面平台都可按住 Shift 在接管鼠标的 TUI 中选择文本;macOS 的终端鼠标适配器将 Shift 转为 xterm 的 Option 选择,同时保留 canvas 的 Option 导航。
- Canvas plugin app 使用同一 movable card grammar、`54px` header、resize/snap behavior,以及 `0.5×` 以下的 semantic summary。`window` contribution 打开 CanvasTTY 管理的独立 sandbox window;不支持嵌入任意原生窗口。
- 内置 Browser 是唯一的可移动、可调整尺寸 core canvas card,而不是 plugin contribution。它使用与其他 canvas card 相同的 `54px` 外层窗口 header,将 identity 与 hide-card action 和下方内部 tab strip 分开。可信 DOM chrome 负责 tab/favicon、address/search、back/forward/reload、download、per-tab provider badge、site dialog 以及明确的 Close tab/Close all。隐藏卡片会保留 tab 与共享的已认证 Chromium profile。低于 `0.5×`、Edit HOME 期间及可信 dialog/popover 后方,native page 会由稳定 semantic surface 替代;卡片或 camera 移动时它保持实时渲染,并跟随按帧合并的 viewport geometry。
- 仅有 authenticated connection 或 heartbeat 不会显示智能体 presence。智能体实际发出 browser command 后才显示品牌 badge,获得真实 pointer position 后才显示 cursor。Claude 使用 `#D97757`,Codex 使用 `#10A37F`,Qwen 使用 `#6D44E8`,Kimi 使用 `#7C5CFC`,OpenCode 使用 `#5A5858`,Hermes 使用 `#D6A700`,未知 provider 使用 `#7A8291`。Browser Settings 可单独控制 indicator visibility 而不撤销访问;智能体访问 kill switch 会撤销连接本身。
Expand Down
2 changes: 2 additions & 0 deletions src/main/services/SettingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ function createDefaults(systemLocale: string, platform: string): AppSettings {
terminalSkinAnimationEnabled: true,
appSkin: "classic",
snapToGrid: true,
copyOnSelect: false,
invertTerminalWheel: true,
invertCanvasWheel: false,
edgePan: false,
Expand Down Expand Up @@ -605,6 +606,7 @@ export function normalizeSettings(
? source.appSkin as AppSkinId
: fallback.appSkin,
snapToGrid: typeof source.snapToGrid === "boolean" ? source.snapToGrid : fallback.snapToGrid,
copyOnSelect: typeof source.copyOnSelect === "boolean" ? source.copyOnSelect : fallback.copyOnSelect,
invertTerminalWheel: typeof source.invertTerminalWheel === "boolean"
? source.invertTerminalWheel
: fallback.invertTerminalWheel,
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const FALLBACK_SETTINGS: AppSettings = {
terminalSkinAnimationEnabled: true,
appSkin: "classic",
snapToGrid: true,
copyOnSelect: false,
invertTerminalWheel: true,
invertCanvasWheel: false,
edgePan: false,
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/features/settings/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,13 @@ export function SettingsPanel({
onChange={(value) => void onChange({ useScrollWheelToZoom: value === "on" })}
/>
</SettingGroup>
<SettingGroup label={t(locale, "copyOnSelect")} description={t(locale, "copyOnSelectDescription")}>
<Segmented
value={settings.copyOnSelect ? "on" : "off"}
options={[["on", t(locale, "on")], ["off", t(locale, "off")]]}
onChange={(value) => void onChange({ copyOnSelect: value === "on" })}
/>
</SettingGroup>
<SettingGroup label={t(locale, "terminalWheelDirection")}>
<Segmented
value={settings.invertTerminalWheel ? "inverted" : "normal"}
Expand Down
30 changes: 29 additions & 1 deletion src/renderer/src/features/terminal/TerminalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { GitRiskNotice } from "./GitRiskNotice";
import { isCustomTerminalBorderSkinId, terminalBorderSkinFallback } from "../../lib/skinStyles";
import { sessionStatusLabel } from "../../lib/sessionStatus";
import { attachTerminalMouseCoordinateAdapter, attachTerminalScrollbarCoordinateAdapter } from "./terminalMouseCoordinates";
import { attachTerminalCopyOnSelect } from "./terminalCopyOnSelect";
import {
CODEX_SELECT_ALL_SEQUENCE,
SHIFT_ENTER_SEQUENCE,
Expand Down Expand Up @@ -80,6 +81,7 @@ interface TerminalCardProps {
snapEnabled: boolean;
focusActivation: FocusActivation;
invertTerminalWheel: boolean;
copyOnSelect: boolean;
captureCanvasWheelOverWidgets: boolean;
focused: boolean;
focusChangeSource: "explicit" | "hover";
Expand Down Expand Up @@ -159,6 +161,7 @@ function TerminalCardView({
snapEnabled,
focusActivation,
invertTerminalWheel,
copyOnSelect,
captureCanvasWheelOverWidgets,
focused,
focusChangeSource,
Expand Down Expand Up @@ -210,6 +213,8 @@ function TerminalCardView({
const restartAction = useRef<(resume?: boolean) => Promise<void>>(async () => undefined);
const invertTerminalWheelRef = useRef(invertTerminalWheel);
invertTerminalWheelRef.current = invertTerminalWheel;
const copyOnSelectRef = useRef(copyOnSelect);
copyOnSelectRef.current = copyOnSelect;
const captureCanvasWheelRef = useRef(captureCanvasWheelOverWidgets);
captureCanvasWheelRef.current = captureCanvasWheelOverWidgets;
const dragState = useRef<DragState | null>(null);
Expand Down Expand Up @@ -314,6 +319,8 @@ function TerminalCardView({
// into the pinned row below it.
scrollOnUserInput: false,
allowTransparency: true,
macOptionClickForcesSelection: copyOnSelectRef.current,
altClickMovesCursor: !(copyOnSelectRef.current && window.canvasTTY.window.isMacOS),
// 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.
Expand Down Expand Up @@ -537,7 +544,20 @@ function TerminalCardView({
? attachTerminalMouseCoordinateAdapter(
screen,
() => invertTerminalWheelRef.current ? -1 : 1,
() => captureCanvasWheelRef.current
() => captureCanvasWheelRef.current,
() => copyOnSelectRef.current && window.canvasTTY.window.isMacOS
)
: () => undefined;
const detachCopyOnSelect = screen
? attachTerminalCopyOnSelect(
screen,
() => terminal.getSelection(),
() => copyOnSelectRef.current,
(text) => window.canvasTTY.clipboard.writeText(text),
(listener) => {
const disposable = terminal.onSelectionChange(listener);
return () => disposable.dispose();
}
)
: () => undefined;
terminalRef.current = terminal;
Expand Down Expand Up @@ -585,6 +605,7 @@ function TerminalCardView({
unregisterWebgl();
cancelAnimationFrame(frame);
detachMouseCoordinateAdapter();
detachCopyOnSelect();
detachScrollbarCoordinateAdapter();
unsubscribe();
resizeObserver.disconnect();
Expand Down Expand Up @@ -614,6 +635,13 @@ function TerminalCardView({
if (terminal) terminal.options.theme = terminalTheme(palette, pixelSkinTheme);
}, [palette, pixelSkinTheme]);

useEffect(() => {
const terminal = terminalRef.current;
if (!terminal) return;
terminal.options.macOptionClickForcesSelection = copyOnSelect;
terminal.options.altClickMovesCursor = !(copyOnSelect && window.canvasTTY.window.isMacOS);
}, [copyOnSelect]);

const enableWebgl = (terminal: Terminal): boolean => {
if (webglAddonRef.current) return true;
// WebglAddon takes no transparency argument in 0.19.0: it reads the stored
Expand Down
61 changes: 61 additions & 0 deletions src/renderer/src/features/terminal/terminalCopyOnSelect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/** Copy only completed mouse gestures that xterm confirmed as selection changes. */
export function attachTerminalCopyOnSelect(
screen: HTMLElement,
getSelection: () => string,
isEnabled: () => boolean,
copy: (text: string) => void,
subscribeSelectionChange: (listener: () => void) => () => void = () => () => undefined
): () => void {
const ownerDocument = screen.ownerDocument;
const ownerWindow = ownerDocument.defaultView;
if (!ownerWindow) return () => undefined;
let selecting = false;
let selectionChanged = false;
let pendingCopy: number | undefined;

const onSelectionChange = (): void => {
if (selecting || pendingCopy !== undefined) selectionChanged = true;
};
const detachSelectionChange = subscribeSelectionChange(onSelectionChange);

const cancel = (): void => {
selecting = false;
selectionChanged = false;
if (pendingCopy !== undefined) ownerWindow.clearTimeout(pendingCopy);
pendingCopy = undefined;
};
const start = (event: MouseEvent): void => {
if (event.button !== 0) return;
cancel();
selecting = true;
};
const finish = (event: MouseEvent): void => {
if (event.button !== 0 || !selecting) return;
selecting = false;
// xterm finalizes selection in its document mouseup listener. Read after it,
// including when the coordinate adapter redispatches a scaled mouse event.
pendingCopy = ownerWindow.setTimeout(() => {
pendingCopy = undefined;
if (!isEnabled() || !selectionChanged) {
selectionChanged = false;
return;
}
selectionChanged = false;
const text = getSelection();
if (text.length > 0) copy(text);
}, 0);
};

screen.addEventListener("mousedown", start, true);
ownerDocument.addEventListener("mouseup", finish, true);
ownerDocument.addEventListener("pointercancel", cancel, true);
ownerWindow.addEventListener("blur", cancel);
return () => {
cancel();
screen.removeEventListener("mousedown", start, true);
ownerDocument.removeEventListener("mouseup", finish, true);
ownerDocument.removeEventListener("pointercancel", cancel, true);
ownerWindow.removeEventListener("blur", cancel);
detachSelectionChange();
};
}
Loading
Loading