feat(tui): apply semantic content lanes

This commit is contained in:
Kit Langton
2026-08-11 14:10:53 -04:00
parent 1b2e4750e1
commit edfd0bdb0b
4 changed files with 132 additions and 51 deletions
+77 -34
View File
@@ -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) {
)}
</Match>
<Match when={props.row.type === "compaction-queued"}>
<CompactionQueued />
<SessionContentLane width="readable">
<CompactionQueued />
</SessionContentLane>
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
{(row) => (
<SessionContentLane width="readable">
<SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />
</SessionContentLane>
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
<SessionGroupView
refs={row().refs}
pending={row().pending}
completed={row().completed}
message={props.message}
/>
<SessionContentLane width="readable">
<SessionGroupView
refs={row().refs}
pending={row().pending}
completed={row().completed}
message={props.message}
/>
</SessionContentLane>
)}
</Match>
<Match when={props.row.type === "assistant-footer" ? props.row : undefined}>
@@ -1214,7 +1222,13 @@ function SessionRowView(props: SessionRowViewProps) {
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
<SessionContentLane width="technical">
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCache={row().previousCache}
message={props.message}
/>
</SessionContentLane>
)}
</Match>
</Switch>
@@ -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 (
<box width="100%" alignItems="center" flexShrink={0}>
<box width="100%" maxWidth={maxWidth() === "auto" ? undefined : maxWidth()} flexShrink={0}>
{props.children}
<Show when={maxWidth() !== "auto"} fallback={props.children}>
<box width="100%" alignItems="center" flexShrink={0}>
<box width="100%" maxWidth={maxWidth() === "auto" ? undefined : maxWidth()} flexShrink={0}>
{props.children}
</box>
</box>
</box>
</Show>
)
}
@@ -1400,20 +1416,35 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
<UserMessage message={props.message as SessionMessageUser} />
</Match>
<Match when={props.message.type === "shell"}>
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
<SessionContentLane width="technical">
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
</SessionContentLane>
</Match>
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
<SessionSwitchMessageV2 message={props.message} />
<SessionContentLane width="readable">
<SessionSwitchMessageV2 message={props.message} />
</SessionContentLane>
</Match>
<Match
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
>
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
<Show
when={props.message.type === "skill"}
fallback={
<SessionContentLane width="readable">
<SessionNoticeMessageV2 message={props.message} />
</SessionContentLane>
}
>
<SessionContentLane width="readable">
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
</SessionContentLane>
</Show>
</Match>
<Match when={props.message.type === "compaction"}>
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
<SessionContentLane width="readable">
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
</SessionContentLane>
</Match>
</Switch>
)
@@ -1434,11 +1465,13 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
<TextPart part={item() as SessionMessageAssistantText} last={false} />
</Match>
<Match when={item().type === "reasoning"}>
<ReasoningPart
part={item() as SessionMessageAssistantReasoning}
message={message() as SessionMessageAssistant}
last={false}
/>
<SessionContentLane width="readable">
<ReasoningPart
part={item() as SessionMessageAssistantReasoning}
message={message() as SessionMessageAssistant}
last={false}
/>
</SessionContentLane>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
@@ -2273,9 +2306,9 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) {
return (
<box width="100%" marginTop={markdownLaneMarginTop(index, segment().width)} flexShrink={0}>
<Switch>
<Match when={segment().width === "wide"}>{content}</Match>
<Match when={segment().width === "code"}>
<SessionContentLane width="code">{content}</SessionContentLane>
<Match when={segment().width === "full"}>{content}</Match>
<Match when={segment().width === "technical"}>
<SessionContentLane width="technical">{content}</SessionContentLane>
</Match>
<Match when={segment().width === "readable"}>
<SessionContentLane width="readable">{content}</SessionContentLane>
@@ -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 }
</Switch>
)
return [
content,
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>,
<SessionContentLane width={width()}>{content}</SessionContentLane>,
<SessionContentLane width="readable">
<Show when={props.images !== false}>
<ToolImages parts={[props.part]} />
</Show>
</SessionContentLane>,
]
}
@@ -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<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return
return value as Record<string, unknown>
@@ -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
}
@@ -10,6 +10,7 @@ import {
parseQuestionAnswers,
parseQuestions,
toolDisplay,
toolLane,
} from "../../../src/routes/session"
let testSetup: Awaited<ReturnType<typeof testRender>> | 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 () => {
@@ -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)
})