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
17 changes: 16 additions & 1 deletion apps/web/src/components/ThreadTerminalDrawer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,29 @@ describe("terminal selection menus", () => {
"add-to-chat",
"copy",
"paste",
"select-all",
"scroll-to-bottom",
]);

expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([
"copy",
]);
expect(
terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id),
).toEqual(["copy", "paste"]);
).toEqual(["copy", "paste", "select-all", "scroll-to-bottom"]);
});

it("keeps local viewing actions available to a read-only observer", () => {
const items = terminalContextMenuItems({ hasSelection: false, readOnly: true });
expect(items.filter((item) => item.disabled).map((item) => item.id)).toEqual([
"add-to-chat",
"copy",
"paste",
]);
expect(items.filter((item) => !item.disabled).map((item) => item.id)).toEqual([
"select-all",
"scroll-to-bottom",
]);
});
});

Expand Down
21 changes: 18 additions & 3 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,12 @@ export function terminalSelectionLineRange(position: {
};
}

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

/** Post-selection popup: available selection actions, always enabled. */
export function terminalSelectionMenuItems(options?: {
Expand All @@ -279,8 +284,8 @@ export function terminalSelectionMenuItems(options?: {
}

/**
* Right-click menu for the terminal canvas: the selection actions (disabled
* until a selection exists) plus Paste. Paste is always offered: the browser
* Right-click menu for selection, clipboard, and local scrollback actions.
* 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.
*/
Expand All @@ -296,6 +301,8 @@ export function terminalContextMenuItems(options: {
disabled: !hasSelection,
})),
{ id: "paste", label: "Paste", ...(options.readOnly ? { disabled: true } : {}) },
{ id: "select-all", label: "Select all" },
{ id: "scroll-to-bottom", label: "Jump to latest" },
];
}

Expand Down Expand Up @@ -749,6 +756,14 @@ export function TerminalViewport({
case "paste":
await pasteFromClipboard(requestId);
return;
case "select-all":
terminalRef.current?.selectAll();
focusIfCurrent(requestId);
return;
case "scroll-to-bottom":
terminalRef.current?.scrollToBottom();
focusIfCurrent(requestId);
return;
}
};

Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/terminal/ghostty/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,26 @@ describe("GhosttyTerminalCore snapshots", () => {
});
});

