One mod for Claude Code, Windows and CJK first: pasted-image and text previews in any terminal, themed replies with CJK-correct tables and charts, long edit…

<img src="docs/banner.svg" alt="tessera: one Claude Code mod for every terminal" width="100%">
<a href="https://github.com/OG-Matcha/tessera/actions/workflows/ci.yml"><img src="https://github.com/OG-Matcha/tessera/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://github.com/OG-Matcha/tessera/releases"><img src="https://img.shields.io/github/v/release/OG-Matcha/tessera?color=89b4fa" alt="Release"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/OG-Matcha/tessera?color=a6e3a1" alt="MIT license"></a> <img src="https://img.shields.io/badge/Claude_Code-%E2%89%A52.1.292-cba6f7" alt="Claude Code 2.1.292 or later"> <img src="https://img.shields.io/badge/Windows%20%C2%B7%20macOS%20%C2%B7%20Linux-tested-94e2d5" alt="Windows, macOS and Linux">
<img src="docs/demo.gif" alt="tessera in Claude Code: a pasted 30-line log previewed above the prompt, a reply with a CJK-aligned table, the Traditional Chinese guard refusing a Simplified write, then a 32-line edit folded to its first changes" width="760">
English · <a href="README.zh-TW.md">繁體中文</a>
tessera is a Claude Code mod that makes the terminal show what Claude Code hides and stops the mistakes that cost work: pasted images and text you can actually see, replies drawn with CJK-correct tables and diagrams, and guards for long multi-agent runs. One install, every feature switchable, nothing running that you turned off.
| Problem | Where it is reported | tessera |
|---|---|---|
A pasted image shows as [Image #1] in most terminals | Thumbnails above the prompt: real pixels in kitty and Ghostty, cell art everywhere else | |
Pasted text collapses to [Pasted text #1 +40 lines] before you send it | #23134 | Its first lines shown above the prompt |
Korean, Chinese or Japanese written as \uXXXX comes out as wrong characters | #83033 | Such tool calls are refused before they write |
| Claude answers a Chinese question in English because the pasted log was English | Replies follow the language of your own words | |
| Claude writes Simplified characters or zh-CN terms into a Traditional Chinese project | The write is refused once, with the zh-TW forms | |
A new session or /clear loses track of what was left to do | The unfinished tasks are offered back above the prompt | |
| A long edit fills the screen with its diff | The first lines of what was removed and added, the counts, and an expand button | |
| The Read tool does not say which file it read | #21151 | Tool rows name the file |
| Copying from the terminal brings indentation and trailing spaces | #18170 | Copy buttons and /tessera copy copy clean text |
claude plugin marketplace add OG-Matcha/tessera
claude plugin install tessera@tessera --scope user
Start a new session, then run /tessera setup to pick the features you want.
To get new releases automatically, open /plugin, go to Marketplaces, choose tessera and select Enable auto-update. Claude Code leaves auto-update off for marketplaces other than Anthropic's own; tessera reminds you of it once, above the prompt, with a button that opens /plugin. Without it, update by hand:
claude plugin marketplace update tessera
claude plugin update tessera@tessera
[!IMPORTANT] tessera includes the reply rendering of prismantis and the paste preview idea of cc-mod-image-view. Uninstall those two first; two mods drawing the same part of the screen fight over it.
| Feature | Default | What it does |
|---|---|---|
| Themed replies | on | Tables, headings, highlighted code, mermaid diagrams and charts, tool rows, copy buttons; 16 themes |
| Fold long diffs | on | An Edit or Write diff over 12 lines shows up to 8 lines (the first of what was removed and of what was added), the counts, and expand; ctrl+o and --verbose show it whole. Display only: what Claude reads is unchanged |
| Paste previews | on | Image thumbnails and collapsed pasted text above the prompt (the text is read from the clipboard and shown only when it matches the paste's line count); "original" opens the full image in an Orca tab, a VS Code tab or your system viewer |
| Reply in my language | on | When your own words are Chinese, Japanese or Korean, Claude replies in that language; pasted code, logs and quotes do not count |
| Carry over tasks | on | A new session, or the conversation after /clear, offers the tasks the last one in this repository left open: continue puts them in the prompt box, dismiss forgets them. It turns on Claude's task tools for the session (CLAUDE_CODE_ENABLE_TODO_TOOLS), which Claude Code leaves off for Claude 5.x, unless you set that variable yourself; Claude may then keep a task list on screen (Ctrl+T hides it) |
| CJK escape guard | on | Refuses Korean, Chinese or Japanese written as \uXXXX escapes |
| Traditional Chinese guard | auto | While you write Traditional Chinese, refuses Simplified characters and zh-CN terms written into a file and names the zh-TW forms (这→這, 服務器→伺服器); zh-CN files, files already in Simplified, terms the file already uses and Japanese lines are left alone |
A prompt Claude writes for another agent or tool draws as a card with a token estimate and a copy button.
On by default and quiet until they matter.
| Feature | Default | What it does |
|---|---|---|
| Tree guard | on | Refuses recursive deletes through links and links to node_modules; reminds once before a force push to main or master; while agents run, refuses tree rewrites and git add -A in the main tree |
| Heredoc guard | on | Refuses a Bash heredoc with an unquoted delimiter (<<EOF) whose body the shell would change: ${x}, $(cmd) and backticks expanded, \\ turned into \; send it again if the expansion is intended |
| Model per agent | auto | An agent with no model gets haiku, sonnet, opus or fable picked from its task by a short Haiku call, with a toast naming it; choose asks Claude to name one instead. A Workflow script without models is reminded once to name one per agent() |
Off by default, for particular workflows. Turn them on in /tessera setup.
| Feature | Default | What it does |
|---|---|---|
| Glossary guard | off | When the repository's CLAUDE.md has a table with Use and Avoid columns, refuses a write that brings in an avoided wording and names the term to use; does nothing without such a table |
| Workflows quote you | off | A Workflow script must carry your own words, so its agents keep your standing instruction |
| Client feedback inbox | off | Pasted chat logs (22:55 Name message) become numbered items; a complaint like a fixed item is flagged as a likely regression |
| Resume after limits | off | A usage-limit stop continues a minute after the reset |
| Command | Does |
|---|---|
/tessera setup | Switch features on and off |
/tessera peek <file> | Preview Markdown, CSV, JSON, docx, xlsx or pptx in the terminal |
/tessera copy · copy code · copy prompt | Copy the last reply, its last code block, or its prompt card |
/tessera inbox · inbox fixed 3 5 | List the feedback inbox, mark items fixed at the current commit |
/tessera theme <name> | Switch theme |
/tessera demo | Show every element tessera draws |
Type /tessera and a letter for suggestions with descriptions.
| Terminal | Image previews | "original" opens in |
|---|---|---|
| kitty, Ghostty | real pixels | system viewer |
| Windows Terminal, iTerm2, Apple Terminal, SSH | cell art | system viewer (explorer, open, xdg-open) |
| Orca | cell art | an Orca browser tab |
| VS Code, Cursor | cell art | a VS Code tab |
| inside tmux or screen | cell art | as above |
| WSL | cell art | Windows viewer through \\wsl.localhost |
If detection is wrong, set imageMode to pixels or cells in /config. Orca can draw kitty graphics but not yet the Unicode placeholders Claude Code uses (stablyai/orca#23615).
No network requests of its own: the one model call, a short Haiku classification per Agent call that names no model (agentModel: auto, the default), goes through your Claude Code session. It reads your Claude Code settings (the language setting and whether tessera's marketplace auto-updates), Claude Code's paste cache, Workflow scripts when one starts, files you peek, the repository's CLAUDE.md for a glossary table, and a file Claude is about to write Simplified characters, zh-CN terms or avoided glossary wordings into; it runs git rev-parse, a link listing before a recursive delete, a clipboard read once per collapsed text paste, and your platform's viewer when you ask for an original; while carry-over is on it sets CLAUDE_CODE_ENABLE_TODO_TOOLS=1 for the session unless you set it yourself, and writes nothing to your settings. Details in SECURITY.md.
Does it slow Claude Code down? A feature that is off registers no hooks and no timers. Paste previews check the prompt box four times a second while on.
Does it add tokens? Little, and only where it says. Measured on Claude Code 2.1.294 against a first request of about 55,000 tokens:
| Feature | Adds | When |
|---|---|---|
Diagram hint (themed replies, diagramHints) | about 300 tokens | once per context, again after a compaction |
| Reply in my language | about 70–100 tokens | once per context, again when your language changes or Claude's last reply drifted |
| Guards | the refusal's reason, a short paragraph | only when a call is refused |
| Model per agent | one separate Haiku call with the agent's task (its first 2,000 characters) | each Agent call without a model; nothing in your conversation |
| Carry over tasks | no measurable change: Claude Code loads the task tools on demand | |
| Client feedback inbox | a note naming the filed items | when you paste a chat log |
| Themed replies, paste previews, fold long diffs | nothing: display only |
Each one can be switched off in /tessera setup or /config.
Why not just install prismantis and cc-mod-image-view? You can, if you use macOS or Linux and a kitty-graphics terminal. tessera exists for everything else: Windows, CJK, terminals without image protocols, and long agent runs.
Reply rendering is adapted from prismantis by Nahum Litvin; the paste-cache lookup follows cc-mod-image-view by gggodlin, itself from claude-image-view by Jarrod Watts; PNG and zip inflation use fflate. All MIT; see NOTICE.
Issues and pull requests are welcome in English or Chinese. Read CONTRIBUTING.md first; the code of conduct applies. Questions go to Discussions (SUPPORT.md); every release is listed in CHANGELOG.md.
hooks/register.tsx 624 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register, RenderElement, Timer } from 'claude-code'
3
4import type { CarryOver } from '../types'
5import type { Env } from './platform'
6import type { InboxItem } from './inbox'
7import { intake, intakeNote, listText, markFixed, parseChat } from './inbox'
8
9import { parse } from './markdown'
10import { boxArt, mermaidText, unpad } from './mermaid'
11import type { CopyKind, Drawn } from './render'
12import { breakUnits, remember, renderBlocks, renderExpandedShell, renderFoldedDiff, renderToolGroup, renderToolRow, renderTurnDuration, renderUserPrompt, width } from './render'
13import { helpText, rtlShowcaseText, showcaseText } from './help'
14import { helpTextZh, showcaseTextZh } from './help-zh'
15import type { CarryStore, TaskLog } from './carry'
16import { carriedFrom, recordSession, restoredTasks } from './carry'
17import { pickLang } from './i18n'
18import { session, t } from './session'
19import { isAbsolute, registerGuards } from './guard-hooks'
20import { registerPastes } from './paste-hooks'
21import { completions } from './complete'
22import { FEATURES } from './features'
23import { foldPatch, patchOf } from './fold'
24import { marketplaceWithoutUpdates } from './update'
25import { peek } from './peek'
26import { resumeAt } from './limits'
27import type { Voice } from './voice'
28import { replyNote, voiceOf } from './voice'
29import { PRESET_NAMES } from './presets'
30import type { Style } from './theme'
31import { resolveStyle } from './theme'
32import type { Terminal } from './rtl'
33import { TERMINALS, hasRtl } from './rtl'
34
35const carryOver = atom({ plugin: 'tessera', key: 'carryOver' } as const, null as CarryOver | null)
36// The marketplace named in the one-time offer to turn on auto-update, while it shows.
37const updateOffer = atom({ plugin: 'tessera', key: 'updateOffer' } as const, null as string | null)
38// Edit results the person unfolded.
39const unfoldedDiffs = atom({ plugin: 'tessera', key: 'unfoldedDiffs' } as const, [] as string[])
40const CARRY_SHOWN = 5
41// False while no setting or locale chose Chinese, so a prompt written in Chinese may still switch to it.
42let langSettled = false
43// Notes stay in the transcript once sent, so each goes once per context: again only after a compaction drops it.
44let hintSent = false
45let notedVoice: Voice | undefined
46let matchReplyLanguage = true
47// Renders cannot ask the engine, so tool rows read the session's working directory from here.
48let workDir: string | undefined
49
50
51// Every variable the platform decisions read, each named literally so the engine can list them, read
52// at once: each is a round trip to the engine.
53async function readEnv($: EngineInterface): Promise<Env> {
54 const [OS, TEMP, HOME, CLAUDE_CODE_TMPDIR, CLAUDE_CODE_FORCE_TERMINAL_IMAGES, TERM, TERM_PROGRAM, KITTY_WINDOW_ID, TMUX, STY, WSL_DISTRO_NAME] = await Promise.all([
55 $.env.get('OS'),
56 $.env.get('TEMP'),
57 $.env.get('HOME'),
58 $.env.get('CLAUDE_CODE_TMPDIR'),
59 $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
60 $.env.get('TERM'),
61 $.env.get('TERM_PROGRAM'),
62 $.env.get('KITTY_WINDOW_ID'),
63 $.env.get('TMUX'),
64 $.env.get('STY'),
65 $.env.get('WSL_DISTRO_NAME'),
66 ])
67 return { OS, TEMP, HOME, CLAUDE_CODE_TMPDIR, CLAUDE_CODE_FORCE_TERMINAL_IMAGES, TERM, TERM_PROGRAM, KITTY_WINDOW_ID, TMUX, STY, WSL_DISTRO_NAME }
68}
69
70// The reply note sits far back in a long context and stops holding; a last reply in another language
71// than the person's gets the note sent again.
72async function replyDrifted($: EngineInterface, wanted: Voice): Promise<boolean> {
73 const messages = await $.session.messages().catch(() => [])
74 const last = messages.findLast(m => m.role === 'assistant')
75 const spoken = last === undefined ? undefined : voiceOf(last.text)
76 return spoken !== undefined && spoken !== wanted
77}
78
79// Carry-over: what this session's task lists leave open is kept per repository, and the next session
80// there offers it back above the prompt.
81let carryOn = true
82const taskLog: TaskLog = { tasks: new Map(), todos: [] }
83
84async function carryKey($: EngineInterface): Promise<string> {
85 return `carry:${(await $.session.repo())?.root ?? (await $.session.cwd())}`
86}
87
88async function readCarry($: EngineInterface, key: string): Promise<CarryStore> {
89 const stored = await $.store.get(key).catch(() => undefined)
90 return stored !== null && typeof stored === 'object' ? (stored as CarryStore) : {}
91}
92
93async function saveTasks($: EngineInterface) {
94 const key = await carryKey($)
95 await $.store.set(key, recordSession(await readCarry($, key), await $.session.id(), await $.clock.now(), taskLog)).catch(() => undefined)
96}
97
98async function settleCarry($: EngineInterface, from: string, fill: string | undefined) {
99 if (fill !== undefined) await $.prompt.fill({ text: fill })
100 const key = await carryKey($)
101 const { [from]: _, ...rest } = await readCarry($, key)
102 await $.store.set(key, rest).catch(() => undefined)
103 await update($, carryOver, () => null)
104}
105
106async function settleUpdate($: EngineInterface, fill: string | undefined) {
107 if (fill !== undefined) await $.prompt.fill({ text: fill })
108 await $.store.set('updateOffered', true).catch(() => undefined)
109 await update($, updateOffer, () => null)
110}
111
112function registerUpdateOffer(on: On) {
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 const market = await read($, updateOffer)
115 if (e.surface !== 'terminal' || e.props.hasSurvey || market === null) return next(e)
116 const { Box, Text, Button } = $.ui.resolve(e)
117 const below = await next(e)
118 return (
119 <Box flexDirection="column">
120 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
121 <Box flexDirection="row" gap={2}>
122 <Text bold>{t().updateTitle}</Text>
123 <Button key="update-open" label={t().updateOpen} onPress={() => settleUpdate($, '/plugin')} />
124 <Button key="update-later" label={t().updateLater} onPress={() => settleUpdate($, undefined)} />
125 </Box>
126 <Text dimColor>{t().updateSteps(market)}</Text>
127 </Box>
128 {below}
129 </Box>
130 )
131 })
132}
133
134function registerCarryOver(on: On) {
135 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
136 const out = await next(e)
137 const task = (out.result as { task?: { id: string; subject: string } } | undefined)?.task
138 if (e.agentId !== undefined || task === undefined) return out
139 taskLog.tasks.set(task.id, { subject: task.subject, status: 'pending' })
140 await saveTasks($)
141 return out
142 })
143 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
144 const out = await next(e)
145 const task = taskLog.tasks.get(e.taskId)
146 if (e.agentId !== undefined || task === undefined || out.deny !== undefined) return out
147 if (e.status === 'deleted') taskLog.tasks.delete(e.taskId)
148 else taskLog.tasks.set(e.taskId, { subject: e.subject ?? task.subject, status: e.status ?? task.status })
149 await saveTasks($)
150 return out
151 })
152 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
153 const out = await next(e)
154 if (e.agentId !== undefined || out.deny !== undefined) return out
155 taskLog.todos = e.todos.map(t => ({ content: t.content, status: t.status }))
156 await saveTasks($)
157 return out
158 })
159 // A /clear ends the conversation with no session.start after it: the fresh one starts with an empty
160 // task list, and what the cleared one left open is offered like a new session's.
161 on('session.end', async ($, e, next) => {
162 if (e.reason !== 'clear') return next(e)
163 taskLog.tasks.clear()
164 taskLog.todos = []
165 const open = (await readCarry($, await carryKey($)))[e.sessionId]?.open ?? []
166 // The session's state is reset once session.end is done, so the offer is written when the fresh
167 // conversation's id is in place.
168 if (open.length > 0) {
169 let tries = 0
170 const wait: Timer = $.clock.every(100, async () => {
171 if (++tries > 50) return wait.cancel()
172 if ((await $.session.id()) === e.sessionId) return
173 wait.cancel()
174 await update($, carryOver, () => ({ from: e.sessionId, items: open }))
175 })
176 }
177 return next(e)
178 })
179
180 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
181 const offer = await read($, carryOver)
182 if (e.surface !== 'terminal' || e.props.hasSurvey || offer === null) return next(e)
183 const { Box, Text, Button } = $.ui.resolve(e)
184 const width = Math.max(20, (e.viewport?.columns ?? 80) - 6)
185 const below = await next(e)
186 return (
187 <Box flexDirection="column">
188 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
189 <Box flexDirection="row" gap={2}>
190 <Text bold>{t().carryTitle(offer.items.length)}</Text>
191 <Button key="carry-continue" label={t().carryContinue} onPress={() => settleCarry($, offer.from, t().carryPrompt(offer.items))} />
192 <Button key="carry-dismiss" label={t().carryDismiss} onPress={() => settleCarry($, offer.from, undefined)} />
193 </Box>
194 {offer.items.slice(0, CARRY_SHOWN).map((item, i) => (
195 <Text key={`carry-${i}`} wrap="truncate-end">{`· ${[...item].slice(0, width).join('')}`}</Text>
196 ))}
197 {offer.items.length > CARRY_SHOWN && <Text dimColor>…</Text>}
198 </Box>
199 {below}
200 </Box>
201 )
202 })
203}
204
205// The optional client-feedback inbox: pasted chat logs become numbered items per repository, kept across sessions.
206let inboxOn = false
207
208async function inboxKey($: EngineInterface): Promise<string> {
209 return `inbox:${(await $.session.repo())?.root ?? (await $.session.cwd())}`
210}
211
212const registerInboxTool = ($: EngineInterface) =>
213 $.tool
214 .register({
215 name: 'inbox_fixed',
216 description: "Mark client-feedback inbox items as fixed once a commit fixes them, so a later complaint like them is flagged as a regression. Pass the item numbers from tessera's inbox note and the commit hash.",
217 inputSchema: { type: 'object', properties: { ids: { type: 'array', items: { type: 'number' } }, commit: { type: 'string' } }, required: ['ids', 'commit'] },
218 })
219 .catch(() => undefined)
220
221async function readInbox($: EngineInterface, key: string): Promise<InboxItem[]> {
222 const stored = await $.store.get(key).catch(() => undefined)
223 return Array.isArray(stored) ? (stored as InboxItem[]) : []
224}
225
226async function fileFeedback($: EngineInterface, text: string): Promise<string | undefined> {
227 const lines = parseChat(text)
228 if (lines.length === 0) return undefined
229 const key = await inboxKey($)
230 const result = intake(await readInbox($, key), lines)
231 if (result.added.length === 0) return undefined
232 await $.store.set(key, result.items)
233 $.ui.toast(t().inboxFiled(result.added.length, result.regressions.map(r => r.like.id)))
234 return intakeNote(result)
235}
236
237async function markInbox($: EngineInterface, ids: number[], commit: string): Promise<string> {
238 const key = await inboxKey($)
239 await $.store.set(key, markFixed(await readInbox($, key), ids, commit))
240 return t().inboxMarked(ids, commit)
241}
242
243// Resume after a rate limit: one queued "go on" prompt at the reset, cancelled when the person types first.
244let pendingResume: Timer | undefined
245
246async function scheduleResume($: EngineInterface) {
247 const now = await $.clock.now()
248 const at = resumeAt((await $.session.usage()).rateLimits, now)
249 if (at === undefined) return
250 pendingResume?.cancel()
251 pendingResume = $.clock.after(at - now + 60_000, () => {
252 pendingResume = undefined
253 void $.prompt.submit({ text: t().resumePrompt })
254 })
255 $.ui.toast(t().resumeScheduled(new Date(at + 60_000).toTimeString().slice(0, 5)))
256}
257
258function registerResume(on: On) {
259 on('classic.StopFailure', async ($, e, next) => {
260 const done = await next(e)
261 if (e.error === 'rate_limit') await scheduleResume($)
262 return done
263 })
264}
265
266const SETUP_PANE = 'tessera-setup'
267
268// /tessera setup: one row per feature; a press writes its option, which reloads tessera with it.
269function registerSetup(on: On, options: Record<string, unknown>) {
270 on('ui.render', { component: 'Pane', requestId: SETUP_PANE }, async ($, e) => {
271 const { Box, Text, Button } = $.ui.resolve(e)
272 const s = t()
273 return (
274 <Box flexDirection="column">
275 <Box flexDirection="row" justifyContent="space-between">
276 <Text bold>{s.setupTitle}</Text>
277 <Button key="close" label={s.closePane} onPress={() => $.ui.close({ id: SETUP_PANE })} />
278 </Box>
279 {FEATURES.map(feature => {
280 const isOn = feature.isOn(options[feature.key])
281 return (
282 <Box key={feature.key} flexDirection="column" marginTop={1}>
283 <Button
284 key={`toggle-${feature.key}`}
285 label={`${isOn ? '☑' : '☐'} ${feature.name[session.lang]}`}
286 onPress={() => $.config.set({ key: `${$.plugin.name}.${feature.key}`, value: isOn ? feature.off : feature.on })}
287 />
288 {/* The pane's width is not known here, so the text wraps by its own pieces, between CJK characters too. */}
289 <Box flexDirection="row" flexWrap="wrap" paddingLeft={3}>
290 {breakUnits(feature.about[session.lang]).map((unit, i) => <Text key={`a${i}`} dimColor>{unit}</Text>)}
291 </Box>
292 </Box>
293 )
294 })}
295 </Box>
296 )
297 })
298}
299
300const HINT = [
301 'Replies in this session are drawn by the tessera mod, which runs inside Claude Code and is not a command or tool to call: when the user asks to show something with tessera, write it as markdown in the reply.',
302 'Markdown tables, GitHub alerts (> [!WARNING], > [!NOTE]), fenced code with a language tag, and ```mermaid blocks render as colored terminal graphics:',
303 'flowcharts, sequence diagrams and xychart-beta bar or line charts.',
304 'When a reply carries a numeric series or a flow that is easier to see than read, add one small diagram or chart with short labels.',
305 'Skip diagrams for simple answers.',
306 'When you write a prompt meant for another AI agent or tool, put the whole prompt in one ```prompt fenced block: it draws as a card with a copy button.',
307 'Put any command or snippet the user may run or copy in a fenced block with a language tag, never inline code: fenced blocks get a copy button, inline code does not.',
308].join(' ')
309
310const detectTerminal = async ($: EngineInterface): Promise<Terminal | null> => {
311 const program = await $.env.get('TERM_PROGRAM')
312 const term = await $.env.get('TERM')
313 if ((await $.env.get('KITTY_WINDOW_ID')) || term === 'xterm-kitty') return 'kitty'
314 if (program === 'Apple_Terminal') return 'apple-terminal'
315 if (program === 'WarpTerminal') return 'warp'
316 if (program === 'ghostty') return 'ghostty'
317 if (program === 'WezTerm') return 'wezterm'
318 if (program === 'vscode') return 'vscode'
319 if (program === 'iTerm.app') return 'iterm'
320 if (term === 'alacritty' || (await $.env.get('ALACRITTY_WINDOW_ID'))) return 'alacritty'
321 if (await $.env.get('WT_SESSION')) return 'windows-terminal'
322 if (await $.env.get('VTE_VERSION')) return 'gnome'
323 if (await $.env.get('KONSOLE_VERSION')) return 'konsole'
324 return null
325}
326
327const applyRtl = async ($: EngineInterface, style: Style): Promise<void> => {
328 if (style.rtl !== 'auto') return
329 const terminal = await detectTerminal($)
330 style.reorder = terminal !== null
331 if (terminal) style.shape = TERMINALS[terminal]
332}
333
334const expandedCalls = new Set<string>()
335// True while the transcript draws every row in full (ctrl+o, --verbose), which tool rows and folded diffs
336// leave to the engine. A user message says which view is drawing: every transcript has one, drawn first.
337let transcriptExpanded = false
338
339const drawMarkdown = ($: EngineInterface, el: ReturnType<EngineInterface['ui']['resolve']>, style: Style, blocks: ReturnType<typeof parse>, columns: number, reply?: string): RenderElement[] => {
340 const { Button } = el
341 const copy = (text: string | (() => string), key: string, kind: CopyKind | 'reply' = 'block') =>
342 style.copyButtons ? (
343 <Button
344 key={key}
345 variant="primary"
346 label={t().copyLabels[kind]}
347 onPress={press => {
348 $.ui.copy({ text: typeof text === 'function' ? text() : text, surface: press.surface })
349 .then(r => $.ui.toast(r.isCopied ? t().copied : `${t().copyFailed}: ${r.reason}`))
350 .catch(() => $.ui.toast(t().copyFailed))
351 }}
352 />
353 ) : null
354 const drawn: Drawn = new Map()
355 if (style.mermaid) {
356 for (const [i, block] of blocks.entries()) {
357 if (block.kind !== 'code' || block.lang.toLowerCase() !== 'mermaid') continue
358 const art = mermaidText(block.lines.join('\n'), style.mermaidAscii, columns)
359 if (art !== null && unpad(art).split('\n').every(l => width(l) <= columns - 2)) drawn.set(i, { element: boxArt(el, style, art, `b${i}`), art: unpad(art) })
360 }
361 }
362 const elements = renderBlocks(el, style, blocks, columns, drawn, copy)
363 const button = reply === undefined ? null : copy(reply, 'reply', 'reply')
364 return button ? [...elements, <el.Box key="reply" alignSelf="flex-end">{button}</el.Box>] : elements
365}
366
367export const register: Register = (on, options) => {
368 const isDrawing = options.enabled !== false
369 const style = resolveStyle(options)
370
371 session.lang = pickLang(options.language, [])
372 langSettled = options.language === 'en' || options.language === 'zh-TW'
373 matchReplyLanguage = options.replyLanguage !== 'off'
374 session.voice = undefined
375 hintSent = false
376 notedVoice = undefined
377
378 on('session.start', async ($, e, next) => {
379 // Claude 5.x gets no task tools unless asked, and carry-over has nothing to keep without them; a
380 // value the person set, on or off, stands.
381 // Everything read before the session starts is read at once: each read is a round trip to the engine.
382 const [todoTools, readsEnv, settings, wroteLang, lcAll, langVar, setupSeen, sessionCwd] = await Promise.all([
383 $.env.get('CLAUDE_CODE_ENABLE_TODO_TOOLS'),
384 readEnv($),
385 $.settings.read({}).catch(() => ({}) as Record<string, unknown>),
386 $.store.get('wroteLang').catch(() => undefined),
387 $.env.get('LC_ALL'),
388 $.env.get('LANG'),
389 $.store.get('setupSeen').catch(() => true),
390 $.session.cwd().catch(() => undefined),
391 ])
392 workDir = sessionCwd
393 if (carryOn && todoTools === undefined) await $.env.set('CLAUDE_CODE_ENABLE_TODO_TOOLS', '1').catch(() => undefined)
394 session.env = readsEnv
395 const hints = [
396 typeof settings.language === 'string' ? settings.language : undefined,
397 // The language the person wrote in last time, ahead of a system locale that may not be theirs.
398 typeof wroteLang === 'string' ? wroteLang : undefined,
399 lcAll,
400 langVar,
401 Intl.DateTimeFormat().resolvedOptions().locale,
402 ]
403 session.lang = pickLang(options.language, hints)
404 langSettled = options.language === 'en' || options.language === 'zh-TW' || session.lang === 'zh-TW'
405 const returning = setupSeen === true
406 if (!returning) {
407 await $.store.set('setupSeen', true).catch(() => undefined)
408 $.ui.toast(t().setupHint)
409 }
410 if (isDrawing) await applyRtl($, style)
411 const started = await next(e)
412 const carry = async () => {
413 const [store, sessionId] = await Promise.all([carryKey($).then(key => readCarry($, key)), $.session.id()])
414 // A reload starts the module over within the same session: pick its task list back up.
415 if (taskLog.tasks.size === 0) taskLog.tasks = restoredTasks(store, sessionId)
416 const offer = e.isInteractive ? carriedFrom(store, sessionId) : undefined
417 if (offer !== undefined) await update($, carryOver, () => offer)
418 }
419 // Asked once, from the second session on, so the first one only shows the setup hint.
420 const market = marketplaceWithoutUpdates(settings)
421 const offerUpdate = async () => {
422 if (e.isInteractive && returning && market !== undefined && (await $.store.get('updateOffered').catch(() => true)) !== true)
423 await update($, updateOffer, () => market)
424 }
425 // What follows the start is independent, so it runs at once too.
426 await Promise.all([
427 carryOn ? carry() : undefined,
428 offerUpdate(),
429 $.command
430 .register({ name: 'tessera', description: t().commandDescription, argumentHint: '[setup | peek <file> | inbox [fixed <n…>] | theme <name> | copy [code|prompt] | demo]' })
431 .catch(() => undefined),
432 inboxOn ? registerInboxTool($) : undefined,
433 ])
434 return started
435 })
436
437 const imagesOn = options.pastePreview !== false
438 if (imagesOn) registerPastes(on, options)
439 if (options.guardGit !== false || options.guardCjkEscapes !== false || options.guardSimplified !== 'off' || options.guardHeredoc !== false || options.guardGlossary === true || options.agentModel !== 'off' || options.requireUserQuote === true)
440 registerGuards(on, options)
441 registerSetup(on, options)
442 if (options.resumeAfterLimit === true) registerResume(on)
443 carryOn = options.carryOver !== false
444 if (carryOn) registerCarryOver(on)
445 registerUpdateOffer(on)
446 inboxOn = options.feedbackInbox === true
447 const parsed = new Map<string, ReturnType<typeof parse>>()
448 const parseCached = (text: string, cache = parsed, limit?: number) => remember(cache, text, () => parse(text, { numbers: style.highlightNumbers, paths: style.highlightPaths }), limit)
449
450 on('tool.call', { tool: 'mcp__tessera__inbox_fixed' }, async ($, e) => {
451 const input = e as unknown as { ids?: unknown; commit?: unknown }
452 const ids = Array.isArray(input.ids) ? input.ids.filter((n): n is number => typeof n === 'number') : []
453 const text = await markInbox($, ids, typeof input.commit === 'string' ? input.commit : '')
454 // An MCP tool's result is its text (or a content list), not a whole CallToolResult.
455 return { result: text } as never
456 })
457
458 on('session.compact', async (_, e, next) => {
459 const done = await next(e)
460 hintSent = false
461 notedVoice = undefined
462 return done
463 })
464
465 on('prompt.autocomplete', async (_, e, next) => {
466 const rows = completions(e.text, e.start, e.token, PRESET_NAMES, session.lang)
467 if (rows.length === 0) return next(e)
468 return { suggestions: [...(await next(e)).suggestions, ...rows] }
469 })
470
471 on('prompt.submit', async ($, e, next) => {
472 const own = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
473 const context = [...(e.context ?? [])]
474 if (own) {
475 if (carryOn && (await read($, carryOver)) !== null) await update($, carryOver, () => null)
476 pendingResume?.cancel()
477 pendingResume = undefined
478 session.voice = voiceOf(e.text) ?? session.voice
479 if (!langSettled && (session.voice === 'zh-Hant' || session.voice === 'zh-Hans')) {
480 session.lang = 'zh-TW'
481 langSettled = true
482 await $.store.set('wroteLang', 'zh-TW').catch(() => undefined)
483 }
484 if (matchReplyLanguage && session.voice !== undefined && session.voice !== 'en' && (session.voice !== notedVoice || (await replyDrifted($, session.voice)))) context.push(replyNote(session.voice))
485 if (session.voice !== undefined) notedVoice = session.voice
486 }
487 if (isDrawing) {
488 await applyRtl($, style)
489 if (style.diagramHints && own && !hintSent) {
490 context.push(HINT)
491 hintSent = true
492 }
493 }
494 if (inboxOn && own) {
495 const note = await fileFeedback($, e.text)
496 if (note !== undefined) context.push(note)
497 }
498 return context.length === (e.context ?? []).length ? next(e) : next({ ...e, context })
499 })
500
501 on('command.run', { command: 'tessera' }, async ($, e) => {
502 const [sub, name, ...rest] = e.args.trim().split(/\s+/)
503 if (sub === 'peek') {
504 const target = [name, ...rest].filter(Boolean).join(' ').replace(/^@/, '').replace(/^["']|["']$/g, '')
505 if (target === '') return { text: t().peekUsage }
506 const path = isAbsolute(target) ? target : `${await $.session.cwd()}/${target}`
507 const file = await $.fs.read(path, { as: 'bytes' }).catch(() => undefined)
508 if (file === undefined) return { text: t().peekUnreadable(target) }
509 const summary = peek(path, Uint8Array.fromBase64(file.base64))
510 return { text: summary === null ? t().peekUnknown(target) : `### ${target.replace(/^.*[\\/]/, '')}\n\n${summary}` }
511 }
512 if (sub === 'setup') {
513 await $.store.set('setupSeen', true).catch(() => undefined)
514 await $.ui.open({ id: SETUP_PANE, title: 'tessera' })
515 return { text: t().setupOpened }
516 }
517 if (sub === 'inbox') {
518 if (!inboxOn) return { text: t().inboxOff }
519 if (name === 'fixed') {
520 const ids = rest.map(Number).filter(n => Number.isInteger(n) && n > 0)
521 const head = await $.process.run(['git', '-C', await $.session.cwd(), 'rev-parse', '--short', 'HEAD'], { timeoutMs: 5_000 }).catch(() => undefined)
522 return { text: await markInbox($, ids, head?.exitCode === 0 ? head.stdout.trim() : 'manual') }
523 }
524 return { text: listText(await readInbox($, await inboxKey($)), t().inboxEmpty, t().inboxHeaders) }
525 }
526 if (!isDrawing) return { text: t().drawingOff }
527
528
529 if (sub === 'demo') return { text: session.lang === 'zh-TW' ? showcaseTextZh(PRESET_NAMES) : showcaseText(PRESET_NAMES) }
530 if (sub === 'copy') {
531 const reply = (await $.session.messages()).findLast(m => m.role === 'assistant' && m.text.trim())
532 if (!reply) return { text: t().nothingToCopy }
533 const wanted = name === 'code' || name === 'prompt' ? name : undefined
534 const code = wanted === undefined ? undefined : parseCached(reply.text).findLast(b => b.kind === 'code' && (wanted === 'code' || b.lang === 'prompt'))
535 if (wanted !== undefined && code?.kind !== 'code') return { text: wanted === 'prompt' ? t().noPromptBlock : t().noCodeBlock }
536 const result = await $.ui.copy({ text: code?.kind === 'code' ? code.lines.join('\n') : reply.text })
537 const done = wanted === 'prompt' ? t().copiedPrompt : code ? t().copiedCode : t().copiedReply
538 return { text: result.isCopied ? done : `${t().copyFailed}: ${result.reason}` }
539 }
540 if (sub === 'demo-rtl') {
541 await applyRtl($, style)
542 return { text: rtlShowcaseText() }
543 }
544 if (sub !== 'theme' || !name) return { text: session.lang === 'zh-TW' ? helpTextZh(PRESET_NAMES) : helpText(PRESET_NAMES) }
545 if (!(PRESET_NAMES as readonly string[]).includes(name)) return { text: t().unknownTheme(name, PRESET_NAMES.join(', ')) }
546 const result = await $.config.set({ key: `${$.plugin.name}.theme`, value: name })
547 return { text: result.deny ? `${t().themeFailed}: ${result.deny}` : t().themeSet(name) }
548 })
549
550 if (options.foldDiffs !== false) {
551 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
552 const { tool, tool_use_id: id, output, isErrored } = e.props
553 if (transcriptExpanded || isErrored || !/^(Edit|MultiEdit|Write)$/.test(tool) || expandedCalls.has(id)) return next(e)
554 const folded = foldPatch(patchOf(output))
555 if (folded === undefined || (await read($, unfoldedDiffs)).includes(id)) return next(e)
556 const labels = { summary: t().diffSummary(folded.added, folded.removed), hidden: t().diffHidden(folded.hidden), expand: t().diffExpand }
557 return renderFoldedDiff($.ui.resolve(e), style, folded, labels, () => update($, unfoldedDiffs, ids => [...ids, id]))
558 })
559 }
560
561 on('ui.render', { component: 'UserMessage' }, ($, e, next) => {
562 // A toggle changes no tool row's props, so the engine would keep their drawings: they are redrawn here.
563 if (e.props.isExpanded !== transcriptExpanded) {
564 transcriptExpanded = e.props.isExpanded
565 $.ui.invalidate('ui.render')
566 }
567 const kind = e.props.origin.kind
568 const own = kind === 'composer' || kind === 'bridge' || (kind === 'unclassified' && !e.props.from && !e.props.task)
569 if (!isDrawing || style.promptStyle === 'off' || !own) return next(e)
570 return renderUserPrompt($.ui.resolve(e), style, e.props.text, Math.max(20, (e.viewport?.columns ?? 100) - 4))
571 })
572
573 if (!isDrawing) return
574
575 if (options.toolRows !== false) {
576 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
577 if (e.props.isExpanded) {
578 for (const call of e.props.calls) if (call.tool_use_id) expandedCalls.add(call.tool_use_id)
579 return next(e)
580 }
581 return renderToolGroup($.ui.resolve(e), style, e.props.calls, e.props.isActive, e.viewport?.columns, workDir, t().toolWords)
582 })
583 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
584 if (!transcriptExpanded && !expandedCalls.has(e.props.tool_use_id)) return renderToolRow($.ui.resolve(e), style, e.props, e.viewport?.columns, workDir, t().toolWords)
585 return e.props.tool === 'Bash' || e.props.tool === 'PowerShell' ? renderExpandedShell($.ui.resolve(e), style, e.props) : next(e)
586 })
587 }
588
589 on('ui.render', { component: 'TurnDuration' }, ($, e) => renderTurnDuration($.ui.resolve(e), style, e.props.word, e.props.durationMs))
590
591
592 on('ui.render', { component: 'CommandOutput' }, ($, e, next) => {
593 if (e.props.isErrored) return next(e)
594 // An answer to a plugin's own command arrives as "<plugin>: <text>", which breaks a table or heading on
595 // its first line; the echoed command above already says whose it is.
596 const blocks = parseCached(e.props.command === 'tessera' ? e.props.text.replace(/^tessera: /, '') : e.props.text)
597 if (blocks.length === 0) return next(e)
598 const el = $.ui.resolve(e)
599 const { Box } = el
600 const columns = Math.max(20, (e.viewport?.columns ?? 100) - 4)
601 return <Box flexDirection="column" rowGap={1} {...(style.reorder && hasRtl(e.props.text) ? { width: '100%' } : {})}>{drawMarkdown($, el, style, blocks, columns)}</Box>
602 })
603
604 on('ui.render', { component: 'AssistantMessage' }, ($, e, next) => {
605 const blocks = parseCached(e.props.text)
606 if (blocks.length === 0) return next(e)
607 const el = $.ui.resolve(e)
608 const { Box, Text } = el
609 const columns = Math.max(20, (e.viewport?.columns ?? 100) - 4)
610 const narration = style.toolStyle === 'tree-bold' && blocks.length === 1 && blocks[0]!.kind === 'paragraph'
611 return (
612 <Box flexDirection="row">
613 <Box width={2} flexShrink={0}>
614 <Text color={style.theme.accent}>{e.props.isFirstOfReply ? '●' : ' '}</Text>
615 </Box>
616 <Box flexDirection="column" rowGap={1} flexGrow={1}>
617 {drawMarkdown($, el, narration ? { ...style, narration } : style, blocks, columns, blocks.length > 1 || hasRtl(e.props.text) ? e.props.text : undefined)}
618 </Box>
619 </Box>
620 )
621 })
622
623}
624hooks/platform.ts 65 lines1export type Env = Record<string, string | undefined>
2
3type Platform = 'windows' | 'wsl' | 'mac' | 'linux'
4
5export function platformOf(env: Env): Platform {
6 if (env.OS === 'Windows_NT') return 'windows'
7 if (env.WSL_DISTRO_NAME !== undefined) return 'wsl'
8 if (env.HOME?.startsWith('/Users/')) return 'mac'
9 return 'linux'
10}
11
12// Where Claude Code caches pastes: %TEMP%\claude on Windows, /tmp/claude-<uid> elsewhere ({uid} is filled in by the caller).
13export function pasteRoot(env: Env): string {
14 if (env.CLAUDE_CODE_TMPDIR) return env.CLAUDE_CODE_TMPDIR.replace(/\\/g, '/')
15 if (platformOf(env) === 'windows' && env.TEMP) return `${env.TEMP.replace(/\\/g, '/')}/claude`
16 return '/tmp/claude-{uid}'
17}
18
19// Whether Claude Code's Image element will show real pixels: kitty graphics outside a multiplexer, or forced on.
20export function drawsPixels(mode: string, env: Env): boolean {
21 if (mode === 'pixels') return true
22 if (mode === 'cells') return false
23 if (env.CLAUDE_CODE_FORCE_TERMINAL_IMAGES === '1') return true
24 if (env.TMUX !== undefined || env.STY !== undefined) return false
25 return env.TERM === 'xterm-kitty' || env.TERM === 'xterm-ghostty' || env.TERM_PROGRAM === 'ghostty' || env.KITTY_WINDOW_ID !== undefined
26}
27
28const fileUrl = (path: string) => `file:///${encodeURI(path.replace(/\\/g, '/').replace(/^\//, ''))}`
29
30export function openers(env: Env, path: string): string[][] {
31 const host: string[][] = []
32 if (env.TERM_PROGRAM === 'Orca') host.push(['orca', 'tab', 'create', '--url', fileUrl(path)])
33 if (env.TERM_PROGRAM === 'vscode') host.push(platformOf(env) === 'windows' ? ['cmd', '/c', 'code', '--reuse-window', path] : ['code', '--reuse-window', path])
34 return [...host, opener(env, path)]
35}
36
37// Commands that print the clipboard as UTF-8, tried in order. PowerShell is told to, since it otherwise
38// prints in the console code page and turns CJK text into question marks.
39export function clipboardReaders(env: Env): string[][] {
40 const powershell = '[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-Clipboard -Raw'
41 switch (platformOf(env)) {
42 case 'windows':
43 return [['powershell', '-NoProfile', '-NonInteractive', '-Command', powershell]]
44 case 'wsl':
45 return [['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', powershell]]
46 case 'mac':
47 return [['pbpaste']]
48 case 'linux':
49 return [['wl-paste', '--no-newline'], ['xclip', '-selection', 'clipboard', '-o'], ['xsel', '--clipboard', '--output']]
50 }
51}
52
53function opener(env: Env, path: string): string[] {
54 switch (platformOf(env)) {
55 case 'windows':
56 return ['explorer.exe', path.replace(/\//g, '\\')]
57 case 'wsl':
58 return ['explorer.exe', `\\\\wsl.localhost\\${env.WSL_DISTRO_NAME}${path.replace(/\//g, '\\')}`]
59 case 'mac':
60 return ['open', path]
61 case 'linux':
62 return ['xdg-open', path]
63 }
64}
65hooks/inbox.ts 77 lines1type ChatLine = { at: string; who: string; text: string }
2
3export type InboxItem = ChatLine & { id: number; status: 'open' | 'fixed'; fixedBy?: string; reopenedFrom?: number }
4
5// "22:55 Amy 首頁按鈕沒反應", "[09:01] 王小明:表單送不出去", "14:03:12 Amy: login fails"
6const LINE = /^\s*\[?(\d{1,2}:\d{2})(?::\d{2})?\]?\s+([^\s::\]]{1,20})(?:\s*[::]\s*|\s+)(\S.{2,})$/
7
8// A paste counts as a chat log only when at least two of its lines look like timestamped messages.
9export function parseChat(text: string): ChatLine[] {
10 const lines = text.split(/\r?\n/).flatMap(l => {
11 const m = LINE.exec(l)
12 return m ? [{ at: m[1] ?? '', who: m[2] ?? '', text: (m[3] ?? '').trim() }] : []
13 })
14 return lines.length >= 2 ? lines : []
15}
16
17const grams = (s: string) => {
18 const chars = [...s.toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '')]
19 const out = new Set<string>()
20 for (let i = 0; i + 1 < chars.length; i++) out.add(`${chars[i]}${chars[i + 1]}`)
21 return out
22}
23
24// Dice coefficient over character bigrams, which treats Chinese and English text alike.
25export function similarity(a: string, b: string): number {
26 const x = grams(a)
27 const y = grams(b)
28 if (x.size === 0 || y.size === 0) return 0
29 let both = 0
30 for (const g of x) if (y.has(g)) both++
31 return (2 * both) / (x.size + y.size)
32}
33
34const SIMILAR = 0.55
35
36type Intake = { items: InboxItem[]; added: InboxItem[]; regressions: { item: InboxItem; like: InboxItem }[] }
37
38export function intake(items: InboxItem[], lines: ChatLine[]): Intake {
39 const next = [...items]
40 const added: InboxItem[] = []
41 const regressions: Intake['regressions'] = []
42 let id = items.reduce((m, i) => Math.max(m, i.id), 0)
43 for (const line of lines) {
44 if (next.some(i => i.status === 'open' && i.text === line.text)) continue
45 const like = next
46 .filter(i => i.status === 'fixed')
47 .map(i => ({ i, score: similarity(i.text, line.text) }))
48 .filter(c => c.score >= SIMILAR)
49 .sort((a, b) => b.score - a.score)[0]?.i
50 const item: InboxItem = { ...line, id: ++id, status: 'open', ...(like ? { reopenedFrom: like.id } : {}) }
51 next.push(item)
52 added.push(item)
53 if (like) regressions.push({ item, like })
54 }
55 return { items: next, added, regressions }
56}
57
58export function markFixed(items: InboxItem[], ids: number[], commit: string): InboxItem[] {
59 return items.map(i => (ids.includes(i.id) ? { ...i, status: 'fixed' as const, fixedBy: commit } : i))
60}
61
62const clip = (s: string, n: number) => ([...s].length > n ? `${[...s].slice(0, n - 1).join('')}…` : s)
63
64export function intakeNote(result: Intake): string {
65 const lines = [`tessera feedback inbox: filed ${result.added.map(i => `#${i.id}`).join(', ')} from the pasted chat log.`]
66 for (const { item, like } of result.regressions)
67 lines.push(`#${item.id} ("${clip(item.text, 40)}") resembles #${like.id} ("${clip(like.text, 40)}"), marked fixed in ${like.fixedBy ?? 'an earlier change'}: check whether that fix regressed before fixing it again.`)
68 lines.push('When you fix items, call the tessera inbox_fixed tool with their numbers and the commit.')
69 return lines.join('\n')
70}
71
72export function listText(items: InboxItem[], empty: string, headers: readonly string[]): string {
73 if (items.length === 0) return empty
74 const mark = (i: InboxItem) => (i.status === 'fixed' ? `✓ ${i.fixedBy ?? ''}`.trim() : i.reopenedFrom ? `↺ #${i.reopenedFrom}` : '…')
75 return [`| ${headers.join(' | ')} |`, '|---|---|---|---|---|', ...items.slice(-40).map(i => `| ${i.id} | ${i.at} | ${i.who} | ${clip(i.text, 48).replace(/\|/g, '\\|')} | ${mark(i)} |`)].join('\n')
76}
77hooks/markdown.ts 215 lines1export type Inline =
2 | { kind: 'text'; text: string }
3 | { kind: 'strong'; children: Inline[] }
4 | { kind: 'emphasis'; children: Inline[] }
5 | { kind: 'strike'; children: Inline[] }
6 | { kind: 'code'; text: string }
7 | { kind: 'link'; text: string; href: string }
8 | { kind: 'number'; text: string }
9 | { kind: 'path'; text: string }
10 | { kind: 'dim'; text: string }
11
12export type Block = { raw: string } & (
13 | { kind: 'heading'; level: number; inline: Inline[] }
14 | { kind: 'paragraph'; inline: Inline[] }
15 | { kind: 'list'; ordered: boolean; items: { marker: string; depth: number; task?: boolean; inline: Inline[] }[] }
16 | { kind: 'code'; lang: string; lines: string[] }
17 | { kind: 'quote'; inline: Inline[] }
18 | { kind: 'alert'; level: AlertLevel; inline: Inline[] }
19 | { kind: 'rule' }
20 | { kind: 'table'; header: Inline[][]; align: ('left' | 'right' | 'center')[]; rows: Inline[][][] }
21)
22
23type Draft = Block extends infer B ? (B extends unknown ? Omit<B, 'raw'> : never) : never
24
25export type AlertLevel = 'note' | 'tip' | 'important' | 'warning' | 'caution'
26const ALERT = /^\[!(note|tip|important|warning|caution)\]\s*(.*)$/i
27
28// A line break inside a paragraph reads as a space, except between Chinese or Japanese characters, which
29// take none: a reply wrapped by hand would otherwise show a space at each of its line ends.
30const CJK_END = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana} -〿-]\s*$/u
31const CJK_START = /^\s*[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana} -〿-]/u
32const joinLines = (lines: readonly string[]): string =>
33 lines.reduce((text, line, i) => (i === 0 ? line : CJK_END.test(text) && CJK_START.test(line) ? text.trimEnd() + line.trimStart() : `${text} ${line}`), '')
34
35export type Highlight = { numbers: boolean; paths: boolean }
36
37const MAX_INLINE = 4000
38const TABLE_SEP = /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/
39const LIST_ITEM = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/
40const FENCE = /^\s*(`{3,}|~{3,})\s*([\w+-]*)/
41
42const splitRow = (line: string): string[] => {
43 const trimmed = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
44 const cells: string[] = []
45 let cell = ''
46 for (let i = 0; i < trimmed.length; i++) {
47 const ch = trimmed[i]
48 if (ch === '\\' && trimmed[i + 1] === '|') {
49 cell += '|'
50 i++
51 } else if (ch === '|') {
52 cells.push(cell.trim())
53 cell = ''
54 } else {
55 cell += ch
56 }
57 }
58 cells.push(cell.trim())
59 return cells
60}
61
62const INLINE = /(`+)(?!`)(.+?)(?<!`)\1(?!`)|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+?)\*\*|__([^_]+?)__|~~([^~]+?)~~|(?<![\w*])\*([^*\s][^*]*?)\*(?!\w)|(?<![\w_])_([^_\s][^_]*?)_(?!\w)|(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g
63const NUMBER = /(?<![\w.#/-])(v?\d+(?:[.,:]\d+)*(?:%|ms|s|m|h|d|Gi|Mi|GB|MB|KB|x)?)(?![\w/])/g
64const PATH = /(?<![\w/.:])((?:~|\.{1,2})?\/[\w.@+-]+(?:\/[\w.@+-]*)*)/g
65
66const decorate = (text: string, hl: Highlight): Inline[] => {
67 if (!hl.numbers && !hl.paths) return [{ kind: 'text', text }]
68 const marks: { start: number; end: number; kind: 'number' | 'path' }[] = []
69 if (hl.paths) for (const m of text.matchAll(PATH)) marks.push({ start: m.index, end: m.index + m[0].length, kind: 'path' })
70 if (hl.numbers) {
71 for (const m of text.matchAll(NUMBER)) {
72 const start = m.index
73 if (!marks.some(p => start < p.end && start + m[0].length > p.start)) {
74 marks.push({ start, end: start + m[0].length, kind: 'number' })
75 }
76 }
77 }
78 marks.sort((a, b) => a.start - b.start)
79 const out: Inline[] = []
80 let at = 0
81 for (const m of marks) {
82 if (m.start > at) out.push({ kind: 'text', text: text.slice(at, m.start) })
83 out.push({ kind: m.kind, text: text.slice(m.start, m.end) })
84 at = m.end
85 }
86 if (at < text.length) out.push({ kind: 'text', text: text.slice(at) })
87 return out
88}
89
90export const parseInline = (text: string, hl: Highlight): Inline[] => {
91 if (text.length > MAX_INLINE) return [{ kind: 'text', text }]
92 const out: Inline[] = []
93 let at = 0
94 for (const m of text.matchAll(INLINE)) {
95 if (m.index > at) out.push(...decorate(text.slice(at, m.index), hl))
96 if (m[2] !== undefined) out.push({ kind: 'code', text: m[2] })
97 else if (m[3] !== undefined) out.push({ kind: 'link', text: m[3], href: m[4] ?? "" })
98 else if (m[5] !== undefined || m[6] !== undefined) out.push({ kind: 'strong', children: parseInline(m[5] ?? m[6] ?? "", hl) })
99 else if (m[7] !== undefined) out.push({ kind: 'strike', children: parseInline(m[7], hl) })
100 else if (m[8] !== undefined || m[9] !== undefined) out.push({ kind: 'emphasis', children: parseInline(m[8] ?? m[9] ?? "", hl) })
101 else if (m[10] !== undefined) out.push({ kind: 'link', text: m[10], href: m[10] })
102 at = m.index + m[0].length
103 }
104 if (at < text.length) out.push(...decorate(text.slice(at), hl))
105 return out
106}
107
108export const inlineText = (inline: Inline[]): string =>
109 inline.map(n => ('children' in n ? inlineText(n.children) : n.text)).join('')
110
111export const parse = (source: string, hl: Highlight): Block[] => {
112 const lines = source.replace(/\r\n?/g, '\n').split('\n')
113 const at = (n: number) => lines[n] ?? ''
114 const blocks: Block[] = []
115 const add = (block: Draft, from: number, to: number) => blocks.push({ ...block, raw: lines.slice(from, to).join('\n') } as Block)
116 let paraStart = 0
117 let para: string[] = []
118
119 const flush = (end: number) => {
120 if (para.length) add({ kind: 'paragraph', inline: parseInline(joinLines(para), hl) }, paraStart, end)
121 para = []
122 }
123
124 for (let i = 0; i < lines.length; i++) {
125 const line = at(i)
126 const fence = FENCE.exec(line)
127 if (fence) {
128 flush(i)
129 const start = i
130 const run = fence[1] ?? '```'
131 const closer = new RegExp(`^\\s*\\${run[0]}{${run.length},}\\s*$`)
132 const body: string[] = []
133 i++
134 while (i < lines.length && !closer.test(at(i))) body.push(at(i++))
135 add({ kind: 'code', lang: fence[2] ?? '', lines: body }, start, i + 1)
136 continue
137 }
138 if (line.trim() === '') {
139 flush(i)
140 continue
141 }
142 const heading = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)
143 if (heading) {
144 flush(i)
145 add({ kind: 'heading', level: (heading[1] ?? '#').length, inline: parseInline(heading[2] ?? '', hl) }, i, i + 1)
146 continue
147 }
148 if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
149 flush(i)
150 add({ kind: 'rule' }, i, i + 1)
151 continue
152 }
153 if (line.includes('|') && i + 1 < lines.length && TABLE_SEP.test(at(i + 1))) {
154 flush(i)
155 const start = i
156 const header = splitRow(line)
157 const align = splitRow(at(i + 1)).map(c =>
158 c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left',
159 )
160 i += 2
161 const rows: Inline[][][] = []
162 while (i < lines.length && at(i).includes('|') && at(i).trim() !== '') {
163 const cells = splitRow(at(i++))
164 rows.push(header.map((_, c) => parseInline(cells[c] ?? '', hl)))
165 }
166 add({ kind: 'table', header: header.map(c => parseInline(c, { numbers: false, paths: false })), align, rows }, start, i)
167 i--
168 continue
169 }
170 if (/^\s*>/.test(line)) {
171 flush(i)
172 const start = i
173 const body: string[] = []
174 while (i < lines.length && /^\s*>/.test(at(i))) body.push(at(i++).replace(/^\s*>\s?/, ''))
175 const alert = ALERT.exec(body[0] ?? '')
176 if (alert) add({ kind: 'alert', level: alert[1]!.toLowerCase() as AlertLevel, inline: parseInline(joinLines([alert[2]!, ...body.slice(1)]).trim(), hl) }, start, i)
177 else add({ kind: 'quote', inline: parseInline(joinLines(body), hl) }, start, i)
178 i--
179 continue
180 }
181 const item = LIST_ITEM.exec(line)
182 if (item) {
183 flush(i)
184 const start = i
185 const ordered = /\d/.test(item[2] ?? '')
186 const items: { marker: string; depth: number; task?: boolean; inline: Inline[] }[] = []
187 const contentIndent: number[] = []
188 while (i < lines.length) {
189 const it = LIST_ITEM.exec(at(i))
190 if (it) {
191 contentIndent.push((it[1] ?? '').replace(/\t/g, ' ').length + (it[2] ?? '').length + 1)
192 const task = /^\[([ xX])\]\s+(.*)$/.exec(it[3] ?? '')
193 items.push({ marker: it[2] ?? '-', depth: Math.floor((it[1] ?? '').replace(/\t/g, ' ').length / 2), ...(task ? { task: task[1] !== ' ' } : {}), inline: parseInline(task ? task[2]! : it[3] ?? '', hl) })
194 } else if (/^\s{2,}\S/.test(at(i)) && items.length) {
195 const indent = (at(i).match(/^\s*/)?.[0] ?? '').replace(/\t/g, ' ').length
196 let owner = items.length - 1
197 while (owner > 0 && contentIndent[owner]! > indent) owner--
198 const target = items[owner]!
199 target.inline = [...target.inline, { kind: 'text', text: ' ' }, ...parseInline(at(i).trim(), hl)]
200 } else {
201 break
202 }
203 i++
204 }
205 add({ kind: 'list', ordered, items }, start, i)
206 i--
207 continue
208 }
209 if (!para.length) paraStart = i
210 para.push(line.trim())
211 }
212 flush(lines.length)
213 return blocks
214}
215hooks/mermaid.tsx 144 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import { remember, width } from './render'
4import type { Style } from './theme'
5import { renderMermaidAscii, setChartSize } from './vendor/mermaid-text.js'
6
7const MAX_LINES = 80
8
9// The diagram library sizes boxes by UTF-16 length, so a wide character one unit long gets a
10// private-use pad after it while laying out; the pads come out when the art is drawn or copied.
11const PAD = String.fromCharCode(0xe000)
12const padWide = (s: string) => [...s].map(c => c + PAD.repeat(Math.max(0, width(c) - c.length))).join('')
13export const unpad = (s: string) => s.replaceAll(PAD, '')
14const textCache = new Map<string, string | null>()
15
16export const chartSize = (columns: number, source = '') => {
17 const labels = (/^\s*x-axis\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1] ?? '').split(',').map(s => s.trim().replace(/^"|"$/g, ''))
18 const fit = labels.length * (Math.max(...labels.map(width)) + 2)
19 const w = Math.max(24, Math.min(60, Math.max(Math.floor(columns / 6), fit), columns - 12))
20 return { width: w, height: Math.max(8, Math.min(20, Math.round(w * 0.3))) }
21}
22
23const unquoteCategories = (source: string) =>
24 source.replace(/^(\s*x-axis\b[^[\n]*\[)([^\]\n]*)\]/m, (_, head: string, items: string) => `${head}${items.replace(/"([^"]*)"/g, (_q, s: string) => s.replaceAll(',', ' '))}]`)
25
26const labelBars = (art: string, source: string): string => {
27 const series = source.match(/^\s*(bar|line)\b.*$/gm) ?? []
28 const values = /^\s*bar\b[^[\n]*\[([^\]\n]*)\]/m.exec(source)?.[1]?.split(',').map(v => v.trim())
29 if (series.length !== 1 || !values || /^\s*xychart(-beta)?\s+horizontal/m.test(source)) return art
30 const grid = art.split('\n').map(l => [...l])
31 const axis = grid.findLastIndex(row => row.includes('┬'))
32 const ticks = grid[axis]?.flatMap((ch, x) => (ch === '┬' ? [x] : [])) ?? []
33 if (ticks.length !== values.length) return art
34 ticks.forEach((x, k) => {
35 const top = grid.findIndex(row => row[x] === '█')
36 const y = top === -1 ? axis - 1 : top - 1
37 const text = [...values[k]!]
38 const from = x - Math.floor((text.length - 1) / 2)
39 const row = grid[y]
40 if (!row || y < 0) return
41 while (row.length < from + text.length) row.push(' ')
42 if (!text.every((_, i) => /[ ·]/.test(row[from + i] ?? ' '))) return
43 text.forEach((ch, i) => (row[from + i] = ch))
44 })
45 return grid.map(row => row.join('').trimEnd()).join('\n')
46}
47
48export const mermaidText =(source: string, ascii: boolean, columns: number): string | null => {
49 if (source.length > 8000 || source.split('\n').length > MAX_LINES) return null
50 const isChart = /^\s*xychart/.test(source)
51 const size = chartSize(columns, isChart ? source : '')
52 const key = `${ascii}:${isChart ? size.width : 0}:${source}`
53 return remember(textCache, key, () => {
54 try {
55 if (isChart) setChartSize(size.width, size.height)
56 const art = renderMermaidAscii(unquoteCategories(padWide(source).replace(/^(\s*%%[^\n]*\n)+/, '')).replace(/(-->|-\.->|==>|---|-\.-|===)[ \t]+\|/g, '$1|'), { useAscii: ascii, colorMode: 'none', paddingX: 3, paddingY: 1 }).replace(/[ \t]+$/gm, '').trimEnd().replace(/▶/g, '►').replace(/◀/g, '◄')
57 return isChart ? labelBars(art, source) : art.split('\n').filter(l => !/^[\s│|]*$/.test(l)).join('\n')
58 } catch {
59 return null
60 }
61 })
62}
63
64const LINE = /[─-╿◇]/
65const ARROW = /[►◄▲▼]/
66
67const paint = (art: string, style: Style): (string | undefined)[][] => {
68 const t = style.theme
69 const grid = art.split('\n').map(l => [...l])
70 const cell = (r: number, c: number) => grid[r]?.[c] ?? ''
71 const color: (string | undefined)[][] = grid.map(row => row.map(() => undefined))
72 const palette = [...new Set([t.link, t.number, t.heading, t.emphasis, t.path, t.codeFlag, t.accent])].filter((c): c is string => c !== undefined)
73 const next = (i: number) => palette[i % palette.length]
74 const labels = new Map<string, string | undefined>()
75
76 const rects: { r: number; c: number; r2: number; c2: number }[] = []
77 for (let r = 0; r < grid.length; r++) {
78 for (let c = 0; c < (grid[r]?.length ?? 0); c++) {
79 if (!/[┌╭(]/.test(cell(r, c))) continue
80 let c2 = c + 1
81 while (/[─┬┴┼▲▼]/.test(cell(r, c2))) c2++
82 if (!/[┐╮)]/.test(cell(r, c2)) || c2 === c + 1) continue
83 let r2 = r + 1
84 while (/[│├┤┼►◄]/.test(cell(r2, c))) r2++
85 if (!/[└╰(]/.test(cell(r2, c)) || !/[┘╯)]/.test(cell(r2, c2))) continue
86 rects.push({ r, c, r2, c2 })
87 }
88 }
89 const inside = (a: (typeof rects)[number], b: (typeof rects)[number]) => a !== b && b.r > a.r && b.r2 < a.r2 && b.c > a.c && b.c2 < a.c2
90 for (const box of rects.filter(a => !rects.some(b => inside(a, b)))) {
91 const { r, c, r2, c2 } = box
92 const label = grid.slice(r + 1, r2).map(row => row.slice(c + 1, c2).join('')).join(' ').trim()
93 if (!labels.has(label)) labels.set(label, next(labels.size))
94 const hue = labels.get(label)
95 for (let y = r; y <= r2; y++) for (let x = c; x <= c2; x++) if (cell(y, x).trim()) color[y]![x] = hue
96 }
97
98 const bars = [...new Set(grid.flatMap(row => row.flatMap((ch, c) => (ch === '█' && row[c - 1] !== '█' ? [c] : []))))].sort((a, b) => a - b)
99 const ticks = grid.findLast(row => row.includes('┬'))?.filter(ch => ch === '┬').length ?? 0
100 const tops = bars.map(x => grid.findIndex(row => row[x] === '█'))
101 const single = bars.length > 1 && bars.length <= ticks
102 const barColor = (i: number) => (single ? (tops[i] === Math.min(...tops) ? t.emphasis ?? t.accent : t.quote) : next(i))
103 grid.forEach((row, r) =>
104 row.forEach((ch, c) => {
105 if (color[r]![c] !== undefined) return
106 if (ch === '█') {
107 let start = c
108 while (row[start - 1] === '█') start--
109 color[r]![c] = barColor(bars.indexOf(start))
110 } else if (ch === '·') color[r]![c] = t.rule
111 else if (ARROW.test(ch)) color[r]![c] = t.accent
112 else if (LINE.test(ch)) color[r]![c] = t.diagram
113 else if (/^\d+[┤┼]/.test(row.slice(c).join(''))) color[r]![c] = t.number
114 else color[r]![c] = t.diagramText
115 }),
116 )
117 return color
118}
119
120const painted = new WeakMap<Style, Map<string, (string | undefined)[][]>>()
121
122export const boxArt = ({ Box, Text }: ElementTable, style: Style, art: string, key: string): RenderElement => {
123 const cache = painted.get(style) ?? new Map<string, (string | undefined)[][]>()
124 painted.set(style, cache)
125 const colors = remember(cache, art, () => paint(art, style))
126 return (
127 <Box key={key} flexDirection="column" paddingLeft={2}>
128 {art.split('\n').map((line, i) => {
129 const chars = [...line]
130 const parts: RenderElement[] = []
131 let at = 0
132 while (at < chars.length) {
133 const hue = colors[i]?.[at]
134 let end = at + 1
135 while (end < chars.length && colors[i]?.[end] === hue) end++
136 parts.push(<Text key={`t${parts.length}`} color={hue}>{unpad(chars.slice(at, end).join(''))}</Text>)
137 at = end
138 }
139 return <Text key={`${key}.${i}`}>{parts.length ? parts : ' '}</Text>
140 })}
141 </Box>
142 )
143}
144hooks/render.tsx 816 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { Folded } from './fold'
4import type { ToolKind, ToolWords } from './i18n'
5import { STRINGS } from './i18n'
6import type { Block, Inline } from './markdown'
7import { inlineText } from './markdown'
8import { commentTail, commentVisual, flow, hasRtl } from './rtl'
9import type { Style, Theme } from './theme'
10import type { PrismToken } from './vendor/prism.js'
11import { languages, tokenize } from './vendor/prism.js'
12
13const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]|\p{Extended_Pictographic}/u
14const segmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl ? new Intl.Segmenter() : undefined
15
16const graphemes = (s: string): string[] => (segmenter ? [...segmenter.segment(s)].map(g => g.segment) : [...s])
17
18export const width = (s: string): number =>
19 graphemes(s).reduce((w, g) => (/^\p{M}+$/u.test(g) ? w : w + (WIDE.test(g) ? 2 : 1)), 0)
20
21// Where a line may break: between words, and between Chinese or Japanese characters, which take no spaces.
22// Closing punctuation stays with the character before it and opening punctuation with the one after, so no
23// line starts with 「,」 or ends with 「「」. Korean puts spaces between words, so it breaks there.
24const OPEN = '「『(《〈【〔“‘'
25const HAN = '\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}'
26const CLOSE = '」』)》〉】〕”’,。、:;!?…‥ー・%'
27const BREAKS = new RegExp(`[${OPEN}]*[${HAN}][${CLOSE}]*|[${OPEN}]*[^\\s${HAN}${OPEN}]+|[${OPEN}]+`, 'gu')
28const HAS_HAN = new RegExp(`[${HAN}]`, 'u')
29
30// The text cut where a line may break, each piece with the spaces after it.
31export const breakUnits = (text: string): string[] => {
32 const starts = [...text.matchAll(BREAKS)].map(m => m.index)
33 return starts.map((start, i) => text.slice(start, starts[i + 1] ?? text.length))
34}
35
36const wrapRanges = (text: string, w: number): [number, number][] => {
37 if (width(text) <= w) return [[0, text.length]]
38 const out: [number, number][] = []
39 let line: [number, number] | undefined
40 for (const word of text.matchAll(BREAKS)) {
41 let start = word.index
42 const end = start + word[0].length
43 while (width(text.slice(start, end)) > w) {
44 if (line) out.push(line)
45 line = undefined
46 let cut = start
47 for (const g of graphemes(text.slice(start, end))) {
48 if (cut > start && width(text.slice(start, cut + g.length)) > w) break
49 cut += g.length
50 }
51 out.push([start, cut])
52 start = cut
53 }
54 if (start === end) continue
55 if (line && width(text.slice(line[0], end)) <= w) line = [line[0], end]
56 else {
57 if (line) out.push(line)
58 line = [start, end]
59 }
60 }
61 if (line) out.push(line)
62 return out.length ? out : [[0, 0]]
63}
64
65const sliceInline = (nodes: Inline[], from: number, to: number): Inline[] => {
66 const out: Inline[] = []
67 let at = 0
68 for (const n of nodes) {
69 const length = inlineText([n]).length
70 const a = Math.max(from - at, 0)
71 const b = Math.min(to - at, length)
72 at += length
73 if (a >= b) continue
74 if ('children' in n) out.push({ ...n, children: sliceInline(n.children, a, b) })
75 else out.push({ ...n, text: n.text.slice(a, b) })
76 }
77 return out
78}
79
80const hasLink = (nodes: Inline[]): boolean => nodes.some(n => n.kind === 'link' || ('children' in n && hasLink(n.children)))
81
82const mostLines = (text: string, w: number): number => (text.match(/\S+/g)?.length ?? 0) + Math.ceil(width(text) / w)
83
84const wrapInline = (nodes: Inline[], w: number): Inline[][] => {
85 const text = inlineText(nodes)
86 return width(text) <= w ? [nodes] : wrapRanges(text, w).map(([a, b]) => sliceInline(nodes, a, b))
87}
88
89const flowOf = (style: Style, nodes: Inline[], columns: number) => (style.reorder ? flow(nodes, columns, width, style.shape) : null)
90
91const renderInline = (el: ElementTable, style: Style, nodes: Inline[], keyBase: string): RenderElement[] => {
92 const { Text } = el
93 const t = style.theme
94 return nodes.map((n, i) => {
95 const key = `${keyBase}.${i}`
96 switch (n.kind) {
97 case 'text':
98 return <Text key={key}>{n.text}</Text>
99 case 'strong':
100 return <Text key={key} bold color={t.strong}>{renderInline(el, style, n.children, key)}</Text>
101 case 'emphasis':
102 return <Text key={key} italic color={t.emphasis}>{renderInline(el, style, n.children, key)}</Text>
103 case 'strike':
104 return <Text key={key} strikethrough dimColor>{renderInline(el, style, n.children, key)}</Text>
105 case 'code':
106 return <Text key={key} color={t.inlineCode}>{n.text}</Text>
107 case 'link':
108 return /^[a-z][\w+.-]*:/i.test(n.href)
109 ? n.text === n.href ? <el.Link key={key} href={n.href} /> : <el.Link key={key} href={n.href}><Text color={t.link} underline>{n.text}</Text></el.Link>
110 : <Text key={key} color={t.link} underline>{n.text}</Text>
111 case 'number':
112 return <Text key={key} color={t.number}>{n.text}</Text>
113 case 'path':
114 return <Text key={key} color={t.path}>{n.text}</Text>
115 case 'dim':
116 return <Text key={key} dimColor>{n.text}</Text>
117 }
118 })
119}
120
121type TextProps = { italic?: boolean; color?: string; bold?: boolean; dimColor?: boolean; strikethrough?: boolean }
122
123const renderFlow = (el: ElementTable, style: Style, lines: Inline[][], key: string, props: TextProps = {}) => {
124 const { Text } = el
125 return lines.map((line, i) => <Text key={`${key}.${i}`} {...props}>{renderInline(el, style, line, `${key}.${i}`)}</Text>)
126}
127
128// The terminal wraps only at spaces, so a Chinese or Japanese run too long for the rest of a line moves
129// whole to the next one and leaves the line before nearly empty. Such text is broken into lines here.
130const renderWrapped = (el: ElementTable, style: Style, nodes: Inline[], w: number, key: string, props: TextProps = {}) => {
131 const text = inlineText(nodes)
132 if (!HAS_HAN.test(text) || width(text) <= w) return <el.Text key={key} {...props}>{renderInline(el, style, nodes, key)}</el.Text>
133 return <el.Box key={key} flexDirection="column">{renderFlow(el, style, wrapInline(nodes, w), key, props)}</el.Box>
134}
135
136const PRISM_COLORS: Record<string, keyof Theme> = {
137 comment: 'codeComment', prolog: 'codeComment', doctype: 'codeComment', cdata: 'codeComment',
138 string: 'codeString', char: 'codeString', 'template-string': 'codeString', 'attr-value': 'codeString', url: 'codeString',
139 number: 'number', boolean: 'number', constant: 'number', symbol: 'number', inserted: 'number',
140 keyword: 'codeFlag', important: 'codeFlag', atrule: 'codeFlag', rule: 'codeFlag', deleted: 'codeFlag',
141 function: 'codeCommand', 'class-name': 'codeCommand', builtin: 'codeCommand', key: 'codeCommand', selector: 'codeCommand',
142 property: 'link', tag: 'link', 'attr-name': 'emphasis', variable: 'emphasis', regex: 'path',
143}
144
145type Segment = { text: string; color?: string; italic: boolean }
146
147const flatten = (tokens: PrismToken[], style: Style, color?: string, italic = false): Segment[] =>
148 tokens.flatMap(token => {
149 if (typeof token === 'string') return [{ text: token, color, italic }]
150 const names = [token.type, ...(Array.isArray(token.alias) ? token.alias : token.alias ? [token.alias] : [])]
151 const slot = names.map(n => PRISM_COLORS[n]).find(Boolean)
152 const inner = Array.isArray(token.content) ? token.content : [token.type === 'comment' && typeof token.content === 'string' && style.reorder ? commentVisual(token.content, style.shape) : token.content]
153 return flatten(inner, style, slot ? style.theme[slot] : color, italic || token.type === 'comment')
154 })
155
156export const remember = <T,>(cache: Map<string, T>, key: string, make: () => T, limit = 200): T => {
157 const hit = cache.get(key)
158 if (hit !== undefined) return hit
159 const value = make()
160 cache.set(key, value)
161 if (cache.size > limit) cache.delete(cache.keys().next().value!)
162 return value
163}
164
165const highlighted = new WeakMap<Style, Map<string, Segment[][]>>()
166
167const grammarFor = (lang: string) => {
168 const name = lang.toLowerCase()
169 const grammar = Object.hasOwn(languages, name) ? languages[name] : undefined
170 return grammar !== null && typeof grammar === 'object' ? grammar : undefined
171}
172
173export const highlightBlock = ({ Text }: ElementTable, style: Style, lines: string[], lang: string, key: string): RenderElement[] | null => {
174 const grammar = grammarFor(lang)
175 if (!grammar) return null
176 const code = lines.join('\n')
177 const cache = highlighted.get(style) ?? new Map<string, Segment[][]>()
178 highlighted.set(style, cache)
179 const rows = remember(cache, `${lang}\0${code}`, () => {
180 const out: Segment[][] = [[]]
181 for (const seg of flatten(tokenize(code, grammar), style)) {
182 seg.text.split('\n').forEach((piece, i) => {
183 if (i > 0) out.push([])
184 if (piece) out[out.length - 1]!.push({ ...seg, text: piece })
185 })
186 }
187 return out
188 })
189 return rows.map((row, r) => (
190 <Text key={`${key}.${r}`} color={style.theme.codeText}>
191 {row.length ? row.map((s, i) => <Text key={`${key}.${r}.${i}`} color={s.color} italic={s.italic}>{s.text}</Text>) : ' '}
192 </Text>
193 ))
194}
195
196const isShellLang = (lang: string) => lang === '' || /^(sh|bash|zsh|shell|console|fish|powershell|ps1)$/i.test(lang)
197
198export const codeLine = (el: ElementTable, style: Style, line: string, lang: string, key: string): RenderElement => {
199 const { Text } = el
200 const t = style.theme
201 const isShell = isShellLang(lang)
202 if (/[\u2500-\u257F]/.test(line)) return <Text key={key} color={t.codeText}>{line}</Text>
203 const comment = style.reorder ? commentTail(line, style.shape) : null
204 if (comment) {
205 return (
206 <Text key={key} color={t.codeText}>
207 {comment.head ? codeLine(el, style, comment.head, lang, `${key}.h`) : null}
208 <Text color={t.codeComment}>{comment.marker + comment.tail}</Text>
209 </Text>
210 )
211 }
212 if (!isShell) return <Text key={key} color={t.codeText}>{line || ' '}</Text>
213 if (/^\s*#/.test(line)) return <Text key={key} color={t.codeComment}>{line}</Text>
214 const parts = line.split(/("[^"]*"|'[^']*'|\s+)/).filter(p => p !== '')
215 let seenCommand = false
216 return (
217 <Text key={key} color={t.codeText}>
218 {parts.map((p, i) => {
219 if (/^\s+$/.test(p)) return <Text key={`${key}.${i}`}>{p}</Text>
220 if (/^["']/.test(p)) return <Text key={`${key}.${i}`} color={t.codeString}>{p}</Text>
221 if (/^--?[\w-]/.test(p)) return <Text key={`${key}.${i}`} color={t.codeFlag}>{p}</Text>
222 if (!seenCommand && !/^[$>|&;]+$/.test(p)) {
223 seenCommand = true
224 return <Text key={`${key}.${i}`} color={t.codeCommand}>{p}</Text>
225 }
226 if (/^(\||&&|;|\|\|)$/.test(p)) seenCommand = false
227 return <Text key={`${key}.${i}`}>{p}</Text>
228 })}
229 </Text>
230 )
231}
232
233const columnWidths = (natural: number[], available: number, gap: number): number[] => {
234 const room = Math.max(natural.length, available - gap * (natural.length - 1))
235 const total = natural.reduce((a, b) => a + b, 0)
236 if (total <= room) return natural
237 const widths = natural.map(() => 0)
238 let open = natural.map((_, c) => c)
239 let left = room
240 for (let fits = [-1]; fits.length; ) {
241 const share = Math.floor(left / open.length)
242 fits = open.filter(c => natural[c]! <= share)
243 fits.forEach(c => (widths[c] = natural[c]!, left -= natural[c]!))
244 open = open.filter(c => !fits.includes(c))
245 }
246 open.forEach((c, i) => (widths[c] = Math.max(1, Math.floor(left / open.length) + (i < left % open.length ? 1 : 0))))
247 while (widths.reduce((a, b) => a + b, 0) > room) {
248 const widest = widths.indexOf(Math.max(...widths))
249 if (widths[widest]! <= 1) break
250 widths[widest]!--
251 }
252 return widths
253}
254
255const displayText = (inline: Inline[]): string =>
256 inline.map(n => (n.kind === 'link' && n.text !== n.href ? `${n.text} (${n.href})` : 'children' in n ? displayText(n.children) : n.text)).join('')
257
258const isRtlTable = (style: Style, block: Extract<Block, { kind: 'table' }>): boolean => {
259 const cells = [...block.header, ...block.rows.flat()].filter(cell => displayText(cell).trim() !== '')
260 return cells.filter(cell => flowOf(style, cell, Infinity)?.base === 'R').length * 2 > cells.length
261}
262
263const ART_WIDTH = 100
264
265export const tableArt = (block: Extract<Block, { kind: 'table' }>): string => {
266 const cells = [block.header, ...block.rows].map(r => block.header.map((_, c) => displayText(r[c] ?? [])))
267 const widths = columnWidths(block.header.map((_, c) => Math.max(...cells.map(r => width(r[c]!)))), ART_WIDTH - 4, 3)
268 const pad = (text: string, c: number, align: 'left' | 'right' | 'center') => {
269 const room = widths[c]! - width(text)
270 const left = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
271 return ' '.repeat(left) + text + ' '.repeat(room - left)
272 }
273 const line = (l: string, m: string, r: string) => l + widths.map(w => '─'.repeat(w + 2)).join(m) + r
274 const row = (r: string[], header: boolean) => {
275 const lines = r.map((text, c) => wrapRanges(text, widths[c]!).map(([a, b]) => text.slice(a, b)))
276 return Array.from({ length: Math.max(...lines.map(l => l.length)) }, (_, i) =>
277 `│ ${lines.map((l, c) => pad(l[i] ?? '', c, header ? 'center' : block.align[c] ?? 'left')).join(' │ ')} │`)
278 }
279 const art = [line('┌', '┬', '┐'), ...row(cells[0]!, true), ...cells.slice(1).flatMap(r => [line('├', '┼', '┤'), ...row(r, false)]), line('└', '┴', '┘')]
280 return ['```', ...art, '```'].join('\n')
281}
282
283const renderTable = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'table' }>, columns: number, key: string) => {
284 const { Box, Text } = el
285 const t = style.theme
286 const rtl = isRtlTable(style, block)
287 const box = style.tableStyle === 'box'
288 const gap = box ? 0 : style.tableStyle === 'grid' ? 3 : 2
289 const natural = block.header.map((h, c) =>
290 Math.max(width(displayText(h)), ...block.rows.map(r => width(displayText(r[c] ?? [])))),
291 )
292 const widths = box ? columnWidths(natural, columns - 4, 3) : columnWidths(natural, columns, gap)
293 const order = natural.map((_, c) => c)
294 if (rtl) order.reverse()
295 const ruleChar = style.tableStyle === 'grid' ? '━' : '─'
296 const justify = (c: number) =>
297 block.align[c] === 'right' ? 'flex-end' : block.align[c] === 'center' ? 'center' : 'flex-start'
298
299 const rule = (k: string, heavy: boolean) => (
300 <Box key={k} flexDirection="row" columnGap={gap}>
301 {order.map(c => (
302 <Text key={`${k}.${c}`} color={t.tableRule} dimColor={!heavy && !t.tableRule}>
303 {(heavy ? ruleChar : '─').repeat(widths[c]!)}
304 </Text>
305 ))}
306 </Box>
307 )
308
309 const bar = (k: string, text: string, lines = 1) => (
310 <Text key={k} color={t.tableRule} dimColor={!t.tableRule}>{Array.from({ length: lines }, () => text).join('\n')}</Text>
311 )
312 const clipped = (lines: number) => (k: string, text: string) => (
313 <Box key={k} minWidth={width(text)}>
314 <Box position="absolute" top={0} bottom={0} left={0} minWidth={width(text)} overflow="hidden">
315 {bar(`${k}.b`, text, lines)}
316 </Box>
317 </Box>
318 )
319 const edge = (k: string, [left, fill, mid, right]: string) =>
320 bar(k, left + order.map(c => fill!.repeat(widths[c]! + 2)).join(mid) + right)
321
322 const row = (cells: Inline[][], k: string, isHeader: boolean, border: (k: string, text: string) => RenderElement = bar) => (
323 <Box key={k} flexDirection="row" columnGap={gap} alignItems="stretch">
324 {box && border(`${k}.l`, '│ ')}
325 {order.map((c, i) => {
326 const w = widths[c]!
327 const cell = flowOf(style, cells[c] ?? [], Infinity)
328 const content = cell ? cell.lines[0]! : (cells[c] ?? [])
329 const side = cell?.base === 'R' && block.align[c] !== 'center' ? 'flex-end' : justify(c)
330 const cellBox = (
331 <Box key={`${k}.${c}`} width={w} flexShrink={0} justifyContent={side}>
332 {isHeader
333 ? <Text bold color={t.tableHeader}>{inlineText(content)}</Text>
334 : <Text>{renderInline(el, style, content, `${k}.${c}`)}</Text>}
335 </Box>
336 )
337 return box && i > 0 ? [border(`${k}.${c}s`, ' │ '), cellBox] : cellBox
338 })}
339 {box && border(`${k}.r`, ' │')}
340 </Box>
341 )
342
343 const wrappedRow = (cells: Inline[][], k: string, isHeader: boolean) => {
344 if (!isHeader && cells.some(hasLink)) return [row(cells, k, isHeader, clipped(Math.max(1, ...widths.map((w, c) => mostLines(displayText(cells[c] ?? []), w)))))]
345 const wrapped = widths.map((w, c) => {
346 const content = cells[c] ?? []
347 if (natural[c]! <= w) return [content]
348 return wrapInline(isHeader ? [{ kind: 'text', text: inlineText(content) }] : content, w)
349 })
350 return Array.from({ length: Math.max(...wrapped.map(l => l.length)) }, (_, i) =>
351 row(wrapped.map(l => l[i] ?? []), `${k}.${i}`, isHeader))
352 }
353
354 if (box) {
355 const lines: RenderElement[] = [edge(`${key}.t`, '┌─┬┐'), ...wrappedRow(block.header, `${key}.h`, true), edge(`${key}.hr`, '╞═╪╡')]
356 block.rows.forEach((r, i) => {
357 if (i > 0) lines.push(edge(`${key}.r${i}r`, '├─┼┤'))
358 lines.push(...wrappedRow(r, `${key}.r${i}`, false))
359 })
360 lines.push(edge(`${key}.b`, '└─┴┘'))
361 return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{lines}</Box>
362 }
363
364 const body: RenderElement[] = [row(block.header, `${key}.h`, true), rule(`${key}.hr`, true)]
365 block.rows.forEach((r, i) => {
366 body.push(row(r, `${key}.r${i}`, false))
367 if (style.tableStyle !== 'minimal' && i < block.rows.length - 1) body.push(rule(`${key}.r${i}r`, false))
368 })
369 return <Box key={key} flexDirection="column" {...(rtl ? { alignSelf: 'flex-end' as const } : {})}>{body}</Box>
370}
371
372const renderHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, key: string) => {
373 const rtl = flowOf(style, block.inline, Infinity)
374 if (!rtl) return drawHeading(el, style, block, block.inline, key)
375 const heading = drawHeading(el, style, block, rtl.lines[0]!, key)
376 return rtl.base === 'R' ? <el.Box key={key} alignSelf="flex-end">{heading}</el.Box> : heading
377}
378
379const drawHeading = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'heading' }>, inline: Inline[], key: string) => {
380 const { Box, Text } = el
381 const t = style.theme
382 const color = block.level <= 2 ? t.heading : t.accent ?? t.heading
383 const label = inlineText(inline)
384 switch (style.headingStyle) {
385 case 'uppercase':
386 return <Text key={key} bold color={color}>{block.level === 1 ? label.toUpperCase() : label}</Text>
387 case 'underline':
388 return <Text key={key} bold underline={block.level <= 2} color={color}>{label}</Text>
389 case 'banner':
390 if (block.level === 1) return <Box key={key} alignSelf="flex-start" borderStyle="bold" borderColor={color} paddingX={1}><Text bold color={color}>{renderInline(el, style, inline, key)}</Text></Box>
391 return block.level === 2
392 ? <Box key={key} flexDirection="column" alignSelf="flex-start"><Text bold color={color}>{renderInline(el, style, inline, key)}</Text><Text color={color}>{'━'.repeat(width(label))}</Text></Box>
393 : <Text key={key} bold color={block.level === 3 ? color : t.strong}>{renderInline(el, style, inline, key)}</Text>
394 default:
395 return <Text key={key} bold color={color}>{renderInline(el, style, inline, key)}</Text>
396 }
397}
398
399const ALERT_COLOR = { note: 'blue', tip: 'green', important: 'magenta', warning: 'yellow', caution: 'red' } as const
400
401const renderParagraph = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'paragraph' }>, columns: number, key: string) => {
402 const { Box } = el
403 const rtl = flowOf(style, block.inline, columns)
404 if (rtl?.base === 'R') return <Box key={key} flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key)}</Box>
405 return renderWrapped(el, style, rtl ? rtl.lines[0]! : block.inline, columns, key, { bold: style.narration })
406}
407
408const renderQuote = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'quote' }>, columns: number, key: string) => {
409 const { Box, Text } = el
410 const t = style.theme
411 const rtl = flowOf(style, block.inline, columns - 2)
412 if (rtl?.base === 'R') {
413 return (
414 <Box key={key} flexDirection="row" justifyContent="flex-end">
415 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, key, { italic: true, color: t.quote })}</Box>
416 <Text color={t.accent}> │</Text>
417 </Box>
418 )
419 }
420 return (
421 <Box key={key} flexDirection="row">
422 <Text color={t.accent}>│ </Text>
423 {renderWrapped(el, style, rtl ? rtl.lines[0]! : block.inline, columns - 2, key, { italic: true, color: t.quote })}
424 </Box>
425 )
426}
427
428const renderAlert = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'alert' }>, columns: number, key: string) => {
429 const { Box, Text } = el
430 const color = ALERT_COLOR[block.level]
431 const title = <Text bold color={color}>{block.level[0]!.toUpperCase() + block.level.slice(1)}</Text>
432 const rtl = flowOf(style, block.inline, columns - 4)
433 if (rtl?.base === 'R') {
434 return (
435 <Box key={key} flexDirection="column" alignSelf="flex-end" alignItems="flex-end" borderStyle="round" borderColor={color} paddingX={1}>
436 {title}
437 {renderFlow(el, style, rtl.lines, key)}
438 </Box>
439 )
440 }
441 const inline = rtl ? rtl.lines[0]! : block.inline
442 return (
443 <Box key={key} flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={color} paddingX={1}>
444 {title}
445 {inline.length ? renderWrapped(el, style, inline, columns - 4, key) : null}
446 </Box>
447 )
448}
449
450const TASK_GLYPHS = { checks: ['[ ]', '[✓]'], ticks: ['○', '✓'], box: ['□', '✓'], progress: ['○', '✓'] } as const
451
452const renderList = (el: ElementTable, style: Style, block: Extract<Block, { kind: 'list' }>, columns: number, key: string) => {
453 const { Box, Text } = el
454 const t = style.theme
455 const tasks = block.items.filter(item => item.task !== undefined)
456 const done = tasks.filter(item => item.task).length
457 const filled = tasks.length ? Math.round((done / tasks.length) * 20) : 0
458 const strike = style.taskStyle === 'checks' || style.taskStyle === 'box'
459 const tick = style.taskStyle === 'ticks' || style.taskStyle === 'progress'
460 return (
461 <Box key={key} flexDirection="column">
462 {style.taskStyle === 'progress' && tasks.length > 0 && (
463 <Text>
464 <Text color={t.accent}>{'━'.repeat(filled)}</Text>
465 <Text color={t.bullet} dimColor>{'─'.repeat(20 - filled)}</Text>
466 <Text color={t.bullet}>{` ${done}/${tasks.length} done`}</Text>
467 </Text>
468 )}
469 {block.items.map((item, i) => {
470 const k = `${key}.${i}`
471 const glyph = item.task !== undefined ? TASK_GLYPHS[style.taskStyle][item.task ? 1 : 0] : /\d/.test(item.marker) ? item.marker : item.depth ? '◦' : '•'
472 const glyphColor = item.task && tick ? t.accent : t.bullet
473 const rtl = flowOf(style, item.inline, columns - item.depth * 2 - glyph.length - 1)
474 if (rtl?.base === 'R') {
475 return (
476 <Box key={k} flexDirection="row" justifyContent="flex-end" paddingRight={item.depth * 2}>
477 <Box flexDirection="column" alignItems="flex-end">{renderFlow(el, style, rtl.lines, k)}</Box>
478 <Text color={glyphColor}>{` ${glyph}`}</Text>
479 </Box>
480 )
481 }
482 return (
483 <Box key={k} flexDirection="row" paddingLeft={item.depth * 2}>
484 <Text color={glyphColor}>{`${glyph} `}</Text>
485 {renderWrapped(el, style, rtl ? rtl.lines[0]! : item.inline, columns - item.depth * 2 - glyph.length - 1, k, { dimColor: item.task === true, strikethrough: item.task === true && strike })}
486 </Box>
487 )
488 })}
489 </Box>
490 )
491}
492
493// Rough token count: about four Latin characters or one CJK character per token.
494export const roughTokens = (text: string) => {
495 const cjk = (text.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? []).length
496 return Math.max(1, Math.round(cjk + (text.length - cjk) / 4))
497}
498
499// A prompt written for another agent or tool: boxed, wrapped, unhighlighted, copied verbatim.
500const renderPrompt = (el: ElementTable, style: Style, lines: string[], columns: number, key: string, button: RenderElement | null | undefined): RenderElement => {
501 const { Box, Text } = el
502 const t = style.theme
503 return (
504 <Box key={key} flexDirection="column" borderStyle="round" borderColor={t.accent} paddingX={1} width={Math.min(columns, 100)}>
505 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
506 <Text color={t.accent} bold>{`prompt · ≈${roughTokens(lines.join('\n'))} tokens`}</Text>
507 {button ?? null}
508 </Box>
509 {lines.map((line, i) => <Text key={`${key}.${i}`}>{line === '' ? ' ' : line}</Text>)}
510 </Box>
511 )
512}
513
514// Which copy a button makes; the hooks module names it in the person's language.
515export type CopyKind = 'block' | 'prompt' | 'source' | 'art'
516export type CopyButton = (text: string | (() => string), key: string, kind?: CopyKind) => RenderElement | null
517export type Drawn = Map<number, { element: RenderElement; art: string }>
518
519const copySource = (block: Block): string | undefined =>
520 block.kind === 'code' ? block.lines.join('\n') : block.kind === 'table' || block.kind === 'list' ? block.raw : block.kind === 'quote' || block.kind === 'alert' ? block.raw.split('\n').map(line => line.replace(/^\s*>\s?/, '')).join('\n') : undefined
521
522export const renderBlocks = (el: ElementTable, style: Style, blocks: Block[], columns: number, drawn: Drawn = new Map(), copy?: CopyButton): RenderElement[] => {
523 const { Box, Text } = el
524 const t = style.theme
525 const rendered = blocks.map((block, b) => {
526 const key = `b${b}`
527 switch (block.kind) {
528 case 'heading':
529 return renderHeading(el, style, block, key)
530 case 'paragraph':
531 return renderParagraph(el, style, block, columns, key)
532 case 'quote':
533 return renderQuote(el, style, block, columns, key)
534 case 'alert':
535 return renderAlert(el, style, block, columns, key)
536 case 'rule':
537 return <Text key={key} color={t.rule} dimColor={!t.rule}>{'─'.repeat(Math.max(8, Math.min(columns, 80)))}</Text>
538 case 'code':
539 if (block.lang === 'prompt') return renderPrompt(el, style, block.lines, columns, key, copy?.(block.lines.join('\n'), `copy${b}`, 'prompt'))
540 return drawn.get(b)?.element ?? (
541 <Box key={key} flexDirection="column" alignSelf="flex-start">
542 <Box flexDirection="row" justifyContent="space-between" columnGap={4}>
543 <Text color={t.codeComment}>{`── ${block.lang || 'code'}`}</Text>
544 {copy?.(block.lines.join('\n'), `copy${b}`) ?? null}
545 </Box>
546 <Box flexDirection="column" paddingLeft={2}>
547 {(isShellLang(block.lang) ? null : highlightBlock(el, style, block.lines, block.lang, key)) ?? block.lines.map((line, i) => codeLine(el, style, line, block.lang, `${key}.${i}`))}
548 </Box>
549 </Box>
550 )
551 case 'list':
552 return renderList(el, style, block, columns, key)
553 case 'table':
554 return renderTable(el, style, block, columns, key)
555 }
556 })
557 const copied = rendered.map((element, b) => {
558 const block = blocks[b]
559 const text = block ? copySource(block) : undefined
560 const isPlainCode = block?.kind === 'code' && !drawn.has(b)
561 const art = drawn.get(b)?.art ?? (block?.kind === 'table' ? () => tableArt(block) : undefined)
562 const first = text === undefined || isPlainCode ? null : copy?.(text, `copy${b}`, art === undefined || block?.kind === 'table' ? undefined : 'source')
563 const second = first && art !== undefined ? copy?.(art, `art${b}`, 'art') : null
564 const button = second ? (
565 <el.Box key={`copies${b}`} flexDirection="row" columnGap={1}>
566 {first}
567 {second}
568 </el.Box>
569 ) : first
570 if (!button) return element
571 const { Box } = el
572 const rtl = style.reorder && block !== undefined && hasRtl(block.raw)
573 return block?.kind === 'quote' || block?.kind === 'alert' || block?.kind === 'list' ? (
574 <Box key={`c${b}`} flexDirection="row" columnGap={2} {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>
575 {element}
576 {button}
577 </Box>
578 ) : (
579 <Box key={`c${b}`} flexDirection="column" {...(rtl && block?.kind === 'table' && isRtlTable(style, block) ? {} : { alignSelf: 'flex-start' as const })}>
580 <Box justifyContent="flex-end">{button}</Box>
581 {element}
582 </Box>
583 )
584 })
585 const isFigure = (b: number) => blocks[b]?.kind === 'table' || drawn.has(b)
586 const out: RenderElement[] = []
587 for (let b = 0; b < rendered.length; b++) {
588 if (!isFigure(b) || !isFigure(b + 1)) {
589 out.push(copied[b]!)
590 continue
591 }
592 const start = b
593 while (isFigure(b + 1)) b++
594 out.push(
595 <Box key={`row${start}`} flexDirection="row" flexWrap="wrap" columnGap={4} rowGap={1}>
596 {copied.slice(start, b + 1).map((figure, i) => <Box key={`f${start + i}`} flexShrink={0}>{figure}</Box>)}
597 </Box>,
598 )
599 }
600 return out
601}
602
603export type ToolRow = { tool: string; input: unknown; isRunning: boolean; isErrored: boolean; isInterrupted: boolean }
604
605
606const field = (input: unknown, ...keys: string[]): string | undefined => {
607 if (input === null || typeof input !== 'object') return undefined
608 for (const k of keys) {
609 const v = (input as Record<string, unknown>)[k]
610 if (typeof v === 'string' && v.trim() !== '') return v
611 }
612 return undefined
613}
614
615// Claude Code names a file by its path from the working directory; the model passes absolute paths.
616export const shortPath = (path: string, base: string | undefined): string => {
617 if (base === undefined) return path
618 const norm = (p: string) => p.replace(/\\/g, '/').replace(/\/+$/, '')
619 const root = norm(base)
620 const isWindows = /^[a-z]:\//i.test(root)
621 const within = isWindows ? norm(path).toLowerCase().startsWith(`${root.toLowerCase()}/`) : norm(path).startsWith(`${root}/`)
622 return within && root !== '' ? path.slice(root.length + 1) : path
623}
624
625const toolDim = (style: Style) => style.toolStyle !== "classic"
626
627const toolGutter = ({ Box, Text }: ElementTable, style: Style, color: string | undefined, running: boolean) =>
628 style.toolStyle.startsWith("tree")
629 ? <Box width={4} flexShrink={0}><Text color={color}>{' ⎿ '}</Text></Box>
630 : <Box width={2} flexShrink={0}><Text color={color}>{running ? '◌' : '●'}</Text></Box>
631
632const toolLayout = (el: ElementTable, style: Style, columns: number, label: string, color: string | undefined, running: boolean, text: RenderElement) => {
633 const { Box, Text } = el
634 if (style.toolStyle !== "chat") return <Box flexDirection="row">{toolGutter(el, style, color, running)}{text}</Box>
635 const w = Math.min(width(label) + 2, Math.max(20, Math.floor(columns * 0.6)))
636 return (
637 <Box flexDirection="row" justifyContent="flex-end" width="100%">
638 <Box width={w} flexDirection="row">{text}<Text color={color}>{running ? " ◌" : " ●"}</Text></Box>
639 </Box>
640 )
641}
642
643export const renderToolRow = (el: ElementTable, style: Style, row: ToolRow, columns = 100, cwd?: string, words: ToolWords = STRINGS.en.toolWords): RenderElement => {
644 const { Text } = el
645 const t = style.theme
646 const isShell = row.tool === 'Bash' || row.tool === 'PowerShell'
647 const verb = words.verbs[row.tool] ?? row.tool.replace(/^mcp__([^_]+)__/, '$1 ')
648 const file = isShell ? undefined : field(row.input, 'file_path', 'notebook_path', 'path')
649 const target = isShell
650 ? field(row.input, 'command')?.split('\n')[0]
651 : file !== undefined ? shortPath(file, cwd) : field(row.input, 'pattern', 'url', 'query', 'description')
652 const dot = row.isErrored ? t.codeFlag : row.isInterrupted ? t.codeComment : row.isRunning ? t.accent : t.number
653 const isPath = target !== undefined && /^(~|\.{0,2}\/|[A-Za-z]:\\)/.test(target)
654
655 const label = `${verb}${target === undefined ? "" : ` ${target}`}${row.isInterrupted ? words.interrupted : row.isErrored ? words.failedRow : ""}`
656 return toolLayout(el, style, columns, label, dot, row.isRunning, (
657 <Text wrap="truncate-end" dimColor={toolDim(style)}>
658 <Text bold={!toolDim(style)} dimColor={toolDim(style)}>{verb}</Text>
659 {target === undefined ? null : <Text> </Text>}
660 {target === undefined ? null : isShell ? codeLine(el, style, target, 'bash', 'cmd') : <Text color={isPath ? t.path : t.inlineCode} dimColor={toolDim(style)}>{target}</Text>}
661 {row.isInterrupted ? <Text dimColor>{words.interrupted}</Text> : row.isErrored ? <Text color={t.codeFlag}>{words.failedRow}</Text> : null}
662 </Text>
663 ))
664}
665
666const OUTPUT_LINES = 120
667
668const lines = (value: unknown): string[] => (typeof value === 'string' && value !== '' ? value.replace(/\n$/, '').split('\n') : [])
669
670export const renderExpandedShell = (el: ElementTable, style: Style, row: ToolRow & { output?: unknown }): RenderElement => {
671 const { Box, Text } = el
672 const t = style.theme
673 const command = (field(row.input, 'command') ?? '').split('\n')
674 const out = row.output !== null && typeof row.output === 'object' ? (row.output as Record<string, unknown>) : {}
675 const stdout = lines(out.stdout)
676 const stderr = lines(out.stderr)
677 const shown = [...stdout.map(text => ({ text, color: undefined as string | undefined })), ...stderr.map(text => ({ text, color: t.codeFlag as string | undefined }))]
678 const visible = shown.slice(0, OUTPUT_LINES)
679 const dot = row.isErrored ? t.codeFlag : row.isInterrupted ? t.codeComment : row.isRunning ? t.accent : t.number
680 return (
681 <Box flexDirection="column">
682 <Box flexDirection="row">
683 <Box width={2} flexShrink={0}>
684 <Text color={dot}>{row.isRunning ? '◌' : '●'}</Text>
685 </Box>
686 <Box flexDirection="column">
687 {command.map((line, i) => (
688 <Text key={`c${i}`}>
689 {i === 0 ? <Text bold>{`${row.tool}(`}</Text> : null}
690 {codeLine(el, style, line, 'bash', `cmd${i}`)}
691 {i === command.length - 1 ? <Text bold>)</Text> : null}
692 </Text>
693 ))}
694 </Box>
695 </Box>
696 {row.isRunning ? null : (
697 <Box paddingLeft={2}>
698 <Box flexDirection="column" alignSelf="flex-start" borderStyle="round" borderColor={t.codeComment} paddingX={1}>
699 {visible.length === 0 ? <Text dimColor>(No output)</Text> : visible.map((l, i) => <Text key={`o${i}`} color={l.color}>{l.text === '' ? ' ' : l.text}</Text>)}
700 {shown.length > visible.length ? <Text dimColor>{`\u2026 +${shown.length - visible.length} lines`}</Text> : null}
701 </Box>
702 </Box>
703 )}
704 </Box>
705 )
706}
707
708const GROUPS: [RegExp, ToolKind][] = [
709 [/^(Bash|PowerShell)$/, 'command'],
710 [/^Read$/, 'read'],
711 [/^(Write|Edit|MultiEdit|NotebookEdit)$/, 'edit'],
712 [/^(Grep|Glob)$/, 'pattern'],
713 [/^(WebFetch|WebSearch)$/, 'page'],
714 [/^(Agent|Task)$/, 'task'],
715]
716
717export const groupSummary = (calls: readonly { tool: string }[], words: ToolWords = STRINGS.en.toolWords): string => {
718 // A known kind is counted under its kind, any other tool under its own name. ToolSearch only loads other
719 // tools, so it is left out beside them.
720 const counts = new Map<string, { kind?: ToolKind; name: string; n: number }>()
721 const counted = calls.some(c => c.tool !== 'ToolSearch') ? calls.filter(c => c.tool !== 'ToolSearch') : calls
722 for (const call of counted) {
723 const kind = GROUPS.find(([re]) => re.test(call.tool))?.[1]
724 const name = kind ?? call.tool.replace(/^mcp__([^_]+)__/, '$1 ')
725 const entry = counts.get(name) ?? { kind, name, n: 0 }
726 counts.set(name, { ...entry, n: entry.n + 1 })
727 }
728 const text = [...counts.values()].map(c => (c.kind ? words.group(c.kind, c.n) : words.other(c.name, c.n))).join(words.join)
729 return text.charAt(0).toUpperCase() + text.slice(1)
730}
731
732export const renderToolGroup = (el: ElementTable, style: Style, calls: readonly ToolRow[], isActive: boolean, columns = 100, cwd?: string, words: ToolWords = STRINGS.en.toolWords): RenderElement => {
733 const { Text } = el
734 const t = style.theme
735 const failed = calls.filter(c => c.isErrored).length
736 const running = isActive && calls.some(c => c.isRunning)
737 const dot = failed ? t.codeFlag : running ? t.accent : t.number
738 const last = calls[calls.length - 1]
739 const lastLine = last ? field(last.input, 'command', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description')?.split('\n')[0] : undefined
740 const lastTarget = lastLine === undefined ? undefined : shortPath(lastLine, cwd)
741 const label = `${groupSummary(calls, words)}${failed ? words.failed(failed) : ""}${lastTarget ? `${words.last}${lastTarget}` : ""}`
742 return toolLayout(el, style, columns, label, dot, running, (
743 <Text wrap="truncate-end" dimColor={toolDim(style)}>
744 <Text bold={!toolDim(style)} dimColor={toolDim(style)}>{groupSummary(calls, words)}</Text>
745 {failed ? <Text color={t.codeFlag}>{words.failed(failed)}</Text> : null}
746 {lastTarget ? <Text dimColor>{`${words.last}${lastTarget}`}</Text> : null}
747 </Text>
748 ))
749}
750
751export type FoldLabels = { summary: string; hidden: string; expand: string }
752
753// A long edit diff as a few of its lines under Claude Code's own header, with what is left and a button to
754// show it all.
755export const renderFoldedDiff = (el: ElementTable, style: Style, folded: Folded, labels: FoldLabels, onExpand: () => void): RenderElement => {
756 const { Box, Text, Button } = el
757 const t = style.theme
758 const color = (mark: string) => (mark === '+' ? t.codeString : mark === '-' ? t.codeFlag : undefined)
759 return (
760 <Box flexDirection="column">
761 <Text>
762 <Text dimColor>{' ⎿ '}</Text>
763 {labels.summary}
764 </Text>
765 {folded.shown.flatMap((line, i) => [
766 ...(line.gap ? [<Text key={`g${i}`} color={t.codeComment}>{'⋮'.padStart(8)}</Text>] : []),
767 <Text key={`d${i}`} wrap="truncate-end">
768 <Text color={t.codeComment}>{String(line.number).padStart(8)}</Text>
769 <Text color={color(line.mark)}>{` ${line.mark}${line.text}`}</Text>
770 </Text>,
771 ])}
772 <Box flexDirection="row" gap={2} paddingLeft={8}>
773 <Text dimColor>{`… ${labels.hidden}`}</Text>
774 <Button key="diff-expand" label={labels.expand} onPress={onExpand} />
775 </Box>
776 </Box>
777 )
778}
779
780export const formatDuration = (ms: number): string => {
781 const s = Math.round(ms / 1000)
782 if (s < 60) return `${Math.max(s, 0)}s`
783 if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
784 return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
785}
786
787export const renderTurnDuration = ({ Text }: ElementTable, style: Style, word: string, durationMs: number): RenderElement => (
788 <Text color={style.theme.codeComment}>
789 {`✻ ${word} for `}
790 <Text color={style.theme.number}>{formatDuration(durationMs)}</Text>
791 </Text>
792)
793
794export const renderUserPrompt = (el: ElementTable, style: Style, text: string, columns: number): RenderElement => {
795 const { Box, Text } = el
796 const t = style.theme
797 const color = style.promptStyle === 'chevron' ? t.accent : t.heading
798 const lines = text.split('\n').map(line => flowOf(style, [{ kind: 'text', text: line }], columns - 4))
799 const rtl = lines.some(l => l?.base === 'R')
800 const body = (
801 <Box flexDirection="column" {...(rtl ? { alignItems: 'flex-end' as const } : {})}>
802 {text.split('\n').map((line, i) => {
803 const flow = lines[i]
804 return flow
805 ? renderFlow(el, style, flow.lines, `p${i}`, { color, bold: style.promptStyle === 'chevron' })
806 : renderWrapped(el, style, [{ kind: 'text', text: line }], columns - 4, `p${i}`, { color, bold: style.promptStyle === 'chevron' })
807 })}
808 </Box>
809 )
810 if (style.promptStyle === 'bubble') {
811 return <Box borderStyle="round" borderColor={t.accent} paddingX={1} alignSelf={rtl ? 'flex-end' : 'flex-start'}>{body}</Box>
812 }
813 const mark = <Text color={t.accent} bold>{style.promptStyle === 'bar' ? (rtl ? ' ▐' : '▌ ') : rtl ? ' ‹' : '› '}</Text>
814 return <Box flexDirection="row" {...(rtl ? { justifyContent: 'flex-end' as const } : {})}>{rtl ? body : mark}{rtl ? mark : body}</Box>
815}
816hooks/help.ts 180 lines1export const showcaseText = (themes: readonly string[]): string => `
2# tessera
3
4Colored markdown for Claude Code replies: **bold**, *italic*, ~~struck~~, \`inline code\`, a [link](https://github.com/OG-Matcha/tessera) and a bare URL https://github.com/OG-Matcha/tessera/issues, both clickable, numbers like 99.9% and 250ms, paths like ~/src/app.ts.
5
6## Commands
7
8| Command | Does |
9|---------|------|
10| \`/tessera\` | This screen |
11| \`/tessera setup\` | Pick the features you want on |
12| \`/tessera peek <file>\` | Preview a document: .md .csv .json .docx .xlsx .pptx |
13| \`/tessera theme <name>\` | Switch theme on the spot |
14| \`/tessera copy\` | Copy the last reply; \`copy code\` its last code block, \`copy prompt\` its prompt card |
15| \`/config\` | Edit any option |
16
17> [!NOTE]
18> ${themes.length} themes ship with it. \`mono\` uses no color, only bold and dim.
19
20### Pick a theme
21
22\`\`\`bash
23/tessera theme nord
24/tessera theme github-light
25\`\`\`
26
271. Dark: ${themes.filter(t => !/latte|light|dawn/.test(t) && t !== 'mono').join(', ')}
282. Light: ${themes.filter(t => /latte|light|dawn/.test(t)).join(', ')}
293. Plain
30 - mono
31
32> [!TIP]
33> Any color slot beats the theme. Set \`headingColor\` or \`numberColor\` to a hex value in \`/config\`.
34
35## Everything it draws
36
37### Alerts
38
39> [!IMPORTANT]
40> After updating the plugin, open sessions need \`/reload\`.
41
42> [!WARNING]
43> Terminals that copy on select (Warp) can turn a click on a copy button into a selection. Use the keyboard shortcut.
44
45> [!CAUTION]
46> Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls back to plain text.
47
48### Task lists
49
50- [x] Parse the reply
51- [x] Draw it
52 - [x] Tables and code
53 - [ ] Diagrams on the desktop app
54- [ ] Ship the next release
55
56### Your prompts
57
58Scroll up: what you typed draws in a bubble. \`promptStyle\` switches to \`bar\`, \`chevron\` or \`off\` in \`/config\`.
59
60### Tool rows
61
62Tool calls draw as one line, dimmed on the right by default. Set \`toolStyle\` in \`/config\` to \`chat\`, \`tree-dim\`, \`tree-bold\` or \`classic\`.
63
64### Quotes and rules
65
66> A quote keeps its text when you copy it, without the \`> \` markers.
67
68---
69
70### Code
71
72\`\`\`json
73{ "theme": "dracula", "headingStyle": "banner", "mermaid": true }
74\`\`\`
75
76#### Diagrams
77
78\`\`\`mermaid
79flowchart LR
80 R[Reply] --> P[Parse]
81 P --> D[Draw]
82 D --> S[Screen]
83\`\`\`
84
85\`\`\`mermaid
86sequenceDiagram
87 participant U as You
88 participant C as Claude
89 participant P as tessera
90 U->>C: prompt
91 C->>P: markdown
92 P-->>U: colored reply
93\`\`\`
94
95\`\`\`mermaid
96xychart-beta
97 title "Color options per group"
98 x-axis [text, head, num, code, diag]
99 y-axis "options" 0 --> 8
100 bar [7, 3, 2, 6, 2]
101\`\`\`
102`
103
104export const helpText = (themes: readonly string[]): string => `
105## Commands
106
107| Command | Does |
108|---------|------|
109| \`/tessera setup\` | Pick the features you want on |
110| \`/tessera peek <file>\` | Preview a document: .md .csv .json .docx .xlsx .pptx |
111| \`/tessera theme <name>\` | Switch theme on the spot |
112| \`/tessera copy\` | Copy the last reply; \`copy code\` its last code block, \`copy prompt\` its prompt card |
113| \`/tessera demo\` | Full showcase, every element and diagram |
114| \`/tessera demo-rtl\` | Hebrew right-to-left showcase |
115| \`/tessera inbox\` | Client feedback inbox (turn on feedbackInbox in /config); \`inbox fixed 3 5\` marks items fixed |
116
117### ${themes.length} themes
118
119- Dark: ${themes.filter(t => !/latte|light|dawn/.test(t) && t !== 'mono').join(', ')}
120- Light: ${themes.filter(t => /latte|light|dawn/.test(t)).join(', ')}
121- Plain: mono, no color, only bold and dim
122
123Docs and issues: https://github.com/OG-Matcha/tessera
124
125> [!TIP]
126> Any color slot beats the theme. Set \`headingColor\` or \`numberColor\` to a hex value in \`/config\`.
127
128### Task lists
129
130- [x] Tables, diagrams and charts drawn in the terminal
131- [x] A copy button on every block
132- [ ] Pick a \`taskStyle\` in \`/config\`: checks, ticks, box or progress
133
134\`\`\`mermaid
135flowchart LR
136 R[Reply] --> P[Parse]
137 P --> D[Draw]
138 D --> S[Screen]
139\`\`\`
140
141\`\`\`mermaid
142xychart-beta
143 title "Color options per group"
144 x-axis [text, head, num, code, diag]
145 y-axis "options" 0 --> 8
146 bar [7, 3, 2, 6, 2]
147\`\`\`
148
149> [!CAUTION]
150> Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls back to plain text.
151`
152
153export const rtlShowcaseText = (): string => `
154# עברית מימין לשמאל
155
156**שלום חברים**, זו הדגמה של עברית עם מונחים באנגלית כמו \`kubectl\`, מספרים כמו 99.9% ו-250ms, נתיב כמו ~/src/app.ts וגם [קישור](https://github.com/OG-Matcha/tessera).
157
158## רשימות
159
160- פרוסים בשני אזורים (us-east ו-eu-west)
161- מחליפים ערכת נושא עם \`/tessera theme nord\`
162
1631. מתקינים את התוסף
1642. שואלים שאלה בעברית
165
166> עברית נקראת מימין לשמאל, גם בטרמינל בלי תמיכה בכיווניות
167
168> [!TIP]
169> כל ערכת נושא עובדת גם בעברית
170
171| שירות | אזור | גרסה |
172| :--- | :--- | ---: |
173| שער | us-east | 2.14.0 |
174| חיוב | eu-west | 1.8.3 |
175
176\`\`\`bash
177ls -la # רשימת הקבצים בתיקייה
178\`\`\`
179`
180hooks/help-zh.ts 87 lines1const dark = (themes: readonly string[]) => themes.filter(t => !/latte|light|dawn/.test(t) && t !== 'mono').join('、')
2const light = (themes: readonly string[]) => themes.filter(t => /latte|light|dawn/.test(t)).join('、')
3
4export const helpTextZh = (themes: readonly string[]): string => `
5## 指令
6
7| 指令 | 用途 |
8|------|------|
9| \`/tessera setup\` | 選擇要開啟的功能 |
10| \`/tessera peek <檔案>\` | 預覽文件:.md .csv .json .docx .xlsx .pptx |
11| \`/tessera theme <名稱>\` | 立即切換主題 |
12| \`/tessera copy\` | 複製上一則回覆;\`copy code\` 只複製最後一個程式碼區塊,\`copy prompt\` 複製 prompt 卡片 |
13| \`/tessera demo\` | 完整示範:每種元素和圖表 |
14| \`/tessera inbox\` | 客戶回饋收件匣(需在 /config 開啟 feedbackInbox);\`inbox fixed 3 5\` 標為已修 |
15
16### ${themes.length} 套主題
17
18- 深色:${dark(themes)}
19- 淺色:${light(themes)}
20- 單色:mono,只用粗體和淡色
21
22說明與回報問題:https://github.com/OG-Matcha/tessera
23
24> [!TIP]
25> 個別顏色的設定優先於主題。在 \`/config\` 把 \`headingColor\` 或 \`numberColor\` 設成色碼即可。
26
27### 貼圖預覽
28
29在輸入框貼圖後,上方會出現縮圖。支援 kitty 圖片協定的終端機(kitty、Ghostty)顯示原圖;其他終端機用色塊縮圖,按「原圖」用系統檢視器打開。
30`
31
32export const showcaseTextZh = (themes: readonly string[]): string => `
33# tessera
34
35在終端機裡把 Claude 的回覆畫得更好讀:**粗體**、*斜體*、~~刪除線~~、\`行內程式碼\`、[連結](https://github.com/OG-Matcha/tessera),數字如 99.9% 與 250ms、路徑如 ~/src/app.ts 都會上色。
36
37## 表格會算中文寬度
38
39| 功能 | 狀態 | 何時出現 |
40|------|------|------|
41| 貼上預覽 | ✅ 開 | 貼上圖片或長文字 |
42| 繁簡守門 | ✅ 開 | 寫入簡體字或簡中用語 |
43| 接續未完成 | ✅ 開 | 新 session 開始 |
44| 摺疊長差異 | ✅ 開 | 改檔差異超過 12 行 |
45
46> [!NOTE]
47> 共 ${themes.length} 套主題。深色:${dark(themes)};淺色:${light(themes)};mono 不用顏色。
48
49> [!WARNING]
50> 會「選取即複製」的終端機(例如 Warp)點複製按鈕時可能變成選取文字,請改用 \`/tessera copy\`。
51
52### 待辦清單
53
54- [x] 解析回覆
55- [x] 畫出來
56 - [x] 表格與程式碼
57 - [ ] 桌面版的圖表
58- [ ] 發布下一版
59
60### 程式碼
61
62\`\`\`powershell
63claude plugin install tessera@tessera --scope user
64\`\`\`
65
66### 圖表:中文節點不跑版
67
68\`\`\`mermaid
69flowchart LR
70 A[貼上圖片] --> B[輸入框預覽]
71 B --> C[送出]
72 C --> D[回覆上色]
73\`\`\`
74
75\`\`\`mermaid
76sequenceDiagram
77 participant U as 你
78 participant C as Claude
79 participant T as tessera
80 U->>C: 提問
81 C->>T: markdown
82 T-->>U: 上色後的回覆
83\`\`\`
84
85> 引言複製時不會帶 \`> \` 符號。
86`
87hooks/carry.ts 35 lines1export type ItemStatus = 'pending' | 'in_progress' | 'completed'
2
3// What one session left to do, kept per repository: the newest sessions only. `tasks` keeps the task
4// ids, so a reloaded module can follow later updates.
5export type CarryStore = Record<string, { at: number; open: string[]; tasks?: Record<string, { subject: string; status: ItemStatus }> }>
6
7export type TaskLog = {
8 tasks: Map<string, { subject: string; status: ItemStatus }>
9 todos: { content: string; status: ItemStatus }[]
10}
11
12const KEEP = 5
13const isOpen = (status: ItemStatus) => status !== 'completed'
14
15export const openItems = (log: TaskLog): string[] => [
16 ...[...log.tasks.values()].filter(t => isOpen(t.status)).map(t => t.subject),
17 ...log.todos.filter(t => isOpen(t.status)).map(t => t.content),
18]
19
20export function recordSession(store: CarryStore, sessionId: string, at: number, log: TaskLog): CarryStore {
21 const next = { ...store, [sessionId]: { at, open: openItems(log), tasks: Object.fromEntries(log.tasks) } }
22 return Object.fromEntries(Object.entries(next).sort(([, a], [, b]) => b.at - a.at).slice(0, KEEP))
23}
24
25// The task list this session kept before the module was reloaded.
26export const restoredTasks = (store: CarryStore, sessionId: string): TaskLog['tasks'] => new Map(Object.entries(store[sessionId]?.tasks ?? {}))
27
28// The latest other session in this repository, when it stopped with items still open.
29export function carriedFrom(store: CarryStore, sessionId: string): { from: string; items: string[] } | undefined {
30 const [from, latest] = Object.entries(store)
31 .filter(([id]) => id !== sessionId)
32 .sort(([, a], [, b]) => b.at - a.at)[0] ?? []
33 return from !== undefined && latest !== undefined && latest.open.length > 0 ? { from, items: latest.open } : undefined
34}
35hooks/i18n.ts 201 lines1export type Lang = 'en' | 'zh-TW'
2
3export type ToolKind = 'command' | 'read' | 'edit' | 'pattern' | 'page' | 'task'
4
5// The words of tessera's tool rows: one verb per tool, a phrase per kind for a group of calls.
6export type ToolWords = {
7 verbs: Record<string, string>
8 group: (kind: ToolKind, n: number) => string
9 other: (name: string, n: number) => string
10 join: string
11 failed: (n: number) => string
12 failedRow: string
13 interrupted: string
14 last: string
15}
16
17const EN_GROUPS: Record<ToolKind, [string, string]> = {
18 command: ['ran', 'command'],
19 read: ['read', 'file'],
20 edit: ['edited', 'file'],
21 pattern: ['searched', 'pattern'],
22 page: ['fetched', 'page'],
23 task: ['delegated', 'task'],
24}
25const plural = (noun: string, n: number) => (n === 1 ? noun : noun.endsWith('h') ? `${noun}es` : `${noun}s`)
26
27const TOOL_WORDS_EN: ToolWords = {
28 verbs: {
29 Bash: 'Ran', PowerShell: 'Ran', Read: 'Read', Write: 'Wrote', Edit: 'Edited', MultiEdit: 'Edited', NotebookEdit: 'Edited',
30 Grep: 'Searched', Glob: 'Listed', WebFetch: 'Fetched', WebSearch: 'Searched the web for', Agent: 'Delegated', Task: 'Delegated',
31 },
32 group: (kind, n) => `${EN_GROUPS[kind][0]} ${n} ${plural(EN_GROUPS[kind][1], n)}`,
33 other: (name, n) => `used ${n} ${plural(name, n)}`,
34 join: ', ',
35 failed: n => ` · ${n} failed`,
36 failedRow: ' failed',
37 interrupted: ' interrupted',
38 last: ' · last: ',
39}
40
41const ZH_GROUPS: Record<ToolKind, [string, string]> = {
42 command: ['執行', '個指令'],
43 read: ['讀取', '個檔案'],
44 edit: ['編輯', '個檔案'],
45 pattern: ['搜尋', '個模式'],
46 page: ['抓取', '個網頁'],
47 task: ['委派', '個任務'],
48}
49
50const TOOL_WORDS_ZH: ToolWords = {
51 verbs: {
52 Bash: '執行', PowerShell: '執行', Read: '讀取', Write: '寫入', Edit: '編輯', MultiEdit: '編輯', NotebookEdit: '編輯',
53 Grep: '搜尋', Glob: '列出', WebFetch: '抓取', WebSearch: '網路搜尋', Agent: '委派', Task: '委派',
54 },
55 group: (kind, n) => `${ZH_GROUPS[kind][0]} ${n} ${ZH_GROUPS[kind][1]}`,
56 other: (name, n) => `使用 ${name} ${n} 次`,
57 join: ',',
58 failed: n => ` · ${n} 個失敗`,
59 failedRow: ' 失敗',
60 interrupted: ' 已中斷',
61 last: ' · 最後:',
62}
63
64// The names the guards give their rules in the toast; the reason the model reads stays English.
65const RULES_EN = {
66 'node_modules link': 'node_modules link',
67 'delete through a link': 'delete through a link',
68 'git add -A': 'git add -A',
69 'git tree rewrite': 'git tree rewrite',
70 'force push': 'force push',
71 'zh-TW wording': 'zh-TW wording',
72 'project glossary': 'project glossary',
73 'unquoted heredoc': 'unquoted heredoc',
74 'Workflow model': 'Workflow model',
75 'Workflow authorization': 'Workflow authorization',
76 'CJK as \\u escapes': 'CJK as \\u escapes',
77 'Agent model': 'Agent model',
78}
79
80export type Rule = keyof typeof RULES_EN
81
82const RULES_ZH: Record<Rule, string> = {
83 'node_modules link': '連結 node_modules',
84 'delete through a link': '刪除會穿過連結',
85 'git add -A': 'git add -A',
86 'git tree rewrite': '改寫主工作區',
87 'force push': '強制推送',
88 'zh-TW wording': '繁中用語',
89 'project glossary': '專案用語表',
90 'unquoted heredoc': '未加引號的 heredoc',
91 'Workflow model': 'Workflow 未指定模型',
92 'Workflow authorization': 'Workflow 未引用你的話',
93 'CJK as \\u escapes': 'CJK 寫成 \\u 跳脫',
94 'Agent model': 'Agent 未指定模型',
95}
96
97const EN = {
98 copied: 'Copied',
99 toolWords: TOOL_WORDS_EN,
100 copyLabels: { block: '⧉ copy', reply: '⧉ copy reply', prompt: '⧉ copy prompt', source: '⧉ source', art: '⧉ art' },
101 copyFailed: 'Copy failed',
102 nothingToCopy: 'Nothing to copy yet.',
103 noCodeBlock: 'The last reply has no code block.',
104 noPromptBlock: 'The last reply has no prompt card.',
105 copiedPrompt: 'Copied the prompt.',
106 setupTitle: 'tessera features',
107 setupHint: 'tessera: /tessera setup picks the features you want on.',
108 setupOpened: 'tessera setup opened.',
109 closePane: '✕ close',
110 resumeScheduled: (time: string) => `Usage limit reached: tessera will continue at ${time}. Typing anything cancels it.`,
111 resumePrompt: 'The usage limit has reset. Continue from where you stopped.',
112 peekUsage: 'Usage: /tessera peek <file> or @file (.md, .csv, .json, .docx, .xlsx, .pptx)',
113 peekUnreadable: (path: string) => `Cannot read ${path} (missing, or over 4 MiB).`,
114 peekUnknown: (path: string) => `tessera does not preview this kind of file: ${path}`,
115 pastedText: (n: number, lines: number) => `Pasted text #${n} · ${lines} lines`,
116 carryTitle: (n: number) => `${n} unfinished from your last session here`,
117 carryContinue: 'continue',
118 agentPicked: (task: string, model: string) => `tessera: ${task} → ${model}`,
119 carryDismiss: 'dismiss',
120 diffSummary: (added: number, removed: number) => `Added ${added} ${added === 1 ? 'line' : 'lines'}, removed ${removed} ${removed === 1 ? 'line' : 'lines'}`,
121 diffHidden: (n: number) => `${n} more ${n === 1 ? 'line' : 'lines'}`,
122 diffExpand: 'expand',
123 updateTitle: 'tessera gets no updates: auto-update is off for its marketplace',
124 updateOpen: 'open /plugin',
125 updateLater: 'not now',
126 updateSteps: (market: string) => `In /plugin: Marketplaces → ${market} → Enable auto-update`,
127 carryPrompt: (items: string[]) => `Unfinished tasks from the last session in this project:\n${items.map(i => `- ${i}`).join('\n')}\nCheck which still apply and carry on with them.`,
128 copiedReply: 'Copied the last reply.',
129 copiedCode: 'Copied the last code block.',
130 unknownTheme: (name: string, themes: string) => `Unknown theme "${name}". Themes: ${themes}`,
131 themeFailed: 'Could not switch theme',
132 themeSet: (name: string) => `Theme set to ${name}.`,
133 commandDescription: 'Switch the tessera theme, copy the last reply, or show the demo',
134 original: 'original',
135 noPreview: '(no preview)',
136 blocked: (rule: Rule) => `tessera blocked: ${RULES_EN[rule]}`,
137 inboxFiled: (count: number, regressed: number[]) => `Inbox: filed ${count}${regressed.length ? ` · looks like fixed ${regressed.map(n => `#${n}`).join(', ')} again` : ''}`,
138 inboxMarked: (ids: number[], commit: string) => `Marked ${ids.map(n => `#${n}`).join(', ') || 'nothing'} fixed in ${commit}.`,
139 inboxHeaders: ['#', 'Time', 'From', 'Message', 'Status'],
140 inboxEmpty: 'The inbox is empty. Paste a chat log with timestamped lines (22:55 Name message) to fill it.',
141 inboxOff: 'The feedback inbox is off. Turn on feedbackInbox in /config.',
142 drawingOff: 'tessera drawing is off (enabled in /config).',
143}
144
145const ZH: typeof EN = {
146 copied: '已複製',
147 toolWords: TOOL_WORDS_ZH,
148 copyLabels: { block: '⧉ 複製', reply: '⧉ 複製回覆', prompt: '⧉ 複製 prompt', source: '⧉ 原始碼', art: '⧉ 文字圖' },
149 copyFailed: '複製失敗',
150 nothingToCopy: '還沒有可以複製的回覆。',
151 noCodeBlock: '上一則回覆沒有程式碼區塊。',
152 noPromptBlock: '上一則回覆沒有 prompt 卡片。',
153 copiedPrompt: '已複製 prompt。',
154 setupTitle: 'tessera 功能',
155 setupHint: 'tessera:輸入 /tessera setup 選擇要開的功能。',
156 setupOpened: '已開啟 tessera 功能設定。',
157 closePane: '✕ 關閉',
158 resumeScheduled: time => `額度用完了:tessera 會在 ${time} 自動繼續。期間你輸入任何內容就會取消。`,
159 resumePrompt: '額度已重置,請從剛才中斷的地方繼續。',
160 peekUsage: '用法:/tessera peek <檔案> 或 @檔案(.md、.csv、.json、.docx、.xlsx、.pptx)',
161 peekUnreadable: path => `讀不到 ${path}(不存在或超過 4 MiB)。`,
162 peekUnknown: path => `tessera 還不能預覽這種檔案:${path}`,
163 pastedText: (n, lines) => `貼上的文字 #${n} · ${lines} 行`,
164 carryTitle: n => `上次在這個專案還有 ${n} 項沒完成`,
165 carryContinue: '接續',
166 agentPicked: (task, model) => `tessera:${task} → ${model}`,
167 carryDismiss: '略過',
168 diffSummary: (added, removed) => `新增 ${added} 行,刪除 ${removed} 行`,
169 diffHidden: n => `還有 ${n} 行未顯示`,
170 diffExpand: '展開',
171 updateTitle: 'tessera 收不到更新:它的 marketplace 沒開自動更新',
172 updateOpen: '打開 /plugin',
173 updateLater: '不用了',
174 updateSteps: market => `在 /plugin 裡:Marketplaces → ${market} → Enable auto-update`,
175 carryPrompt: items => `上次在這個專案沒完成的待辦:\n${items.map(i => `- ${i}`).join('\n')}\n請確認哪些還需要做,接著處理。`,
176 copiedReply: '已複製上一則回覆。',
177 copiedCode: '已複製上一則回覆的最後一個程式碼區塊。',
178 unknownTheme: (name, themes) => `沒有「${name}」這個主題。可用主題:${themes}`,
179 themeFailed: '無法切換主題',
180 themeSet: name => `主題已切換為 ${name}。`,
181 commandDescription: '切換 tessera 主題、複製上一則回覆,或顯示示範',
182 original: '原圖',
183 noPreview: '(無法預覽)',
184 blocked: rule => `tessera 已攔下:${RULES_ZH[rule]}`,
185 inboxFiled: (count, regressed) => `收件匣:新增 ${count} 則${regressed.length ? ` · 疑似 ${regressed.map(n => `#${n}`).join('、')} 又壞了` : ''}`,
186 inboxMarked: (ids, commit) => `已將 ${ids.map(n => `#${n}`).join('、') || '(無)'} 標為已修(${commit})。`,
187 inboxHeaders: ['#', '時間', '誰', '內容', '狀態'],
188 inboxEmpty: '收件匣是空的。貼上帶時間的聊天紀錄(22:55 名字 訊息)就會建立項目。',
189 inboxOff: '回饋收件匣沒有開啟,請到 /config 打開 feedbackInbox。',
190 drawingOff: 'tessera 的畫面美化已關閉(/config 的 enabled)。',
191}
192
193export const STRINGS: Record<Lang, typeof EN> = { en: EN, 'zh-TW': ZH }
194
195// The first locale hint that names a language decides: any Chinese reads Traditional Chinese.
196export function pickLang(option: unknown, hints: (string | undefined)[]): Lang {
197 if (option === 'en' || option === 'zh-TW') return option
198 const hint = hints.find(h => h !== undefined && h !== '' && h !== 'C' && h !== 'POSIX')
199 return hint !== undefined && /^zh|chinese|中文/i.test(hint) ? 'zh-TW' : 'en'
200}
201hooks/session.ts 16 lines1import type { Lang } from './i18n'
2import { STRINGS } from './i18n'
3import type { Env } from './platform'
4import type { Voice } from './voice'
5
6// What the session settles at start and from the person's prompts, read by every feature module.
7export const session = {
8 lang: 'en' as Lang,
9 // The language the person writes in, from their last prompt that told.
10 voice: undefined as Voice | undefined,
11 // Every variable the platform decisions read, read once at session start.
12 env: {} as Env,
13}
14
15export const t = () => STRINGS[session.lang]
16hooks/guard-hooks.ts 222 lines1import type { EngineInterface, On } from 'claude-code'
2
3import type { Risk } from './guard'
4import { commandDir, expandedHeredoc, forcePushes, isDefaultBranch, misEscapedCjk, quotesUser, recursiveDeletes, scriptNamesModel, shellRisks, writtenFile } from './guard'
5import { zhTwFixes } from './hans'
6import type { Term } from './glossary'
7import { glossaryHits, parseGlossary } from './glossary'
8import type { Rule } from './i18n'
9import { platformOf } from './platform'
10import { session, t } from './session'
11
12// Guards for long multi-agent runs: a subagent's or workflow agent's tool call carries an agentId.
13const AGENTS_QUIET_MS = 180_000
14const AGENT_MODELS = ['opus', 'sonnet', 'haiku', 'fable'] as const
15type AgentModel = (typeof AGENT_MODELS)[number]
16
17let lastAgentCall = -Infinity
18let guardGit = true
19let agentModel: AgentModel | 'choose' | 'auto' | undefined
20
21const PICK = 'haiku for quick mechanical work (search, renames, formatting), sonnet for routine edits, opus for hard reasoning, design or review, fable for the hardest and longest work where quality outweighs speed and cost'
22let requireUserQuote = false
23let guardCjk = true
24let guardHans = 'auto'
25let guardHeredoc = true
26let guardGlossary = false
27let glossary: { root: string; terms: Term[] } | undefined
28// The last call refused by a rule that can misjudge intent; the same call sent again goes through.
29let refusedOnce = ''
30const mainTrees = new Map<string, boolean>()
31
32const RISK_REASONS: Record<Risk, string> = {
33 'tree-rewrite': 'it rewrites the shared main working tree while agents are running; other agents and the person lose uncommitted work. Use a separate `git worktree add` (with its own install) or recorded numbers instead',
34 'stage-all': "it stages every change while agents are running, which can commit another agent's half-done or reverted files. Stage the files you edited by path",
35 'link-node-modules': 'a junction or symlink to node_modules lets a recursive delete (git worktree remove, rm -rf) follow it into the main repo. Run the install inside the worktree instead',
36}
37
38export const isAbsolute = (path: string) => /^([a-z]:)?[\\/]/i.test(path)
39
40// A path as the command would see it: absolute as given, else under the command's cd / git -C, else the session's directory.
41async function resolveIn($: EngineInterface, command: string, path?: string): Promise<string> {
42 const cwd = await $.session.cwd()
43 const named = commandDir(command)
44 const dir = named === undefined ? cwd : isAbsolute(named) ? named : `${cwd}/${named}`
45 return path === undefined ? dir : isAbsolute(path) ? path : `${dir}/${path}`
46}
47
48// Whether a path is, or holds, a junction or symlink that a recursive delete would follow; false when unknown.
49async function holdsLink($: EngineInterface, path: string): Promise<boolean> {
50 const stat = await $.fs.stat(path).catch(() => undefined)
51 if (stat === undefined) return false
52 if (stat.isLink) return true
53 if (stat.kind !== 'dir') return false
54 const argv =
55 platformOf(session.env) === 'windows'
56 ? ['cmd', '/c', 'dir', '/AL', '/S', '/B', path.replace(/\//g, '\\')]
57 : ['find', path, '-maxdepth', '8', '-type', 'l', '-print', '-quit']
58 const run = await $.process.run(argv, { timeoutMs: 8_000 }).catch(() => undefined)
59 return run?.exitCode === 0 && run.stdout.trim() !== ''
60}
61
62async function currentBranch($: EngineInterface, command: string): Promise<string | undefined> {
63 const run = await $.process.run(['git', '-C', await resolveIn($, command), 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5_000 }).catch(() => undefined)
64 return run?.exitCode === 0 ? run.stdout.trim() : undefined
65}
66
67async function isMainTree($: EngineInterface, command: string): Promise<boolean> {
68 const dir = await resolveIn($, command)
69 if (!mainTrees.has(dir)) {
70 const run = await $.process.run(['git', '-C', dir, 'rev-parse', '--git-dir', '--git-common-dir'], { timeoutMs: 5_000 }).catch(() => undefined)
71 const [gitDir, commonDir] = (run?.stdout ?? '').trim().split(/\r?\n/)
72 mainTrees.set(dir, run?.exitCode === 0 && gitDir === commonDir)
73 }
74 return mainTrees.get(dir) ?? false
75}
76
77function refuse($: EngineInterface, rule: Rule, reason: string) {
78 $.ui.toast(t().blocked(rule))
79 return { deny: `tessera blocked this call: ${reason}.` }
80}
81
82async function judgeShell($: EngineInterface, command: string, agentId: string | undefined) {
83 if (!guardGit) return undefined
84 const risks = shellRisks(command)
85 if (risks.includes('link-node-modules')) return refuse($, 'node_modules link', RISK_REASONS['link-node-modules'])
86 for (const target of recursiveDeletes(command)) {
87 if (await holdsLink($, await resolveIn($, command, target)))
88 return refuse($, 'delete through a link', `it deletes ${target} recursively and ${target} is or holds a junction or symlink, so the delete can follow it into another tree (git worktree remove and rm -rf both do). List the links (dir /AL /S /B on Windows, find -type l elsewhere), remove each link itself first (rmdir <link> on Windows, rm <link> elsewhere, no recursion), then delete`)
89 }
90 // Sometimes intended, such as cleaning up a fresh repository, so it is a reminder.
91 for (const branch of forcePushes(command)) {
92 const target = branch ?? (await currentBranch($, command))
93 if (target !== undefined && isDefaultBranch(target))
94 return refuseOnce($, command, 'force push', `it force-pushes to ${target}, rewriting history that others and CI build on. Push a branch and merge it instead. If rewriting ${target} is intended`)
95 }
96 const shared = risks.filter(r => r !== 'link-node-modules')
97 if (shared.length === 0) return undefined
98 const agentsRunning = agentId !== undefined || (await $.clock.now()) - lastAgentCall < AGENTS_QUIET_MS
99 if (!agentsRunning || !(await isMainTree($, command))) return undefined
100 const risk = shared[0] as Risk
101 return refuse($, risk === 'stage-all' ? 'git add -A' : 'git tree rewrite', RISK_REASONS[risk])
102}
103
104async function judgeHans($: EngineInterface, tool: string, input: Record<string, unknown>) {
105 if (guardHans === 'off' || (guardHans === 'auto' && session.voice !== 'zh-Hant')) return undefined
106 const file = writtenFile(tool, input)
107 if (file === undefined || zhTwFixes(file.path, file.texts, '').length === 0) return undefined
108 const existing = await $.fs.read(file.path).catch(() => '')
109 const found = zhTwFixes(file.path, file.texts, typeof existing === 'string' ? existing : '')
110 if (found.length === 0) return undefined
111 return refuseOnce($, `${file.path}\n${file.texts.join('\n')}`, 'zh-TW wording', `it writes Simplified characters or zh-CN terms into zh-TW text (${found.slice(0, 8).join(', ')}). Use the zh-TW forms. If the original is intended here, such as a quotation or a zh-CN string`)
112}
113
114async function projectGlossary($: EngineInterface): Promise<Term[]> {
115 const root = (await $.session.repo())?.root ?? (await $.session.cwd())
116 if (glossary?.root !== root) {
117 const text = await $.fs.read(`${root}/CLAUDE.md`).catch(() => '')
118 glossary = { root, terms: typeof text === 'string' ? parseGlossary(text) : [] }
119 }
120 return glossary.terms
121}
122
123async function judgeGlossary($: EngineInterface, tool: string, input: Record<string, unknown>) {
124 if (!guardGlossary) return undefined
125 const file = writtenFile(tool, input)
126 if (file === undefined) return undefined
127 if (/(^|[\\/])CLAUDE\.md$/i.test(file.path)) {
128 glossary = undefined
129 return undefined
130 }
131 const terms = await projectGlossary($)
132 if (terms.length === 0 || glossaryHits(terms, file.texts, '').length === 0) return undefined
133 const existing = await $.fs.read(file.path).catch(() => '')
134 const hits = glossaryHits(terms, file.texts, typeof existing === 'string' ? existing : '')
135 if (hits.length === 0) return undefined
136 return refuseOnce($, `${file.path}\n${file.texts.join('\n')}`, 'project glossary', `it writes wordings the glossary in CLAUDE.md replaces (${hits.slice(0, 8).join(', ')}). Use the glossary's terms. If the other wording is intended here, such as a quotation or a note about the glossary itself`)
137}
138
139function judgeHeredoc($: EngineInterface, command: string) {
140 const token = guardHeredoc ? expandedHeredoc(command) : undefined
141 if (token === undefined) return undefined
142 return refuseOnce($, command, 'unquoted heredoc', `its heredoc delimiter is unquoted, so the shell expands ${token} in the body before anything is written: \${x}, $(cmd) and backticks are replaced and \\\\ becomes \\. Quote the delimiter (<<'EOF') to keep the text as written. If the expansion is intended`)
143}
144
145function refuseOnce($: EngineInterface, key: string, rule: Rule, reason: string) {
146 if (key === refusedOnce) {
147 refusedOnce = ''
148 return undefined
149 }
150 refusedOnce = key
151 return refuse($, rule, `${reason}, send the same call again unchanged and it goes through`)
152}
153
154// The model an agent's task calls for, picked by Haiku from the same guidance `choose` gives Claude.
155const MODEL_LABELS: Record<string, AgentModel> = {
156 'haiku: quick mechanical work such as search, lookups, listing files, renames or formatting': 'haiku',
157 'sonnet: routine coding, edits, tests and documentation': 'sonnet',
158 'opus: hard reasoning, design, debugging or code review': 'opus',
159 'fable: the hardest and longest work, where quality matters more than speed and cost': 'fable',
160}
161
162async function pickModel($: EngineInterface, task: string): Promise<AgentModel | undefined> {
163 const label = await $.model.classify(task, Object.keys(MODEL_LABELS), { model: 'haiku' }).catch(() => undefined)
164 return label === undefined ? undefined : MODEL_LABELS[label]
165}
166
167async function judgeWorkflow($: EngineInterface, script: string | undefined, scriptPath: string | undefined) {
168 if (agentModel === undefined && !requireUserQuote) return undefined
169 const text = script ?? (scriptPath === undefined ? undefined : await $.fs.read(scriptPath).catch(() => undefined))
170 if (typeof text !== 'string') return undefined
171 // auto cannot fill a script in, so it only reminds: the same script sent again runs as written.
172 if (agentModel === 'auto' && !scriptNamesModel(text))
173 return refuseOnce($, text, 'Workflow model', `its agent() calls name no model, so every agent runs on the session's model. Give each agent() the model its task needs: ${PICK}. If the session's model is meant for all of them`)
174 if (agentModel !== undefined && !scriptNamesModel(text))
175 return refuse($, 'Workflow model', agentModel === 'choose'
176 ? `its agent() calls name no model, so every agent runs on the session's model. Give each agent() the model its task needs: ${PICK}`
177 : `its agent() calls name no model, so every agent runs on the session's model. Add model: '${agentModel}' to each agent()'s options`)
178 if (!requireUserQuote) return undefined
179 const prompts = (await $.session.messages()).filter(m => m.role === 'user').map(m => m.text)
180 if (quotesUser(text, prompts)) return undefined
181 return refuse($, 'Workflow authorization', 'the script does not quote the person. Paste their standing instruction verbatim, dated, into the shared prompt, and say that later status questions do not cancel it; agents only see the latest message and refuse to edit otherwise')
182}
183
184export function registerGuards(on: On, options: Record<string, unknown>) {
185 guardGit = options.guardGit !== false
186 agentModel = options.agentModel === undefined || options.agentModel === 'auto' ? 'auto' : options.agentModel === 'choose' ? 'choose' : AGENT_MODELS.find(m => m === options.agentModel)
187 requireUserQuote = options.requireUserQuote === true
188 guardCjk = options.guardCjkEscapes !== false
189 guardHans = options.guardSimplified === 'on' || options.guardSimplified === 'off' ? options.guardSimplified : 'auto'
190 guardHeredoc = options.guardHeredoc !== false
191 guardGlossary = options.guardGlossary === true
192
193 on('tool.call', async ($, e, next) => {
194 if (e.agentId !== undefined) lastAgentCall = await $.clock.now()
195 const escape = guardCjk ? misEscapedCjk(String(e.tool), e as unknown as Record<string, unknown>) : undefined
196 if (escape !== undefined)
197 return refuse($, 'CJK as \\u escapes', `it writes CJK text as escapes (${escape}). Models mis-spell the hex when they escape, which turns words into wrong characters (anthropics/claude-code#83033). Write the characters themselves`)
198 // PowerShell exists only in the Windows build's tool table, so shells are matched by name here.
199 const tool = String(e.tool)
200 const hans = await judgeHans($, tool, e as unknown as Record<string, unknown>)
201 if (hans !== undefined) return hans
202 const terms = await judgeGlossary($, tool, e as unknown as Record<string, unknown>)
203 if (terms !== undefined) return terms
204 if (tool !== 'Bash' && tool !== 'PowerShell') return next(e)
205 const command = String((e as { command?: unknown }).command ?? '')
206 return (tool === 'Bash' ? judgeHeredoc($, command) : undefined) ?? (await judgeShell($, command, e.agentId)) ?? next(e)
207 }).catch((_, e, next) => next(e))
208 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
209 if (agentModel === undefined || e.model !== undefined) return next(e)
210 if (agentModel === 'choose') return refuse($, 'Agent model', `the call names no model, so the agent runs on the session's model. Pass the model this task needs: ${PICK}`)
211 if (agentModel !== 'auto') return next({ ...e, model: agentModel })
212 // Other agent types carry their own model in their definition, which an override would replace.
213 if (e.subagent_type !== undefined && e.subagent_type !== 'general-purpose') return next(e)
214 // The task's description and opening tell its difficulty; a longer prompt only costs Haiku tokens.
215 const model = await pickModel($, `Agent type: ${e.subagent_type ?? 'general-purpose'}\nTask: ${e.description}\n\n${e.prompt.slice(0, 2000)}`)
216 if (model === undefined) return next(e)
217 $.ui.toast(t().agentPicked(e.description, model))
218 return next({ ...e, model })
219 })
220 on('tool.call', { tool: 'Workflow' }, async ($, e, next) => (await judgeWorkflow($, e.script, e.scriptPath)) ?? next(e)).catch((_, e, next) => next(e))
221}
222