Skip to content
Open
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
26 changes: 26 additions & 0 deletions apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,38 @@ import { describe, expect, it } from "vite-plus/test";

import {
resolveTerminalSelectionActionPosition,
terminalContextMenuItems,
shouldHandleTerminalExit,
shouldHandleTerminalSelectionMouseUp,
terminalSelectionActionDelayForClickCount,
terminalSelectionLineRange,
} from "./ThreadTerminalDrawer";

describe("terminalContextMenuItems", () => {
it("keeps Select All and Paste usable with no selection", () => {
const items = terminalContextMenuItems({ hasSelection: false });
const enabled = items.filter((item) => item.disabled !== true).map((item) => item.id);
expect(enabled).toEqual(["select-all", "paste"]);
});

it("enables the selection actions once a selection exists", () => {
const items = terminalContextMenuItems({ hasSelection: true });
expect(items.some((item) => item.disabled === true)).toBe(false);
expect(items.map((item) => item.id)).toEqual(["add-to-chat", "copy", "select-all", "paste"]);
});

it("omits Select command output unless the click lands on bounded output", () => {
expect(terminalContextMenuItems({ hasSelection: false }).map((item) => item.id)).not.toContain(
"select-output",
);
expect(
terminalContextMenuItems({ hasSelection: false, hasCommandOutput: true }).map(
(item) => item.id,
),
).toEqual(["add-to-chat", "copy", "select-output", "select-all", "paste"]);
});
});

