diff --git a/packages/app/src/terminal/native-renderer/colors.ts b/packages/app/src/terminal/native-renderer/colors.ts index 1ea663d6a..dfebe4eb4 100644 --- a/packages/app/src/terminal/native-renderer/colors.ts +++ b/packages/app/src/terminal/native-renderer/colors.ts @@ -39,6 +39,7 @@ const FLAG_STRIKETHROUGH = 1 << 5; interface ResolvedCellStyle { key: string; style: TextStyle; + foregroundColor: string; } interface ResolvedCellColors { @@ -227,7 +228,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl }); const cachedStyle = styleCache.get(key); if (cachedStyle) { - return { key, style: cachedStyle }; + return { key, style: cachedStyle, foregroundColor: colors.foreground }; } const style = createTextStyle({ @@ -237,7 +238,7 @@ export function createTerminalCellStyleResolver(theme: ITheme): TerminalCellStyl flags, }); styleCache.set(key, style); - return { key, style }; + return { key, style, foregroundColor: colors.foreground }; }, }; } diff --git a/packages/app/src/terminal/native-renderer/terminal-custom-glyph.test.ts b/packages/app/src/terminal/native-renderer/terminal-custom-glyph.test.ts new file mode 100644 index 000000000..95de6c8c5 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-custom-glyph.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTerminalCustomGlyph } from "./terminal-custom-glyph"; + +describe("native terminal custom glyphs", () => { + it("fills the complete cell for the full block used by the Claude mascot", () => { + expect(resolveTerminalCustomGlyph("█")).toEqual({ + kind: "rects", + rects: [{ x: 0, y: 0, width: 1, height: 1 }], + }); + }); + + it("draws every Claude mascot block from exact cell fractions", () => { + const mascot = ["▐▛███▜▌", "▝▜█████▛▘", "▘▘ ▝▝"]; + const customGlyphs = mascot + .join("") + .replaceAll(" ", "") + .split("") + .map(resolveTerminalCustomGlyph); + + expect(customGlyphs).not.toContain(null); + expect(resolveTerminalCustomGlyph("▐")).toEqual({ + kind: "rects", + rects: [{ x: 0.5, y: 0, width: 0.5, height: 1 }], + }); + expect(resolveTerminalCustomGlyph("▛")).toEqual({ + kind: "rects", + rects: [ + { x: 0, y: 0, width: 0.5, height: 0.5 }, + { x: 0.5, y: 0, width: 0.5, height: 0.5 }, + { x: 0, y: 0.5, width: 0.5, height: 0.5 }, + ], + }); + expect(resolveTerminalCustomGlyph("▝")).toEqual({ + kind: "rects", + rects: [{ x: 0.5, y: 0, width: 0.5, height: 0.5 }], + }); + }); + + it("connects the rounded Claude welcome frame to every cell edge", () => { + expect(resolveTerminalCustomGlyph("─")).toEqual({ + kind: "path", + path: "M0,.5 L1,.5", + strokeWidth: 1, + }); + expect(resolveTerminalCustomGlyph("│")).toEqual({ + kind: "path", + path: "M.5,0 L.5,1", + strokeWidth: 1, + }); + expect(resolveTerminalCustomGlyph("╭")).toEqual({ + kind: "path", + path: "M.5,1 L.5,.75 Q.5,.5 .75,.5 L1,.5", + strokeWidth: 1, + }); + expect(resolveTerminalCustomGlyph("╮")).toEqual({ + kind: "path", + path: "M0,.5 L.25,.5 Q.5,.5 .5,.75 L.5,1", + strokeWidth: 1, + }); + expect(resolveTerminalCustomGlyph("╰")).toEqual({ + kind: "path", + path: "M.5,0 L.5,.25 Q.5,.5 .75,.5 L1,.5", + strokeWidth: 1, + }); + expect(resolveTerminalCustomGlyph("╯")).toEqual({ + kind: "path", + path: "M0,.5 L.25,.5 Q.5,.5 .5,.25 L.5,0", + strokeWidth: 1, + }); + }); + + it("keeps ordinary terminal text on the font renderer", () => { + expect(resolveTerminalCustomGlyph("C")).toBeNull(); + expect(resolveTerminalCustomGlyph(" ")).toBeNull(); + }); + + it("covers common light and heavy terminal ASCII-art frames", () => { + const frames = [ + "┌────┬────┐│ │ │├────┼────┤└────┴────┘", + "┏━━━━┳━━━━┓┃ ┃ ┃┣━━━━╋━━━━┫┗━━━━┻━━━━┛", + ]; + + for (const char of frames.join("").replaceAll(" ", "")) { + expect(resolveTerminalCustomGlyph(char), `missing custom glyph ${char}`).not.toBeNull(); + } + }); +}); diff --git a/packages/app/src/terminal/native-renderer/terminal-custom-glyph.ts b/packages/app/src/terminal/native-renderer/terminal-custom-glyph.ts new file mode 100644 index 000000000..f63de40e2 --- /dev/null +++ b/packages/app/src/terminal/native-renderer/terminal-custom-glyph.ts @@ -0,0 +1,119 @@ +export interface TerminalGlyphRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface TerminalRectGlyph { + kind: "rects"; + rects: readonly TerminalGlyphRect[]; +} + +export interface TerminalPathGlyph { + kind: "path"; + path: string; + strokeWidth: number; +} + +export type TerminalCustomGlyph = TerminalRectGlyph | TerminalPathGlyph; + +const LIGHT_BOX_GLYPHS: Readonly> = { + "─": path("M0,.5 L1,.5"), + "│": path("M.5,0 L.5,1"), + "┌": path("M.5,1 L.5,.5 L1,.5"), + "┐": path("M0,.5 L.5,.5 L.5,1"), + "└": path("M.5,0 L.5,.5 L1,.5"), + "┘": path("M0,.5 L.5,.5 L.5,0"), + "├": path("M.5,0 L.5,1 M.5,.5 L1,.5"), + "┤": path("M.5,0 L.5,1 M.5,.5 L0,.5"), + "┬": path("M0,.5 L1,.5 M.5,.5 L.5,1"), + "┴": path("M0,.5 L1,.5 M.5,.5 L.5,0"), + "┼": path("M0,.5 L1,.5 M.5,0 L.5,1"), + "╭": path("M.5,1 L.5,.75 Q.5,.5 .75,.5 L1,.5"), + "╮": path("M0,.5 L.25,.5 Q.5,.5 .5,.75 L.5,1"), + "╰": path("M.5,0 L.5,.25 Q.5,.5 .75,.5 L1,.5"), + "╯": path("M0,.5 L.25,.5 Q.5,.5 .5,.25 L.5,0"), +}; + +const HEAVY_BOX_GLYPHS: Readonly> = { + "━": path("M0,.5 L1,.5", 3), + "┃": path("M.5,0 L.5,1", 3), + "┏": path("M.5,1 L.5,.5 L1,.5", 3), + "┓": path("M0,.5 L.5,.5 L.5,1", 3), + "┗": path("M.5,0 L.5,.5 L1,.5", 3), + "┛": path("M0,.5 L.5,.5 L.5,0", 3), + "┣": path("M.5,0 L.5,1 M.5,.5 L1,.5", 3), + "┫": path("M.5,0 L.5,1 M.5,.5 L0,.5", 3), + "┳": path("M0,.5 L1,.5 M.5,.5 L.5,1", 3), + "┻": path("M0,.5 L1,.5 M.5,.5 L.5,0", 3), + "╋": path("M0,.5 L1,.5 M.5,0 L.5,1", 3), +}; + +const BLOCK_GLYPHS: Readonly> = { + "▀": rects(rect(0, 0, 1, 0.5)), + "▁": rects(rect(0, 0.875, 1, 0.125)), + "▂": rects(rect(0, 0.75, 1, 0.25)), + "▃": rects(rect(0, 0.625, 1, 0.375)), + "▄": rects(rect(0, 0.5, 1, 0.5)), + "▅": rects(rect(0, 0.375, 1, 0.625)), + "▆": rects(rect(0, 0.25, 1, 0.75)), + "▇": rects(rect(0, 0.125, 1, 0.875)), + "█": rects(rect(0, 0, 1, 1)), + "▉": rects(rect(0, 0, 0.875, 1)), + "▊": rects(rect(0, 0, 0.75, 1)), + "▋": rects(rect(0, 0, 0.625, 1)), + "▌": rects(rect(0, 0, 0.5, 1)), + "▍": rects(rect(0, 0, 0.375, 1)), + "▎": rects(rect(0, 0, 0.25, 1)), + "▏": rects(rect(0, 0, 0.125, 1)), + "▐": rects(rect(0.5, 0, 0.5, 1)), + "▔": rects(rect(0, 0, 1, 0.125)), + "▕": rects(rect(0.875, 0, 0.125, 1)), + "▖": rects(lowerLeft()), + "▗": rects(lowerRight()), + "▘": rects(upperLeft()), + "▙": rects(upperLeft(), lowerLeft(), lowerRight()), + "▚": rects(upperLeft(), lowerRight()), + "▛": rects(upperLeft(), upperRight(), lowerLeft()), + "▜": rects(upperLeft(), upperRight(), lowerRight()), + "▝": rects(upperRight()), + "▞": rects(upperRight(), lowerLeft()), + "▟": rects(upperRight(), lowerLeft(), lowerRight()), +}; + +export function resolveTerminalCustomGlyph(char: string): TerminalCustomGlyph | null { + return LIGHT_BOX_GLYPHS[char] ?? HEAVY_BOX_GLYPHS[char] ?? BLOCK_GLYPHS[char] ?? null; +} + +export function isTerminalCustomGlyph(char: string): boolean { + return resolveTerminalCustomGlyph(char) !== null; +} + +function path(pathData: string, strokeWidth = 1): TerminalPathGlyph { + return { kind: "path", path: pathData, strokeWidth }; +} + +function rect(x: number, y: number, width: number, height: number): TerminalGlyphRect { + return { x, y, width, height }; +} + +function rects(...values: TerminalGlyphRect[]): TerminalRectGlyph { + return { kind: "rects", rects: values }; +} + +function upperLeft(): TerminalGlyphRect { + return rect(0, 0, 0.5, 0.5); +} + +function upperRight(): TerminalGlyphRect { + return rect(0.5, 0, 0.5, 0.5); +} + +function lowerLeft(): TerminalGlyphRect { + return rect(0, 0.5, 0.5, 0.5); +} + +function lowerRight(): TerminalGlyphRect { + return rect(0.5, 0.5, 0.5, 0.5); +} diff --git a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx index 7cf3d20c8..9b81c5146 100644 --- a/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx +++ b/packages/app/src/terminal/native-renderer/terminal-grid-view.native.tsx @@ -9,6 +9,7 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import Svg, { G, Path } from "react-native-svg"; import type { ITheme } from "@xterm/xterm"; import { @@ -16,6 +17,7 @@ import { DEFAULT_TERMINAL_THEME, NATIVE_TERMINAL_SELECTION_COLORS, } from "./colors"; +import type { TerminalGlyphRect } from "./terminal-custom-glyph"; import { resolveNativeTerminalFontFamily } from "./font.native"; import type { TerminalViewportState } from "./headless-terminal-state"; import { @@ -24,7 +26,13 @@ import { resolveTerminalCursorOffset, type TerminalGridCellMetrics, } from "./terminal-grid-metrics"; -import { buildRows, type TerminalRowModel, type TerminalRun } from "./terminal-row-model"; +import { + buildRows, + type TerminalCustomGlyphCell, + type TerminalCustomGlyphRun, + type TerminalRowModel, + type TerminalRun, +} from "./terminal-row-model"; import type { TerminalSelectionRange } from "./terminal-selection"; const MEASURE_TEXT = "mmmmmmmmmm"; @@ -109,9 +117,100 @@ function TerminalGridRun({ run, cellWidth, cellHeight, textStyle }: TerminalGrid return ( - - {run.text} - + {run.renderKind === "custom-glyph" ? ( + + ) : ( + + {run.text} + + )} + + ); +} + +interface TerminalGridCustomGlyphRunProps { + run: TerminalCustomGlyphRun; + cellWidth: number; + cellHeight: number; +} + +interface TerminalGridCustomGlyphRectProps { + cell: TerminalCustomGlyphCell; + rect: TerminalGlyphRect; + cellWidth: number; + cellHeight: number; + foregroundColor: string; +} + +function TerminalGridCustomGlyphRect({ + cell, + rect, + cellWidth, + cellHeight, + foregroundColor, +}: TerminalGridCustomGlyphRectProps) { + const style = useMemo( + () => ({ + position: "absolute", + backgroundColor: foregroundColor, + left: (cell.offset + rect.x) * cellWidth, + top: rect.y * cellHeight, + width: rect.width * cellWidth, + height: rect.height * cellHeight, + }), + [cell.offset, cellHeight, cellWidth, foregroundColor, rect], + ); + + return ; +} + +function TerminalGridCustomGlyphRun({ + run, + cellWidth, + cellHeight, +}: TerminalGridCustomGlyphRunProps) { + const opacity = typeof run.style.opacity === "number" ? run.style.opacity : 1; + + return ( + + {run.glyphs.map((cell) => + cell.glyph.kind === "rects" + ? cell.glyph.rects.map((rect) => ( + + )) + : null, + )} + + + {run.glyphs.map((cell) => + cell.glyph.kind === "path" ? ( + + ) : null, + )} + + ); } @@ -337,6 +436,15 @@ const styles = StyleSheet.create({ run: { overflow: "hidden", }, + customGlyphRun: { + flex: 1, + position: "relative", + }, + customGlyphSvg: { + left: 0, + position: "absolute", + top: 0, + }, rowText: { includeFontPadding: false, margin: 0, diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts index c7962e56d..b19922b7a 100644 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-row-model.test.ts @@ -79,4 +79,42 @@ describe("terminal row model", () => { ]); expect(rows[0].hash).not.toBe(buildRows({ grid, resolver })[0].hash); }); + + test("splits Claude box drawing from font text at exact terminal columns", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + const frame = "╭─ Claude Code ───╮"; + + const [row] = buildRows({ + grid: [frame.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))], + resolver, + }); + + expect( + row.runs.map((run) => ({ + text: run.text, + cellCount: run.cellCount, + renderKind: run.renderKind, + })), + ).toEqual([ + { text: "╭─", cellCount: 2, renderKind: "custom-glyph" }, + { text: " Claude Code ", cellCount: 13, renderKind: "text" }, + { text: "───╮", cellCount: 4, renderKind: "custom-glyph" }, + ]); + }); + + test("keeps Claude mascot glyphs custom while preserving copied row text", () => { + const resolver = createTerminalCellStyleResolver(DEFAULT_TERMINAL_THEME); + const mascot = "▐▛███▜▌ Claude Code"; + + const [row] = buildRows({ + grid: [mascot.split("").map((char) => cell(char, { fg: 1, fgMode: 1 }))], + resolver, + }); + + expect(row.runs.map((run) => [run.text, run.renderKind])).toEqual([ + ["▐▛███▜▌", "custom-glyph"], + [" Claude Code", "text"], + ]); + expect(row.runs.map((run) => run.text).join("")).toBe(mascot); + }); }); diff --git a/packages/app/src/terminal/native-renderer/terminal-row-model.ts b/packages/app/src/terminal/native-renderer/terminal-row-model.ts index f814f934f..a65f39fd4 100644 --- a/packages/app/src/terminal/native-renderer/terminal-row-model.ts +++ b/packages/app/src/terminal/native-renderer/terminal-row-model.ts @@ -2,6 +2,7 @@ import type { TextStyle } from "react-native"; import type { TerminalCell } from "@getpaseo/protocol/messages"; import type { TerminalCellStyleResolver } from "./colors"; +import { resolveTerminalCustomGlyph, type TerminalCustomGlyph } from "./terminal-custom-glyph"; import type { TerminalSelectionRange } from "./terminal-selection"; const WIDE_CHAR_RANGES: ReadonlyArray = [ @@ -17,12 +18,30 @@ const WIDE_CHAR_RANGES: ReadonlyArray = [ [0x1f300, 0x1faff], ]; -export interface TerminalRun { +interface TerminalRunBase { key: string; text: string; cellCount: number; styleKey: string; style: TextStyle; + foregroundColor: string; +} + +export interface TerminalTextRun extends TerminalRunBase { + renderKind: "text"; +} + +export interface TerminalCustomGlyphRun extends TerminalRunBase { + renderKind: "custom-glyph"; + glyphs: TerminalCustomGlyphCell[]; +} + +export type TerminalRun = TerminalCustomGlyphRun | TerminalTextRun; + +export interface TerminalCustomGlyphCell { + key: string; + offset: number; + glyph: TerminalCustomGlyph; } export interface TerminalRowModel { @@ -83,22 +102,47 @@ function appendRun(input: { cellCount: number; styleKey: string; style: TextStyle; + foregroundColor: string; + customGlyph: TerminalCustomGlyph | null; col: number; }): void { + const renderKind = input.customGlyph ? "custom-glyph" : "text"; const previousRun = input.runs[input.runs.length - 1]; - if (previousRun && previousRun.styleKey === input.styleKey) { + if ( + previousRun && + previousRun.styleKey === input.styleKey && + previousRun.renderKind === renderKind + ) { + const offset = previousRun.cellCount; previousRun.text += input.text; previousRun.cellCount += input.cellCount; + if (previousRun.renderKind === "custom-glyph" && input.customGlyph) { + previousRun.glyphs.push({ + key: `${input.col}:${input.text}`, + offset, + glyph: input.customGlyph, + }); + } return; } - input.runs.push({ + const baseRun: TerminalRunBase = { key: `${input.col}:${input.styleKey}`, text: input.text, cellCount: input.cellCount, styleKey: input.styleKey, style: input.style, - }); + foregroundColor: input.foregroundColor, + }; + if (input.customGlyph) { + input.runs.push({ + ...baseRun, + renderKind: "custom-glyph", + glyphs: [{ key: `${input.col}:${input.text}`, offset: 0, glyph: input.customGlyph }], + }); + return; + } + input.runs.push({ ...baseRun, renderKind: "text" }); } function cellIntersectsSelection(input: { @@ -149,9 +193,11 @@ function buildRowModel(input: { const text = cell.char || " "; const resolvedStyle = input.resolver.resolve(cell); const cellCount = terminalCellCount(input.cells, col, text); + const customGlyph = resolveTerminalCustomGlyph(text); const selection = input.selection; let styleKey = resolvedStyle.key; let style = resolvedStyle.style; + let foregroundColor = resolvedStyle.foregroundColor; if ( selection && cellIntersectsSelection({ @@ -167,6 +213,7 @@ function buildRowModel(input: { foregroundColor: selection.foregroundColor, backgroundColor: selection.backgroundColor, }); + foregroundColor = selection.foregroundColor; } appendRun({ runs, @@ -174,11 +221,14 @@ function buildRowModel(input: { cellCount, styleKey, style, + foregroundColor, + customGlyph, col, }); hash = hashStringPart(hash, text); hash = hashStringPart(hash, String(cellCount)); hash = hashStringPart(hash, styleKey); + hash = hashStringPart(hash, customGlyph ? "custom-glyph" : "text"); if (cellCount > 1) { col += 1; diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index 4c1dc06f9..5f2a4bb59 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -276,6 +276,26 @@ describe("native terminal selection", () => { expect(copied).toEqual(["COPY_OK_123"]); }); + it("copies custom-drawn Claude glyphs as their original Unicode text", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 }); + const art = "╭─ Claude ─╮\r\n│ ▐▛███▜▌ │\r\n╰──────────╯"; + await terminal.write(art); + const bounds = terminal.getBufferBounds(); + const firstRow = rowWithText(terminal, "╭─ Claude ─╮"); + const lastRow = rowWithText(terminal, "╰──────────╯"); + + expect( + extractTerminalSelectedText({ + terminal, + selection: { + start: { row: firstRow, col: 0 }, + end: { row: lastRow, col: 11 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + }), + ).toBe(art.replaceAll("\r", "")); + }); + it("copies known text after it has scrolled into retained history", async () => { const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 16, scrollbackLines: 20 }); await terminal.write("KEEP_THIS_LINE\r\n");