From edfd0bdb0bd3754cabfd088494a4c0ff64ab16c0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 14:10:53 -0400 Subject: [PATCH] feat(tui): apply semantic content lanes --- packages/tui/src/routes/session/index.tsx | 111 ++++++++++++------ .../tui/src/routes/session/markdown-lanes.ts | 36 ++++-- .../tui/inline-tool-wrap-snapshot.test.tsx | 6 + .../tui/test/cli/tui/markdown-lanes.test.ts | 30 +++-- 4 files changed, 132 insertions(+), 51 deletions(-) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6c53d03f32..75a2d925fa 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -116,7 +116,7 @@ addDefaultParsers(parsers.parsers) const NAVIGATION_SLACK_ID = "session-navigation-slack" const BACKGROUND_TOOL_HINT_DELAY = 1_000 // The assistant inset leaves 85 columns for code, matching common documentation guidance. -const SESSION_CODE_LANE_WIDTH = 88 +const SESSION_TECHNICAL_LANE_WIDTH = 88 // Tail-first transcript mounting: rows mounted with the session, then backfill cadence. // The tail comfortably overfills a tall viewport; backfill drains a 200-message transcript @@ -1181,22 +1181,30 @@ function SessionRowView(props: SessionRowViewProps) { )} - + + + {(row) => } - {(row) => } + {(row) => ( + + + + )} {(row) => ( - + + + )} @@ -1214,7 +1222,13 @@ function SessionRowView(props: SessionRowViewProps) { {(row) => ( - + + + )} @@ -1222,19 +1236,21 @@ function SessionRowView(props: SessionRowViewProps) { ) } -function SessionContentLane(props: { children: JSX.Element; width: "readable" | "code" }) { +function SessionContentLane(props: { children: JSX.Element; width: "readable" | "technical" }) { const ctx = use() const maxWidth = createMemo(() => { const readable = ctx.config.session?.max_width ?? "auto" if (readable === "auto" || props.width === "readable") return readable - return Math.max(readable, SESSION_CODE_LANE_WIDTH) + return Math.max(readable, SESSION_TECHNICAL_LANE_WIDTH) }) return ( - - - {props.children} + + + + {props.children} + - + ) } @@ -1400,20 +1416,35 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { - } /> + + } /> + - + + + - }> - } /> + + + + } + > + + } /> + - } /> + + } /> + ) @@ -1434,11 +1465,13 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string) - + + + @@ -2273,9 +2306,9 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { return ( - {content} - - {content} + {content} + + {content} {content} @@ -2295,6 +2328,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) { const display = createMemo(() => toolDisplay(props.part.name)) + const width = createMemo(() => toolLane(props.part.name)) const toolprops = { get metadata() { @@ -2364,10 +2398,12 @@ function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean } ) return [ - content, - - - , + {content}, + + + + + , ] } @@ -3201,7 +3237,7 @@ function Edit(props: ToolProps) { const diffView = ctx.config.diffs?.view if (diffView === "unified") return "unified" if (diffView === "split") return "split" - // Default to "auto" behavior + if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified" return ctx.width > 120 ? "split" : "unified" }) @@ -3279,6 +3315,7 @@ function ApplyPatch(props: ToolProps) { const view = createMemo(() => { if (ctx.config.diffs?.view === "unified") return "unified" if (ctx.config.diffs?.view === "split") return "split" + if ((ctx.config.session?.max_width ?? "auto") !== "auto") return "unified" return ctx.width > 120 ? "split" : "unified" }) @@ -3464,11 +3501,17 @@ const toolDisplays = new Set([ "skill", ]) +const technicalToolDisplays = new Set(["shell", "write", "edit", "execute", "patch", "generic"]) + export function toolDisplay(tool: string) { const normalized = canonicalToolName(tool) return toolDisplays.has(normalized) ? normalized : "generic" } +export function toolLane(tool: string): "readable" | "technical" { + return technicalToolDisplays.has(toolDisplay(tool)) ? "technical" : "readable" +} + function recordValue(value: unknown): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return return value as Record diff --git a/packages/tui/src/routes/session/markdown-lanes.ts b/packages/tui/src/routes/session/markdown-lanes.ts index 402baaa552..9102d5777b 100644 --- a/packages/tui/src/routes/session/markdown-lanes.ts +++ b/packages/tui/src/routes/session/markdown-lanes.ts @@ -1,29 +1,38 @@ export type MarkdownLane = { content: string - width: "readable" | "code" | "wide" + width: "readable" | "technical" | "full" } export function markdownLanes(content: string): MarkdownLane[] { const result: MarkdownLane[] = [] let fence: { marker: "`" | "~"; length: number } | undefined + let table = false + const lines = content.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? [] - for (const line of content.match(/[^\n]*(?:\n|$)/g)?.filter(Boolean) ?? []) { + for (const [index, line] of lines.entries()) { const opening = fence ? undefined : line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)/) const marker = opening?.[1] + const tableOpening = !opening && !fence && isTableRow(line) && isTableDelimiter(lines[index + 1]) if (marker) fence = { marker: marker.startsWith("`") ? "`" : "~", length: marker.length } + if (tableOpening) table = true const width = opening ? opening[2]?.trim().split(/\s/, 1)[0]?.toLowerCase() === "mermaid" - ? "wide" - : "code" + ? "full" + : "technical" : fence - ? (result.at(-1)?.width ?? "code") - : "readable" + ? (result.at(-1)?.width ?? "technical") + : table + ? "technical" + : "readable" const previous = result.at(-1) if (previous?.width === width) previous.content += line else result.push({ content: line, width }) - if (!fence) continue + if (!fence) { + if (table && !isTableRow(lines[index + 1])) table = false + continue + } const currentFence = fence const trimmed = line.trim() if ( @@ -39,7 +48,18 @@ export function markdownLanes(content: string): MarkdownLane[] { return result } +function isTableRow(line: string | undefined) { + return Boolean(line?.trim() && line.includes("|")) +} + +function isTableDelimiter(line: string | undefined) { + if (!line) return false + const value = line.trim().replace(/^\||\|$/g, "") + const cells = value.split("|") + return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())) +} + export function markdownLaneMarginTop(index: number, width: MarkdownLane["width"]) { - if (index === 0 || width === "wide") return 0 + if (index === 0 || width === "full") return 0 return 1 } diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index e6958334c9..3055db8294 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -10,6 +10,7 @@ import { parseQuestionAnswers, parseQuestions, toolDisplay, + toolLane, } from "../../../src/routes/session" let testSetup: Awaited> | undefined @@ -131,6 +132,11 @@ describe("TUI inline tool wrapping", () => { expect(toolDisplay("apply_patch")).toBe("patch") expect(toolDisplay("patch")).toBe("patch") expect(toolDisplay("plugin_tool")).toBe("generic") + expect(toolLane("glob")).toBe("readable") + expect(toolLane("webfetch")).toBe("readable") + expect(toolLane("shell")).toBe("technical") + expect(toolLane("apply_patch")).toBe("technical") + expect(toolLane("plugin_tool")).toBe("technical") }) test("replaces pending copy when a tool fails before completion", async () => { diff --git a/packages/tui/test/cli/tui/markdown-lanes.test.ts b/packages/tui/test/cli/tui/markdown-lanes.test.ts index 0cf06e3e43..3e6613de1a 100644 --- a/packages/tui/test/cli/tui/markdown-lanes.test.ts +++ b/packages/tui/test/cli/tui/markdown-lanes.test.ts @@ -5,7 +5,7 @@ test("keeps prose in the readable lane", () => { expect(markdownLanes("Before\n\nAfter")).toEqual([{ content: "Before\n\nAfter", width: "readable" }]) }) -test("moves fenced blocks into the wide lane", () => { +test("moves Mermaid fences into the full lane", () => { expect( markdownLanes(`Before @@ -17,40 +17,52 @@ flowchart LR After`), ).toEqual([ { content: "Before\n\n", width: "readable" }, - { content: "```mermaid\nflowchart LR\n A --> B\n```\n", width: "wide" }, + { content: "```mermaid\nflowchart LR\n A --> B\n```\n", width: "full" }, { content: "\nAfter", width: "readable" }, ]) }) -test("keeps an incomplete streaming fence wide", () => { +test("keeps an incomplete streaming Mermaid fence full width", () => { expect(markdownLanes("Before\n```mermaid\nflowchart LR\n A -->")).toEqual([ { content: "Before\n", width: "readable" }, - { content: "```mermaid\nflowchart LR\n A -->", width: "wide" }, + { content: "```mermaid\nflowchart LR\n A -->", width: "full" }, ]) }) test("supports tilde fences and longer closing fences", () => { expect(markdownLanes("~~~ts\nconst value = 1\n~~~~\nAfter")).toEqual([ - { content: "~~~ts\nconst value = 1\n~~~~\n", width: "code" }, + { content: "~~~ts\nconst value = 1\n~~~~\n", width: "technical" }, { content: "After", width: "readable" }, ]) }) test("does not close a fence indented as code", () => { expect(markdownLanes("```ts\n ```\nstill code")).toEqual([ - { content: "```ts\n ```\nstill code", width: "code" }, + { content: "```ts\n ```\nstill code", width: "technical" }, ]) }) test("gives ordinary fenced code an intermediate lane", () => { expect(markdownLanes("```ts\nexport const value = true\n```")).toEqual([ - { content: "```ts\nexport const value = true\n```", width: "code" }, + { content: "```ts\nexport const value = true\n```", width: "technical" }, ]) }) +test("gives Markdown tables the technical lane", () => { + expect(markdownLanes("Before\n\n| Name | Value |\n| --- | ---: |\n| Width | 88 |\n\nAfter")).toEqual([ + { content: "Before\n\n", width: "readable" }, + { content: "| Name | Value |\n| --- | ---: |\n| Width | 88 |\n", width: "technical" }, + { content: "\nAfter", width: "readable" }, + ]) +}) + +test("does not treat ordinary pipe characters as a table", () => { + expect(markdownLanes("Use foo | bar in prose.")).toEqual([{ content: "Use foo | bar in prose.", width: "readable" }]) +}) + test("restores spacing between separately rendered blocks", () => { expect(markdownLaneMarginTop(0, "readable")).toBe(0) - expect(markdownLaneMarginTop(1, "code")).toBe(1) + expect(markdownLaneMarginTop(1, "technical")).toBe(1) expect(markdownLaneMarginTop(2, "readable")).toBe(1) - expect(markdownLaneMarginTop(1, "wide")).toBe(0) + expect(markdownLaneMarginTop(1, "full")).toBe(0) })