describe("resolveTerminalSelectionActionPosition", () => {
it("prefers the selection rect over the last pointer position", () => {
expect(
Expand Down
48 changes: 42 additions & 6 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,12 @@ export function terminalSelectionLineRange(position: {
};
}

export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste";
export type TerminalContextMenuAction =
| "add-to-chat"
| "copy"
| "paste"
| "select-all"
| "select-output";

/** Post-selection popup: just the two selection actions, always enabled. */
export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] {
Expand All @@ -268,18 +273,27 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c

/**
* Right-click menu for the terminal canvas: the selection actions (disabled
* until a selection exists) plus Paste. Paste is always offered: the browser
* (and Electron's default editing menu) can only paste into an editable
* element, so a canvas terminal never gets a usable entry from them.
* until a selection exists) plus Select All and Paste. Both are always offered:
* the browser (and Electron's default editing menu) only act on an editable
* element, so a canvas terminal never gets usable entries from them.
*
* "Select command output" only appears over output Ghostty can bound, which
* needs OSC 133 marks from the shell. Offering it on an unmarked shell would be
* an entry that never does anything.
*/
export function terminalContextMenuItems(options: {
hasSelection: boolean;
hasCommandOutput?: boolean;
}): ContextMenuItem<TerminalContextMenuAction>[] {
return [
...terminalSelectionMenuItems().map((item) => ({
...item,
disabled: !options.hasSelection,
})),
...(options.hasCommandOutput === true
? [{ id: "select-output" as const, label: "Select command output" }]
: []),
{ id: "select-all", label: "Select all" },
{ id: "paste", label: "Paste" },
];
}
Expand Down Expand Up @@ -630,10 +644,18 @@ export function TerminalViewport({
clearSelectionAction();
const selectionAction = readSelectionAction();
const requestId = selectionActionRequestIdRef.current;
// Resolve the command under the pointer now and act on that range, not
// on the coordinates: the menu is asynchronous, and output arriving
// while it is open would put a different command under the same point.
const commandOutput =
terminalRef.current?.commandOutputRangeAt(event.clientX, event.clientY) ?? null;
let clicked: TerminalContextMenuAction | null;
try {
clicked = await localApi.contextMenu.show(
terminalContextMenuItems({ hasSelection: selectionAction !== null }),
terminalContextMenuItems({
hasSelection: selectionAction !== null,
hasCommandOutput: commandOutput !== null,
}),
{ x: event.clientX, y: event.clientY },
);
} catch (error) {
Expand All @@ -651,6 +673,14 @@ export function TerminalViewport({
case "copy":
if (selectionAction) await copySelection(selectionAction.clipboardText, requestId);
return;
case "select-all":
terminalRef.current?.selectAll();
focusIfCurrent(requestId);
return;
case "select-output":
if (commandOutput) terminalRef.current?.selectCommandOutputRange(commandOutput);
Comment thread
cursor[bot] marked this conversation as resolved.
focusIfCurrent(requestId);
return;
case "paste":
await pasteFromClipboard(requestId);
return;
Expand Down Expand Up @@ -928,11 +958,17 @@ export function TerminalViewport({
current.buffer.length >= previous.buffer.length &&
current.buffer.startsWith(previous.buffer)
) {
// An append leaves the selection alone: Ghostty pins it to its content,
// so it survives the output scrolling past. Clearing here dates from the
// xterm.js renderer, whose selection was invalidated by any write, and
// kept making a selection impossible to hold on a live log.
terminal.write(current.buffer.slice(previous.buffer.length));
} else {
// A replace repoints every coordinate, so the old selection is meaningless.
// resetAndWrite drops it, which keeps the invariant with the surface that
// owns the coordinates rather than with each caller that replaces a buffer.
writeTerminalBuffer(terminal, current.buffer);
}
terminal.clearSelection();

if (current.error !== null && current.error !== previous.error) {
writeSystemMessage(terminal, current.error);
Expand Down
74 changes: 67 additions & 7 deletions apps/web/src/terminal/ghostty/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,8 @@ export class GhosttyTerminalCore {
return encoded;
}

setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void {
/** `rectangle` selects the block bounded by the two points instead of the text flow between them. */
setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput, rectangle = false): void {
this.ensureActive();
const selectionLayout = this.runtime.layout("GhosttySelection");
const gridRefSize = this.runtime.layout("GhosttyGridRef").size;
Expand All @@ -621,6 +622,7 @@ export class GhosttyTerminalCore {
this.runtime
.bytes(selection + endField.offset, endField.size)
.set(this.runtime.bytes(endRef, endField.size));
this.runtime.setField(selection, "GhosttySelection", "rectangle", rectangle ? 1 : 0);
this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
} finally {
this.runtime.free(start, gridRefSize);
Expand All @@ -629,17 +631,75 @@ export class GhosttyTerminalCore {
}
}

selectAll(): void {
/**
* The bounds of the command output containing a cell, without selecting it.
* Null unless the shell emits OSC 133 semantic prompt marks and the cell sits
* inside a marked output region — a prompt or an unmarked shell yields none.
*/
outputRangeAt(col: number, row: number): GhosttySelectionRange["screen"] | null {
return this.selectOutputAt(col, row, false);
}

/** Selects the whole output of the command that produced this cell. */
selectOutput(col: number, row: number): GhosttySelectionRange["screen"] | null {
return this.selectOutputAt(col, row, true);
}

private selectOutputAt(
col: number,
row: number,
apply: boolean,
): GhosttySelectionRange["screen"] | null {
this.ensureActive();
const selectionLayout = this.runtime.layout("GhosttySelection");
const selection = this.runtime.alloc(selectionLayout.size);
let ref = 0;
let screen: GhosttySelectionRange["screen"] | null = null;
try {
this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size);
ref = this.gridRef(col, row);
if (
this.runtime.call("ghostty_terminal_select_output", this.terminal, ref, selection) ===
GHOSTTY_SUCCESS
) {
const start = this.pointFromGridRef(selection + selectionLayout.fields.start!.offset, 2);
const end = this.pointFromGridRef(selection + selectionLayout.fields.end!.offset, 2);
if (start !== null && end !== null) screen = { start, end };
if (apply) this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
}
} finally {
this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
this.runtime.free(selection, selectionLayout.size);
}
return screen;
}

/**
* Selects everything the terminal holds, scrollback included, and reports the
* bounds in screen coordinates. Only screen points are returned: the start of
* a full selection usually sits above the viewport, where a viewport point
* does not exist. Null means there was nothing to select.
*/
selectAll(): GhosttySelectionRange["screen"] | null {
this.ensureActive();
const layout = this.runtime.layout("GhosttySelection");
const selection = this.runtime.alloc(layout.size);
this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
if (
this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS
) {
this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
let screen: GhosttySelectionRange["screen"] | null = null;
try {
if (
this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) ===
GHOSTTY_SUCCESS
) {
const start = this.pointFromGridRef(selection + layout.fields.start!.offset, 2);
const end = this.pointFromGridRef(selection + layout.fields.end!.offset, 2);
if (start !== null && end !== null) screen = { start, end };
this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
}
} finally {
this.runtime.free(selection, layout.size);
}
this.runtime.free(selection, layout.size);
return screen;
}

selectWord(col: number, row: number): GhosttySelectionRange | null {
Expand Down
Loading
Loading