it("resolves foreground and background independently without retaining prior cell colors", async () => {
const core = await createCore();
core.write(
"\x1b[38;2;123;45;67mF\x1b[39;48;2;9;8;7mB\x1b[0mD\x1b[38;2;12;34;56;48;2;78;90;123mC\x1b[0mE",
);
expect(
core
.snapshot()
.rowData[0]!.cells.slice(0, 6)
.map(({ text, foreground, background }) => ({ text, foreground, background })),
).toEqual([
{ text: "F", foreground: { r: 123, g: 45, b: 67 }, background: { r: 0, g: 0, b: 0 } },
{ text: "B", foreground: { r: 255, g: 255, b: 255 }, background: { r: 9, g: 8, b: 7 } },
{ text: "D", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
{ text: "C", foreground: { r: 12, g: 34, b: 56 }, background: { r: 78, g: 90, b: 123 } },
{ text: "E", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
{ text: "", foreground: { r: 255, g: 255, b: 255 }, background: { r: 0, g: 0, b: 0 } },
]);
});

it("reuses a grown grapheme buffer and releases it on disposal", async () => {
const core = await createCore();
const runtime = await loadGhosttyRuntime();
Expand Down
121 changes: 77 additions & 44 deletions apps/web/src/terminal/ghostty/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ const CELL_DATA = {
selected: 7,
} as const;

// wasm32 arrays for the official row_cells_get_multi API. Optional colors
// come last because an unset color stops the query at that field.
const CELL_QUERY = {
keys: 0,
values: 20,
graphemesLength: 40,
selected: 44,
background: 48,
foreground: 52,
written: 56,
size: 60,
} as const;

const RAW_CELL_DATA = {
wide: 3,
} as const;
Expand Down Expand Up @@ -210,6 +223,7 @@ export class GhosttyTerminalCore {
private graphemes = 0;
private graphemeCapacity = 0;
private style = 0;
private cellQuery = 0;
private scrollbar = 0;
private rows: GhosttyRow[] = [];
private disposed = false;
Expand Down Expand Up @@ -310,6 +324,19 @@ export class GhosttyTerminalCore {
const styleSize = this.runtime.layout("GhosttyStyle").size;
this.style = this.runtime.alloc(styleSize);
this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
this.cellQuery = this.runtime.alloc(CELL_QUERY.size);
const queryView = this.runtime.memoryDataView();
const cellFields = [
[CELL_DATA.style, this.style],
[CELL_DATA.graphemesLength, this.cellQuery + CELL_QUERY.graphemesLength],
[CELL_DATA.selected, this.cellQuery + CELL_QUERY.selected],
[CELL_DATA.background, this.cellQuery + CELL_QUERY.background],
[CELL_DATA.foreground, this.cellQuery + CELL_QUERY.foreground],
] as const;
for (const [index, [key, pointer]] of cellFields.entries()) {
queryView.setUint32(this.cellQuery + CELL_QUERY.keys + index * 4, key, true);
queryView.setUint32(this.cellQuery + CELL_QUERY.values + index * 4, pointer, true);
}
this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size);
this.setTheme(theme);
this.resize(cols, rows, cellWidth, cellHeight);
Expand Down Expand Up @@ -658,17 +685,26 @@ export class GhosttyTerminalCore {
}
}

selectAll(): void {
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
) {
try {
this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
if (
this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) !==
GHOSTTY_SUCCESS
) {
return null;
}
const start = this.pointFromGridRef(selection + layout.fields.start!.offset, 2);
const end = this.pointFromGridRef(selection + layout.fields.end!.offset, 2);
if (!start || !end) return null;
this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
return { start, end };
} finally {
this.runtime.free(selection, layout.size);
}
this.runtime.free(selection, layout.size);
}

selectWord(col: number, row: number): GhosttySelectionRange | null {
Expand Down Expand Up @@ -877,6 +913,7 @@ export class GhosttyTerminalCore {
this.runtime.call("ghostty_terminal_free", this.terminal);
}
if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size);
if (this.cellQuery) this.runtime.free(this.cellQuery, CELL_QUERY.size);
if (this.scrollbar) {
this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
}
Expand Down Expand Up @@ -954,17 +991,29 @@ export class GhosttyTerminalCore {
cells.length < cols &&
this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0
) {
let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
this.runtime.bytes(this.style, styleSize).fill(0);
this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
this.runtime.call(
"ghostty_render_state_row_cells_get",
const result = this.runtime.call(
"ghostty_render_state_row_cells_get_multi",
cellsIterator,
CELL_DATA.style,
this.style,
5,
this.cellQuery + CELL_QUERY.keys,
this.cellQuery + CELL_QUERY.values,
this.cellQuery + CELL_QUERY.written,
);
const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);
const queryView = this.runtime.memoryDataView();
const written = queryView.getUint32(this.cellQuery + CELL_QUERY.written, true);
if (written < 3) this.assertSuccess("ghostty_render_state_row_cells_get_multi", result);
const graphemeLength = queryView.getUint32(this.cellQuery + CELL_QUERY.graphemesLength, true);
const selected = queryView.getUint8(this.cellQuery + CELL_QUERY.selected) !== 0;
let background =
written >= 4 ? this.readColor(this.cellQuery + CELL_QUERY.background) : defaultBackground;
let foreground =
written === 5
? this.readColor(this.cellQuery + CELL_QUERY.foreground)
: written === 3
? this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground)
: defaultForeground;
let text = "";
if (graphemeLength > 0) {
const bufferSize = graphemeLength * 4;
Expand Down Expand Up @@ -1008,26 +1057,25 @@ export class GhosttyTerminalCore {
);
wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
}
const selected = this.getCellBool(cellsIterator, CELL_DATA.selected);
// Read the style after allocation and ABI calls, which can grow WASM memory.
const styleView = this.runtime.view(this.style, styleSize);
if (styleView.getUint8(styleFields.inverse!.offset) !== 0) {
const styleView = this.runtime.memoryDataView();
if (styleView.getUint8(this.style + styleFields.inverse!.offset) !== 0) {
[foreground, background] = [background, foreground];
}
if (styleView.getUint8(styleFields.faint!.offset) !== 0) {
if (styleView.getUint8(this.style + styleFields.faint!.offset) !== 0) {
foreground = blend(foreground, background);
}
cells.push({
text,
wide,
foreground,
background,
bold: styleView.getUint8(styleFields.bold!.offset) !== 0,
italic: styleView.getUint8(styleFields.italic!.offset) !== 0,
invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0,
strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0,
overline: styleView.getUint8(styleFields.overline!.offset) !== 0,
underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0,
bold: styleView.getUint8(this.style + styleFields.bold!.offset) !== 0,
italic: styleView.getUint8(this.style + styleFields.italic!.offset) !== 0,
invisible: styleView.getUint8(this.style + styleFields.invisible!.offset) !== 0,
strikethrough: styleView.getUint8(this.style + styleFields.strikethrough!.offset) !== 0,
overline: styleView.getUint8(this.style + styleFields.overline!.offset) !== 0,
underline: styleView.getInt32(this.style + styleFields.underline!.offset, true) !== 0,
selected,
});
}
Expand Down Expand Up @@ -1177,25 +1225,6 @@ export class GhosttyTerminalCore {
);
}

private getCellU32(iterator: number, data: number): number {
this.runtime.bytes(this.scratch, 4).fill(0);
const result = this.runtime.call(
"ghostty_render_state_row_cells_get",
iterator,
data,
this.scratch,
);
return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0;
}

private getCellBool(iterator: number, data: number): boolean {
this.runtime.bytes(this.scratch, 1)[0] = 0;
return (
this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) ===
GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
);
}

private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor {
this.runtime.bytes(this.scratch, 3).fill(0);
const result = this.runtime.call(
Expand All @@ -1208,8 +1237,12 @@ export class GhosttyTerminalCore {
}

private readColor(pointer: number): GhosttyColor {
const bytes = this.runtime.bytes(pointer, 3);
return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 };
const view = this.runtime.memoryDataView();
return {
r: view.getUint8(pointer),
g: view.getUint8(pointer + 1),
b: view.getUint8(pointer + 2),
};
}

private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell {
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/terminal/ghostty/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export class GhosttyRuntime {
}

readPointer(slot: number): number {
return this.currentMemoryView().getUint32(slot, true);
return this.memoryDataView().getUint32(slot, true);
}

attachPtyWriter(terminal: number, writer: (data: string) => void): number {
Expand Down Expand Up @@ -135,7 +135,7 @@ export class GhosttyRuntime {
}

/** Reuse scalar reads across cells, refreshing after any terminal grows shared WASM memory. */
private currentMemoryView(): DataView {
memoryDataView(): DataView {
if (this.memoryView.buffer !== this.memory.buffer) {
this.memoryView = new DataView(this.memory.buffer);
}
Expand All @@ -145,7 +145,7 @@ export class GhosttyRuntime {
setField(pointer: number, structName: string, fieldName: string, value: number): void {
const field = this.layout(structName).fields[fieldName];
if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
const view = this.currentMemoryView();
const view = this.memoryDataView();
const offset = pointer + field.offset;
switch (field.type) {
case "bool":
Expand Down Expand Up @@ -173,7 +173,7 @@ export class GhosttyRuntime {
readField(pointer: number, structName: string, fieldName: string): number {
const field = this.layout(structName).fields[fieldName];
if (!field) throw new Error(`libghostty-vt field is unavailable: ${structName}.${fieldName}`);
const view = this.currentMemoryView();
const view = this.memoryDataView();
const offset = pointer + field.offset;
switch (field.type) {
case "bool":
Expand Down
Loading
Loading