SLOPSHOPPER

tessera

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…

newpanebandspinnerrowsguard
v0.6.3MITupdated 2026-10-09OG-Matcha/tessera
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tessera
│ ┃ tessera-setup ✕ ╭───────────────────╭────────────────────────────────────────────╮ │ ┃ tessera features [ ✕ close ] │ fix the failing au│ tessera │ │ ┃ ╰───────────────────│ tessera: /tessera setup picks the features │ │ ┃ [ ☑ Themed replies ] │ you want on. │ │ ┃ Tables, code, diagrams and tool rows draw ⏺ Read(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ⎿ Read 6 lines ╭─────────────────────────────╮ │ ┃ [ ☑ Fold long diffs ] │ tessera │ │ ┃ Long edit diffs show a few removed and ad ⏺ Bash(rm -rf build && git push --for│ tessera blocked: force push │ │ ┃ ⎿ Denied by tessera: tessera block╰─────────────────────────────╯ │ ┃ [ ☑ Paste previews ] │ ┃ Pasted images and collapsed text shown ab Done. I made refresh reject expired claims and added an audit call. │ ┃ │ ┃ [ ☑ Reply in my language ] ✻ Worked for 42s │ ┃ Claude replies in the language of your ow │ ┃ › /tessera │ ┃ [ ☑ Carry over tasks ] Commands │ ┃ Offers tasks left open here, in a new ses ━━━━━━━━ │ ┃ │ ┃ [ ☑ Tree guard ] [ ⧉ copy ] [ ⧉ art │ ┃ Blocks tree rewrites while agents run, de ┌───────────────────────┬────────────────────────────────────────── │ ┃ ─────────────────────────────────────────────────┐ │ ┃ [ ☑ CJK escape guard ] │ Command │ Does │ ┃ Blocks Korean, Chinese or Japanese writte ╞═══════════════════════╪══════════════════════════════════════════ │ ┃ ═════════════════════════════════════════════════╡ │ ┃ [ ☑ Traditional Chinese guard ] │ /tessera setup │ Pick the features you want on │ ┃ While you write Traditional Chinese, bloc ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · tessera-setup
tessera features [ ✕ close ] [ ☑ Themed replies ] Tables, code, diagrams and tool rows drawn by tessera [ ☑ Fold long diffs ] Long edit diffs show a few removed and added lines and an [ ☑ Paste previews ] Pasted images and collapsed text shown above the prompt [ ☑ Reply in my language ] Claude replies in the language of your own words [ ☑ Carry over tasks ] Offers tasks left open here, in a new session or after /c [ ☑ Tree guard ] Blocks tree rewrites while agents run, deletes through li [ ☑ CJK escape guard ] Blocks Korean, Chinese or Japanese written as \u escapes [ ☑ Traditional Chinese guard ] While you write Traditional Chinese, blocks Simplified ch [ ☑ Heredoc guard ] Blocks unquoted heredocs that would expand ${...} or eat [ ☐ Glossary guard ] Blocks wordings the glossary in CLAUDE.md says to avoid [ ☑ Model per agent ] Picks haiku, sonnet, opus or fable for each agent by its [ ☐ Workflows quote you ] A Workflow script must carry your own words [ ☐ Resume after limits ] When a usage limit stops work, continue at the reset [ ☐ Client feedback inbox ] Pasted chat logs become items; regressions are flagged
Turn line
✻ Worked for 42s
Your message
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ fix the failing auth test and add an audit log call │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
Edited src/auth.ts ●
Command output
Commands ━━━━━━━━ [ ⧉ copy ] [ ⧉ art ] ┌───────────────────────┬─────────────────────────────────────────────────────────────────────────── ──────────────────┐ │ Command │ Does ╞═══════════════════════╪═══════════════════════════════════════════════════════════════════════════ ══════════════════╡ │ /tessera setup │ Pick the features you want on ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────┤ │ /tessera peek <file> │ Preview a document: .md .csv .json .docx .xlsx .pptx ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────┤ │ /tessera theme <name> │ Switch theme on the spot ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────┤ │ /tessera copy │ Copy the last reply; copy code its last code block, copy prompt its prompt ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────┤ │ /tessera demo │ Full showcase, every element and diagram ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────┤ │ /tessera demo-rtl │ Hebrew right-to-left showcase ├───────────────────────┼─────────────────────────────────────────────────────────────────────────── ──────────────────┤ │ /tessera inbox │ Client feedback inbox (turn on feedbackInbox in /config); inbox fixed 3 5 └───────────────────────┴─────────────────────────────────────────────────────────────────────────── ──────────────────┘ 16 themes • Dark: catppuccin-mocha, dracula, nord, tokyo-night, gruvbox-dark, rose-pine, everforest, [ ⧉ cop github-dark, one-dark, solarized-dark • Light: catppuccin-latte, gruvbox-light, rose-pine-dawn, github-light, solarized-light • Plain: mono, no color, only bold and dim Docs and issues: https://github.com/OG-Matcha/tessera ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Tip │ │ Any color slot beats the theme. Set headingColor or numberColor to a hex value in /config. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ Task lists [✓] Tables, diagrams and charts drawn in the terminal [ ⧉ copy ] [✓] A copy button on every block [ ] Pick a taskStyle in /config: checks, ticks, box or progress [ ⧉ source ] [ ⧉ art ] ┌───────┐ ┌───────┐ ┌──────┐ ┌────────┐ │ Reply ├──►│ Parse ├──►│ Draw ├──►│ Screen │ └───────┘ └───────┘ └──────┘ └────────┘ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ Caution │ │ Claude Code refuses trees over 20000 nodes. A code block past about 500 highlighted lines falls │ │ back to plain text. │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

<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.

What it fixes

ProblemWhere it is reportedtessera
A pasted image shows as [Image #1] in most terminalsThumbnails 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#23134Its first lines shown above the prompt
Korean, Chinese or Japanese written as \uXXXX comes out as wrong characters#83033Such tool calls are refused before they write
Claude answers a Chinese question in English because the pasted log was EnglishReplies follow the language of your own words
Claude writes Simplified characters or zh-CN terms into a Traditional Chinese projectThe write is refused once, with the zh-TW forms
A new session or /clear loses track of what was left to doThe unfinished tasks are offered back above the prompt
A long edit fills the screen with its diffThe first lines of what was removed and added, the counts, and an expand button
The Read tool does not say which file it read#21151Tool rows name the file
Copying from the terminal brings indentation and trailing spaces#18170Copy buttons and /tessera copy copy clean text

Install

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.

Features

FeatureDefaultWhat it does
Themed repliesonTables, headings, highlighted code, mermaid diagrams and charts, tool rows, copy buttons; 16 themes
Fold long diffsonAn 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 previewsonImage 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 languageonWhen your own words are Chinese, Japanese or Korean, Claude replies in that language; pasted code, logs and quotes do not count
Carry over tasksonA 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 guardonRefuses Korean, Chinese or Japanese written as \uXXXX escapes
Traditional Chinese guardautoWhile 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.

Safety nets

On by default and quiet until they matter.

FeatureDefaultWhat it does
Tree guardonRefuses 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 guardonRefuses 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 agentautoAn 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()

Optional

Off by default, for particular workflows. Turn them on in /tessera setup.

FeatureDefaultWhat it does
Glossary guardoffWhen 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 youoffA Workflow script must carry your own words, so its agents keep your standing instruction
Client feedback inboxoffPasted chat logs (22:55 Name message) become numbered items; a complaint like a fixed item is flagged as a likely regression
Resume after limitsoffA usage-limit stop continues a minute after the reset

Commands

CommandDoes
/tessera setupSwitch features on and off
/tessera peek <file>Preview Markdown, CSV, JSON, docx, xlsx or pptx in the terminal
/tessera copy · copy code · copy promptCopy the last reply, its last code block, or its prompt card
/tessera inbox · inbox fixed 3 5List the feedback inbox, mark items fixed at the current commit
/tessera theme <name>Switch theme
/tessera demoShow every element tessera draws

Type /tessera and a letter for suggestions with descriptions.

Terminals

TerminalImage previews"original" opens in
kitty, Ghosttyreal pixelssystem viewer
Windows Terminal, iTerm2, Apple Terminal, SSHcell artsystem viewer (explorer, open, xdg-open)
Orcacell artan Orca browser tab
VS Code, Cursorcell arta VS Code tab
inside tmux or screencell artas above
WSLcell artWindows 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).

What it does on your machine

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.

FAQ

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:

FeatureAddsWhen
Diagram hint (themed replies, diagramHints)about 300 tokensonce per context, again after a compaction
Reply in my languageabout 70–100 tokensonce per context, again when your language changes or Claude's last reply drifted
Guardsthe refusal's reason, a short paragraphonly when a call is refused
Model per agentone 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 tasksno measurable change: Claude Code loads the task tools on demand
Client feedback inboxa note naming the filed itemswhen you paste a chat log
Themed replies, paste previews, fold long diffsnothing: 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.

Credits

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.

Contributing

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.

License

MIT

Source 34 files
hooks/register.tsx 624 lines
1import { 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}
624
hooks/platform.ts 65 lines
1export 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}
65
hooks/inbox.ts 77 lines
1type 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}
77
hooks/markdown.ts 215 lines
1export 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}
215
hooks/mermaid.tsx 144 lines
1import 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}
144
hooks/render.tsx 816 lines
1import 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}
816
hooks/help.ts 180 lines
1export 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`
180
hooks/help-zh.ts 87 lines
1const 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`
87
hooks/carry.ts 35 lines
1export 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}
35
hooks/i18n.ts 201 lines
1export 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}
201
hooks/session.ts 16 lines
1import 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]
16
hooks/guard-hooks.ts 222 lines
1import 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