From 8c2181a7060a7942fa2965ce7e4a8ec7056dfcf8 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 01/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 39facd25a6a131f71bb57f8d35547b604661bc6e Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 02/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From b83d721b1c5efffc815c721e997983eeb05ac2c8 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 03/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 32e066cf9494df04af9a957c0b3651b0634efef8 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 04/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 16883c4bffffcdb0855e2280c92b0e5e004adc41 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 05/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From fc16b1a0d1394653ad25c1e46b681b2bc5845df4 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 06/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 820fe9ade9072fd1e43ed1203b9b8e9ff36cb0d0 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 07/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From a5f3b32cc14ae5051fba26e3954b9aee4f457d53 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 08/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From b3131ef2be49c9753918d783c75b1f36af634d35 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 09/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From be79c20129e510b8389e79823eecc7a9d7d4e826 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:49:12 +0530 Subject: [PATCH 10/22] fix: adapt upstream #5073 to fork Cursor ACP todo title fallback is N/A: fork migrated Cursor to SDK and deleted CursorAcpExtension. Co-authored-by: aaditagrawal From 76146b2bcbba16450849c1df43bbc994ccace75f Mon Sep 17 00:00:00 2001 From: Fllip <39161142+FllipEis@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:03:56 +0200 Subject: [PATCH 11/22] fix(web): truncate long project switcher names (#5348) (cherry picked from commit 3c5bdb84a93682646848acf48cae94c4d189b7a6) --- apps/web/src/components/chat/DraftHeroHeadline.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 4407e6215259..0a76fcd449bf 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -101,7 +101,8 @@ export function DraftHeroHeadline({ {activeProjectDisplayName ?? "Choose a project"} @@ -122,7 +123,9 @@ export function DraftHeroHeadline({ {projectPickerEntries.map(({ group }) => { return ( - {group.displayName} + + {group.displayName} + ); })} From ff5cb6974f6874aa6eb65ba879796199487c3ae7 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:28:10 +0530 Subject: [PATCH 12/22] fix(mobile): avoid double dividers between thread sections (#5391) Co-authored-by: T3 Code Test (cherry picked from commit e0c85a20ef0ec9b49ade5989a13850493036a49b) --- apps/mobile/src/features/home/HomeScreen.tsx | 9 ++++++++- .../src/features/threads/thread-list-v2-items.tsx | 12 ++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 887047366cd3..cf6399a5a6fc 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -698,7 +698,11 @@ export function HomeScreen(props: HomeScreenProps) { ); const renderV2Item = useCallback( - ({ item }: { readonly item: ThreadListV2ListItem }) => { + ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { + const nextItem = threadListV2Items[index + 1]; + const showTrailingDivider = + nextItem?.type === "v2-thread" || + (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); if (item.type === "v2-pending") { const pendingScopeKey = scopedProjectKey( item.pendingTask.message.environmentId, @@ -716,6 +720,7 @@ export function HomeScreen(props: HomeScreenProps) { : null } showPendingDivider={item.showPendingDivider} + showTrailingDivider={showTrailingDivider} onSelectPendingTask={props.onSelectPendingTask} onDeletePendingTask={props.onDeletePendingTask} /> @@ -748,6 +753,7 @@ export function HomeScreen(props: HomeScreenProps) { pinned={item.item.pinned} snoozePresetMinute={nowMinute} snoozeWakeLabelText={item.snoozeWakeLabelText} + showTrailingDivider={showTrailingDivider} project={ projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null } @@ -818,6 +824,7 @@ export function HomeScreen(props: HomeScreenProps) { serverConfigs, settlementEnvironmentIds, snoozeEnvironmentIds, + threadListV2Items, threadSearchMatchByKey, toggleSettledShelf, toggleSnoozedShelf, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6b9dd52512ac..24f7166916bc 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -204,6 +204,8 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props readonly pane?: "screen" | "sidebar"; /** Draws the "Pending" divider above the first queued row. */ readonly showPendingDivider: boolean; + /** Keeps row hairlines inside a section; section headers draw their own rule. */ + readonly showTrailingDivider?: boolean; readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void; readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void; }) { @@ -291,7 +293,9 @@ export const ThreadListV2PendingRow = memo(function ThreadListV2PendingRow(props ) : ( {rowContent} - + {props.showTrailingDivider !== false ? ( + + ) : null} )} @@ -327,6 +331,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { into the drawer surface, selection filled with the accent color — matching the v1 sidebar rows. */ readonly pane?: "screen" | "sidebar"; + /** Keeps row hairlines inside a section; section headers draw their own rule. */ + readonly showTrailingDivider?: boolean; /** Highlights the thread open in the detail pane (iPad split view). The compact Home list never sets it — phones navigate away on select. */ readonly selected?: boolean; @@ -728,7 +734,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { actions reveal behind the row. */ {cardContent} - + {props.showTrailingDivider !== false ? ( + + ) : null} )} From 6fb38a2549c7912cac46539f61c5e58fa3465ee5 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:19:58 +0200 Subject: [PATCH 13/22] fix(web): keep the composer command menu anchored to the composer (#5336) (cherry picked from commit 9235c83eb7f16c321ad02aa4cc502fbe0be252c1) --- apps/web/src/components/chat/ChatComposer.tsx | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6d1462136d89..d6db3bce5ae7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -108,13 +108,24 @@ import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; +type ComposerCommandMenuPosition = { + bottom: number; + left: number; + maxHeight: number; + width: number; +}; + +function composerCommandMenuPositionsEqual( + a: ComposerCommandMenuPosition, + b: ComposerCommandMenuPosition, +): boolean { + return ( + a.bottom === b.bottom && a.left === b.left && a.maxHeight === b.maxHeight && a.width === b.width + ); +} + function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: ReactNode }) { - const [position, setPosition] = useState<{ - bottom: number; - left: number; - maxHeight: number; - width: number; - } | null>(null); + const [position, setPosition] = useState(null); useLayoutEffect(() => { const anchor = props.anchor; @@ -125,12 +136,15 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: const updatePosition = () => { const rect = anchor.getBoundingClientRect(); - setPosition({ + const next = { bottom: window.innerHeight - rect.top + 8, left: rect.left, maxHeight: Math.max(96, rect.top - 24), width: rect.width, - }); + }; + setPosition((current) => + current && composerCommandMenuPositionsEqual(current, next) ? current : next, + ); }; updatePosition(); @@ -139,7 +153,16 @@ function ComposerCommandMenuLayer(props: { anchor: HTMLElement | null; children: const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(updatePosition); - observer?.observe(anchor); + if (observer) { + // The composer is centered and capped at a max width, so opening a side + // panel slides it sideways without ever resizing it. Watching the anchor + // alone would leave the menu behind; the ancestors are what shrink, and + // they resize on every frame of the panel animation. + observer.observe(anchor); + for (let element = anchor.parentElement; element; element = element.parentElement) { + observer.observe(element); + } + } return () => { observer?.disconnect(); From 4c95d3853ccf65e40134fc93b1dcf2e9d5b03cac Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:20:17 +0200 Subject: [PATCH 14/22] fix(web): restore terminal link hover styles (#5382) (cherry picked from commit 9d9a872bccc6ed099de85a121ab042c9e078987e) --- .../web/src/terminal/ghostty/renderer.test.ts | 56 +++++ apps/web/src/terminal/ghostty/renderer.ts | 21 +- apps/web/src/terminal/ghostty/surface.test.ts | 47 +++++ apps/web/src/terminal/ghostty/surface.ts | 197 +++++++++++++++++- 4 files changed, 309 insertions(+), 12 deletions(-) diff --git a/apps/web/src/terminal/ghostty/renderer.test.ts b/apps/web/src/terminal/ghostty/renderer.test.ts index f3de1725a60b..5f5c41c8fecb 100644 --- a/apps/web/src/terminal/ghostty/renderer.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.test.ts @@ -71,6 +71,62 @@ describe("ghosttyTextRunEnd", () => { }); describe("renderGhosttySnapshot", () => { + it("underlines every cell in a hovered wrapped link", () => { + const fillRectCalls: number[][] = []; + const context = { + canvas: { width: 200, height: 80 }, + beginPath: () => {}, + clip: () => {}, + fillRect: (...args: number[]) => fillRectCalls.push(args), + fillText: () => {}, + rect: () => {}, + resetTransform: () => {}, + restore: () => {}, + save: () => {}, + set fillStyle(_value: string) {}, + set font(_value: string) {}, + set textBaseline(_value: string) {}, + } as unknown as CanvasRenderingContext2D; + const snapshot: GhosttySnapshot = { + cols: 4, + rows: 2, + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0, 1]), + rowData: [0, 1].map(() => ({ + cells: [cell("a"), cell("b"), cell("c"), cell("d")], + text: "abcd", + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + + renderGhosttySnapshot({ + context, + snapshot, + metrics: { width: 10, height: 20, baseline: 15 }, + fontSize: 12, + fontFamily: "monospace", + padding: 4, + forceFull: false, + cursorOn: false, + hoveredLinkRange: { start: { x: 2, y: 0 }, end: { x: 1, y: 1 } }, + }); + + expect(fillRectCalls.filter(([, , , height]) => height === 1)).toEqual([ + [24, 22, 10, 1], + [34, 22, 10, 1], + [4, 42, 10, 1], + [14, 42, 10, 1], + ]); + }); + it("constrains text runs and cursor glyphs to their terminal cells", () => { const fillTextCalls: unknown[][] = []; const context = { diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 0cb7d6f535d6..9d47718464ea 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -12,6 +12,11 @@ export interface GhosttyCellMetrics { readonly baseline: number; } +export interface GhosttyCellRange { + readonly start: { readonly x: number; readonly y: number }; + readonly end: { readonly x: number; readonly y: number }; +} + const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; function cssColor(color: GhosttyColor): string { @@ -98,6 +103,7 @@ export function renderGhosttySnapshot(options: { readonly previousCursorY?: number | null; readonly focused?: boolean; readonly selectionBackground?: string; + readonly hoveredLinkRange?: GhosttyCellRange | null; /** Vertical origin of row 0; defaults to the horizontal padding. */ readonly originY?: number; }): void { @@ -114,6 +120,7 @@ export function renderGhosttySnapshot(options: { } = options; const focused = options.focused ?? true; const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; + const hoveredLinkRange = options.hoveredLinkRange ?? null; const originY = options.originY ?? padding; const rowsToDraw = forceFull ? Array.from({ length: snapshot.rows }, (_, index) => index) @@ -216,10 +223,20 @@ export function renderGhosttySnapshot(options: { for (let column = 0; column < row.cells.length; column += 1) { const cell = row.cells[column]; - if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline)) continue; + const hoveredLink = + hoveredLinkRange !== null && + rowIndex >= hoveredLinkRange.start.y && + rowIndex <= hoveredLinkRange.end.y && + (rowIndex > hoveredLinkRange.start.y || column >= hoveredLinkRange.start.x) && + (rowIndex < hoveredLinkRange.end.y || column <= hoveredLinkRange.end.x); + if (!cell || (!cell.underline && !cell.strikethrough && !cell.overline && !hoveredLink)) { + continue; + } context.fillStyle = cssColor(cell.foreground); const left = padding + column * metrics.width; - if (cell.underline) context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + if (cell.underline || hoveredLink) { + context.fillRect(left, top + metrics.height - 2, metrics.width, 1); + } if (cell.strikethrough) { context.fillRect(left, top + Math.floor(metrics.height * 0.55), metrics.width, 1); } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7f94a4c95c8d..f1559ac1578d 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -13,10 +13,13 @@ import { isTerminalPasteShortcut, shouldBlinkTerminalCursor, shouldReportTerminalMouse, + shouldShowTerminalLinkHover, + terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, terminalLinkAtColumn, terminalLinkAtPosition, + terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, fittedTerminalFontSize, @@ -56,6 +59,32 @@ describe("isTerminalAltGraphText", () => { }); }); +describe("terminalGridCellAt", () => { + const options = { + bounds: { left: 100, top: 200 }, + cols: 3, + rows: 2, + metrics: { width: 10, height: 20 }, + padding: 4, + originY: 24, + }; + + it("maps points inside the rendered grid without clamping its padding", () => { + expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 224 })).toEqual({ + x: 0, + y: 0, + }); + expect(terminalGridCellAt({ ...options, clientX: 133, clientY: 263 })).toEqual({ + x: 2, + y: 1, + }); + expect(terminalGridCellAt({ ...options, clientX: 103, clientY: 224 })).toBeNull(); + expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 223 })).toBeNull(); + expect(terminalGridCellAt({ ...options, clientX: 134, clientY: 224 })).toBeNull(); + expect(terminalGridCellAt({ ...options, clientX: 104, clientY: 264 })).toBeNull(); + }); +}); + describe("shouldBlinkTerminalCursor", () => { const blinking = { focused: true, @@ -99,6 +128,10 @@ describe("terminalLinkAtColumn", () => { expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); expect(terminalLinkAtColumn(row, 0)).toBeNull(); + expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({ + start: { x: 2, y: 0 }, + end: { x: cells.length - 1, y: 0 }, + }); }); it("uses shared path matching and reconstructs soft-wrapped links", () => { @@ -119,6 +152,13 @@ describe("terminalLinkAtColumn", () => { expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ + text: "https://example.com/reference", + range: { + start: { x: 0, y: 0 }, + end: { x: 12, y: 1 }, + }, + }); }); it("refuses links truncated at the viewport edges instead of mis-resolving", () => { @@ -245,6 +285,13 @@ describe("application mouse reporting", () => { it("maps browser buttons to Ghostty's button enum", () => { expect([0, 1, 2, 3, 4, 5].map(ghosttyMouseButton)).toEqual([1, 3, 2, 4, 5, null]); }); + + it("only shows link hover during mouse tracking when the link modifier is held", () => { + expect(shouldShowTerminalLinkHover(false, false)).toBe(true); + expect(shouldShowTerminalLinkHover(false, true)).toBe(true); + expect(shouldShowTerminalLinkHover(true, false)).toBe(false); + expect(shouldShowTerminalLinkHover(true, true)).toBe(true); + }); }); describe("terminal font resolution", () => { diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index b460d38d2df7..853da9c139c2 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -10,6 +10,7 @@ import { measureGhosttyCell, renderGhosttySnapshot, terminalGridSize, + type GhosttyCellRange, type GhosttyCellMetrics, } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; @@ -205,6 +206,28 @@ export function terminalScrollbarOffsetAtPointer( return Math.round((thumbTop / travel) * geometry.maxOffset); } +export function terminalGridCellAt(options: { + bounds: { left: number; top: number }; + clientX: number; + clientY: number; + cols: number; + rows: number; + metrics: Pick; + padding: number; + originY: number; +}): { x: number; y: number } | null { + const { bounds, clientX, clientY, cols, rows, metrics, padding, originY } = options; + const gridX = clientX - bounds.left - padding; + const gridY = clientY - bounds.top - originY; + if (gridX < 0 || gridY < 0 || gridX >= cols * metrics.width || gridY >= rows * metrics.height) { + return null; + } + return { + x: Math.floor(gridX / metrics.width), + y: Math.floor(gridY / metrics.height), + }; +} + function terminalRowText(row: GhosttySnapshot["rowData"][number], trimRight: boolean): string { const text = row.cells.map((cell) => cell.text || " ").join(""); return trimRight ? text.trimEnd() : text; @@ -223,6 +246,27 @@ export function terminalLinkAtPosition( rowIndex: number, column: number, ): string | null { + return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; +} + +export interface TerminalLinkWithRange { + readonly text: string; + readonly range: GhosttyCellRange; +} + +function terminalColumnAtOffset(row: GhosttySnapshot["rowData"][number], offset: number): number { + for (let column = 0; column < row.cells.length; column += 1) { + const nextOffset = terminalColumnOffset(row, column + 1); + if (offset < nextOffset) return column; + } + return Math.max(0, row.cells.length - 1); +} + +export function terminalLinkAtPositionWithRange( + rows: GhosttySnapshot["rowData"], + rowIndex: number, + column: number, +): TerminalLinkWithRange | null { const wrappedLine = collectWrappedTerminalLinkLine(rowIndex + 1, (index) => { const row = rows[index]; if (!row) return null; @@ -251,7 +295,28 @@ export function terminalLinkAtPosition( if (offset >= match.start && offset < match.end) { // A truncated tail must not activate as a complete link. if (match.end === wrappedLine.text.length && continuesBelowViewport) return null; - return match.text; + const startSegment = wrappedLine.segments.find( + (value) => match.start >= value.startIndex && match.start < value.endIndex, + ); + const endSegment = wrappedLine.segments.find( + (value) => match.end - 1 >= value.startIndex && match.end - 1 < value.endIndex, + ); + const startRow = startSegment ? rows[startSegment.bufferLineNumber - 1] : undefined; + const endRow = endSegment ? rows[endSegment.bufferLineNumber - 1] : undefined; + if (!startSegment || !endSegment || !startRow || !endRow) return null; + return { + text: match.text, + range: { + start: { + x: terminalColumnAtOffset(startRow, match.start - startSegment.startIndex), + y: startSegment.bufferLineNumber - 1, + }, + end: { + x: terminalColumnAtOffset(endRow, match.end - 1 - endSegment.startIndex), + y: endSegment.bufferLineNumber - 1, + }, + }, + }; } } return null; @@ -338,6 +403,13 @@ export function isTerminalLinkPointerGesture( : event.ctrlKey && !event.metaKey; } +export function shouldShowTerminalLinkHover( + mouseTracking: boolean, + linkModifierActive: boolean, +): boolean { + return !mouseTracking || linkModifierActive; +} + export function ghosttyMouseButton(button: number): number | null { switch (button) { case 0: @@ -444,6 +516,9 @@ export class GhosttyTerminalSurface { private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; + private hoveredLink: TerminalLinkWithRange | null = null; + private hoverPointer: { x: number; y: number } | null = null; + private linkModifierActive = false; private selectionClickSequence: TerminalSelectionClickSequence | null = null; private selectionMoved = false; private composing = false; @@ -815,6 +890,7 @@ export class GhosttyTerminalSurface { } private readonly onKeyDown = (event: KeyboardEvent) => { + this.updateLinkModifier(event); // Presses handled outside the terminal must also swallow their release: // beforeKey runs side effects (keybindings, navigation sends), so it cannot // be consulted again on keyup, and Kitty report-event-types sessions would @@ -866,6 +942,7 @@ export class GhosttyTerminalSurface { }; private readonly onKeyUp = (event: KeyboardEvent) => { + this.updateLinkModifier(event); if (this.suppressedKeyCodes.delete(event.code)) return; if (event.isComposing || this.composing || event.key === "Process" || event.keyCode === 229) { return; @@ -887,6 +964,8 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + this.linkModifierActive = false; + this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for // example terminal-toggle) must still swallow its own keyup if focus comes // back before release. Stale entries are harmless — an encoding keydown @@ -968,6 +1047,7 @@ export class GhosttyTerminalSurface { if (button === null) return; event.preventDefault(); event.stopPropagation(); + this.clearHoveredLink("default"); this.mouseReportingPointerId = event.pointerId; this.mouseReportingButton = button; this.sendMouse("press", button, event); @@ -982,6 +1062,7 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + this.clearHoveredLink(); const cell = this.cellAt(event.clientX, event.clientY); this.selectionMoved = false; this.selectionClickSequence = advanceTerminalSelectionClickSequence( @@ -1029,6 +1110,11 @@ export class GhosttyTerminalSurface { shouldReportTerminalMouse(this.core.isMouseAnyEventTracking(), event) ) { event.preventDefault(); + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + // A drag whose press was already sent to the terminal application cannot + // turn into link activation midway through, so link feedback would lie. + this.setHoveredLink(null); this.canvas.style.cursor = "default"; this.sendMouse("motion", this.buttonFromButtons(event.buttons), event); return; @@ -1037,6 +1123,7 @@ export class GhosttyTerminalSurface { this.updateHoverCursor(event); return; } + this.clearHoveredLink(); this.selectionPointer = { x: event.clientX, y: event.clientY }; const bounds = this.canvas.getBoundingClientRect(); this.setSelectionAutoscroll( @@ -1097,10 +1184,50 @@ export class GhosttyTerminalSurface { } private updateHoverCursor(event: PointerEvent): void { - const overLink = - isTerminalLinkPointerGesture(event) && this.linkAt(event.clientX, event.clientY) !== null; - const cursor = overLink ? "pointer" : ""; - if (this.canvas.style.cursor !== cursor) this.canvas.style.cursor = cursor; + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } + + private updateLinkModifier(event: Pick): void { + const active = isTerminalLinkPointerGesture(event); + if (active === this.linkModifierActive) return; + this.linkModifierActive = active; + this.refreshHoveredLink(); + } + + private readonly onPointerLeave = () => { + this.clearHoveredLink(); + }; + + private clearHoveredLink(cursor = ""): void { + this.hoverPointer = null; + this.setHoveredLink(null); + this.canvas.style.cursor = cursor; + } + + private refreshHoveredLink(): void { + const pointer = this.hoverPointer; + const link = + pointer && shouldShowTerminalLinkHover(this.core.isMouseTracking(), this.linkModifierActive) + ? this.linkAt(pointer.x, pointer.y) + : null; + this.setHoveredLink(link); + } + + private setHoveredLink(link: TerminalLinkWithRange | null): void { + const previous = this.hoveredLink; + const unchanged = + previous?.text === link?.text && + previous?.range.start.x === link?.range.start.x && + previous?.range.start.y === link?.range.start.y && + previous?.range.end.x === link?.range.end.x && + previous?.range.end.y === link?.range.end.y; + this.canvas.style.cursor = link ? "pointer" : ""; + if (unchanged) return; + this.hoveredLink = link; + this.forceFullRender = true; + this.requestRender(); } private readonly onPointerUp = (event: PointerEvent) => { @@ -1114,7 +1241,7 @@ export class GhosttyTerminalSurface { } if (event.type !== "pointercancel") { const link = this.linkAt(event.clientX, event.clientY); - if (link) this.options.onLinkActivate(link, event); + if (link) this.options.onLinkActivate(link.text, event); } return; } @@ -1127,6 +1254,13 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.type === "pointercancel") { + this.clearHoveredLink(); + } else { + this.hoverPointer = { x: event.clientX, y: event.clientY }; + this.linkModifierActive = isTerminalLinkPointerGesture(event); + this.refreshHoveredLink(); + } return; } if (this.canvas.hasPointerCapture(event.pointerId)) { @@ -1253,6 +1387,7 @@ export class GhosttyTerminalSurface { this.input.addEventListener("compositionend", this.onCompositionEnd); this.canvas.addEventListener("pointerdown", this.onPointerDown); this.canvas.addEventListener("pointermove", this.onPointerMove); + this.canvas.addEventListener("pointerleave", this.onPointerLeave); this.canvas.addEventListener("pointerup", this.onPointerUp); this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); @@ -1276,6 +1411,7 @@ export class GhosttyTerminalSurface { this.input.removeEventListener("compositionend", this.onCompositionEnd); this.canvas.removeEventListener("pointerdown", this.onPointerDown); this.canvas.removeEventListener("pointermove", this.onPointerMove); + this.canvas.removeEventListener("pointerleave", this.onPointerLeave); this.canvas.removeEventListener("pointerup", this.onPointerUp); this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); @@ -1378,6 +1514,7 @@ export class GhosttyTerminalSurface { this.originY = nextOriginY; this.forceFullRender = true; } + this.refreshHoveredLink(); renderGhosttySnapshot({ context: this.context, snapshot: this.snapshot, @@ -1390,6 +1527,7 @@ export class GhosttyTerminalSurface { cursorOn: this.cursorOn, previousCursorY: this.renderedCursorY, focused: this.focused, + hoveredLinkRange: this.hoveredLink?.range ?? null, ...(this.theme.selectionBackground !== undefined ? { selectionBackground: this.theme.selectionBackground } : {}), @@ -1466,12 +1604,51 @@ export class GhosttyTerminalSurface { }; } - private linkAt(clientX: number, clientY: number): string | null { + private linkAt(clientX: number, clientY: number): TerminalLinkWithRange | null { if (!this.snapshot) return null; - const cell = this.cellAt(clientX, clientY); + const cell = terminalGridCellAt({ + bounds: this.canvas.getBoundingClientRect(), + clientX, + clientY, + cols: this.cols, + rows: this.rows, + metrics: this.metrics, + padding: CONTENT_PADDING, + originY: this.originY, + }); + if (!cell) return null; const explicitHyperlink = this.core.hyperlinkAt(cell.x, cell.y); - if (explicitHyperlink) return explicitHyperlink; - return terminalLinkAtPosition(this.snapshot.rowData, cell.y, cell.x); + if (explicitHyperlink) { + const start = { ...cell }; + const end = { ...cell }; + while (true) { + const previous = + start.x > 0 + ? { x: start.x - 1, y: start.y } + : start.y > 0 && this.snapshot.rowData[start.y]?.isWrapContinuation + ? { x: this.cols - 1, y: start.y - 1 } + : null; + if (!previous || this.core.hyperlinkAt(previous.x, previous.y) !== explicitHyperlink) break; + start.x = previous.x; + start.y = previous.y; + } + while (true) { + const next = + end.x + 1 < this.cols + ? { x: end.x + 1, y: end.y } + : end.y + 1 < this.rows && this.snapshot.rowData[end.y]?.wrapsToNext + ? { x: 0, y: end.y + 1 } + : null; + if (!next || this.core.hyperlinkAt(next.x, next.y) !== explicitHyperlink) break; + end.x = next.x; + end.y = next.y; + } + return { + text: explicitHyperlink, + range: { start, end }, + }; + } + return terminalLinkAtPositionWithRange(this.snapshot.rowData, cell.y, cell.x); } private sendMouse( From 40cd33a9eb6d4315dbd4ef9ab2fc977a794db787 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 05:36:10 -0400 Subject: [PATCH 15/22] fix(ci): isolate releases from shared API rate limits (#5394) (cherry picked from commit 9697b765e5c32232e96514e38b6ecfac351d5a8f) --- .github/workflows/release.yml | 18 ++++++------------ docs/operations/release.md | 7 ++++--- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae6917995df5..99bbb06b6e44 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -793,15 +793,9 @@ jobs: if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + permissions: + contents: write steps: - - id: app_token - name: Mint release app token - uses: actions/create-github-app-token@v2 - with: - app-id: ${{ secrets.RELEASE_APP_ID }} - private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - owner: ${{ github.repository_owner }} - - name: Checkout uses: actions/checkout@v6 with: @@ -883,7 +877,7 @@ jobs: - name: Publish release if: needs.preflight.outputs.previous_tag != '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -901,11 +895,11 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ needs.preflight.outputs.tag }} target_commitish: ${{ needs.preflight.outputs.ref }} @@ -922,7 +916,7 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ steps.app_token.outputs.token }} + token: ${{ github.token }} deploy_web: name: Deploy hosted web app diff --git a/docs/operations/release.md b/docs/operations/release.md index 3d5b584e5e7a..38bfcba3e26d 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -50,14 +50,15 @@ The production job also reads `CLERK_PUBLISHABLE_KEY`, `CLERK_JWT_TEMPLATE`, `RE ## Required release credentials -The release workflow requires these GitHub Actions secrets in addition to the platform and deployment +Stable releases require these GitHub Actions secrets in addition to the platform and deployment credentials documented below: - `RELEASE_APP_ID` - `RELEASE_APP_PRIVATE_KEY` -The GitHub Release job uses them to mint the token that publishes release assets. Stable releases use -them again in the finalize job, which can commit and push aligned package versions to `main`. +The finalize job uses them to commit and push aligned package versions to `main` as the Release App. +GitHub Release publication uses the repository-scoped workflow token so it has a rate-limit quota +independent from the shared Release App installation. ## T3 Connect relay deployment From a2cf5e6074eabcf0de4f093bef0eed43f9da642a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 07:23:18 -0400 Subject: [PATCH 16/22] fix(web): keep model picker shortcuts in sync (#5400) (cherry picked from commit 37d3667de4e45ffb42dc96e8b7fbf39e3a78c1da) --- apps/web/src/components/chat/ModelPickerContent.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 6371fc20558c..7c86ec630141 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -541,6 +541,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); + const modelListExtraData = useMemo( + () => ({ favoritesSet, modelJumpLabelByKey }), + [favoritesSet, modelJumpLabelByKey], + ); useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { @@ -710,7 +714,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={modelListRef} data={filteredItemKeys} - extraData={favoritesSet} + extraData={modelListExtraData} keyExtractor={(modelKey) => modelKey} renderItem={({ item: modelKey, index }) => { if (legacySection?.key === modelKey) { From c708fc5ec3bb1e3a2938fcda1570a300f1862d44 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:42:56 +0200 Subject: [PATCH 17/22] fix(web): keep terminal font settings reliable (#5397) Co-authored-by: aaditagrawal --- apps/web/src/appearanceFonts.test.ts | 43 +++++++++++ apps/web/src/appearanceFonts.ts | 44 ++++++++++-- .../src/components/ThreadTerminalDrawer.tsx | 22 ++++-- .../components/settings/SettingsPanels.tsx | 30 +++++--- apps/web/src/terminal/ghostty/surface.test.ts | 24 ++++++- apps/web/src/terminal/ghostty/surface.ts | 71 ++++++++++++++++--- 6 files changed, 204 insertions(+), 30 deletions(-) diff --git a/apps/web/src/appearanceFonts.test.ts b/apps/web/src/appearanceFonts.test.ts index 8467c13c2cef..5c642e33f0f7 100644 --- a/apps/web/src/appearanceFonts.test.ts +++ b/apps/web/src/appearanceFonts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + areFontAdvancesMonospace, clampCodeFontSize, clampInterfaceFontSize, clampPromptFontSize, @@ -9,8 +10,22 @@ import { appearanceFontStack, cssFontFamilies, resolveDefaultFamilyLabel, + resolveTerminalFontPreference, } from "./appearanceFonts"; +describe("areFontAdvancesMonospace", () => { + it("accepts a fixed advance and rejects any proportional glyph", () => { + expect(areFontAdvancesMonospace([10, 10, 10, 10])).toBe(true); + expect(areFontAdvancesMonospace([10, 10, 7, 10])).toBe(false); + expect(areFontAdvancesMonospace([10, 10.02])).toBe(false); + }); + + it("fails open when canvas metrics are unavailable", () => { + expect(areFontAdvancesMonospace([])).toBe(true); + expect(areFontAdvancesMonospace([Number.NaN, Number.NaN])).toBe(true); + }); +}); + describe("cssFontFamilies", () => { it("returns null for effectively empty input", () => { expect(cssFontFamilies("")).toBeNull(); @@ -54,6 +69,34 @@ describe("appearanceFontStack", () => { }); }); +describe("resolveTerminalFontPreference", () => { + it("inherits the code font in simple mode", () => { + expect( + resolveTerminalFontPreference({ advanced: false, code: "Fira Code", terminal: "" }), + ).toBe("Fira Code"); + expect( + resolveTerminalFontPreference({ + advanced: false, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Fira Code"); + }); + + it("keeps code and terminal fonts independent in advanced mode", () => { + expect(resolveTerminalFontPreference({ advanced: true, code: "Fira Code", terminal: "" })).toBe( + "", + ); + expect( + resolveTerminalFontPreference({ + advanced: true, + code: "Fira Code", + terminal: "Berkeley Mono", + }), + ).toBe("Berkeley Mono"); + }); +}); + describe("font size clamping", () => { it("keeps sizes inside the ranges the UI can absorb", () => { expect(clampInterfaceFontSize(16)).toBe(16); diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 3fb6c821a1b1..60801ef01182 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -25,6 +25,22 @@ export const DEFAULT_SANS_FONT_STACK = export const DEFAULT_CODE_FONT_STACK = '"SF Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace'; +export const TYPOGRAPHY_ADVANCED_STORAGE_KEY = "t3code:typography-advanced"; + +/** + * Simple typography treats the terminal as another monospace surface. In + * Advanced mode an empty terminal preference means the terminal default, + * keeping later code-font changes isolated to code surfaces. + */ +export function resolveTerminalFontPreference(input: { + readonly advanced: boolean; + readonly code: string; + readonly terminal: string; +}): string { + if (input.advanced) return input.terminal; + return input.code; +} + function quoteFontFamilyName(name: string): string { const bare = name.trim(); if (bare.length === 0) return ""; @@ -165,6 +181,22 @@ export function isFontFamilyAvailable(family: string): boolean { } } +const MONOSPACE_PROBE_VARIANTS = ["normal 400", "normal 700", "italic 400", "italic 700"] as const; +const MONOSPACE_PROBE_GLYPHS = ["i", "M", "W", "0", "@", "#", ".", " "] as const; +const MONOSPACE_ADVANCE_TOLERANCE = 0.01; + +export function areFontAdvancesMonospace(advances: readonly number[]): boolean { + const reference = advances[0]; + if ( + reference === undefined || + reference <= 0 || + advances.some((advance) => !Number.isFinite(advance) || advance <= 0) + ) { + return true; + } + return advances.every((advance) => Math.abs(advance - reference) < MONOSPACE_ADVANCE_TOLERANCE); +} + /** * Whether a family renders every character on the same advance. Cell-grid * surfaces (the terminal) require this: a proportional face draws its text @@ -182,13 +214,15 @@ export function isMonospaceFamily(family: string): boolean { fontProbeContext = document.createElement("canvas").getContext("2d"); } if (fontProbeContext === null) return true; + const context = fontProbeContext; // Fall back to a generic mono so an absent face measures as monospace and // is left for the normal fallback chain to resolve. - fontProbeContext.font = `32px ${families}, monospace`; - const narrow = fontProbeContext.measureText("i").width; - const wide = fontProbeContext.measureText("M").width; - if (!Number.isFinite(narrow) || !Number.isFinite(wide) || wide === 0) return true; - return Math.abs(wide - narrow) < 0.5; + for (const variant of MONOSPACE_PROBE_VARIANTS) { + context.font = `${variant} 32px ${families}, monospace`; + const advances = MONOSPACE_PROBE_GLYPHS.map((glyph) => context.measureText(glyph).width); + if (!areFontAdvancesMonospace(advances)) return false; + } + return true; } catch { return true; } diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 914e04b647dd..072241426e25 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -18,6 +18,7 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import * as Schema from "effect/Schema"; import { type PointerEvent as ReactPointerEvent, type ReactNode, @@ -57,6 +58,7 @@ import { } from "../types"; import { readLocalApi } from "~/localApi"; import { useClientSettings } from "../hooks/useSettings"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { useAttachedTerminalSession } from "../state/terminalSessions"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; @@ -64,6 +66,7 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { resolveTerminalFontPreference, TYPOGRAPHY_ADVANCED_STORAGE_KEY } from "../appearanceFonts"; const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; @@ -241,6 +244,7 @@ export function shouldHandleTerminalExit( } interface TerminalViewportProps { + advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; terminalId: string; @@ -264,6 +268,7 @@ interface TerminalLaunchLocation { } export function TerminalViewport({ + advancedTypography, threadRef, threadId, terminalId, @@ -312,10 +317,12 @@ export function TerminalViewport({ onAddTerminalContext(selection); }); const readTerminalLabel = useEffectEvent(() => terminalLabel); - // The terminal inherits the monospace (code) preference unless it has an - // override of its own, so one font choice drives every mono surface. - const terminalFontFamily = useClientSettings( - (settings) => settings.fontFamilyTerminal.trim() || settings.fontFamilyCode, + const terminalFontFamily = useClientSettings((settings) => + resolveTerminalFontPreference({ + advanced: advancedTypography, + code: settings.fontFamilyCode, + terminal: settings.fontFamilyTerminal, + }), ); const terminalFontSize = useClientSettings((settings) => settings.fontSizeTerminal); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); @@ -921,6 +928,11 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const isPanel = mode === "panel"; + const [advancedTypography] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); const controlledDrawerHeight = clampDrawerHeight(height); const [drawerHeightState, setDrawerHeightState] = useState(() => ({ threadId, @@ -1357,6 +1369,7 @@ export default function ThreadTerminalDrawer({ >
updateSettings({ fontFamilyTerminal })} requireMonospace @@ -1258,7 +1258,11 @@ function TerminalFontRow() { }} preview={ } @@ -1350,7 +1354,11 @@ function SimpleFontRows() { <> @@ -1370,8 +1378,6 @@ const ADVANCED_TYPOGRAPHY_TARGET_IDS: ReadonlySet = new Set([ : []), ]); -const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; - /** * The two-font view by default - one sans, one monospace, each cascading to * every surface it reaches - with an Advanced switch in the section header @@ -1380,7 +1386,11 @@ const TYPOGRAPHY_ADVANCED_KEY = "t3code:typography-advanced"; * target exists to scroll to. */ function TypographySection() { - const [advanced, setAdvanced] = useLocalStorage(TYPOGRAPHY_ADVANCED_KEY, false, Schema.Boolean); + const [advanced, setAdvanced] = useLocalStorage( + TYPOGRAPHY_ADVANCED_STORAGE_KEY, + false, + Schema.Boolean, + ); const searchTargetId = useSettingsSearchTargetId(); // Flip Advanced on once per search jump so the hidden target can mount and // scroll; tracking the handled id lets the user turn it back off without diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index f1559ac1578d..d20a6cfe0059 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import type { GhosttyCell, GhosttyRow } from "./core"; import { @@ -11,6 +11,7 @@ import { isTerminalCopyShortcut, isTerminalLinkPointerGesture, isTerminalPasteShortcut, + loadTerminalFontFamily, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -295,6 +296,27 @@ describe("application mouse reporting", () => { }); describe("terminal font resolution", () => { + it("validates the requested face after its styles load", async () => { + let loaded = false; + const load = vi.fn(async () => { + loaded = true; + return []; + }); + const resolve = vi.fn(() => { + expect(loaded).toBe(true); + return DEFAULT_TERMINAL_FONT_FAMILY; + }); + + await expect( + loadTerminalFontFamily("Proportional Test", 12, { + load, + resolve, + }), + ).resolves.toBe(DEFAULT_TERMINAL_FONT_FAMILY); + expect(load).toHaveBeenCalledTimes(4); + expect(resolve).toHaveBeenCalledWith("Proportional Test"); + }); + it("keeps the glyph fallbacks behind a custom text face", () => { expect(terminalFontFamily()).toBe(DEFAULT_TERMINAL_FONT_FAMILY); expect(terminalFontFamily(" ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 853da9c139c2..8ff92c01c0cf 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -36,6 +36,13 @@ const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; +const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; +const TERMINAL_FONT_LOAD_VARIANTS = [ + "normal 400", + "normal 700", + "italic 400", + "italic 700", +] as const; /** Requested terminal font; omitted fields fall back to the defaults. */ export interface GhosttyTerminalFont { @@ -78,6 +85,13 @@ function quoteTerminalFontFamilies(list: string): string { .join(", "); } +function uncheckedTerminalFontFamily(family?: string): string { + const custom = family === undefined ? "" : quoteTerminalFontFamilies(family); + return custom.length === 0 + ? DEFAULT_TERMINAL_FONT_FAMILY + : `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; +} + export function terminalFontFamily(family?: string): string { // Quote non-ident names ("3270 Nerd Font", "M+ 1m"): an unquoted one makes // the whole canvas font string invalid and the assignment silently no-ops. @@ -88,7 +102,31 @@ export function terminalFontFamily(family?: string): string { // it here rather than render a ragged grid with a stranded cursor. if (!isMonospaceFamily(custom)) return DEFAULT_TERMINAL_FONT_FAMILY; // A custom face keeps the glyph fallbacks so prompt symbols stay covered. - return `${custom}, ${TERMINAL_GLYPH_FALLBACKS}`; + return uncheckedTerminalFontFamily(custom); +} + +/** Load every style the renderer can request, then validate the actual face. */ +export async function loadTerminalFontFamily( + family: string | undefined, + size: number, + environment?: { + readonly load: (font: string, text: string) => Promise; + readonly resolve: (family: string | undefined) => string; + }, +): Promise { + const candidate = uncheckedTerminalFontFamily(family); + const load = + environment?.load ?? ((font: string, text: string) => document.fonts.load(font, text)); + try { + await Promise.all( + TERMINAL_FONT_LOAD_VARIANTS.map((variant) => + load(`${variant} ${size}px ${candidate}`, TERMINAL_FONT_LOAD_TEXT), + ), + ); + } catch { + // The fixed-width fallback stack remains available if a face cannot load. + } + return (environment?.resolve ?? terminalFontFamily)(family); } /** @@ -479,9 +517,11 @@ export class GhosttyTerminalSurface { private readonly options: GhosttyTerminalSurfaceOptions; private metrics: GhosttyCellMetrics; private fontFamily: string; + private requestedFontFamily: string | undefined; private fontSize: number; private requestedFontSize: number; private fontEpoch = 0; + private pendingFontEpoch: number | null = null; private readonly resizeObserver: ResizeObserver; private readonly scrollbarThumb: HTMLDivElement; private snapshot: GhosttySnapshot | null = null; @@ -545,6 +585,7 @@ export class GhosttyTerminalSurface { context: CanvasRenderingContext2D, core: GhosttyTerminalCore, metrics: GhosttyCellMetrics, + fontFamily: string, options: GhosttyTerminalSurfaceOptions, ) { this.mount = mount; @@ -557,7 +598,8 @@ export class GhosttyTerminalSurface { this.metrics = metrics; this.options = options; this.theme = options.theme; - this.fontFamily = terminalFontFamily(options.font?.family); + this.fontFamily = fontFamily; + this.requestedFontFamily = options.font?.family; this.fontSize = terminalFontSize(options.font?.size); this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); @@ -600,16 +642,15 @@ export class GhosttyTerminalSurface { const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("Canvas 2D is unavailable"); - const fontFamily = terminalFontFamily(options.font?.family); const fontSize = terminalFontSize(options.font?.size); try { // Cell metrics must come from the faces that will render; measuring before // the bundled webfonts load would size the grid from a fallback font. await ensureTerminalSymbolsFont(); - await document.fonts.load(`${fontSize}px ${fontFamily}`); } catch { // Metrics fall back to whichever faces are already available. } + const fontFamily = await loadTerminalFontFamily(options.font?.family, fontSize); const metrics = measureGhosttyCell(context, fontSize, fontFamily); const grid = terminalGridSize(mount.clientWidth, mount.clientHeight, metrics, CONTENT_PADDING); const core = await GhosttyTerminalCore.create( @@ -629,6 +670,7 @@ export class GhosttyTerminalSurface { context, core, metrics, + fontFamily, options, ); surface.fit(); @@ -667,18 +709,16 @@ export class GhosttyTerminalSurface { async setFont(font: GhosttyTerminalFont): Promise { if (this.disposed) return; - const fontFamily = terminalFontFamily(font.family); const fontSize = terminalFontSize(font.size); // The fields only change together with their metrics after the load, and // the epoch lets the newest overlapping call win regardless of load order. const epoch = ++this.fontEpoch; - try { - await document.fonts.load(`${fontSize}px ${fontFamily}`); - } catch { - // Metrics fall back to whichever faces are already available. - } + this.pendingFontEpoch = epoch; + const fontFamily = await loadTerminalFontFamily(font.family, fontSize); if (this.disposed || epoch !== this.fontEpoch) return; + this.pendingFontEpoch = null; this.fontFamily = fontFamily; + this.requestedFontFamily = font.family; this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); @@ -706,6 +746,17 @@ export class GhosttyTerminalSurface { private readonly onFontsLoaded = () => { if (this.disposed) return; + // The explicit load validates every style and applies the newest request. + // Its own loading events must not revalidate the previously applied face. + if (this.pendingFontEpoch !== null) return; + // A face may become available after an earlier fallback measurement. Run + // the fixed-width guard again before using its newly loaded metrics. + const fontFamily = terminalFontFamily(this.requestedFontFamily); + if (fontFamily !== this.fontFamily) { + this.fontFamily = fontFamily; + this.applyFontMetrics(); + return; + } // A face that finished loading after the initial measurement changes glyph // advances; re-measure and refit so the grid matches what actually renders. const metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); From ff42556cae8ee0f4e34460dd02bb4f38835bb737 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 5 Aug 2026 18:03:10 +0200 Subject: [PATCH 18/22] Enrich terminal font previews (#5428) (cherry picked from commit de592a00e89776d2e0614f3be6e666012d90cd51) --- .../components/AppearancePreviews.tsx | 53 ++++++++++++++++--- .../settings/SettingsFontPreviews.tsx | 25 +++++++-- scripts/mobile-showcase-environment.ts | 24 ++++++--- 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx index fe9601096349..b111035c01fe 100644 --- a/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx +++ b/apps/mobile/src/features/settings/appearance/components/AppearancePreviews.tsx @@ -1,4 +1,11 @@ -import { Platform, ScrollView, View, useColorScheme } from "react-native"; +import { + Platform, + ScrollView, + type StyleProp, + type TextStyle, + View, + useColorScheme, +} from "react-native"; import { AppText as Text } from "../../../../components/AppText"; import { @@ -54,14 +61,48 @@ export function TerminalAppearancePreview(props: { readonly fontSize: number }) fontSize: props.fontSize, lineHeight, } as const; + // AppText stamps the sans font on every node, so nested spans must + // re-apply the terminal font instead of relying on inheritance, exactly + // like the code preview's tokens below. + const span = (color: string, extra?: TextStyle): StyleProp => [ + lineStyle, + { color, ...extra }, + ]; return ( - $ npm run dev - ✓ Ready in 430ms - - Local: http://localhost:3000{" "} - + + + t3code + git:( + main + ) + + vpr dev + + + VITE v7.1.1 + ready in + 1.24s + + + + Local: + + http://127.0.0.1:5173/ + + + + ✓ 85 passed + △ 2 warnings + ✗ 0 failed + + + + {" READY "} + + watching for changes{" "} + ); diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx index 9b719d8a576f..9541a0f07e00 100644 --- a/apps/web/src/components/settings/SettingsFontPreviews.tsx +++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx @@ -120,11 +120,26 @@ export function CodeFontPreview() { ); } -const TERMINAL_PROMPT = "\x1b[2m$\x1b[0m "; +// A zsh-style prompt: green arrow, cyan project, blue/red git segment, +// yellow dirty marker. It doubles as the echo loop's fresh-line prompt. +const TERMINAL_PROMPT = + "\x1b[1;32m→\x1b[0m \x1b[1;36mt3code\x1b[0m \x1b[1;34mgit:(\x1b[1;31mmain\x1b[1;34m)\x1b[0m \x1b[1;33m✗\x1b[0m "; +// A dev-server startup: brand line, addresses, a test summary, and a READY +// badge. Together the lines cover bold, dim, underline, the six accent +// colors, and a background cell, so a font choice shows every SGR the +// terminal actually renders. const TERMINAL_PREVIEW_TRANSCRIPT = - `${TERMINAL_PROMPT}npm run dev\r\n` + - "\x1b[32m✓\x1b[0m Ready in 430ms\r\n" + - "\x1b[2mLocal:\x1b[0m \x1b[36mhttp://localhost:3000\x1b[0m\r\n" + + `${TERMINAL_PROMPT}vpr dev\r\n` + + "\r\n" + + " \x1b[1;32mVITE\x1b[0m \x1b[32mv7.1.1\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m1.24s\x1b[0m\r\n" + + "\r\n" + + " \x1b[32m→\x1b[0m \x1b[2mLocal:\x1b[0m \x1b[4;36mhttp://127.0.0.1:5173/\x1b[0m\r\n" + + " \x1b[32m→\x1b[0m \x1b[2mNetwork:\x1b[0m \x1b[4;36mhttp://192.168.1.24:5173/\x1b[0m\r\n" + + "\r\n" + + " \x1b[32m✓ 85 passed\x1b[0m \x1b[33m△ 2 warnings\x1b[0m \x1b[31m✗ 0 failed\x1b[0m\r\n" + + "\r\n" + + " \x1b[42;30m READY \x1b[0m \x1b[2mwatching for changes — press\x1b[0m \x1b[1mq\x1b[0m \x1b[2mto quit\x1b[0m\r\n" + + "\r\n" + TERMINAL_PROMPT; /** The surface treats an omitted family or size as "use the built-in default". */ @@ -225,7 +240,7 @@ export function TerminalFontPreview({ family, size }: { family: string; size: nu return (
); diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index bb6b5f65d067..389d57df099e 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -59,17 +59,27 @@ const PROJECT_SCRIPTS = JSON.stringify([ }, ]); +const SHOWCASE_TERMINAL_PROMPT = + "\u001b[1;32m→\u001b[0m \u001b[1;36mt3code\u001b[0m \u001b[1;34mgit:(\u001b[1;31mfeat/remote-command-center\u001b[1;34m)\u001b[0m \u001b[1;33m✗\u001b[0m "; + +// A dev-server startup mirroring the web settings' terminal font preview: +// zsh-style prompt, brand line, addresses, the thread's 612-test summary, +// and a READY badge, so the scene exercises bold, dim, underline, the six +// accent colors, and a background cell. export const SHOWCASE_TERMINAL_BUFFER = [ - "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m", - "$ vp test run --changed", + `${SHOWCASE_TERMINAL_PROMPT}vpr dev`, + "", + " \u001b[1;32mVITE\u001b[0m \u001b[32mv7.1.1\u001b[0m \u001b[2mready in\u001b[0m \u001b[1m1.24s\u001b[0m", + "", + " \u001b[32m→\u001b[0m \u001b[2mLocal:\u001b[0m \u001b[4;36mhttp://127.0.0.1:5173/\u001b[0m", + " \u001b[32m→\u001b[0m \u001b[2mNetwork:\u001b[0m \u001b[4;36mhttp://192.168.1.24:5173/\u001b[0m", + " \u001b[32m→\u001b[0m \u001b[2mProject:\u001b[0m \u001b[1mt3code\u001b[0m \u001b[2m— ~/Code/t3code\u001b[0m", "", - " \u001b[38;5;117mt3code-mobile\u001b[0m 184 passed", - " \u001b[38;5;213mclient-runtime\u001b[0m 263 passed", - " \u001b[38;5;221mserver\u001b[0m 165 passed", + " \u001b[32m✓ 612 passed\u001b[0m \u001b[33m△ 2 warnings\u001b[0m \u001b[31m✗ 0 failed\u001b[0m", "", - "\u001b[32m✨ 612 tests passed\u001b[0m · 3 environments online", + " \u001b[42;30m READY \u001b[0m \u001b[2mwatching for changes — press\u001b[0m \u001b[1mq\u001b[0m \u001b[2mto quit\u001b[0m", "", - "\u001b[38;5;75m~/Code/t3code\u001b[0m \u001b[38;5;212mfeat/remote-command-center\u001b[0m $ ", + SHOWCASE_TERMINAL_PROMPT, ].join("\r\n"); const BASE_ENVIRONMENT_PRESENCE = `export function environmentLabel(count: number): string { From 416b74b1a0a4f4ff3475aaab3b88bb52e9ffb8ee Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:02:53 -0400 Subject: [PATCH 19/22] fix(web): preserve terminal font size when splitting (#5444) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> (cherry picked from commit 30e471530b3e2ddf59caf309636bed940b8b3776) --- apps/web/src/terminal/ghostty/surface.test.ts | 19 ------- apps/web/src/terminal/ghostty/surface.ts | 55 ------------------- 2 files changed, 74 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index d20a6cfe0059..31bc47bdff79 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -23,7 +23,6 @@ import { terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, - fittedTerminalFontSize, terminalFontSize, terminalWheelArrowData, terminalWheelDeltaRows, @@ -343,24 +342,6 @@ describe("terminal font resolution", () => { expect(terminalFontFamily(" , ")).toBe(DEFAULT_TERMINAL_FONT_FAMILY); }); - it("slides the rendered size down until a full-width grid fits the canvas", () => { - // SF Mono-like advance: 0.6em per cell. - const cellWidthAt = (size: number) => size * 0.6; - // A wide drawer keeps the preference untouched. - expect(fittedTerminalFontSize(cellWidthAt, 20, 1140)).toBe(20); - // A split pane at the same preference shrinks until 80 columns fit. - const fitted = fittedTerminalFontSize(cellWidthAt, 20, 570); - expect(fitted).toBeLessThan(20); - expect(Math.floor((570 - 8) / cellWidthAt(fitted))).toBeGreaterThanOrEqual(80); - // A tiny pane stops at the legibility floor instead of vanishing. - expect(fittedTerminalFontSize(cellWidthAt, 20, 220)).toBe(8); - // A preference below the floor is honored as-is. - expect(fittedTerminalFontSize(cellWidthAt, 6, 220)).toBe(6); - // Unmeasured layouts leave the preference alone. - expect(fittedTerminalFontSize(cellWidthAt, 14, 0)).toBe(14); - expect(fittedTerminalFontSize(() => 0, 14, 600)).toBe(14); - }); - it("clamps requested font sizes to the supported range", () => { expect(terminalFontSize()).toBe(DEFAULT_TERMINAL_FONT_SIZE); expect(terminalFontSize(Number.NaN)).toBe(DEFAULT_TERMINAL_FONT_SIZE); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8ff92c01c0cf..8dee6218fb91 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -129,42 +129,6 @@ export async function loadTerminalFontFamily( return (environment?.resolve ?? terminalFontFamily)(family); } -/** - * Grids narrower than a classic 80-column terminal wrap command output hard, - * so the rendered font size follows the canvas width: the preference is the - * ceiling, and the size slides down (to a legibility floor) until a full-width - * grid fits. A widening pane slides it back up toward the preference. - */ -const MIN_TERMINAL_FIT_COLUMNS = 80; -const MIN_TERMINAL_FIT_FONT_SIZE = 8; - -export function fittedTerminalFontSize( - cellWidthAt: (size: number) => number, - requested: number, - mountWidth: number, -): number { - const available = mountWidth - CONTENT_PADDING * 2; - if (available <= 0) return requested; - const floor = Math.min(requested, MIN_TERMINAL_FIT_FONT_SIZE); - const fits = (cellWidth: number) => - cellWidth > 0 && Math.floor(available / cellWidth) >= MIN_TERMINAL_FIT_COLUMNS; - let cellWidth = cellWidthAt(requested); - if (cellWidth <= 0 || fits(cellWidth)) return requested; - // The advance scales linearly with size for monospace faces: jump close to - // the fitting size, then settle the remaining rounding one step at a time. - const targetCellWidth = available / MIN_TERMINAL_FIT_COLUMNS; - let size = Math.max( - floor, - Math.min(requested, Math.floor((requested * targetCellWidth) / cellWidth)), - ); - while (size > floor) { - cellWidth = cellWidthAt(size); - if (cellWidth <= 0 || fits(cellWidth)) break; - size -= 1; - } - return size; -} - export function terminalFontSize(size?: number): number { if (size === undefined || !Number.isFinite(size)) return DEFAULT_TERMINAL_FONT_SIZE; return Math.max(MIN_TERMINAL_FONT_SIZE, Math.min(MAX_TERMINAL_FONT_SIZE, Math.round(size))); @@ -519,7 +483,6 @@ export class GhosttyTerminalSurface { private fontFamily: string; private requestedFontFamily: string | undefined; private fontSize: number; - private requestedFontSize: number; private fontEpoch = 0; private pendingFontEpoch: number | null = null; private readonly resizeObserver: ResizeObserver; @@ -601,7 +564,6 @@ export class GhosttyTerminalSurface { this.fontFamily = fontFamily; this.requestedFontFamily = options.font?.family; this.fontSize = terminalFontSize(options.font?.size); - this.requestedFontSize = this.fontSize; this.resizeObserver = new ResizeObserver(() => this.fit()); this.installEvents(); this.watchDevicePixelRatio(); @@ -719,7 +681,6 @@ export class GhosttyTerminalSurface { this.pendingFontEpoch = null; this.fontFamily = fontFamily; this.requestedFontFamily = font.family; - this.requestedFontSize = fontSize; this.fontSize = fontSize; this.applyFontMetrics(); } @@ -775,22 +736,6 @@ export class GhosttyTerminalSurface { const width = this.mount.clientWidth; const height = this.mount.clientHeight; if (width <= 0 || height <= 0) return false; - const fitted = fittedTerminalFontSize( - (size) => measureGhosttyCell(this.context, size, this.fontFamily).width, - this.requestedFontSize, - width, - ); - if (fitted !== this.fontSize) { - this.fontSize = fitted; - this.metrics = measureGhosttyCell(this.context, this.fontSize, this.fontFamily); - // The grid-change branch below resizes the core, but only when the - // column count moved; the cell geometry always did, so sync it here. - this.core.resize(this.cols, this.rows, this.metrics.width, this.metrics.height); - this.inputLeft = -1; - this.inputTop = -1; - this.forceFullRender = true; - this.scrollbarDirty = true; - } const ratio = window.devicePixelRatio || 1; const pixelWidth = Math.max(1, Math.round(width * ratio)); const pixelHeight = Math.max(1, Math.round(height * ratio)); From 66747926804e2e37fbac9df04d34aa640fdc4039 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 5 Aug 2026 23:09:23 +0200 Subject: [PATCH 20/22] Prevent terminal loading flash (#5432) (cherry picked from commit 7251f1a1f14afdf626e7c7f8664da423594732a3) --- apps/web/src/terminal/ghostty/surface.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8dee6218fb91..fc7a89c6d31e 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -604,6 +604,11 @@ export class GhosttyTerminalSurface { const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("Canvas 2D is unavailable"); + // An opaque canvas backing store initializes to solid black, and the font + // and WASM loads below leave it on screen for the whole setup window; paint + // the theme background first so the mount never flashes a black box. + context.fillStyle = `rgb(${options.theme.background.r}, ${options.theme.background.g}, ${options.theme.background.b})`; + context.fillRect(0, 0, canvas.width, canvas.height); const fontSize = terminalFontSize(options.font?.size); try { // Cell metrics must come from the faces that will render; measuring before From 2296024e04fe6bceca073598f19eb8eb3afbb27c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 5 Aug 2026 22:12:52 -0400 Subject: [PATCH 21/22] fix: reconnect faster after remote server updates (#5404) Co-authored-by: Claude Fable 5 (cherry picked from commit 990bb0b689b23a33f8e53f12962fe2542b3f4005) --- apps/server/src/server.ts | 9 ++- .../client-runtime/src/state/server.test.ts | 55 +++++++++++++++++++ packages/client-runtime/src/state/server.ts | 48 ++++++++++++---- 3 files changed, 98 insertions(+), 14 deletions(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 534d216aade6..49a3a31940f7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -583,8 +583,13 @@ export const makeServerLayer = Layer.unwrap( const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) return; - yield* Effect.sleep("250 millis").pipe( - Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), + // No settling delay before the first attempt: routes are already + // serving by the time activation opens this gate (the startup + // sequence awaits routesReady), and the retry schedule below + // covers anything this sleep used to hedge against. Every + // millisecond here is dead time on the path to remote + // reachability after a restart. + yield* reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`).pipe( Effect.retry({ while: (error) => error._tag !== "EnvironmentHttpBadRequestError" && diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index d764b729fc30..8edecae5646e 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -7,12 +7,16 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; @@ -30,6 +34,7 @@ import { makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, + nudgeReconnectDuringUpdateRestart, projectServerWelcome, resolveServerConfigValue, resolveServerUpdateProgressResult, @@ -71,6 +76,56 @@ function session(client: WsRpcProtocolClient): RpcSession { }; } +describe("update restart reconnect nudges", () => { + it.effect("retries once per backoff entry instead of only the first", () => + Effect.gen(function* () { + const retries = yield* Ref.make(0); + const states = [ + { phase: "backoff" }, + { phase: "connecting" }, + { phase: "backoff" }, + { phase: "backoff" }, + ]; + + yield* nudgeReconnectDuringUpdateRestart({ + stateChanges: Stream.fromIterable(states), + retryNow: Ref.update(retries, (count) => count + 1), + interval: Duration.zero, + }); + + // Three backoff entries, three nudges. The old one-shot behavior fired + // once and then let the supervisor's ladder stretch to 16-second gaps. + expect(yield* Ref.get(retries)).toBe(3); + }), + ); + + it.effect("paces nudges so a fast-failing connection cannot spin", () => + Effect.gen(function* () { + const retries = yield* Ref.make(0); + + const fiber = yield* Effect.forkChild( + nudgeReconnectDuringUpdateRestart({ + stateChanges: Stream.fromIterable([{ phase: "backoff" }, { phase: "backoff" }]), + retryNow: Ref.update(retries, (count) => count + 1), + }), + { startImmediately: true }, + ); + + // Each nudge waits out the interval first, so nothing fires immediately. + yield* TestClock.adjust(Duration.millis(999)); + expect(yield* Ref.get(retries)).toBe(0); + + yield* TestClock.adjust(Duration.millis(1)); + expect(yield* Ref.get(retries)).toBe(1); + + yield* TestClock.adjust(Duration.seconds(1)); + expect(yield* Ref.get(retries)).toBe(2); + + yield* Fiber.join(fiber); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); + describe("server state projection", () => { it("only treats a legacy transport interruption as an unacknowledged handoff", () => { expect(isLegacyUpdateHandoffLoss(Cause.interrupt(1))).toBe(true); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 12639c4ed7b7..8c61a939e9e9 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -149,6 +149,35 @@ export function validateServerUpdateReadyEvent( ); } +/** + * Keeps reconnect attempts ~1s apart for the whole update restart. + * + * A restart takes the server down for ~15 seconds, but the supervisor's normal + * backoff ladder (1/2/4/8/16s) assumes an unexpected failure and lands attempts + * at ~3, 5, 9, 17 and 33 seconds — so a 15-second restart is observed as a + * 33-second "Resuming". Nudging on every backoff entry (not just the first) + * holds the retry cadence flat until the server answers again. The sleep before + * each nudge is the pacer: a connection that fails instantly re-enters backoff + * immediately and would otherwise spin a tight retry loop. + * + * Callers fork this as a child of the update command so it is interrupted as + * soon as the update settles, whether it succeeds, fails, or times out. + */ +export function nudgeReconnectDuringUpdateRestart(input: { + readonly stateChanges: Stream.Stream<{ readonly phase: string }, unknown>; + readonly retryNow: Effect.Effect; + readonly interval?: Duration.Duration; +}): Effect.Effect { + return input.stateChanges.pipe( + Stream.filter((state) => state.phase === "backoff"), + Stream.runForEach(() => + Effect.sleep(input.interval ?? Duration.seconds(1)).pipe(Effect.andThen(input.retryNow)), + ), + Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT), + Effect.ignore, + ); +} + export function serverUpdateStateForProgressEvent( fromVersion: string, targetVersion: string, @@ -589,18 +618,13 @@ export function createServerEnvironmentAtoms( }), ); - // The update restart is intentional. As soon as the supervisor sees - // that first failed connection, discard any prior backoff debt and - // retry immediately instead of carrying an old 16-second delay. - yield* environmentRegistry.stateChanges(target.environmentId).pipe( - Stream.filter((state) => state.phase === "backoff"), - Stream.take(1), - Stream.runDrain, - Effect.andThen(environmentRegistry.retryNow(target.environmentId)), - Effect.timeoutOption(Duration.seconds(30)), - Effect.ignore, - Effect.forkChild, - ); + // The update restart is intentional and the server stays unreachable + // for the whole restart, so hold the retry cadence flat instead of + // letting the supervisor climb its backoff ladder. + yield* nudgeReconnectDuringUpdateRestart({ + stateChanges: environmentRegistry.stateChanges(target.environmentId), + retryNow: environmentRegistry.retryNow(target.environmentId), + }).pipe(Effect.forkChild); const resumed = yield* environmentRegistry .followStream(target.environmentId, subscribe(WS_METHODS.subscribeServerLifecycle, {})) From 471b31aa5bf6744d169f88039c1cd615ff09321d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:30:17 +0000 Subject: [PATCH 22/22] fix: keep RELEASE_APP token for GitHub Release publish Upstream #5394 switched the Publish job to github.token for rate-limit isolation. This fork already publishes via RELEASE_APP_* secrets, so restore minting that app token for softprops/action-gh-release while keeping the rest of the #5394 workflow updates. Co-authored-by: aaditagrawal --- .github/workflows/release.yml | 15 +++++++++++++-- docs/operations/release.md | 5 ++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 99bbb06b6e44..210a9331cd61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -796,6 +796,17 @@ jobs: permissions: contents: write steps: + # Fork: keep publishing GitHub Releases with the Release App token rather + # than github.token from upstream #5394. This fork's RELEASE_APP_* secrets + # are the established publish path for assets and release notes. + - id: app_token + name: Mint release app token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout uses: actions/checkout@v6 with: @@ -895,7 +906,7 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ github.token }} + token: ${{ steps.app_token.outputs.token }} - name: Publish first release if: needs.preflight.outputs.previous_tag == '' @@ -916,7 +927,7 @@ jobs: release-assets/*.blockmap release-assets/*.yml fail_on_unmatched_files: true - token: ${{ github.token }} + token: ${{ steps.app_token.outputs.token }} deploy_web: name: Deploy hosted web app diff --git a/docs/operations/release.md b/docs/operations/release.md index 38bfcba3e26d..d64cbebfcf8c 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -56,9 +56,8 @@ credentials documented below: - `RELEASE_APP_ID` - `RELEASE_APP_PRIVATE_KEY` -The finalize job uses them to commit and push aligned package versions to `main` as the Release App. -GitHub Release publication uses the repository-scoped workflow token so it has a rate-limit quota -independent from the shared Release App installation. +The GitHub Release job uses them to mint the token that publishes release assets. Stable releases use +them again in the finalize job, which can commit and push aligned package versions to `main`. ## T3 Connect relay deployment