Skip to content

Commit dfbf7f6

Browse files
committed
feat(tui): color the bash tool's command as shell code
The bash tool's command was the one block of code in a session drawn as plain text, while write, edit and apply_patch already go through tree-sitter. It now renders through <code filetype="bash">, with the `$ ` prompt (or the spinner while it runs) beside it, so a multi-line command keeps its indentation under the prompt. The text appears plain at once and takes its colors when the parser worker answers. Highlighting goes through a cache keyed by language and content (packages/tui/src/util/highlight-cache.ts): a <code> block asks the worker every time it is created, so a remounted transcript, a reopened session or a theme switch would otherwise reparse every command. Identical concurrent requests share one round trip, a failed one is retried, and the cache holds at most 1000 entries or 4M characters. nvim-treesitter's bash query narrows its number, constant and shebang captures with `#lua-match?`, which web-tree-sitter does not evaluate, so every word came back as a number and every variable as a constant. packages/tui/src/util/bash-highlight.ts applies those patterns after the worker answers. It also drops the query's `@spell` captures: the theme colors spell as plain text, which turned every comment back to the text color. `syntax.bash: false` in tui.json restores the plain-text command. AI-Model: anthropic/claude-opus-5-5 AI-Variant: high AI-Tool: opencode 1.19.0-vt-227-dfb56f76c7 AI-Platform: linux AI-Harness: Vibeterm e8a9833 AI-Session-ID: ses_ed1e2b932ffe6tTqhsOiGuYXRE
1 parent 08c6f7f commit dfbf7f6

8 files changed

Lines changed: 256 additions & 2 deletions

File tree

