diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index bde0bfb0718a..0b6798002f6d 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -30,6 +30,19 @@ export const ScrollAcceleration = Schema.Struct({ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({ description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column", }) +export const Syntax = Schema.Struct({ + bash: Schema.optional(Schema.Boolean).annotate({ + description: "Color the bash tool's command as shell code (default: true)", + }), + tool_output: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({ + description: + "Color plugin and MCP tool output by tool name or glob ('browser', 'github_*', '*'): a language id such as 'json', 'auto' to color output recognized as JSON, or 'none'. An exact name wins over globs, the most specific glob over the rest. Unset leaves output plain (default)", + }), + file_output: Schema.optional(Schema.Boolean).annotate({ + description: + "Color tool output that shows the contents of files in each file's language: bash reads and searches (cat, sed -n, head, tail, grep, rg, git show :), JSON and diffs, the read and grep tools, GitHub code search snippets and docs with fenced code. A path or line number a line starts with is muted. Read and grep calls can be clicked to show what they found. Output whose language is unclear stays plain, and a tool set to 'none' in tool_output stays plain (default: false)", + }), +}).annotate({ description: "Syntax highlighting in tool calls" }) export const Cursor = Schema.Struct({ style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({ description: "Cursor shape. Use 'default' to preserve the terminal setting", @@ -70,6 +83,7 @@ export const Info = Schema.Struct({ scroll_speed: Schema.optional(ScrollSpeed).annotate({ description: "TUI scroll speed" }), scroll_acceleration: Schema.optional(ScrollAcceleration), diff_style: Schema.optional(DiffStyle), + syntax: Schema.optional(Syntax), cursor: Schema.optional(Cursor), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }), }) diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 909dd69ac249..9a3b7258ee2b 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -19,6 +19,7 @@ import { upsertTheme, type ThemeJson, } from "../theme" +import { toolCodeTheme } from "../theme/tool-code" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "./helper" @@ -270,6 +271,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ const syntax = createSyntaxStyleMemo(() => generateSyntax(values())) const subtleSyntax = createSyntaxStyleMemo(() => generateSubtleSyntax(values())) + const toolSyntax = createSyntaxStyleMemo(() => generateSyntax(toolCodeTheme(values()))) return { theme: new Proxy(values(), { @@ -285,6 +287,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ has: hasTheme, syntax, subtleSyntax, + toolSyntax, mode: () => store.mode, locked: () => store.lock !== undefined, lock: () => pin(store.mode), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 2b54f21671b3..cdc4773d3008 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1,5 +1,6 @@ import { batch, + children, createContext, createEffect, createMemo, @@ -24,7 +25,19 @@ import { SplitBorder } from "../../ui/border" import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner } from "../../component/spinner" import { createSyntaxStyleMemo, generateSubtleSyntax, selectedForeground, useTheme } from "../../context/theme" -import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" +import { + BoxRenderable, + ScrollBoxRenderable, + addDefaultParsers, + TextAttributes, + RGBA, + StyledText, + TextRenderable, + treeSitterToTextChunks, + type SimpleHighlight, + type SyntaxStyle, + type TextChunk, +} from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { AssistantMessage, @@ -55,6 +68,19 @@ import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" import { SubagentFooter } from "./subagent-footer.tsx" import { filetype } from "../../util/filetype" +import { highlightCached } from "../../util/highlight-cache" +import { outputLanguage, previewHighlights, toolOutputSetting } from "../../util/tool-output" +import { + bashOutputModel, + FILE_OUTPUT_CHUNKS, + genericOutputModel, + grepToolOutputModel, + previewLines, + readDisplay, + readOutputModel, + type FileOutputModel, +} from "../../util/file-output" +import { narrowBashHighlights } from "../../util/bash-highlight" import parsers from "../../parsers-config" import { errorMessage } from "../../util/error" import { Toast, useToast } from "../../ui/toast" @@ -1807,6 +1833,19 @@ function GenericTool(props: ToolProps) { if (expanded() || !collapsed().overflow) return output() return collapsed().output }) + // Only output that is shown is sniffed or colored. + const language = createMemo(() => + props.output && ctx.showGenericToolOutput() + ? outputLanguage(ctx.tui.syntax?.tool_output, props.tool, output()) + : undefined, + ) + // Output `syntax.tool_output` leaves plain, unless it is set to 'none' for the tool, may still show + // files: code search snippets, docs with fenced code, `path:line:` matches. + const fileModel = createMemo(() => { + if (ctx.tui.syntax?.file_output !== true || !props.output || !ctx.showGenericToolOutput() || language()) return + if (toolOutputSetting(ctx.tui.syntax?.tool_output, props.tool) === "none") return + return genericOutputModel(output()) + }) return ( setExpanded((prev) => !prev) : undefined} > - {limited()} + }> + {(language) => } + {expanded() ? "Click to collapse" : "Click to expand"} @@ -1833,6 +1874,157 @@ function GenericTool(props: ToolProps) { ) } +// A tool's output colored as `language`. It is drawn plain at once and takes its colors when the +// parser worker answers, in the same non-red palette as a bash command: red means failure. +// `shown` is the output as displayed: the whole of it, or a collapsed preview that is a prefix of it +// plus an ellipsis. The whole output is highlighted, once and cached, because a preview cut through +// the middle of a JSON value does not parse; the preview takes the highlights that fall inside the +// part it shares with the output, and the ellipsis stays plain. +function ToolOutputText(props: { output: string; shown: string; language: string }) { + const { theme, toolSyntax } = useTheme() + const [highlighted, setHighlighted] = createSignal<{ + output: string + language: string + highlights: SimpleHighlight[] + }>() + createEffect(() => { + const output = props.output + const language = props.language + highlightCached(output, language) + .then((result) => setHighlighted({ output, language, highlights: result.highlights ?? [] })) + .catch(() => setHighlighted(undefined)) + }) + const chunks = createMemo(() => { + const result = highlighted() + if (!result || result.output !== props.output || result.language !== props.language) return + if (result.highlights.length === 0) return + return treeSitterToTextChunks( + props.shown, + previewHighlights(props.shown, result.output, result.highlights), + toolSyntax(), + { enabled: false }, + ) + }) + return +} + +// Output that shows the contents of files (`syntax.file_output`), colored by `model`: the prefixes +// muted at once, and each file's code in its language when the parser worker answers. Each file's +// lines are highlighted together, once and cached, and the colors go onto whatever part of them +// `shown` holds: the whole output or a collapsed preview. The text itself is unchanged, so it +// wraps exactly as it does plain. +function FileOutputText(props: { model: FileOutputModel; shown: string }) { + const { theme, toolSyntax } = useTheme() + const [highlighted, setHighlighted] = createSignal<{ + model: FileOutputModel + lines: (SimpleHighlight[] | undefined)[] + }>() + createEffect(() => { + const model = props.model + Promise.all( + model.chunks.slice(0, FILE_OUTPUT_CHUNKS).map((chunk) => { + const bodies = chunk.lines.map((index) => model.lines[index].slice(model.info[index].gutter)) + return highlightCached(bodies.join("\n"), chunk.language).then((result) => ({ + chunk, + bodies, + highlights: result.highlights ?? [], + })) + }), + ) + .then((results) => { + const lines: (SimpleHighlight[] | undefined)[] = [] + for (const result of results) { + let offset = 0 + result.chunk.lines.forEach((index, position) => { + const end = offset + result.bodies[position].length + lines[index] = shiftHighlights(result.highlights, offset, end) + offset = end + 1 + }) + } + setHighlighted({ model, lines }) + }) + .catch(() => setHighlighted(undefined)) + }) + const chunks = createMemo(() => { + const found = highlighted() + const lines = found?.model === props.model ? found.lines : [] + return fileOutputChunks(props.model, props.shown, lines, { text: theme.text, muted: theme.textMuted }, toolSyntax()) + }) + return +} + +// `shown`, a whole output or a preview of it, as chunks: each line's prefix muted, its code colored by +// `lines` (the highlights of each output line's code), and a preview's markers plain. +export function fileOutputChunks( + model: FileOutputModel, + shown: string, + lines: (SimpleHighlight[] | undefined)[], + colors: { text: RGBA; muted: RGBA }, + syntax: SyntaxStyle, +) { + const plain = (text: string, fg = colors.text): TextChunk[] => (text ? [{ __isChunk: true, text, fg }] : []) + const rows = shown.split("\n") + const sources = previewLines(rows, model.lines) + return rows.flatMap((text, index): TextChunk[] => { + const newline = index > 0 ? plain("\n") : [] + const at = sources[index] + if (!at) return [...newline, ...plain(text)] + const source = model.lines[at.line] + const gutter = model.info[at.line].gutter + const start = Math.max(at.from, gutter) + const body = source.slice(start, Math.max(start, at.to)) + const highlights = lines[at.line] && shiftHighlights(lines[at.line]!, start - gutter, at.to - gutter) + return [ + ...newline, + ...plain(source.slice(at.from, Math.min(at.to, gutter)), colors.muted), + ...(highlights?.length ? treeSitterToTextChunks(body, highlights, syntax, { enabled: false }) : plain(body)), + ...plain(text.slice(at.to - at.from)), + ] + }) +} + +// The highlights inside [start, end), moved so `start` is 0. +function shiftHighlights(highlights: SimpleHighlight[], start: number, end: number): SimpleHighlight[] { + return highlights.flatMap((highlight): SimpleHighlight[] => { + if (highlight[1] <= start || highlight[0] >= end) return [] + const moved: SimpleHighlight = [...highlight] + moved[0] = Math.max(highlight[0], start) - start + moved[1] = Math.min(highlight[1], end) - start + return [moved] + }) +} + +// `shown` colored by `model` when there is one, else plain. +function FileOutput(props: { model: FileOutputModel | undefined; shown: string }) { + const { theme } = useTheme() + return ( + {props.shown}}> + {(model) => } + + ) +} + +// One text renderable whose content is the plain text, or the same text as colored chunks, so the +// colors never change how it wraps. Chunks are set on the renderable rather than drawn as one +// span each, which keeps a large output a single renderable. +export function StyledOutput(props: { content: string; chunks?: TextChunk[]; fg: RGBA }) { + let text: TextRenderable | undefined + const content = () => (props.chunks ? new StyledText(props.chunks) : props.content) + createEffect(() => { + const value = content() + if (text) text.content = value + }) + return ( + { + text = el + el.content = content() + }} + /> + ) +} + function InlineTool(props: { icon: string iconColor?: RGBA @@ -2057,6 +2249,12 @@ function Shell(props: ToolProps) { if (expanded() || !collapsed().overflow) return output() return collapsed().output }) + // Worked out once the call has finished, from the whole output, never per frame or while it streams. + const fileModel = createMemo(() => + ctx.tui.syntax?.file_output === true && !isRunning() + ? bashOutputModel(stringValue(props.input.command) ?? "", output()) + : undefined, + ) const workdirDisplay = createMemo(() => { const workdir = stringValue(props.input.workdir) @@ -2081,11 +2279,18 @@ function Shell(props: ToolProps) { onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > - $ {stringValue(props.input.command)}}> - {stringValue(props.input.command)} + $ {stringValue(props.input.command)}}> + {stringValue(props.input.command)} + + } + > + - {limited()} + {expanded() ? "Click to collapse" : "Click to expand"} @@ -2102,6 +2307,49 @@ function Shell(props: ToolProps) { ) } +// The command as shell code. It is the same the plain command is, so it wraps and indents +// exactly as before, with each token a colored span. It draws plain at once and takes its colors +// when the parser worker answers, which the shared cache makes immediate for a command already +// seen. Colors come from toolSyntax, which never uses red: in the transcript red means failure. +export function ShellCommand(props: { command: string; running: boolean }) { + const { theme, toolSyntax } = useTheme() + const [highlighted, setHighlighted] = createSignal<{ command: string; highlights: SimpleHighlight[] }>() + createEffect(() => { + const command = props.command + shellHighlights(command) + .then((highlights) => setHighlighted({ command, highlights })) + .catch(() => setHighlighted(undefined)) + }) + const segments = createMemo((): CommandSegment[] => { + const result = highlighted() + if (result?.command !== props.command) return [{ text: props.command, fg: theme.text }] + return treeSitterToTextChunks(result.command, result.highlights, toolSyntax(), { enabled: false }) + }) + const spans = children(() => ) + return ( + $ {spans()}}> + {spans()} + + ) +} + +type CommandSegment = { text: string; fg?: RGBA; bg?: RGBA; attributes?: number } + +export function CommandSpans(props: { segments: CommandSegment[] }) { + return ( + + {(segment) => ( + {segment.text} + )} + + ) +} + +async function shellHighlights(command: string) { + const result = await highlightCached(command, "bash") + return narrowBashHighlights(result.highlights ?? [], command) +} + function Write(props: ToolProps) { const { theme, syntax } = useTheme() const pathFormatter = usePathFormatter() @@ -2151,6 +2399,11 @@ function Read(props: ToolProps) { const { theme } = useTheme() const pathFormatter = usePathFormatter() const isRunning = createMemo(() => props.part.state.status === "running") + const found = useFoundOutput(props) + const display = createMemo(() => (found.reachable() ? readDisplay(props.output ?? "") : "")) + const model = createMemo(() => + display() ? readOutputModel(stringValue(props.input.filePath), display()) : undefined, + ) const loaded = createMemo(() => { if (props.part.state.status !== "completed") return [] if (props.part.state.time.compacted) return [] @@ -2158,16 +2411,29 @@ function Read(props: ToolProps) { if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) + const title = () => + `Read ${pathFormatter.format(stringValue(props.input.filePath))} ${input(props.input, ["filePath"])}` return ( - <> + + + + Click to collapse + + + } + > - Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} + {title()} {(filepath) => ( @@ -2178,23 +2444,72 @@ function Read(props: ToolProps) { )} - + ) } function Grep(props: ToolProps) { + const { theme } = useTheme() const pathFormatter = usePathFormatter() + const found = useFoundOutput(props) + const model = createMemo(() => (found.reachable() ? grepToolOutputModel(props.output ?? "") : undefined)) + const where = () => + stringValue(props.input.path) ? ` in ${pathFormatter.format(stringValue(props.input.path))}` : "" return ( - - Grep "{stringValue(props.input.pattern)}"{" "} - in {pathFormatter.format(stringValue(props.input.path))} - - ({numberValue(props.metadata.matches)} {numberValue(props.metadata.matches) === 1 ? "match" : "matches"}) - - + + + + Click to collapse + + + } + > + + Grep "{stringValue(props.input.pattern)}"{" "} + in {pathFormatter.format(stringValue(props.input.path))} + + ({numberValue(props.metadata.matches)} {numberValue(props.metadata.matches) === 1 ? "match" : "matches"}) + + + ) } +// With `syntax.file_output`, a finished read or grep call's row can be clicked to show what it +// found as a block, colored by file, and the block clicked to fold back into the row. +function useFoundOutput(props: ToolProps) { + const ctx = use() + const [expanded, setExpanded] = createSignal(false) + const reachable = createMemo(() => { + const state = props.part.state + return ( + ctx.tui.syntax?.file_output === true && + state.status === "completed" && + !state.time.compacted && + Boolean(props.output?.trim()) + ) + }) + return { + reachable, + expanded: () => reachable() && expanded(), + expand: () => (reachable() ? () => setExpanded(true) : undefined), + collapse: () => setExpanded(false), + } +} + function WebFetch(props: ToolProps) { return ( diff --git a/packages/tui/src/theme/tool-code.ts b/packages/tui/src/theme/tool-code.ts new file mode 100644 index 000000000000..8d79302679d0 --- /dev/null +++ b/packages/tui/src/theme/tool-code.ts @@ -0,0 +1,59 @@ +import type { RGBA } from "@opentui/core" +import type { Theme } from "./index" + +type Slot = keyof Theme + +// In the transcript red means a failed or rejected tool call, so code shown inside a tool call +// (a bash command, a heredoc body, a tool's input) must not draw a token in red. Each slot the +// syntax rules read that comes out red, or in the theme's error color, takes the first of its +// alternatives that does not; `text` is never red in a built-in theme and ends every list. +const ALTERNATIVES: Partial> = { + error: ["syntaxFunction", "syntaxType", "text"], + syntaxVariable: ["syntaxFunction", "syntaxType", "text"], + syntaxFunction: ["syntaxType", "syntaxKeyword", "text"], + syntaxKeyword: ["syntaxType", "syntaxFunction", "text"], + syntaxNumber: ["syntaxType", "syntaxKeyword", "text"], + syntaxString: ["success", "syntaxType", "text"], + syntaxType: ["syntaxFunction", "text"], + syntaxOperator: ["syntaxPunctuation", "text"], + syntaxPunctuation: ["textMuted", "text"], + syntaxComment: ["textMuted", "text"], + markdownLink: ["syntaxString", "syntaxType", "text"], + markdownLinkText: ["syntaxString", "syntaxType", "text"], + warning: ["syntaxType", "text"], +} + +export function toolCodeTheme(theme: Theme): Theme { + const alarming = (color: RGBA) => isRedColor(color) || closeTo(color, theme.error) + const replaced = Object.fromEntries( + Object.entries(ALTERNATIVES).flatMap(([slot, alternatives]) => { + const color = theme[slot as Slot] as RGBA + if (slot !== "error" && !alarming(color)) return [] + const pick = alternatives.find((alternative) => !alarming(theme[alternative] as RGBA)) ?? "text" + return [[slot, theme[pick]]] + }), + ) + return { ...theme, ...replaced } +} + +// A hue within 20 degrees of pure red, saturated and neither near black nor near white. +export function isRedColor(color: RGBA) { + const max = Math.max(color.r, color.g, color.b) + const min = Math.min(color.r, color.g, color.b) + const lightness = (max + min) / 2 + const delta = max - min + if (delta === 0) return false + const saturation = delta / (1 - Math.abs(2 * lightness - 1)) + const hue = + max === color.r + ? (((color.g - color.b) / delta) % 6) * 60 + : max === color.g + ? ((color.b - color.r) / delta + 2) * 60 + : ((color.r - color.g) / delta + 4) * 60 + const degrees = hue < 0 ? hue + 360 : hue + return (degrees >= 340 || degrees <= 20) && saturation >= 0.35 && lightness >= 0.2 && lightness <= 0.85 +} + +function closeTo(a: RGBA, b: RGBA) { + return Math.hypot(a.r - b.r, a.g - b.g, a.b - b.b) < 0.08 +} diff --git a/packages/tui/src/util/bash-highlight.ts b/packages/tui/src/util/bash-highlight.ts new file mode 100644 index 000000000000..1448720b9987 --- /dev/null +++ b/packages/tui/src/util/bash-highlight.ts @@ -0,0 +1,21 @@ +import type { SimpleHighlight } from "@opentui/core" + +// nvim-treesitter's bash query narrows three captures with #lua-match?, a Neovim predicate that +// web-tree-sitter does not evaluate, so every word would come back as a number and every variable +// as a constant. These are those patterns, widened to the other captures sharing the group name: +// number nodes in arithmetic and special variables such as $? and $@. +const NARROWED: Record = { + number: /^[-+]?(0x[\da-f]+|\d+(#\w+)?)$/i, + constant: /^([A-Z][A-Z_0-9]*|[*@?!#$0_-])$/, + "keyword.directive": /^#!\//, +} + +// The query also tags comments `@comment @spell`, and the theme colors spell as plain text, which +// would override the comment color. Spell marks text for a spell checker, not for color. +const SPELL = new Set(["spell", "nospell"]) + +export function narrowBashHighlights(highlights: SimpleHighlight[], content: string) { + return highlights.filter( + ([start, end, group]) => !SPELL.has(group) && (NARROWED[group]?.test(content.slice(start, end)) ?? true), + ) +} diff --git a/packages/tui/src/util/file-output.ts b/packages/tui/src/util/file-output.ts new file mode 100644 index 000000000000..22aae6cc22bb --- /dev/null +++ b/packages/tui/src/util/file-output.ts @@ -0,0 +1,426 @@ +import path from "node:path" +import { filetype } from "./filetype" +import { detectOutputLanguage, OUTPUT_LANGUAGE_LIMIT } from "./tool-output" + +// Tool output that shows the contents of files, split into what is colored and what is not. +// Every output line has a `gutter`: how many leading characters are a prefix the tool added (a +// path, a line number, a header line), drawn muted. The rest of the line is code, in `chunk`'s +// language; a line without a chunk is drawn plain. A chunk gathers the lines of one file (or one +// snippet) so the parser sees them together, the way they sit in the file. +export type FileOutputLine = { gutter: number; chunk?: number } +export type FileOutputModel = { + lines: string[] + info: FileOutputLine[] + chunks: { language: string; lines: number[] }[] +} + +// Only this many chunks are highlighted; the rest of a very wide search stays plain. +export const FILE_OUTPUT_CHUNKS = 64 + +// Commands whose output is the content of the files they are given, or lines of it. +const READERS = new Set(["cat", "head", "tail", "sed", "nl", "bat", "batcat", "less", "more", "tac", "awk", "cut"]) +// Search commands: their first argument is a pattern, the rest are files or directories. +const SEARCHERS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"]) +// Commands that leave the output of the stage before them as file content. +const FILTERS = new Set([...READERS, ...SEARCHERS, "sort", "uniq", "column", "expand", "fold"]) +// Commands that print nothing, or only a separator between reads. +const SILENT = new Set(["cd", "pushd", "popd", "export", "set", "unset", "true", ":", "mkdir", "echo", "printf"]) +const WRAPPERS = new Set(["sudo", "env", "exec", "time", "timeout", "nice", "nohup", "command", "stdbuf"]) +// Options that take a value in a separate word, per command, so the value is not taken for a file. +const SEARCH_VALUES = ["-e", "-f", "-m", "-A", "-B", "-C", "--regexp", "--file", "--max-count", "--context"] +const VALUE_OPTIONS: Record> = { + head: new Set(["-n", "-c", "--lines", "--bytes"]), + tail: new Set(["-n", "-c", "--lines", "--bytes"]), + sed: new Set(["-e", "-f", "--expression", "--file"]), + awk: new Set(["-f", "-v", "-F"]), + cut: new Set(["-d", "-f", "-c", "-b", "--delimiter", "--fields"]), + nl: new Set(["-b", "-s", "-w", "-v", "-i", "-n"]), + grep: new Set([...SEARCH_VALUES, "--include", "--exclude", "--exclude-dir"]), + egrep: new Set([...SEARCH_VALUES, "--include", "--exclude", "--exclude-dir"]), + fgrep: new Set([...SEARCH_VALUES, "--include", "--exclude", "--exclude-dir"]), + rg: new Set([...SEARCH_VALUES, "-g", "-t", "-T", "-M", "--glob", "--iglob", "--type", "--type-not", "--max-columns"]), + ag: new Set([...SEARCH_VALUES, "-G", "--file-search-regex"]), + ack: new Set([...SEARCH_VALUES, "--type"]), +} +const RG_TYPES: Record = { + ts: "typescript", + js: "typescript", + py: "python", + go: "go", + rust: "rust", + rb: "ruby", + ruby: "ruby", + md: "markdown", + markdown: "markdown", + json: "json", + yaml: "yaml", + toml: "toml", + sh: "bash", + c: "c", + cpp: "cpp", + java: "java", + lua: "lua", + php: "php", + sql: "sql", + css: "css", + html: "html", +} + +const PATH_LINE_NUMBER = /^([^\s:]+?\.[A-Za-z0-9]+|[^\s:]*\/(?:Dockerfile|Makefile|Gemfile|Rakefile))([:-])(\d+)\2/ +const PATH_CONTENT = /^([^\s:]+?\.[A-Za-z0-9]+):(?!\d)/ +const NUMBER_GUTTER = /^(\s*\d+(?::\s?|\t|-))/ +const RG_HEADING = /^[\w./~@+-]*[\w~@+-]\.[A-Za-z0-9]+$/ +const DIFF_START = /^(diff --git |--- \S.*\n\+\+\+ |Index: \S.*\n=+\n)/ + +// A bash call's output, by the shape of its lines and the files its command reads. +export function bashOutputModel(command: string, output: string) { + if (!output || output.length > OUTPUT_LANGUAGE_LIMIT) return + const fromCommand = commandLanguage(command) + const shaped = shapedModel(output, fromCommand) + if (shaped) return shaped + const language = fromCommand ?? sniffLanguage(output) + return language ? wholeModel(output, language) : undefined +} + +// A plugin or MCP tool's output that `syntax.tool_output` left alone: GitHub code search results, +// docs with fenced code blocks, and the line shapes of a search. +export function genericOutputModel(output: string) { + if (!output || output.length > OUTPUT_LANGUAGE_LIMIT) return + return codeSearchModel(output) ?? shapedModel(output, undefined) ?? fencedModel(output) +} + +// What a read call shows: the file's `N: line` lines and the read tool's closing note, without the +// ``, `` and `` wrapper the model reads. +export function readDisplay(output: string) { + const start = output.indexOf("\n") + if (start < 0) return output + const end = output.lastIndexOf("\n") + return output.slice(start + "\n".length, end > start ? end : undefined).replace(/\s+$/, "") +} + +// A read call's lines: `N: line`, the number muted and the line in the file's language. +export function readOutputModel(filePath: string | undefined, output: string) { + if (!output || output.length > OUTPUT_LANGUAGE_LIMIT || !/^\d+: /m.test(output)) return + const lines = output.split("\n") + const language = known(detectFiletype(filePath)) + const model = empty(lines) + const chunk = language ? addChunk(model, language) : undefined + lines.forEach((line, index) => { + const gutter = line.match(/^\d+: /)?.[0].length + if (gutter === undefined) return (model.info[index] = { gutter: line.length }) + model.info[index] = { gutter, chunk } + if (chunk !== undefined) model.chunks[chunk].lines.push(index) + }) + return model +} + +// The grep tool's output: `Found N matches`, then a `path:` line and ` Line N: text` lines per file. +export function grepToolOutputModel(output: string) { + if (!output || output.length > OUTPUT_LANGUAGE_LIMIT) return + const lines = output.split("\n") + const model = empty(lines) + let chunk: number | undefined + lines.forEach((line, index) => { + const match = line.match(/^(\s+Line \d+: )/) + if (match) { + model.info[index] = { gutter: match[1].length, chunk } + if (chunk !== undefined) model.chunks[chunk].lines.push(index) + return + } + model.info[index] = { gutter: line.length } + if (/\S:$/.test(line)) { + const language = known(detectFiletype(line.slice(0, -1))) + chunk = language ? addChunk(model, language) : undefined + } + }) + return model.chunks.length > 0 ? model : undefined +} + +// `path:12:code`, `path-12-code` (a context line), `path:code`, rg's headings, and `12:code` or +// ` 12\tcode` gutters. A path names each line's language; a bare gutter takes the command's. +function shapedModel(output: string, fallback: string | undefined): FileOutputModel | undefined { + const lines = output.split("\n") + const filled = lines.filter((line) => line.trim() && line !== "--") + if (filled.length === 0) return + const share = (pattern: RegExp) => filled.filter((line) => pattern.test(line)).length / filled.length + + if (share(PATH_LINE_NUMBER) >= 0.6) return perLine(lines, (line) => line.match(PATH_LINE_NUMBER)?.[0], pathOf) + const headings = lines.filter((line, index) => RG_HEADING.test(line) && /^\d+[:-]/.test(lines[index + 1] ?? "")) + if (headings.length > 0 && share(/^\d+[:-]|^[\w./~@+-]+\.[A-Za-z0-9]+$/) >= 0.8) return headingModel(lines) + if (filled.length >= 2 && share(PATH_CONTENT) >= 0.6) { + const model = perLine( + lines, + (line) => line.match(PATH_CONTENT)?.[0], + (prefix) => prefix.slice(0, -1), + ) + if (model) return model + } + if (fallback && share(NUMBER_GUTTER) >= 0.6) { + const model = empty(lines) + const chunk = addChunk(model, fallback) + lines.forEach((line, index) => { + const gutter = line.match(NUMBER_GUTTER)?.[1].length ?? 0 + model.info[index] = { gutter, chunk } + model.chunks[chunk].lines.push(index) + }) + return model + } +} + +function pathOf(prefix: string) { + return prefix.match(PATH_LINE_NUMBER)?.[1] ?? prefix +} + +// Lines whose prefix names their file; one chunk per run of lines from the same file. +function perLine(lines: string[], prefix: (line: string) => string | undefined, file: (prefix: string) => string) { + const model = empty(lines) + let current: { file: string; chunk?: number } | undefined + lines.forEach((line, index) => { + const found = prefix(line) + if (found === undefined) return (model.info[index] = { gutter: line === "--" ? line.length : 0 }) + const name = file(found) + if (current?.file !== name) { + const language = known(detectFiletype(name)) + current = { file: name, chunk: language ? addChunk(model, language) : undefined } + } + model.info[index] = { gutter: found.length, chunk: current.chunk } + if (current.chunk !== undefined) model.chunks[current.chunk].lines.push(index) + }) + return model.chunks.length > 0 ? model : undefined +} + +// rg's default output on a terminal-less run of `--heading`: a path line, then `12:code` lines. +function headingModel(lines: string[]) { + const model = empty(lines) + let chunk: number | undefined + lines.forEach((line, index) => { + const gutter = line.match(/^\d+[:-]/)?.[0].length + if (gutter !== undefined) { + model.info[index] = { gutter, chunk } + if (chunk !== undefined) model.chunks[chunk].lines.push(index) + return + } + model.info[index] = { gutter: line.length } + if (RG_HEADING.test(line)) { + const language = known(detectFiletype(line)) + chunk = language ? addChunk(model, language) : undefined + } + if (!line.trim()) chunk = undefined + }) + return model.chunks.length > 0 ? model : undefined +} + +// grep_app's results: `Repository:`, `Path:`, `URL:`, `License:` and `Snippets:` lines, then each +// `--- Snippet N (Line X) ---` followed by the snippet's code, in the language of its `Path:`. +function codeSearchModel(output: string) { + if (!/^Repository: .*\nPath: /m.test(output) || !output.includes("--- Snippet ")) return + const lines = output.split("\n") + const model = empty(lines) + let language: string | undefined + let chunk: number | undefined + lines.forEach((line, index) => { + const header = /^(Repository|Path|URL|License|Snippets):/.test(line) || /^--- Snippet \d+/.test(line) + if (header) { + model.info[index] = { gutter: line.length } + if (line.startsWith("Path: ")) language = known(detectFiletype(line.slice(6).trim())) + if (line.startsWith("Repository:")) language = undefined + chunk = line.startsWith("--- Snippet") && language ? addChunk(model, language) : undefined + return + } + model.info[index] = { gutter: 0, chunk } + if (chunk !== undefined) model.chunks[chunk].lines.push(index) + }) + return model.chunks.length > 0 ? model : undefined +} + +// Docs with a fenced code block that names its language (context7, code search tools) read as +// markdown, which colors each fenced block in its own language. +function fencedModel(output: string) { + if (!/^ *```[\w+#.-]+ *$/m.test(output)) return + return wholeModel(output, "markdown") +} + +// The one language of the files a command reads, when every file it names has the same one and +// nothing else in it prints. Search commands also count a `--type` or `--glob` of one language. +export function commandLanguage(command: string) { + const languages = new Set() + for (const parts of pipelines(command.replace(/\\\n/g, " "))) { + for (const [position, part] of parts.entries()) { + const words = commandWords(part) + const name = words[0] ? path.basename(words[0]) : undefined + if (!name) continue + if (name === "git" && words[1] === "show") { + const target = words.slice(2).find((word) => /^[^-][^:]*:.+/.test(word)) + if (!target) return + const language = known(detectFiletype(target.slice(target.indexOf(":") + 1))) + if (!language) return + languages.add(language) + continue + } + if (SILENT.has(name) && position === 0) continue + // A stage reading the one before keeps it file content only if it is a filter. + if (!(position > 0 ? FILTERS.has(name) : READERS.has(name) || SEARCHERS.has(name))) return + const found = files(name, words) + if (found.includes(undefined)) return + found.forEach((language) => languages.add(language!)) + } + } + return languages.size === 1 ? [...languages][0] : undefined +} + +// The languages of the files a reader or search command is given, `undefined` for a file whose +// language is unknown. A directory or a pattern adds nothing. +function files(name: string, words: string[]) { + const result: (string | undefined)[] = [] + const search = SEARCHERS.has(name) || name === "sed" || name === "awk" + let pattern = search && !words.some((word) => ["-e", "--regexp", "-f", "--file"].includes(word)) + for (let index = 1; index < words.length; index++) { + const word = words[index] + if (word === "--") continue + if (word.startsWith("-")) { + const [option, inline] = word.includes("=") ? word.split("=", 2) : [word, undefined] + const value = inline ?? (VALUE_OPTIONS[name]?.has(option) ? words[++index] : undefined) + if (value && (option === "-t" || option === "--type")) result.push(RG_TYPES[value]) + if (value && (option === "-g" || option === "--glob" || option === "--include") && !value.startsWith("!")) + result.push(...globLanguages(value)) + continue + } + if (word.startsWith("<") || word.startsWith(">") || /^\d?>/.test(word)) { + if (/^<\S/.test(word)) result.push(known(detectFiletype(word.slice(1)))) + if (word === "<") result.push(known(detectFiletype(words[++index]))) + else if (/^\d?>+$/.test(word)) index++ + continue + } + if (pattern) { + pattern = false + continue + } + // A directory, `.` or a word with no extension names no single language. + if (!path.extname(word) && !known(detectFiletype(word))) continue + result.push(known(detectFiletype(word))) + } + return result +} + +// The languages of the files a glob such as `*.ts`, `src/**/*.rs` or `*.{ts,tsx}` matches. +function globLanguages(glob: string) { + const name = glob.replace(/^.*[*/]/, "file") + const braces = name.match(/^(.*)\{([^}]*)\}(.*)$/) + const names = braces ? braces[2].split(",").map((part) => braces[1] + part + braces[3]) : [name] + return names.map((file) => known(detectFiletype(file))) +} + +// The command's pipelines, each a list of its stages, split on `;`, `&&`, `||`, `|` and newlines +// outside quotes. +function pipelines(command: string) { + const result: string[][] = [[""]] + let quote: string | undefined + for (let index = 0; index < command.length; index++) { + const char = command[index] + const current = result[result.length - 1] + if (quote) { + current[current.length - 1] += char + if (char === "\\" && quote === '"') current[current.length - 1] += command[++index] ?? "" + else if (char === quote) quote = undefined + continue + } + if (char === "'" || char === '"') quote = char + const pair = command.slice(index, index + 2) + if (pair === "&&" || pair === "||") { + result.push([""]) + index++ + continue + } + if (char === ";" || char === "\n") { + result.push([""]) + continue + } + if (char === "|") { + current.push("") + continue + } + current[current.length - 1] += char + } + return result.filter((parts) => parts.some((part) => part.trim())) +} + +function commandWords(segment: string) { + const words = (segment.trim().match(/'[^']*'|"(?:\\.|[^"])*"|\S+/g) ?? []).map((word) => + word.replace(/^['"]|['"]$/g, ""), + ) + let start = 0 + while (start < words.length) { + const word = words[start] + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word) || WRAPPERS.has(word)) start++ + else if (start > 0 && WRAPPERS.has(words[start - 1]) && (word.startsWith("-") || /^\d+(\.\d+)?[smhd]?$/.test(word))) + start++ + else break + } + return words.slice(start) +} + +// Output that is a whole JSON document or a diff, whatever printed it. +function sniffLanguage(output: string) { + if (DIFF_START.test(output)) return "diff" + return detectOutputLanguage(output) +} + +function wholeModel(output: string, language: string) { + const lines = output.split("\n") + const model = empty(lines) + const chunk = addChunk(model, language) + lines.forEach((_, index) => { + model.info[index] = { gutter: 0, chunk } + model.chunks[chunk].lines.push(index) + }) + return model +} + +function empty(lines: string[]): FileOutputModel { + return { lines, info: [], chunks: [] } +} + +function addChunk(model: FileOutputModel, language: string) { + model.chunks.push({ language, lines: [] }) + return model.chunks.length - 1 +} + +function known(language: string | undefined) { + return language && language !== "none" ? language : undefined +} + +// Which line of the output each line of `shown` is, and where in it `shown`'s line starts: shown +// is the whole output or a collapsed preview of it. A preview keeps the first lines (the last one +// maybe cut, ending in `…`), the last lines (the first one maybe cut at its start), or both, with +// markers such as `…` or `(N more lines)` between them, which match no line. +export function previewLines(shown: string[], output: string[]) { + const result: ({ line: number; from: number; to: number } | undefined)[] = shown.map(() => undefined) + let head = 0 + while (head < shown.length && head < output.length) { + const line = shown[head] + if (line === output[head]) result[head] = { line: head, from: 0, to: line.length } + else if (line.endsWith("…") && line.length > 1 && output[head].startsWith(line.slice(0, -1))) + result[head] = { line: head, from: 0, to: line.length - 1 } + else break + head++ + if (line !== output[head - 1]) break + } + for (let back = 1; back <= shown.length - head && back <= output.length - head; back++) { + const line = shown[shown.length - back] + const source = output[output.length - back] + if (line === source) { + result[shown.length - back] = { line: output.length - back, from: 0, to: line.length } + continue + } + if (line && source.endsWith(line)) + result[shown.length - back] = { line: output.length - back, from: source.length - line.length, to: source.length } + break + } + return result +} + +// The parser language for a file, by its extension, with shell scripts as bash. +function detectFiletype(file: string | undefined) { + const language = filetype(file) + return language === "shellscript" ? "bash" : language +} diff --git a/packages/tui/src/util/highlight-cache.ts b/packages/tui/src/util/highlight-cache.ts new file mode 100644 index 000000000000..29498052e666 --- /dev/null +++ b/packages/tui/src/util/highlight-cache.ts @@ -0,0 +1,53 @@ +import { getTreeSitterClient, type TreeSitterClient } from "@opentui/core" + +type HighlightOnce = TreeSitterClient["highlightOnce"] +type HighlightResult = Awaited> + +// The parser worker's answer for a piece of text, kept by content and language. A command asks +// for its colors every time its block is created, so without this a remounted transcript or a +// reopened session reparses every command it shows. Concurrent requests for the same +// text share one worker round trip, and a failed request is forgotten so it can be retried. +export function createHighlightCache(highlightOnce: HighlightOnce, limit = { entries: 1000, chars: 4_000_000 }) { + const entries = new Map; chars: number }>() + let chars = 0 + + function evict() { + for (const [key, entry] of entries) { + if (entries.size <= limit.entries && chars <= limit.chars) return + entries.delete(key) + chars -= entry.chars + } + } + + return (content: string, filetype: string): Promise => { + const key = `${filetype}\0${content}` + const hit = entries.get(key) + if (hit) { + entries.delete(key) + entries.set(key, hit) + return hit.result + } + const result = highlightOnce(content, filetype) + const entry = { result, chars: content.length } + entries.set(key, entry) + chars += entry.chars + evict() + result.catch(() => { + if (entries.get(key) !== entry) return + entries.delete(key) + chars -= entry.chars + }) + return result + } +} + +let shared: ((content: string, filetype: string) => Promise) | undefined + +// The shared parser worker's highlightOnce, answered from the cache above. +export function highlightCached(content: string, filetype: string) { + if (!shared) { + const client = getTreeSitterClient() + shared = createHighlightCache(client.highlightOnce.bind(client)) + } + return shared(content, filetype) +} diff --git a/packages/tui/src/util/tool-output.ts b/packages/tui/src/util/tool-output.ts new file mode 100644 index 000000000000..bed152ef2317 --- /dev/null +++ b/packages/tui/src/util/tool-output.ts @@ -0,0 +1,69 @@ +import type { SimpleHighlight } from "@opentui/core" +import { Schema } from "effect" + +// Output larger than this is never sniffed or colored: parsing and highlighting it would cost +// more than the colors are worth, and it is drawn as plain text exactly as before. +export const OUTPUT_LANGUAGE_LIMIT = 100_000 + +// The language a tool's output is colored in, from `syntax.tool_output`: a map from a tool name or +// a glob over tool names (`*` any run of characters, `?` one) to a language id, `auto` or `none`. +// An exact name wins over any glob; among globs the one with the most literal characters wins. +// `auto` sniffs the output and colors only what it recognizes; unset or `none` leaves it plain. +export function outputLanguage(settings: Record | undefined, tool: string, output: string) { + if (!settings || output.length > OUTPUT_LANGUAGE_LIMIT) return + const value = settings[tool] ?? bestGlob(settings, tool) + if (!value || value === "none") return + if (value === "auto") return detectOutputLanguage(output) + return value +} + +function bestGlob(settings: Record, tool: string) { + return Object.entries(settings) + .filter(([pattern]) => /[*?]/.test(pattern) && globToRegExp(pattern).test(tool)) + .toSorted(([a], [b]) => b.replace(/[*?]/g, "").length - a.replace(/[*?]/g, "").length) + .at(0)?.[1] +} + +function globToRegExp(pattern: string) { + const body = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, ".") + return new RegExp(`^${body}$`) +} + +// What `syntax.tool_output` says for a tool: its exact name, else the most specific glob. +export function toolOutputSetting(settings: Record | undefined, tool: string) { + if (!settings) return + return settings[tool] ?? bestGlob(settings, tool) +} + +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const detected = new Map() +const DETECTED_ENTRIES = 500 + +// JSON is the only format sniffed: an output that starts and ends with a bracket pair and parses +// as JSON. The answer is kept by content, so a remounted or reopened transcript parses nothing. +export function detectOutputLanguage(output: string) { + const text = output.trim() + if (text.length < 2 || text.length > OUTPUT_LANGUAGE_LIMIT) return + const pair = text[0] + text[text.length - 1] + if (pair !== "{}" && pair !== "[]") return + if (detected.has(text)) return detected.get(text) + const language = decodeJson(text)._tag === "Some" ? "json" : undefined + detected.set(text, language) + if (detected.size > DETECTED_ENTRIES) detected.delete(detected.keys().next().value!) + return language +} + +// The highlights of the whole output that fall inside the part of `shown` it shares with the +// output, so a collapsed preview takes the colors the whole output parses to. +export function previewHighlights(shown: string, output: string, highlights: SimpleHighlight[]) { + const limit = Math.min(shown.length, output.length) + let shared = 0 + while (shared < limit && shown[shared] === output[shared]) shared++ + if (shared === output.length) return highlights + return highlights.flatMap((highlight): SimpleHighlight[] => + highlight[0] >= shared ? [] : [[highlight[0], Math.min(highlight[1], shared), highlight[2]]], + ) +} diff --git a/packages/tui/test/cli/tui/file-output.test.tsx b/packages/tui/test/cli/tui/file-output.test.tsx new file mode 100644 index 000000000000..4e20d7f6f91c --- /dev/null +++ b/packages/tui/test/cli/tui/file-output.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, expect, test } from "bun:test" +import { RGBA, SyntaxStyle, type SimpleHighlight, type TextChunk } from "@opentui/core" +import { testRender, type JSX } from "@opentui/solid" +import { fileOutputChunks, StyledOutput } from "../../../src/routes/session" +import { collapseToolOutput } from "../../../src/util/collapse-tool-output" +import { bashOutputModel } from "../../../src/util/file-output" + +let setup: Awaited> | undefined + +afterEach(() => { + setup?.renderer.destroy() + setup = undefined +}) + +const text = RGBA.fromHex("#eeeeee") +const muted = RGBA.fromHex("#808080") +const keyword = RGBA.fromHex("#9d7cd8") +const syntax = SyntaxStyle.fromStyles({ keyword: { fg: keyword } }) + +const output = Array.from( + { length: 12 }, + (_, index) => + `packages/tui/src/routes/session/index.tsx:${index + 1}:const value${index} = createMemo(() => props.something.rather.long.indeed)`, +).join("\n") +const model = bashOutputModel("rg -n createMemo packages/tui", output)! +// `const` colored in every line's code, as the TypeScript highlighter would. +const lines: SimpleHighlight[][] = model.lines.map(() => [[0, 5, "keyword"]]) + +const joined = (chunks: TextChunk[]) => chunks.map((chunk) => chunk.text).join("") + +async function frame(width: number, node: () => JSX.Element) { + setup?.renderer.destroy() + setup = await testRender(() => {node()}, { width, height: 60 }) + await setup.renderOnce() + return setup.captureCharFrame() +} + +for (const width of [40, 80, 160]) { + test(`grep output colored by file wraps exactly like the plain text at ${width} columns`, async () => { + const chunks = fileOutputChunks(model, output, lines, { text, muted }, syntax) + expect(joined(chunks)).toBe(output) + const plain = await frame(width, () => {output}) + const colored = await frame(width, () => ) + expect(colored).toBe(plain) + }) + + test(`a collapsed preview is colored like the whole output at ${width} columns`, async () => { + const shown = collapseToolOutput(output, 3, 3 * Math.max(20, width - 6)).output + const chunks = fileOutputChunks(model, shown, lines, { text, muted }, syntax) + expect(joined(chunks)).toBe(shown) + const consts = shown.split("\n").filter((line: string) => line.includes(":const ")).length + expect(consts).toBeGreaterThan(0) + expect(chunks.filter((chunk) => chunk.text === "const").length).toBe(consts) + expect(chunks.filter((chunk) => chunk.text === "const").every((chunk) => chunk.fg === keyword)).toBe(true) + expect(chunks.filter((chunk) => chunk.text.startsWith("packages/tui/")).every((chunk) => chunk.fg === muted)).toBe( + true, + ) + const plain = await frame(width, () => {shown}) + expect(await frame(width, () => )).toBe(plain) + }) +} + +test("mutes the prefix at once and draws the code plain until its highlights arrive", () => { + const chunks = fileOutputChunks(model, output, [], { text, muted }, syntax) + expect(chunks[0]).toMatchObject({ text: "packages/tui/src/routes/session/index.tsx:1:", fg: muted }) + expect(chunks[1]).toMatchObject({ text: model.lines[0].slice(chunks[0].text.length), fg: text }) +}) diff --git a/packages/tui/test/cli/tui/shell-command.test.tsx b/packages/tui/test/cli/tui/shell-command.test.tsx new file mode 100644 index 000000000000..0843fc42e3c4 --- /dev/null +++ b/packages/tui/test/cli/tui/shell-command.test.tsx @@ -0,0 +1,43 @@ +import { afterEach, expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { testRender, type JSX } from "@opentui/solid" +import { CommandSpans } from "../../../src/routes/session" + +let setup: Awaited> | undefined + +afterEach(() => { + setup?.renderer.destroy() + setup = undefined +}) + +const command = + "cp ./tmp/op13-keyguard-check.png /home/nowaker/projekty/modern/worldmodern/.vibeterm/tmp/./tmp/op13-keyguard-check.png /home/nowaker/projekty/modern/worldmodern/.vibeterm/tmp/opencode/op13-keyguard-check.png 2>&1" +const text = RGBA.fromHex("#eeeeee") + +async function frame(width: number, node: () => JSX.Element) { + setup?.renderer.destroy() + setup = await testRender(() => {node()}, { width, height: 12 }) + await setup.renderOnce() + return setup.captureCharFrame() +} + +// Every token its own span, the way the highlighter splits a command. +function segments() { + return command.split(/(\s+)/).map((token, index) => ({ + text: token, + fg: RGBA.fromHex(index % 4 === 0 ? "#61afef" : "#7fd88f"), + })) +} + +for (const width of [40, 80, 160]) { + test(`a colored command wraps exactly like the plain one at ${width} columns`, async () => { + const plain = await frame(width, () => $ {command}) + const colored = await frame(width, () => ( + + $ + + )) + expect(colored).toBe(plain) + expect(plain).toContain("$ cp ./tmp/op13-keyguard-check.png") + }) +} diff --git a/packages/tui/test/cli/tui/tool-output.test.tsx b/packages/tui/test/cli/tui/tool-output.test.tsx new file mode 100644 index 000000000000..e4bcccada552 --- /dev/null +++ b/packages/tui/test/cli/tui/tool-output.test.tsx @@ -0,0 +1,50 @@ +import { afterEach, expect, test } from "bun:test" +import { RGBA, type TextChunk } from "@opentui/core" +import { testRender, type JSX } from "@opentui/solid" +import { StyledOutput } from "../../../src/routes/session" + +let setup: Awaited> | undefined + +afterEach(() => { + setup?.renderer.destroy() + setup = undefined +}) + +const output = JSON.stringify( + { + p: "/console/u/0/developers/billing/projects/an-unusually-long-project-identifier/users-and-permissions/invite", + els: ['- textbox "user@example.com" [ref=e174]', '- button "Send invite" [ref=e181]'], + count: 2, + ok: true, + }, + null, + 2, +) +const fg = RGBA.fromHex("#eeeeee") + +// Every token its own chunk, as the highlighter splits JSON. +function chunks(): TextChunk[] { + return output.split(/(\s+|[{}[\]:,])/).map((text, index) => ({ + __isChunk: true, + text, + fg: RGBA.fromHex(index % 3 === 0 ? "#fab283" : "#7fd88f"), + })) +} + +async function frame(width: number, node: () => JSX.Element) { + setup?.renderer.destroy() + setup = await testRender(() => {node()}, { width, height: 40 }) + await setup.renderOnce() + return setup.captureCharFrame() +} + +for (const width of [40, 80, 160]) { + test(`colored output wraps exactly like the plain text at ${width} columns`, async () => { + const plain = await frame(width, () => {output}) + const unstyled = await frame(width, () => ) + const colored = await frame(width, () => ) + expect(unstyled).toBe(plain) + expect(colored).toBe(plain) + expect(plain).toContain('"count": 2') + }) +} diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 37fc0033e561..07be374ad545 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -45,6 +45,14 @@ test("validates config constraints", () => { expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow() expect(() => decodeInfo({ scroll_speed: 0 })).toThrow() expect(() => decodeInfo({ cursor: { style: "beam" } })).toThrow() + expect(decodeInfo({ syntax: { bash: false } })).toEqual({ syntax: { bash: false } }) + expect(() => decodeInfo({ syntax: { bash: "off" } })).toThrow() + expect(decodeInfo({ syntax: { tool_output: { browser: "json", "*": "auto" } } })).toEqual({ + syntax: { tool_output: { browser: "json", "*": "auto" } }, + }) + expect(() => decodeInfo({ syntax: { tool_output: { browser: true } } })).toThrow() + expect(decodeInfo({ syntax: { file_output: true } })).toEqual({ syntax: { file_output: true } }) + expect(() => decodeInfo({ syntax: { file_output: "yes" } })).toThrow() expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } }) }) diff --git a/packages/tui/test/tool-code-theme.test.ts b/packages/tui/test/tool-code-theme.test.ts new file mode 100644 index 000000000000..b0dace2f3410 --- /dev/null +++ b/packages/tui/test/tool-code-theme.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test" +import { DEFAULT_THEMES, resolveTheme } from "../src/theme" +import { isRedColor, toolCodeTheme } from "../src/theme/tool-code" + +const slots = [ + "error", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation", + "markdownLink", + "markdownLinkText", + "warning", +] as const + +test("tool code never draws a token in red or in the error color, in any built-in theme", () => { + for (const [name, json] of Object.entries(DEFAULT_THEMES)) { + for (const mode of ["dark", "light"] as const) { + const theme = resolveTheme(json, mode) + const tool = toolCodeTheme(theme) + for (const slot of slots) { + const color = tool[slot] + expect({ theme: `${name}/${mode}`, slot, red: isRedColor(color) }).toEqual({ + theme: `${name}/${mode}`, + slot, + red: false, + }) + expect({ theme: `${name}/${mode}`, slot, error: color.equals(theme.error) }).toEqual({ + theme: `${name}/${mode}`, + slot, + error: false, + }) + } + } + } +}) + +test("keeps every slot that is not red", () => { + const theme = resolveTheme(DEFAULT_THEMES.opencode, "dark") + const tool = toolCodeTheme(theme) + expect(isRedColor(theme.syntaxVariable)).toBe(true) + expect(tool.syntaxVariable).toEqual(theme.syntaxFunction) + expect(tool.syntaxString).toEqual(theme.syntaxString) + expect(tool.syntaxKeyword).toEqual(theme.syntaxKeyword) + expect(tool.text).toEqual(theme.text) +}) diff --git a/packages/tui/test/util/bash-highlight.test.ts b/packages/tui/test/util/bash-highlight.test.ts new file mode 100644 index 000000000000..b91fe7e60ca2 --- /dev/null +++ b/packages/tui/test/util/bash-highlight.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test" +import type { SimpleHighlight } from "@opentui/core" +import { narrowBashHighlights } from "../../src/util/bash-highlight" + +function groups(content: string, highlights: SimpleHighlight[]) { + return narrowBashHighlights(highlights, content).map(([start, end, group]) => `${content.slice(start, end)}:${group}`) +} + +test("keeps the number capture only on numbers", () => { + const content = "sleep 10 ls 0x1F 2#101" + expect( + groups(content, [ + [0, 5, "number"], + [6, 8, "number"], + [9, 11, "number"], + [12, 16, "number"], + [17, 22, "number"], + ]), + ).toEqual(["10:number", "0x1F:number", "2#101:number"]) +}) + +test("keeps the constant capture only on upper-case and special variables", () => { + const content = "$HOME $path $? $@" + expect( + groups(content, [ + [1, 5, "constant"], + [7, 11, "constant"], + [13, 14, "constant"], + [16, 17, "constant"], + ]), + ).toEqual(["HOME:constant", "?:constant", "@:constant"]) +}) + +test("keeps the directive capture only on a shebang and leaves other groups alone", () => { + const content = "#!/bin/sh\n# note\necho" + expect( + groups(content, [ + [0, 9, "keyword.directive"], + [10, 16, "keyword.directive"], + [17, 21, "function.call"], + ]), + ).toEqual(["#!/bin/sh:keyword.directive", "echo:function.call"]) +}) + +test("drops the spell captures that would override a comment's color", () => { + const content = "ls # list" + expect( + groups(content, [ + [3, 9, "comment"], + [3, 9, "spell"], + [0, 2, "nospell"], + ]), + ).toEqual(["# list:comment"]) +}) diff --git a/packages/tui/test/util/file-output.test.ts b/packages/tui/test/util/file-output.test.ts new file mode 100644 index 000000000000..5500081e1896 --- /dev/null +++ b/packages/tui/test/util/file-output.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test" +import { + bashOutputModel, + commandLanguage, + genericOutputModel, + grepToolOutputModel, + previewLines, + readDisplay, + readOutputModel, + type FileOutputModel, +} from "../../src/util/file-output" + +// Each line as gutter|code@language, so a test reads what is muted and what is colored how. +const view = (model: FileOutputModel | undefined) => + model?.lines.map((line, index) => { + const info = model.info[index] + const language = info.chunk === undefined ? "plain" : model.chunks[info.chunk].language + return `${line.slice(0, info.gutter)}|${line.slice(info.gutter)}@${language}` + }) + +describe("commandLanguage", () => { + test("takes the one language of every file a command reads", () => { + expect( + commandLanguage( + "cd /x/vibeterm-opencode-client && cat tab-trail.ts | sed -n 1,60p; sed -n 1,40p ../_lib/opencode-db-schema/index.ts; sed -n 90,125p tab-history.ts", + ), + ).toBe("typescript") + expect(commandLanguage("head -n 40 app/models/user.rb")).toBe("ruby") + expect(commandLanguage("sed -n '1p;5p' tool.py")).toBe("python") + expect(commandLanguage('rg -n "a|b" main.go')).toBe("go") + expect(commandLanguage("grep -n foo a.ts b.tsx")).toBe("typescript") + expect(commandLanguage("rg -t py foo src")).toBe("python") + expect(commandLanguage("rg -g '*.rs' unsafe")).toBe("rust") + expect(commandLanguage("rg --glob='src/**/*.{ts,tsx}' foo")).toBe("typescript") + expect(commandLanguage("git show HEAD~2:src/lib.rs | head -50")).toBe("rust") + expect(commandLanguage("timeout 10 tail -n 20 notes.md 2>&1")).toBe("markdown") + }) + + test("names no language when the files differ, a stage transforms them or another command prints", () => { + expect(commandLanguage("grep -n foo a.ts README.md")).toBeUndefined() + expect(commandLanguage("cat package.json | jq .name")).toBeUndefined() + expect(commandLanguage("ls && cat a.ts")).toBeUndefined() + expect(commandLanguage("rg -n foo src/")).toBeUndefined() + expect(commandLanguage("cat server.log")).toBeUndefined() + expect(commandLanguage("bun test a.test.ts")).toBeUndefined() + }) +}) + +describe("bashOutputModel", () => { + test("colors a read of files of one language whole", () => { + expect(view(bashOutputModel("cat a.ts | sed -n 1,2p", "import x from 'y'\nexport const a = 1"))).toEqual([ + "|import x from 'y'@typescript", + "|export const a = 1@typescript", + ]) + }) + + test("colors path:line: matches by each line's file and mutes the prefix", () => { + const output = "src/a.ts:3:const a = 1\nsrc/a.ts-4-const b = 2\n--\ndocs/b.md:10:# Title\nsrc/c.py:1:import os" + expect(view(bashOutputModel("grep -rn -C1 a .", output))).toEqual([ + "src/a.ts:3:|const a = 1@typescript", + "src/a.ts-4-|const b = 2@typescript", + "--|@plain", + "docs/b.md:10:|# Title@markdown", + "src/c.py:1:|import os@python", + ]) + const model = bashOutputModel("grep -rn -C1 a .", output)! + expect(model.chunks.map((chunk) => chunk.lines)).toEqual([[0, 1], [3], [4]]) + }) + + test("colors rg's headings: the path line muted, then each 12:code line in its language", () => { + const output = "src/a.ts\n3:const a = 1\n9:let b\n\nREADME.md\n1:# Hello" + expect(view(bashOutputModel("rg -n a", output))).toEqual([ + "src/a.ts|@plain", + "3:|const a = 1@typescript", + "9:|let b@typescript", + "|@plain", + "README.md|@plain", + "1:|# Hello@markdown", + ]) + }) + + test("colors a line-number gutter in the command's language", () => { + expect(view(bashOutputModel("rg -n def tool.py", "1:def a():\n7:def b():"))).toEqual([ + "1:|def a():@python", + "7:|def b():@python", + ]) + expect(view(bashOutputModel("cat -n main.go", " 1\tpackage main\n 2\t"))).toEqual([ + " 1\t|package main@go", + " 2\t|@go", + ]) + }) + + test("recognizes JSON and diffs whatever printed them", () => { + expect(bashOutputModel("curl -s localhost:3000/health", '{"ok": true}')?.chunks[0].language).toBe("json") + expect(bashOutputModel("git show HEAD", "diff --git a/x b/x\n--- a/x\n+++ b/x")?.chunks[0].language).toBe("diff") + }) + + test("leaves output of no clear language plain", () => { + expect(bashOutputModel("bun test", "12 pass\n0 fail")).toBeUndefined() + expect(bashOutputModel("grep -n foo a.ts README.md", "plain words")).toBeUndefined() + expect(bashOutputModel("cat a.ts", "x".repeat(200_000))).toBeUndefined() + }) +}) + +describe("readOutputModel and grepToolOutputModel", () => { + test("mutes the read tool's wrapper and line numbers and colors the file", () => { + const output = + "/x/main.go\nfile\n\n1: package main\n2: \n\n(End of file - total 2 lines)\n" + expect(readDisplay(output)).toBe("1: package main\n2: \n\n(End of file - total 2 lines)") + expect(readDisplay("/x\ndirectory\n\na/\n")).toContain("") + expect(view(readOutputModel("/x/main.go", readDisplay(output)))).toEqual([ + "1: |package main@go", + "2: |@go", + "|@plain", + "(End of file - total 2 lines)|@plain", + ]) + }) + + test("colors each file's matches from the grep tool in its language", () => { + const output = "Found 2 matches\n/x/a.ts:\n Line 3: const a = 1\n\n/x/b.py:\n Line 1: import os" + expect(view(grepToolOutputModel(output))).toEqual([ + "Found 2 matches|@plain", + "/x/a.ts:|@plain", + " Line 3: |const a = 1@typescript", + "|@plain", + "/x/b.py:|@plain", + " Line 1: |import os@python", + ]) + }) +}) + +describe("genericOutputModel", () => { + test("colors each GitHub code search snippet in the language of its path", () => { + const output = [ + "Repository: a/b", + "Path: src/x.swift", + "URL: https://github.com/a/b", + "License: MIT", + "", + "Snippets:", + "--- Snippet 1 (Line 86) ---", + "let a = 1", + "", + "Repository: c/d", + "Path: lib/y.rb", + "Snippets:", + "--- Snippet 1 (Line 2) ---", + "def z; end", + ].join("\n") + const lines = view(genericOutputModel(output))! + expect(lines.filter((line) => !line.endsWith("@plain"))).toEqual([ + "|let a = 1@swift", + "|@swift", + "|def z; end@ruby", + ]) + expect(lines[1]).toBe("Path: src/x.swift|@plain") + }) + + test("reads docs with a fenced code block as markdown, and leaves prose plain", () => { + expect(genericOutputModel("### Use it\n\n```bash\necho foo\n```")?.chunks[0].language).toBe("markdown") + expect(genericOutputModel("Found 5 matches:\nsome prose")).toBeUndefined() + }) +}) + +describe("previewLines", () => { + const output = ["a", "b", "c", "d", "e", "f"] + test("maps the whole output line for line", () => { + expect(previewLines(output, output).map((line) => line?.line)).toEqual([0, 1, 2, 3, 4, 5]) + }) + test("maps a head preview, its cut last line and the marker", () => { + expect(previewLines(["a", "b", "…"], output)).toEqual([ + { line: 0, from: 0, to: 1 }, + { line: 1, from: 0, to: 1 }, + undefined, + ]) + expect(previewLines(["a", "bc…"], ["a", "bcdef"])[1]).toEqual({ line: 1, from: 0, to: 2 }) + }) + test("maps a head and tail around a marker, and a tail cut at its start", () => { + expect(previewLines(["a", "(3 more lines)", "e", "f"], output).map((line) => line?.line)).toEqual([ + 0, + undefined, + 4, + 5, + ]) + expect(previewLines(["(4 earlier lines)", "ef", "g"], ["a", "b", "c", "d", "def", "g"])).toEqual([ + undefined, + { line: 4, from: 1, to: 3 }, + { line: 5, from: 0, to: 1 }, + ]) + }) +}) diff --git a/packages/tui/test/util/highlight-cache.test.ts b/packages/tui/test/util/highlight-cache.test.ts new file mode 100644 index 000000000000..1386b0365f7a --- /dev/null +++ b/packages/tui/test/util/highlight-cache.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { createHighlightCache } from "../../src/util/highlight-cache" + +function worker() { + const calls: string[] = [] + const highlightOnce = (content: string, filetype: string) => { + calls.push(`${filetype}:${content}`) + if (content === "fail") return Promise.reject(new Error("worker failed")) + return Promise.resolve({ highlights: [[0, content.length, "string"] as [number, number, string]] }) + } + return { calls, highlightOnce } +} + +describe("createHighlightCache", () => { + test("answers the same text and language from the first result", async () => { + const fake = worker() + const highlight = createHighlightCache(fake.highlightOnce) + const first = highlight("echo hi", "bash") + const second = highlight("echo hi", "bash") + expect(second).toBe(first) + expect(await second).toEqual({ highlights: [[0, 7, "string"]] }) + expect(fake.calls).toEqual(["bash:echo hi"]) + }) + + test("keys by language as well as content", async () => { + const fake = worker() + const highlight = createHighlightCache(fake.highlightOnce) + await highlight("x = 1", "bash") + await highlight("x = 1", "python") + expect(fake.calls).toEqual(["bash:x = 1", "python:x = 1"]) + }) + + test("drops the least recently used entry past the entry limit", async () => { + const fake = worker() + const highlight = createHighlightCache(fake.highlightOnce, { entries: 2, chars: 1000 }) + await highlight("a", "bash") + await highlight("b", "bash") + await highlight("a", "bash") + await highlight("c", "bash") + await highlight("a", "bash") + await highlight("b", "bash") + expect(fake.calls).toEqual(["bash:a", "bash:b", "bash:c", "bash:b"]) + }) + + test("drops entries past the character budget", async () => { + const fake = worker() + const highlight = createHighlightCache(fake.highlightOnce, { entries: 100, chars: 5 }) + await highlight("aaa", "bash") + await highlight("bbb", "bash") + await highlight("aaa", "bash") + expect(fake.calls).toEqual(["bash:aaa", "bash:bbb", "bash:aaa"]) + }) + + test("asks the worker again after a failed request", async () => { + const fake = worker() + const highlight = createHighlightCache(fake.highlightOnce) + await expect(highlight("fail", "bash")).rejects.toThrow("worker failed") + await expect(highlight("fail", "bash")).rejects.toThrow("worker failed") + expect(fake.calls).toEqual(["bash:fail", "bash:fail"]) + }) +}) diff --git a/packages/tui/test/util/tool-output.test.ts b/packages/tui/test/util/tool-output.test.ts new file mode 100644 index 000000000000..5f78a86a330b --- /dev/null +++ b/packages/tui/test/util/tool-output.test.ts @@ -0,0 +1,102 @@ +import type { SimpleHighlight } from "@opentui/core" +import { describe, expect, test } from "bun:test" +import { + OUTPUT_LANGUAGE_LIMIT, + detectOutputLanguage, + outputLanguage, + previewHighlights, +} from "../../src/util/tool-output" + +const json = '{"p": "/console/invite", "els": ["- textbox \\"user@example.com\\" [ref=e174]"]}' + +describe("outputLanguage", () => { + test("leaves output plain when nothing is configured", () => { + expect(outputLanguage(undefined, "browser", json)).toBeUndefined() + expect(outputLanguage({}, "browser", json)).toBeUndefined() + }) + + test("takes the exact tool name over any glob", () => { + const settings = { "*": "auto", "browser*": "yaml", browser: "json" } + expect(outputLanguage(settings, "browser", "x")).toBe("json") + }) + + test("takes the glob with the most literal characters", () => { + const settings = { "*": "markdown", "github_*": "json", "github_create_*": "yaml", "gh?b": "toml" } + expect(outputLanguage(settings, "github_create_issue", "x")).toBe("yaml") + expect(outputLanguage(settings, "github_list", "x")).toBe("json") + expect(outputLanguage(settings, "gh_b", "x")).toBe("toml") + expect(outputLanguage(settings, "context7_query", "x")).toBe("markdown") + }) + + test("treats regex characters in a name literally", () => { + expect(outputLanguage({ "a.b*": "json" }, "axb1", "x")).toBeUndefined() + expect(outputLanguage({ "a.b*": "json" }, "a.b1", "x")).toBe("json") + }) + + test("none switches a tool off under a broader glob", () => { + expect(outputLanguage({ "*": "json", todo: "none" }, "todo", json)).toBeUndefined() + }) + + test("auto colors only output recognized as JSON", () => { + expect(outputLanguage({ "*": "auto" }, "browser", json)).toBe("json") + expect(outputLanguage({ "*": "auto" }, "browser", "Navigated to https://x")).toBeUndefined() + }) + + test("never colors output past the size limit", () => { + const big = `[${'"x",'.repeat(OUTPUT_LANGUAGE_LIMIT / 4)}"x"]` + expect(outputLanguage({ browser: "json" }, "browser", big)).toBeUndefined() + }) +}) + +describe("detectOutputLanguage", () => { + test("recognizes objects and arrays that parse", () => { + expect(detectOutputLanguage(` ${json}\n`)).toBe("json") + expect(detectOutputLanguage("[1, 2, 3]")).toBe("json") + }) + + test("rejects text that only looks like JSON", () => { + expect(detectOutputLanguage("{not json}")).toBeUndefined() + expect(detectOutputLanguage("[ref=e174] clicked")).toBeUndefined() + expect(detectOutputLanguage('{"a": 1} trailing')).toBeUndefined() + expect(detectOutputLanguage('"a string"')).toBeUndefined() + expect(detectOutputLanguage("42")).toBeUndefined() + expect(detectOutputLanguage("")).toBeUndefined() + }) + + test("answers the same content again from its cache", () => { + const text = `{"n": ${Date.now()}}` + expect(detectOutputLanguage(text)).toBe("json") + expect(detectOutputLanguage(text)).toBe("json") + }) +}) + +describe("previewHighlights", () => { + const output = '{\n "p": "/x",\n "els": [1, 2]\n}' + const highlights: SimpleHighlight[] = [ + [4, 7, "property"], + [9, 13, "string"], + [17, 22, "property"], + [25, 26, "number"], + ] + + test("keeps every highlight when the whole output is shown", () => { + expect(previewHighlights(output, output, highlights)).toBe(highlights) + }) + + test("keeps the highlights inside a collapsed preview and cuts the one it splits", () => { + const shown = output.slice(0, 20) + "…" + expect(previewHighlights(shown, output, highlights)).toEqual([ + [4, 7, "property"], + [9, 13, "string"], + [17, 20, "property"], + ]) + }) + + test("gives a line-collapsed preview the colors of the lines it keeps", () => { + const shown = output.split("\n").slice(0, 2).join("\n") + "\n…" + expect(previewHighlights(shown, output, highlights)).toEqual([ + [4, 7, "property"], + [9, 13, "string"], + ]) + }) +}) diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx index 0351c46cde10..9566cbf7c7d8 100644 --- a/packages/web/src/content/docs/tui.mdx +++ b/packages/web/src/content/docs/tui.mdx @@ -412,9 +412,58 @@ This is separate from `opencode.json`, which configures server/runtime behavior. - `cursor` - Controls the terminal cursor in TUI input fields. `style` defaults to `"block"`, can be `"underline"`, `"line"`, or `"default"`; `blinking` defaults to `true`. When `style` is `"default"`, the terminal default cursor is restored, so `blinking` has no effect. - `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved. - `attention` - Configures TUI desktop notifications and sounds. Disabled by default. +- `syntax` - Controls syntax highlighting in tool calls. [Learn more](#syntax-highlighting). Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path. +### Syntax highlighting + +Tool calls are colored by language. Each part can be turned off under `syntax`: + +```json title="tui.json" +{ + "syntax": { + "bash": false + } +} +``` + +- `bash` - Color the bash tool's command as shell code. Defaults to `true`. +- `tool_output` - Color plugin and MCP tool output, by tool name. Keys are tool names or globs (`*` matches any run of characters, `?` one); values are a language id such as `json`, `yaml` or `markdown`, `auto`, or `none`. An exact name wins over any glob, and the glob with the most literal characters wins over the others. `auto` colors output that is a JSON object or array and leaves anything else plain. Output over 100,000 characters stays plain. Unset by default, which leaves all output plain. Colored output wraps exactly as the plain text does. +- `file_output` - Color tool output that shows the contents of files in each file's language. Defaults to `false`. See [File contents in tool output](#file-contents-in-tool-output). + +```json title="tui.json" +{ + "syntax": { + "tool_output": { + "browser": "json", + "todo_*": "none", + "*": "auto" + } + } +} +``` + +#### File contents in tool output + +With `file_output` on, output that shows code from files is colored in each file's language, and a path or line number a line starts with is muted: + +| Output | Language | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| A bash command reading files: `cat`, `head`, `tail`, `sed -n`, `nl`, `awk`, `bat`, `git show :`, chained with `;`, `&&` or a pipe through filters such as `head` or `grep` | The files', when they all have one language | +| `grep -n`, `rg` and other `path:12:code`, `path-12-code` and `path:code` lines | Each line's file | +| `rg` with headings: a path line, then `12:code` lines | Each heading's file | +| `12:code` or ` 12code` lines from `rg -n` or `cat -n` on files of one language | The files' | +| A `--type`, `-t` or `--glob` of one language for `rg` and `grep` | That language | +| Any bash output that is a whole JSON document, or starts like a diff (`git diff`, `git show `) | JSON, diff | +| The read and grep tools | The file read, each matched file | +| GitHub code search results (`Repository:`, `Path:` and `--- Snippet` blocks) | Each snippet's path | +| Plugin and MCP output with a fenced code block that names its language, such as context7 docs | Markdown, each block in its own language | + +Output whose language is not clear stays plain: files of different languages read by one command, a command that also runs something else (`ls && cat a.ts`), or a pipe through a command that changes the text (`cat a.json | jq`). A plugin or MCP tool's language set in `tool_output` comes first, and one set to `none` stays plain. Collapsed output is colored like the expanded output. Output over 100,000 characters stays plain, and only the first 64 files of a search are colored. + +The read and grep tools show no output by default, only their row. With `file_output` on, clicking a finished read or grep row shows the file or the matches it found as a block, colored, and clicking the block folds it back. + ### Attention The TUI can request attention for questions, permissions, session errors, and completed sessions. Enable it with `attention.enabled`; built-in events play sounds when triggered, and non-subagent events request desktop notifications only when the terminal is blurred.