‎packages/tui/src/config/index.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ export const ModelLabel = Schema.Literals(["name", "id"]).annotate({
3434
description:
3535
"How message footers and the prompt label a model: 'name' (default) shows its display name, 'id' shows providerID/modelID, e.g. anthropic/claude-sonnet-4-5",
3636
})
37+
export const Syntax = Schema.Struct({
38+
bash: Schema.optional(Schema.Boolean).annotate({
39+
description: "Color the bash tool's command as shell code (default: true)",
40+
}),
41+
}).annotate({ description: "Syntax highlighting in tool calls" })
3742
export const Cursor = Schema.Struct({
3843
style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({
3944
description: "Cursor shape. Use 'default' to preserve the terminal setting",
@@ -471,6 +476,7 @@ export const Info = Schema.Struct({
471476
footer: Schema.optional(Footer),
472477
datetime_format: Schema.optional(DateTimeFormat),
473478
tool_output: Schema.optional(ToolOutput),
479+
syntax: Schema.optional(Syntax),
474480
cursor: Schema.optional(Cursor),
475481
sidebar: Schema.optional(Sidebar),
476482
usage: Schema.optional(Usage),

‎packages/tui/src/routes/session/index.tsx‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ import { DialogSessionRename } from "../../component/dialog-session-rename"
6565
import { Sidebar } from "./sidebar"
6666
import { SubagentFooter } from "./subagent-footer.tsx"
6767
import { filetype } from "../../util/filetype"
68+
import { cachedTreeSitterClient } from "../../util/highlight-cache"
69+
import { narrowBashHighlights } from "../../util/bash-highlight"
6870
import parsers from "../../parsers-config"
6971
import { errorMessage } from "../../util/error"
7072
import { Toast, useToast } from "../../ui/toast"
@@ -2569,8 +2571,15 @@ function Shell(props: ToolProps) {
25692571
onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined}
25702572
>
25712573
<box gap={1}>
2572-
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
2573-
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
2574+
<Show
2575+
when={ctx.tui.syntax?.bash !== false}
2576+
fallback={
2577+
<Show when={isRunning()} fallback={<text fg={theme.text}>$ {stringValue(props.input.command)}</text>}>
2578+
<Spinner color={theme.text}>{stringValue(props.input.command)}</Spinner>
2579+
</Show>
2580+
}
2581+
>
2582+
<ShellCommand command={stringValue(props.input.command) ?? ""} running={isRunning()} />
25742583
</Show>
25752584
<Show when={limited()}>
25762585
<text fg={theme.text}>{limited()}</text>
@@ -2587,6 +2596,32 @@ function Shell(props: ToolProps) {
25872596
)
25882597
}
25892598

2599+
// The command as shell code. It draws as plain text at once and takes its colors when the parser
2600+
// worker answers, which the shared cache makes immediate for a command already seen.
2601+
export function ShellCommand(props: { command: string; running: boolean }) {
2602+
const { theme, syntax } = useTheme()
2603+
return (
2604+
<box flexDirection="row">
2605+
<Show when={props.running} fallback={<text fg={theme.text}>$ </text>}>
2606+
<box paddingRight={1}>
2607+
<Spinner color={theme.text} />
2608+
</box>
2609+
</Show>
2610+
<code
2611+
flexGrow={1}
2612+
flexShrink={1}
2613+
conceal={false}
2614+
fg={theme.text}
2615+
filetype="bash"
2616+
syntaxStyle={syntax()}
2617+
treeSitterClient={cachedTreeSitterClient()}
2618+
onHighlight={(highlights, context) => narrowBashHighlights(highlights, context.content)}
2619+
content={props.command}
2620+
/>
2621+
</box>
2622+
)
2623+
}
2624+
25902625
function Write(props: ToolProps) {
25912626
const { theme, syntax } = useTheme()
25922627
const pathFormatter = usePathFormatter()
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { SimpleHighlight } from "@opentui/core"
2+
3+
// nvim-treesitter's bash query narrows three captures with #lua-match?, a Neovim predicate that
4+
// web-tree-sitter does not evaluate, so every word would come back as a number and every variable
5+
// as a constant. These are those patterns, widened to the other captures sharing the group name:
6+
// number nodes in arithmetic and special variables such as $? and $@.
7+
const NARROWED: Record<string, RegExp> = {
8+
number: /^[-+]?(0x[\da-f]+|\d+(#\w+)?)$/i,
9+
constant: /^([A-Z][A-Z_0-9]*|[*@?!#$0_-])$/,
10+
"keyword.directive": /^#!\//,
11+
}
12+
13+
// The query also tags comments `@comment @spell`, and the theme colors spell as plain text, which
14+
// would override the comment color. Spell marks text for a spell checker, not for color.
15+
const SPELL = new Set(["spell", "nospell"])
16+
17+
export function narrowBashHighlights(highlights: SimpleHighlight[], content: string) {
18+
return highlights.filter(
19+
([start, end, group]) => !SPELL.has(group) && (NARROWED[group]?.test(content.slice(start, end)) ?? true),
20+
)
21+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core"
2+
3+
type HighlightOnce = TreeSitterClient["highlightOnce"]
4+
type HighlightResult = Awaited<ReturnType<HighlightOnce>>
5+
6+
// The parser worker's answer for a piece of text, kept by content and language. A <code> block
7+
// asks the worker every time it is created, so without this a remounted transcript, a reopened
8+
// session or a theme switch reparses every command it shows. Concurrent requests for the same
9+
// text share one worker round trip, and a failed request is forgotten so it can be retried.
10+
export function createHighlightCache(highlightOnce: HighlightOnce, limit = { entries: 1000, chars: 4_000_000 }) {
11+
const entries = new Map<string, { result: Promise<HighlightResult>; chars: number }>()
12+
let chars = 0
13+
14+
function evict() {
15+
for (const [key, entry] of entries) {
16+
if (entries.size <= limit.entries && chars <= limit.chars) return
17+
entries.delete(key)
18+
chars -= entry.chars
19+
}
20+
}
21+
22+
return (content: string, filetype: string): Promise<HighlightResult> => {
23+
const key = `${filetype}\0${content}`
24+
const hit = entries.get(key)
25+
if (hit) {
26+
entries.delete(key)
27+
entries.set(key, hit)
28+
return hit.result
29+
}
30+
const result = highlightOnce(content, filetype)
31+
const entry = { result, chars: content.length }
32+
entries.set(key, entry)
33+
chars += entry.chars
34+
evict()
35+
result.catch(() => {
36+
if (entries.get(key) !== entry) return
37+
entries.delete(key)
38+
chars -= entry.chars
39+
})
40+
return result
41+
}
42+
}
43+
44+
let shared: TreeSitterClient | undefined
45+
46+
// The shared parser worker with highlightOnce answered from the cache above. Everything else is
47+
// the worker itself, so <code treeSitterClient={...}> keeps a single worker.
48+
export function cachedTreeSitterClient() {
49+
if (shared) return shared
50+
const client = getTreeSitterClient()
51+
const highlightOnce = createHighlightCache(client.highlightOnce.bind(client))
52+
shared = new Proxy(client, {
53+
get(target, property) {
54+
if (property === "highlightOnce") return highlightOnce
55+
const value = Reflect.get(target, property, target)
56+
return typeof value === "function" ? value.bind(target) : value
57+
},
58+
})
59+
return shared
60+
}

‎packages/tui/test/config.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ test("validates config constraints", () => {
100100
expect(() => decodeInfo({ datetime_format: { date: "YYYY-mm-dd" } })).toThrow()
101101
expect(() => decodeInfo({ datetime_format: { date: "DD Mon YYYY" } })).toThrow()
102102
expect(() => decodeInfo({ datetime_format: { time: "" } })).toThrow()
103+
expect(decodeInfo({ syntax: { bash: false } })).toEqual({ syntax: { bash: false } })
104+
expect(() => decodeInfo({ syntax: { bash: "off" } })).toThrow()
103105
expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } })
104106
})
105107

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { expect, test } from "bun:test"
2+
import type { SimpleHighlight } from "@opentui/core"
3+
import { narrowBashHighlights } from "../../src/util/bash-highlight"
4+
5+
function groups(content: string, highlights: SimpleHighlight[]) {
6+
return narrowBashHighlights(highlights, content).map(([start, end, group]) => `${content.slice(start, end)}:${group}`)
7+
}
8+
9+
test("keeps the number capture only on numbers", () => {
10+
const content = "sleep 10 ls 0x1F 2#101"
11+
expect(
12+
groups(content, [
13+
[0, 5, "number"],
14+
[6, 8, "number"],
15+
[9, 11, "number"],
16+
[12, 16, "number"],
17+
[17, 22, "number"],
18+
]),
19+
).toEqual(["10:number", "0x1F:number", "2#101:number"])
20+
})
21+
22+
test("keeps the constant capture only on upper-case and special variables", () => {
23+
const content = "$HOME $path $? $@"
24+
expect(
25+
groups(content, [
26+
[1, 5, "constant"],
27+
[7, 11, "constant"],
28+
[13, 14, "constant"],
29+
[16, 17, "constant"],
30+
]),
31+
).toEqual(["HOME:constant", "?:constant", "@:constant"])
32+
})
33+
34+
test("keeps the directive capture only on a shebang and leaves other groups alone", () => {
35+
const content = "#!/bin/sh\n# note\necho"
36+
expect(
37+
groups(content, [
38+
[0, 9, "keyword.directive"],
39+
[10, 16, "keyword.directive"],
40+
[17, 21, "function.call"],
41+
]),
42+
).toEqual(["#!/bin/sh:keyword.directive", "echo:function.call"])
43+
})
44+
45+
test("drops the spell captures that would override a comment's color", () => {
46+
const content = "ls # list"
47+
expect(
48+
groups(content, [
49+
[3, 9, "comment"],
50+
[3, 9, "spell"],
51+
[0, 2, "nospell"],
52+
]),
53+
).toEqual(["# list:comment"])
54+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { createHighlightCache } from "../../src/util/highlight-cache"
3+
4+
function worker() {
5+
const calls: string[] = []
6+
const highlightOnce = (content: string, filetype: string) => {
7+
calls.push(`${filetype}:${content}`)
8+
if (content === "fail") return Promise.reject(new Error("worker failed"))
9+
return Promise.resolve({ highlights: [[0, content.length, "string"] as [number, number, string]] })
10+
}
11+
return { calls, highlightOnce }
12+
}
13+
14+
describe("createHighlightCache", () => {
15+
test("answers the same text and language from the first result", async () => {
16+
const fake = worker()
17+
const highlight = createHighlightCache(fake.highlightOnce)
18+
const first = highlight("echo hi", "bash")
19+
const second = highlight("echo hi", "bash")
20+
expect(second).toBe(first)
21+
expect(await second).toEqual({ highlights: [[0, 7, "string"]] })
22+
expect(fake.calls).toEqual(["bash:echo hi"])
23+
})
24+
25+
test("keys by language as well as content", async () => {
26+
const fake = worker()
27+
const highlight = createHighlightCache(fake.highlightOnce)
28+
await highlight("x = 1", "bash")
29+
await highlight("x = 1", "python")
30+
expect(fake.calls).toEqual(["bash:x = 1", "python:x = 1"])
31+
})
32+
33+
test("drops the least recently used entry past the entry limit", async () => {
34+
const fake = worker()
35+
const highlight = createHighlightCache(fake.highlightOnce, { entries: 2, chars: 1000 })
36+
await highlight("a", "bash")
37+
await highlight("b", "bash")
38+
await highlight("a", "bash")
39+
await highlight("c", "bash")
40+
await highlight("a", "bash")
41+
await highlight("b", "bash")
42+
expect(fake.calls).toEqual(["bash:a", "bash:b", "bash:c", "bash:b"])
43+
})
44+
45+
test("drops entries past the character budget", async () => {
46+
const fake = worker()
47+
const highlight = createHighlightCache(fake.highlightOnce, { entries: 100, chars: 5 })
48+
await highlight("aaa", "bash")
49+
await highlight("bbb", "bash")
50+
await highlight("aaa", "bash")
51+
expect(fake.calls).toEqual(["bash:aaa", "bash:bbb", "bash:aaa"])
52+
})
53+
54+
test("asks the worker again after a failed request", async () => {
55+
const fake = worker()
56+
const highlight = createHighlightCache(fake.highlightOnce)
57+
await expect(highlight("fail", "bash")).rejects.toThrow("worker failed")
58+
await expect(highlight("fail", "bash")).rejects.toThrow("worker failed")
59+
expect(fake.calls).toEqual(["bash:fail", "bash:fail"])
60+
})
61+
})

‎packages/web/src/content/docs/tui.mdx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ No key is shared between the prompt and conversation scrolling, so each one does
528528
- `transcript.max_messages` - How many of a session's most recent messages the TUI keeps loaded (default: `100`). Older messages are hidden behind a divider showing how many there are, with actions to load 50 more from either edge of the gap or all of them. The same actions are in the command palette and `/load-hidden` loads all of them.
529529
- `transcript.keep_first_prompt` - Keep the session's first prompt at the top, above the divider, when older messages are hidden (default: `true`).
530530
- `attention` - Configures TUI desktop notifications and sounds. Disabled by default.
531+
- `syntax` - Controls syntax highlighting in tool calls. [Learn more](#syntax-highlighting).
531532
- `sidebar.order` - Session sidebar sections in display order, for example `["todo", "context"]`. Built-in sections are `context`, `mcp`, `lsp`, `todo`, and `files`; plugin sections use their plugin id. Sections you do not list follow in their default order. The session title always stays first.
532533
- `sidebar.hidden` - Session sidebar sections to hide, using the same names as `sidebar.order`.
533534

@@ -657,6 +658,20 @@ Each value is a number of lines or `"never"`:
657658

658659
`write`, `edit` and `apply_patch` count rendered rows of the file or diff, so a side-by-side diff collapses to that many rows. A deleted file in a patch is a single line and never collapses. Generic tools show their output only while **Show generic tool output** is on.
659660

661+
### Syntax highlighting
662+
663+
Tool calls are colored by language. Each part can be turned on or off under `syntax`:
664+
665+
```json title="tui.json"
666+
{
667+
"syntax": {
668+
"bash": true
669+
}
670+
}
671+
```
672+
673+
- `bash` - Color the bash tool's command as shell code. Defaults to `true`.
674+
660675
### Paste summary
661676

662677
A paste of three lines or more, or of more than 150 characters, goes into the prompt as a placeholder such as `[Pasted ~12 lines]`; the pasted text is sent in its place. `prompt.paste_summary` changes when that happens and what the placeholder says:

0 commit comments

Comments
 (0)