Colors Claude's replies by meaning and makes the session easier to follow, every aid behind its own flag

A Claude Code plugin (mod) that makes the screen easier to read. Every feature has its own flag and turns on or off at once:
The interface follows Claude Code's language: English, or Portuguese for whoever set Claude Code to Portuguese (/realce language en|pt|auto picks one).

Drawn by the mod itself, as /realce print draws a reply (/realce demo shows it in the terminal); the table's numbers are just an example. On a light theme: demo-light.png.
Current version: 0.6.4 · what's new in the CHANGELOG and in the releases · repository: Th3-W6rst/claude-realce (questions and problems in Issues).
claude --version), in the terminal. In the desktop and mobile apps the replies, tool rows, diff, turn's line and spinner keep Claude Code's drawing, with Claude's marks as plain bold; the band above the prompt (plan, limits, away summary, "Needs you") and the commands work there too.echo $COLORTERM should answer truecolor./realce print (see Diagrams).At the prompt of a Claude Code session:
/plugin install realce --marketplace Th3-W6rst/claude-realce
Answer y to add the marketplace and press Enter on the user scope. From the shell, the same: claude plugin install realce --marketplace Th3-W6rst/claude-realce. In the desktop app: + beside the prompt box, Plugins, Add marketplace with Th3-W6rst/claude-realce, Add plugin.
Once installed, close and reopen Claude Code. The drawings already work in the session you installed it from, but Claude only learns the colored marks in the next session.
Good to know before using it:
errors) and the "Needs you" band (pending): those four are a matter of taste, and the last two add lines to every turn.errors (explains a command that failed, at most 20 each time the mod loads, only in an interactive session; off until you run /realce errors on) and explain (only when you ask). The pane's Agent tab calls the session's model only on a question you ask./realce off pauses every feature; claude plugin uninstall realce removes it.To update: claude plugin marketplace update claude-realce, then claude plugin update realce@claude-realce, and reopen Claude Code. /realce status says when a new version is out.
/realce doctor to see what your machine already has and what is missing, and /realce demo for a reply with every feature, without calling the model./realce opens the flags pane. /realce pane opens the session pane, which also opens by itself in a terminal 144 columns wide or more.✔ proved. If Claude edits and says it is done without testing, the seal is ⚠ no proof./realce help lists the commands; /realce status shows what is on.A turn is one message of yours and everything Claude does until it answers.

A turn recorded in a real session (Claude Code 2.1.304, 4× while Claude works; MP4): the tool rows with the word diff, the reply colored by meaning, the rule that closes the turn with its proof seal, the detailed transcript (ctrl+o) with the test score, the same turn with /realce off and back on, and the session pane.

A real session in Ghostty, on a small test project: the reply colored by meaning, the rule that closes the turn, and the session pane with usage, turns and files. Below, the same turn in the detailed transcript (ctrl+o): the edit with its word diff and the test run's score.

| Command | What it does |
|---|---|
/realce | Opens the flags pane: each feature has a key (a letter or digit), the last ones only Tab and Enter; Esc closes |
/realce help | Lists the commands |
/realce doctor | Checks what your machine gives realce (version, colors, macOS, fullscreen, editor, mermaid, Chrome) and what is missing |
/realce on · /realce off | Turns the whole mod on or off, keeping each flag's choice |
/realce <flag> on · off | Turns one feature on or off, for example /realce column off |
/realce pane | Opens or closes the session pane, in four tabs (keys 1–4): Overview (usage and plan with bars, what needs you, failures with their why and a Fix, agents at work), Agent, Files, Turns (pinned replies, timeline) |
/realce ask [question] | Asks the pane's agent about the session; alone, opens the Agent tab to type |
/realce copy | Copies Claude's last reply (the marks become plain markdown) |
/realce summary | Shows and copies a summary of the session in markdown: turns (time, duration, seal), tests, files, git, failures |
/realce focus | Toggles focus mode, which hides the tools that finished without error |
/realce explain [text] | Explains, in the pane's Agent tab, the text you wrote, or the text selected in the conversation |
/realce pin | Pins the selected reply, or the last one, in the pane; again, unpins it |
/realce print | Saves the last reply as HTML and PNG on the Desktop, in your theme's colors |
/realce demo | Shows a reply with every feature, without calling the model |
/realce status | What is off, language, theme, fullscreen, notices, updates and the cost of each drawing |
/realce language en · pt · auto | The interface's language; auto follows Claude Code's |

A real session (Claude Code 2.1.306, Haiku): /realce ask from the prompt, answered in the pane's Agent tab from the conversation itself in 2.9 s, with what it cost and the buttons that take the answer to the chat.
The Agent tab answers questions about the session while Claude works on its own: what changed, why a test failed, what comes next. In Session mode the answer comes from the conversation itself (a fork of it, no tools, served mostly from the prompt cache: about two seconds and a few hundred new tokens); a question asked while a turn runs waits for it to end, since a fork in the middle of a turn answers about the turn's next step instead. In Tools mode the mod's own agent (agents/reader.md) opens the project's files: Read is its only tool, so Claude Code refuses it any edit or command in every permission mode, bypassPermissions included. It gets the file list from git ls-files; like Claude's own Read, it can open any file your account can. Ready-made questions sum the session up, suggest the next step, explain the last failure or review the changed files. Each answer goes to the prompt box (To chat), is sent (Send) or copied; a failure's Fix in the Overview leaves a ready prompt in the box. Nothing calls the model until you ask; /realce agent off takes the field, the ready-made questions and Fix away, and a question still waiting is not asked (a history already there stays readable in the tab until Clear).
copy, summary, pane, focus, pin, explain, print and demo are flags too: /realce focus toggles the mode, and /realce explain off turns the feature off (the command then says how to turn it back on). Two of them name a part of the feature: /realce copy off takes the copy buttons away, while /realce copy still copies the last reply; /realce pane off stops the pane from opening by itself, while /realce pane still opens it. Every command and flag also answers to its Portuguese name (/realce ajuda, /realce foco, /realce coluna off).
| Group | Flag | Feature | What it does | Default |
|---|---|---|---|---|
| Text | emphasis (enfase) | Colored emphasis | Claude's marks and automatic colors for numbers, paths and status | on |
| Text | column (coluna) | Reading column | Prose kept to 100 columns | on |
| Text | callouts | Callouts | Boxes for decision, risk, done, summary, note and tip | on |
| Text | tables (tabelas) | Colored tables | Highlighted header, striped rows, numbers on the right | on |
| Text | deltas | Automatic deltas | "from 87 to 106" gets (+19) | on |
| Text | spacing (respiro) | Room between items | A blank line between list items that wrap | on |
| Text | numbers (numeros) | Human numbers | "102761 ms" gets "(1m 42s)" beside it | on |
| Code | highlight | Own highlighter | Neon colors for ts, py, sh, json, yaml, swift, sql and diff | on |
| Code | copy (copiar) | One-click copy | A copy button on every code block and on long replies | on |
| Code | fold (dobrar) | Fold what is long | Blocks over 25 lines start folded | on |
| Code | paths (caminhos) | Clickable paths | Paths open in the editor, at the line | on |
| Code | diagrams (diagramas) | Diagrams | Mermaid blocks become pictures | on |
| Tools | tools (ferramentas) | Tools by kind | An icon and a color per kind; reads dimmed | on |
| Tools | score (placar) | Test score | The test summary above the Bash output | on |
| Tools | rule (separador) | Turn rule | A rule with the turn's time, duration and numbers | on |
| Tools | agents (agentes) | Agent cards | A background agent ends in a compact card | on |
| Tools | seal (selo) | Proof seal | The turn's rule says whether what Claude claimed was tested | on |
| Tools | diff | Readable diff | Edits with word highlights and line numbers | on |
| Tools | errors (erros) | Explained errors | A failed command gets one sentence saying why (Haiku) | off |
| Tools | commits | Commit cards | Commit, push and PR become a one-line card | on |
| Tools | focus (foco) | Focus mode | Hides the tools that finished without error | off |
| Guidance | pending (pendencias) | Needs you | A band above the prompt with what asks for your decision | off |
| Guidance | plan (plano) | Plan progress | A bar with the tasks while Claude works | on |
| Guidance | pane (painel) | Pane at start | Opens the session's side pane by itself | on |
| Guidance | spinner | Spinner that says what runs | "Thinking", "Writing" and what runs now ("Running the tests", "Editing x.ts") | on |
| Guidance | notify (avisos) | Notify when done | A macOS notification at the end of a long turn or on a question | on |
| Guidance | sound (som) | Notification sound | A short sound with the notification | off |
| Guidance | away (ausencia) | While you were away | What happened, once you start typing again | on |
| Guidance | timeline (linha) | Timeline | The turns in the pane, a click scrolls to each | on |
| Guidance | questions (perguntas) | Question header | A colored line above Claude's questions | on |
| Guidance | pin (fixar) | Pin replies | /realce pin keeps a reply in the pane | on |
| Guidance | explain (explica) | Explain this | /realce explain explains the selected text (Haiku) | on |
| Guidance | limits (limite) | Usage and limits | Usage in the pane; a band when a limit or the context nears its end | on |
| Guidance | agent (agente) | Agent in the pane | Ask about the session in the pane; the answer goes to the chat in one press | on |
| System | theme (tema) | Follow the theme | Colors for light backgrounds, color blindness and ANSI, from Claude Code's theme | on |
| System | print | Pretty print | /realce print saves the last reply as a picture | on |
| System | demo | Demo | /realce demo shows everything the mod does | on |
| System | updates (atualizacao) | Update notice | Says when a new version is out on GitHub | on |
| System | summary (resumo) | Session summary | /realce summary copies a summary of the session in markdown | on |
| Mark | Meaning | Color |
|---|---|---|
Label: opening a line | Subtopic | red |
term mid-sentence | Key term | magenta |
==fact== | The most important fact of the reply | yellow highlighter |
{+text+} | Went well | green |
{-text-} | Went wrong | red on wine |
{!text!} | Needs your attention or decision; with pending on, it also goes up to the "Needs you" band above the prompt | yellow |
> [!DECISION] … | Decision box (also RISK, DONE, SUMMARY, NOTE, TIP; the Portuguese tags work too) | the kind's color |
With the seal on, Claude also knows the rule shows whether it ran the tests before saying it was done. With the mod off, the marks show as plain bold. The colors follow Claude Code's theme (/config): dark, light, color-blind and ANSI, custom: themes included through their base field.
Optional. They need mermaid-cli and the headless browser it uses:
brew install mermaid-cli
npx -y @puppeteer/browsers install chrome-headless-shell@154.0.8037.57 --path ~/.cache/puppeteer
The browser version is the one mermaid-cli 12 asks for; if mmdc complains about another, install the one it names. Without mmdc the feature stays quiet: Claude is not taught to draw diagrams, and mermaid blocks show as code. The same browser makes the PNG of /realce print.
date, uname and defaults read -g AppleInterfaceStyle (clock offset, platform, system theme); which mmdc and mmdc (mermaid-cli) to render diagrams into ~/.cache/realce/diagrams; osascript for a macOS notification when the terminal has no notification channel; afplay for a sound (off by default); find to locate a headless Chrome for /realce print and to remove its own week-old perf files in ~/.cache/realce; that headless Chrome, or Google Chrome run headless when there is none, to save /realce print's PNG; git ls-files for the pane agent's Tools mode.plugin.json and, for a custom: theme, ~/.claude/themes/<name>.json; checks whether Cursor, Visual Studio Code or Zed is in /Applications (where paths open) and whether there is a Desktop. Writes under ~/.cache/realce/; /realce print writes an HTML page and a PNG to the Desktop, or to the home folder when there is none.$.config.list) for its language and theme.$.state, held for the session and readable by any plugin in it): the turns and their seals, the files touched, the failures and their explanations, commit cards, the plan, the agents, pinned replies, the timeline, the away summary, usage, the diagrams drawn, what needs you (the {!…!} texts), the calls running now (their files and commands), the limit warning, the pane's state (open, opened once, tab, Agent mode), which code blocks are unfolded, and the Agent tab's last 20 questions and answers./realce doctor), and copies to the clipboard.plugin.json from raw.githubusercontent.com, about once a day (a failed one counts), to say when a new version is out (/realce updates off stops it)./realce errors on) and a selection on /realce explain; on a question in the pane's Agent tab, a fork of the session (Session mode) or its own agent, whose only tool is Read (Tools mode)./realce demo submits one prompt, which the mod answers itself, without the model. In the pane, only on your press: To chat and a failure's Fix put text in the prompt box, and Send submits an answer as your prompt. /realce status reads the composed system prompt's sections to say whether its own is there.HOME and COLORTERM.Every one of these calls goes through hooks/io.ts; claude plugin validate . lists them all.
"tui": "fullscreen" in ~/.claude/settings.json): scrolling to a turn or a pinned reply, and selecting text for /realce explain, only work with it; without it, the mod says so.node --test, RSpec and Deno./realce <flag> off, or /realce off, gives it back). The band above the prompt, the question dialog and the spinner keep what Claude Code and other mods draw there.Performance: with the mod on, typing and scrolling take as long as with /realce off. To work on the mod, see CONTRIBUTING.
MIT.
hooks/register.tsx 19 lines1import type { Register } from 'claude-code'
2
3import { registerCommands } from './commands'
4import { registerEvents } from './events'
5import { registerOrient } from './orient/register'
6import { registerPrompt } from './prompt'
7import { registerScreen } from './screen/register'
8import { registerText } from './text/register'
9
10// Shared events are hooked once in events.ts; each area hooks its own drawings. Every feature asks its flag first.
11export const register: Register = on => {
12 registerEvents(on)
13 registerCommands(on)
14 registerPrompt(on)
15 registerText(on)
16 registerScreen(on)
17 registerOrient(on)
18}
19hooks/commands.ts 183 lines1import type { EngineInterface, On } from 'claude-code'
2
3import { featureOff, flagName, flagOf, FLAGS, flagSpec, getFlags, getStoredFlags, isFlagId, isMasterOn, pickLanguage, setFlag, setMaster, type FlagId } from './flags'
4import { isPicked, pick, tr, type Lang } from './i18n'
5import { explainCommand } from './orient/explain'
6import { ask } from './orient/ask'
7import { showAgentTab } from './orient/pane/tab'
8import { pinCommand } from './orient/pins'
9import { demoCommand } from './system/demo'
10import { lastReply, printCommand } from './system/print'
11import { applyFlag, openFlagsPane } from './orient/flagsPane'
12import { retitleSessionPane, toggleSessionPane } from './orient/paneToggle'
13import { statusCommand } from './system/status'
14import { toPlainMarkdown } from './text/inline'
15import { cachedEnv, sessionOffset } from './env'
16import { summaryCommand } from './system/summary'
17import { doctorCommand } from './system/doctor'
18import { addCommand, copyText } from './io'
19
20const usage = () =>
21 tr(
22 '[help|doctor|on|off|status|pane|ask|copy|summary|focus|explain|pin|print|demo|language|<feature> on|off]',
23 '[ajuda|diagnostico|on|off|status|painel|pergunta|copiar|resumo|foco|explica|fixar|print|demo|idioma|<recurso> on|off]',
24 )
25
26// Every command answers to its English and its Portuguese name; the help shows the one of the language in force.
27// The commands named after a flag (copy, summary, pane, focus, explain, pin) take the flag table's names.
28const COMMAND_WORDS: Readonly<Record<string, string>> = { help: 'ajuda', doctor: 'diagnostico', language: 'idioma', ask: 'pergunta' }
29
30/** A command word or a flag name as the dispatcher knows it: Portuguese, the store's ids. */
31const canonical = (word: string): string => (Object.hasOwn(COMMAND_WORDS, word) ? COMMAND_WORDS[word]! : (flagOf(word)?.id ?? word))
32
33const help = () =>
34 tr(
35 [
36 'realce commands:',
37 '/realce — flags pane (a letter or digit toggles, Tab and Enter too, Esc closes)',
38 '/realce on | off — turns everything on or off, keeping each feature’s choice',
39 '/realce <feature> on | off — turns one feature on or off',
40 '/realce pane — opens or closes the session pane',
41 "/realce ask [question] — asks the pane's agent about the session; alone, opens it to type",
42 "/realce copy — copies Claude's last reply",
43 '/realce summary — shows and copies a summary of the session in markdown',
44 '/realce focus — hides the tools that finished without error',
45 '/realce explain [text] — explains the given text in the pane, or the selected text',
46 '/realce pin — pins the selected reply, or the last one',
47 '/realce print — saves the last reply as a picture on the Desktop',
48 '/realce demo — shows everything the mod does, without calling the model',
49 '/realce status — what is off, theme, fullscreen, notifications and performance',
50 '/realce doctor — checks what this machine gives realce and what to install',
51 "/realce language en | pt | auto — the interface's language; auto follows Claude Code",
52 ].join('\n'),
53 [
54 'Comandos do realce:',
55 '/realce — painel de flags (letra ou número alterna, Tab e Enter também, Esc fecha)',
56 '/realce on | off — liga ou desliga tudo, sem perder a escolha de cada recurso',
57 '/realce <recurso> on | off — liga ou desliga um recurso',
58 '/realce painel — abre ou fecha o painel da sessão',
59 '/realce pergunta [pergunta] — pergunta ao agente do painel sobre a sessão; sozinho, abre para digitar',
60 '/realce copiar — copia a última resposta do Claude',
61 '/realce resumo — mostra e copia um resumo da sessão em markdown',
62 '/realce foco — esconde as ferramentas concluídas sem erro',
63 '/realce explica [texto] — explica no painel o texto dado, ou o trecho selecionado',
64 '/realce fixar — fixa a resposta selecionada, ou a última',
65 '/realce print — salva a última resposta como imagem no Desktop',
66 '/realce demo — mostra tudo o que o mod faz, sem chamar o modelo',
67 '/realce status — o que está desligado, tema, tela cheia, avisos e desempenho',
68 '/realce diagnostico — confere o que esta máquina dá ao realce e o que falta instalar',
69 '/realce idioma en | pt | auto — o idioma da interface; auto segue o Claude Code',
70 ].join('\n'),
71 )
72
73/** `/realce copiar`: the last reply on the clipboard, its typed marks as plain markdown. */
74async function copyLastReply($: EngineInterface): Promise<string> {
75 const reply = await lastReply($)
76 if (reply === undefined) return tr('No reply to copy yet.', 'Nenhuma resposta para copiar ainda.')
77 const copied = await copyText($, { text: toPlainMarkdown(reply.text) })
78 return copied.isCopied ? tr('Last reply copied.', 'Última resposta copiada.') : tr(`Could not copy: ${copied.reason}.`, `Não deu para copiar: ${copied.reason}.`)
79}
80
81/** `/realce pergunta [texto]`: the question to the pane's agent, over the session; alone, the field to type it in. */
82async function askCommand($: EngineInterface, question: string): Promise<string> {
83 await showAgentTab($, question === '')
84 if (question === '') return tr('Type the question in the pane.', 'Escreva a pergunta no painel.')
85 await ask($, question, 'session')
86 return tr('Asking in the pane.', 'Perguntando no painel.')
87}
88
89export async function registerRealceCommand($: EngineInterface): Promise<void> {
90 await addCommand($, {
91 name: 'realce',
92 description: tr("Turns realce's reading aids on and off", 'Liga e desliga os recursos de leitura do realce'),
93 argumentHint: usage(),
94 })
95}
96
97const LANGUAGE_WORDS: Readonly<Record<string, Lang | null>> = {
98 en: 'en', english: 'en', ingles: 'en', inglês: 'en', pt: 'pt', portuguese: 'pt', portugues: 'pt', português: 'pt', auto: null,
99}
100
101/** The language in force and where it comes from, as `/realce language` alone says it. */
102const languageNow = () =>
103 tr(
104 `Interface language: English (${isPicked() ? 'picked' : "Claude Code's"}). /realce language en|pt|auto changes it.`,
105 `Idioma da interface: português (${isPicked() ? 'escolhido' : 'o do Claude Code'}). /realce idioma en|pt|auto muda.`,
106 )
107
108/** `/realce language en|pt|auto`: the answer comes in the language it leaves in force. */
109async function languageCommand($: EngineInterface, word: string): Promise<string> {
110 if (word === '') return languageNow()
111 if (!Object.hasOwn(LANGUAGE_WORDS, word)) return tr(`Usage: /realce language en|pt|auto (got "${word}").`, `Uso: /realce idioma en|pt|auto (recebi "${word}").`)
112 const next = LANGUAGE_WORDS[word]!
113 await pickLanguage($, next)
114 await registerRealceCommand($)
115 await retitleSessionPane($)
116 if (next === 'en') return 'Interface language: English. /realce language auto follows Claude Code again.'
117 if (next === 'pt') return 'Idioma da interface: português. /realce idioma auto volta a seguir o Claude Code.'
118 return tr("Interface language: Claude Code's (English).", 'Idioma da interface: o do Claude Code (português).')
119}
120
121/** A feature's command, or what it answers while the feature's flag is off. */
122async function gated($: EngineInterface, id: FlagId, run: () => Promise<string>): Promise<string> {
123 return (await getFlags($))[id] ? run() : featureOff(id, await isMasterOn($))
124}
125
126export function registerCommands(on: On): void {
127 on('command.run', { command: 'realce' }, async ($, e) => {
128 // The flags' read also brings the language the person picked: every answer below speaks it.
129 await getStoredFlags($)
130 const words = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
131 const typed = words[0] ?? ''
132 const first = canonical(typed)
133 const second = words[1]
134 if (words.length === 0) {
135 await openFlagsPane($)
136 return {}
137 }
138 if (words.length === 1 && (first === 'on' || first === 'off')) {
139 await setMaster($, first === 'on')
140 return { text: first === 'on' ? tr('Realce on.', 'Realce ligado.') : tr('Realce off.', 'Realce desligado.') }
141 }
142 if (words.length <= 2 && first === 'idioma') return { text: await languageCommand($, second ?? '') }
143 if (words.length === 1 && first === 'ajuda') return { text: help() }
144 if (words.length === 1 && first === 'diagnostico') return { text: await doctorCommand($, e.presentation.isFullscreen) }
145 if (words.length === 1 && first === 'copiar') return { text: await copyLastReply($) }
146 if (words.length === 1 && first === 'resumo') {
147 return { text: await gated($, 'resumo', async () => summaryCommand($, cachedEnv()?.cwd ?? null, await sessionOffset($))) }
148 }
149 if (words.length === 1 && first === 'status') return { text: await statusCommand($, e.presentation.isFullscreen) }
150 if (words.length === 1 && first === 'painel') {
151 return { text: (await toggleSessionPane($)) ? tr('Session pane open.', 'Painel da sessão aberto.') : tr('Session pane closed.', 'Painel da sessão fechado.') }
152 }
153 if (words.length === 1 && first === 'foco') {
154 const isOn = !(await getStoredFlags($)).foco
155 await setFlag($, 'foco', isOn)
156 return { text: isOn ? tr('Focus mode on.', 'Modo foco ligado.') : tr('Focus mode off.', 'Modo foco desligado.') }
157 }
158 if (first === 'explica' && (words.length === 1 || !(words.length === 2 && (second === 'on' || second === 'off')))) {
159 // What follows the word keeps its case: it is the text to explain.
160 const given = e.args.trim().slice(typed.length)
161 return { text: await gated($, 'explica', () => explainCommand($, given)) }
162 }
163 if (first === 'pergunta') {
164 const question = e.args.trim().slice(typed.length).trim()
165 return { text: await gated($, 'agente', () => askCommand($, question)) }
166 }
167 if (words.length === 1 && first === 'demo') {
168 if (!(await getFlags($)).demo) return { text: featureOff('demo', await isMasterOn($)) }
169 await demoCommand($)
170 return {}
171 }
172 if (words.length === 1 && first === 'print') return { text: await gated($, 'print', () => printCommand($)) }
173 if (words.length === 1 && first === 'fixar') return { text: await gated($, 'fixar', () => pinCommand($)) }
174 if (words.length === 2 && isFlagId(first) && (second === 'on' || second === 'off')) {
175 await applyFlag($, first, second === 'on')
176 const label = pick(flagSpec(first).label)
177 return { text: second === 'on' ? tr(`On: ${label}.`, `Ligado: ${label}.`) : tr(`Off: ${label}.`, `Desligado: ${label}.`) }
178 }
179 const names = FLAGS.map(flagName).join(', ')
180 return { text: tr(`Usage: /realce ${usage()} (got "${e.args.trim()}"). Features: ${names}.`, `Uso: /realce ${usage()} (recebi "${e.args.trim()}"). Recursos: ${names}.`) }
181 })
182}
183hooks/events.ts 118 lines1import type { On } from 'claude-code'
2
3import { codeAppend, codeSessionStart, queueTranscriptDiagrams } from './code/events'
4import { registerRealceCommand } from './commands'
5import { recordEnv } from './env'
6import { startPerfLog } from './perf'
7import { refreshPromptSection } from './prompt'
8import { orientAppend, orientPromptSubmit, orientSessionStart, orientToolCall, orientTurnComplete, orientTurnStep } from './orient/events'
9import { screenPromptSubmit, screenSessionStart, screenToolCall, screenTurnComplete, screenTurnStart } from './screen/events'
10import { clearRunning, spinnerToolEnd, spinnerToolStart } from './screen/spinner'
11import { takeDemo, welcomeHint } from './system/demo'
12import { checkUpdate } from './system/update'
13import { refreshLanguage } from './i18n'
14import { retitleSessionPane } from './orient/paneToggle'
15import { askPromptSubmit, askTurnEnd } from './orient/ask'
16import { refreshTheme } from './theme'
17import { guard } from './safe'
18
19// The engine takes one hook per event without a matcher in a module, and follows $ only into declared
20// functions: each shared event is hooked once here and calls, by name, the handler of every area that listens.
21
22export function registerEvents(on: On): void {
23 on('session.start', async ($, e, next) => {
24 // First: every drawing after this one paints in the person's theme.
25 await guard($, 'session.start (theme)', () => refreshTheme($))
26 // Before the command registers: its description speaks the language.
27 await guard($, 'session.start (language)', async () => void (await refreshLanguage($)))
28 await guard($, 'session.start (env)', () => recordEnv($, e))
29 await guard($, 'session.start (command)', () => registerRealceCommand($))
30 await guard($, 'session.start (prompt)', async () => refreshPromptSection($))
31 await guard($, 'session.start (perf)', () => startPerfLog($))
32 await guard($, 'session.start (code)', () => codeSessionStart($, e))
33 await guard($, 'session.start (screen)', () => screenSessionStart($, e))
34 await guard($, 'session.start (spinner)', () => clearRunning($))
35 await guard($, 'session.start (guide)', () => orientSessionStart($, e))
36 await guard($, 'session.start (welcome)', () => welcomeHint($, e))
37 await guard($, 'session.start (updates)', () => checkUpdate($, e))
38 return next(e)
39 })
40
41 // The theme picked in /config (or by a plugin): repaint, and draw the transcript's diagrams in it.
42 on('config.set', { key: 'theme' }, async ($, e, next) => {
43 const result = await next(e)
44 if (result.deny === undefined && typeof result.value === 'string') {
45 const value = result.value
46 await guard($, 'config.set (theme)', () => refreshTheme($, value))
47 await guard($, 'config.set (diagrams)', () => queueTranscriptDiagrams($))
48 }
49 return result
50 })
51
52 on('config.set', { key: 'language' }, async ($, e, next) => {
53 const result = await next(e)
54 if (result.deny === undefined) {
55 const value = result.value
56 await guard($, 'config.set (language)', async () => {
57 if (!(await refreshLanguage($, value))) return
58 await registerRealceCommand($)
59 await retitleSessionPane($)
60 })
61 }
62 return result
63 })
64
65 on('prompt.submit', async ($, e, next) => {
66 await guard($, 'prompt.submit (screen)', () => screenPromptSubmit($, e))
67 await guard($, 'prompt.submit (guide)', () => orientPromptSubmit($, e))
68 const result = await next(e)
69 askPromptSubmit(e, result)
70 return result
71 })
72
73 on('tool.call', async ($, e, next) => {
74 await guard($, 'tool.call (spinner)', () => spinnerToolStart($, e))
75 let result: Awaited<ReturnType<typeof next>>
76 try {
77 result = await next(e)
78 } finally {
79 await guard($, 'tool.call (spinner)', () => spinnerToolEnd($, e))
80 }
81 await guard($, 'tool.call (screen)', () => screenToolCall($, e, result))
82 await guard($, 'tool.call (guide)', () => orientToolCall($, e, result))
83 return result
84 })
85
86 on('turn.complete', async ($, e, next) => {
87 let result: Awaited<ReturnType<typeof next>>
88 try {
89 result = await next(e)
90 } finally {
91 askTurnEnd(e)
92 }
93 await guard($, 'turn.complete (screen)', () => screenTurnComplete($, e))
94 await guard($, 'turn.complete (spinner)', () => clearRunning($, e.agentId))
95 await guard($, 'turn.complete (guide)', () => orientTurnComplete($, e))
96 return result
97 })
98
99 on('session.append', async ($, e, next) => {
100 const stored = await next(e)
101 await guard($, 'session.append (code)', () => codeAppend($, e, stored))
102 await guard($, 'session.append (guide)', () => orientAppend($, e, stored))
103 return stored
104 })
105
106 // A main turn's first step: its stats open if no prompt.submit did, then the demo, when its own turn, answers it.
107 on('turn.step', async function* ($, e, next) {
108 await guard($, 'turn.step (screen)', () => screenTurnStart($, e))
109 await guard($, 'turn.step (guide)', () => orientTurnStep($, e))
110 // Only a turn's first step: a prompt that joined a running turn must not cut that turn's work short.
111 const text = e.agentId === undefined && e.index === 0 ? await takeDemo($) : null
112 if (text === null) return yield* next(e)
113 yield { kind: 'text' as const, index: 0, text }
114 yield { kind: 'stop' as const, stopReason: 'end_turn' as const, usage: null }
115 return { turnId: e.turnId, index: e.index, answer: text, toolUses: [], stopReason: 'end_turn' as const, usage: null }
116 })
117}
118hooks/orient/register.ts 25 lines1import type { On } from 'claude-code'
2
3import { registerReader } from './ask'
4import { registerAway } from './away'
5import { registerBand } from './band'
6import { registerFlagsPane } from './flagsPane'
7import { registerLimits } from './limits'
8import { registerNotify } from './notify'
9import { registerPaneToggle } from './paneToggle'
10import { registerQuestion } from './question'
11import { registerSessionPane } from './pane'
12
13/** The orient area's hooks with a matcher (drawings, its own events); shared events go through orient/events.ts. */
14export function registerOrient(on: On): void {
15 registerBand(on)
16 registerSessionPane(on)
17 registerNotify(on)
18 registerAway(on)
19 registerQuestion(on)
20 registerLimits(on)
21 registerFlagsPane(on)
22 registerPaneToggle(on)
23 registerReader(on)
24}
25hooks/prompt.ts 101 lines1import type { EngineInterface, On, PromptComposeSection } from 'claude-code'
2
3import { findMermaid } from './env'
4import { getFlags, type Flags } from './flags'
5import { ensureLanguage, pick, type Text } from './i18n'
6import { logLine, redraw } from './io'
7import { errorText } from './safe'
8
9export const SECTION_ID = 'realce:reading-aids'
10
11// What the last system prompt render decided, for `/realce status`. Kept in the module, not the store: a store
12// write on every request would redraw every drawing that reads the store.
13type LastCompose = { at: string; surfaces: readonly string[]; traits: readonly string[]; isAdded: boolean }
14let lastCompose: LastCompose | undefined
15
16export const lastComposed = (): LastCompose | undefined => lastCompose
17
18// The section is English for the model; the words it is taught to write (callout tags, the change phrase) are the
19// interface's, so they read right in the person's language.
20const AIDS: readonly { flag: keyof Flags; needsMermaid?: true; text: string | Text }[] = [
21 {
22 flag: 'enfase',
23 text: `- \`**Label:**\` opening a line or list item: a subtopic label.
24- \`**term**\` mid-sentence: a key term.
25- \`==…==\` around the one most important fact of the reply, at most two per reply: \`==the cache was never read==\`.
26- \`{+…+}\` around a good outcome (tests pass, fixed, improved): \`{+all 342 tests pass+}\`.
27- \`{-…-}\` around a bad outcome (a failure, a regression, a bug found): \`{-the build fails-}\`.
28- \`{!…!}\` around what needs the person's attention or decision (pending, a GO, a risk): \`{!waiting for your GO!}\`.
29A mark wraps the few words it colors, never a tag in front of a sentence; never nest them, never put them inside code spans or blocks.`,
30 },
31 {
32 flag: 'callouts',
33 text: {
34 en: '- A quote opening with `[!DECISION]` (a decision only the person can make), `[!RISK]` (a risk or a regression), `[!DONE]` (a delivered result), `[!SUMMARY]` (the summary of a long reply, placed first), `[!NOTE]` or `[!TIP]` becomes a colored box; the rest of that first line is its title. At most two per reply.',
35 pt: '- A quote opening with `[!DECISÃO]` (a decision only the person can make), `[!RISCO]` (a risk or a regression), `[!FEITO]` (a delivered result), `[!RESUMO]` (the summary of a long reply, placed first), `[!NOTA]` or `[!DICA]` becomes a colored box; the rest of that first line is its title. At most two per reply.',
36 },
37 },
38 { flag: 'tabelas', text: '- Prefer a markdown table to compare three or more items across the same attributes.' },
39 {
40 flag: 'selo',
41 text: '- The terminal seals each turn you end by saying the work is done, fixed or passing: ✔ when tests ran after your last edit and passed, ⚠ when none ran since it, ✖ when they failed. Run the tests before you claim it.',
42 },
43 {
44 flag: 'deltas',
45 text: {
46 en: '- Write a change as `from X to Y`, or `X ms → Y ms` with a unit, both numbers in the same unit; the terminal adds the difference. An arrow between bare numbers stays a mapping (input → output) and gets none.',
47 pt: '- Write a change as `de X para Y`, or `X ms → Y ms` with a unit, both numbers in the same unit; the terminal adds the difference. An arrow between bare numbers stays a mapping (input → output) and gets none.',
48 },
49 },
50 {
51 flag: 'diagramas',
52 needsMermaid: true,
53 text: '- When a flow, an architecture or a sequence is clearer as a picture, draw a ```mermaid block (flowchart or sequenceDiagram, up to ~15 nodes); the terminal shows it as an image.',
54 },
55]
56
57/** The system prompt section that teaches the reading aids switched on, or null with none of them on. */
58export function buildSection(flags: Flags, hasMermaid: boolean): PromptComposeSection | null {
59 const aids = AIDS.filter(a => flags[a.flag] && (a.needsMermaid !== true || hasMermaid))
60 if (aids.length === 0) return null
61 return {
62 id: SECTION_ID,
63 scope: 'session',
64 text: [
65 '# Reading aids (realce)',
66 'The person reads your replies in a terminal that draws these marks. Use them only in prose addressed to the person, never in files, code, commit messages, PR text or tool inputs.',
67 ...aids.map(a => (typeof a.text === 'string' ? a.text : pick(a.text))),
68 'Leave most lines unmarked: marks guide the eye only while they are rare.',
69 ].join('\n'),
70 }
71}
72
73/**
74 * Asks the engine to re-read its cached prompt sections, so a flag change can reach the next request. A session
75 * the mod was hot-loaded into keeps the main loop's prompt as rendered at its start (seen 19-10): there the aids
76 * reach the model from the next session on.
77 */
78export function refreshPromptSection($: EngineInterface): void {
79 redraw($, 'prompt.section')
80}
81
82export function registerPrompt(on: On): void {
83 on('prompt.compose', async ($, e, next) => {
84 const composed = await next(e)
85 // Marks only pay off where this mod draws them: the terminal.
86 // A teammate writes to its lead, not to the person reading the screen: no marks for it.
87 const isTaught = e.surfaces.includes('terminal') && !e.traits.includes('bare') && !e.traits.includes('teammate')
88 // The session's first compose runs before session.start has recorded the env, and that prompt is the one the
89 // session keeps: ask for mmdc directly, or no session is ever taught diagrams. A refused `which` is logged by
90 // session.start (the same promise) and takes only the diagrams with it, not the marks.
91 const hasMermaid = isTaught && (await findMermaid($).then(m => m !== null, () => false))
92 // The same goes for the language: the tags and phrases taught are the person's. A setting that cannot be read
93 // is said, and the section goes out in the language in force.
94 if (isTaught) await ensureLanguage($).catch(error => logLine($, `realce: language not read (${errorText(error)})`))
95 const section = isTaught ? buildSection(await getFlags($), hasMermaid) : null
96 // A /context measure sends nothing: it is not the last request /realce status speaks of.
97 if (!e.traits.includes('analysis')) lastCompose = { at: new Date(await $.clock.now()).toISOString(), surfaces: e.surfaces, traits: e.traits, isAdded: section !== null }
98 return section === null ? composed : { sections: [...composed.sections, section] }
99 })
100}
101hooks/screen/register.ts 17 lines1import type { On } from 'claude-code'
2
3import { registerAgentCard } from './agentCard'
4import { registerDiff } from './diff'
5import { registerSpinner } from './spinner'
6import { registerTools } from './tools'
7import { registerTurns } from './turns'
8
9/** The screen area's hooks with a matcher (drawings, its own events); shared events go through screen/events.ts. */
10export function registerScreen(on: On): void {
11 registerTools(on)
12 registerDiff(on)
13 registerTurns(on)
14 registerAgentCard(on)
15 registerSpinner(on)
16}
17hooks/text/register.ts 64 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderSurface } from 'claude-code'
3
4import { isMermaid } from '../code/diagram'
5import { pathHref } from '../code/links'
6import { cachedEnv } from '../env'
7import { getFlags, type FlagId } from '../flags'
8import { memoLatest } from '../memo'
9import { FOLD_AT } from '../layout'
10import { parseBlocks } from './blocks'
11import type { RenderCtx } from './ctx'
12import { toPlainMarkdown } from './inline'
13import { renderReply } from './render'
14import { timed } from '../perf'
15import { tr } from '../i18n'
16import { copyText, toast } from '../io'
17
18const DIAGRAMS = atom({ plugin: 'realce', key: 'diagrams' } as const, {})
19// One member per code block of a reply, `${requestId}:${index}`: whether it is unfolded.
20const FOLDS = { plugin: 'realce', key: 'folds' } as const
21
22// With all of these off the reply is the engine's own drawing.
23const REPLY_FLAGS: readonly FlagId[] = ['enfase', 'coluna', 'callouts', 'tabelas', 'deltas', 'respiro', 'highlight', 'copiar', 'dobrar', 'caminhos', 'diagramas', 'numeros']
24
25export function registerText(on: On): void {
26 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => timed('AssistantMessage', async () => {
27 const flags = await getFlags($)
28 // Everywhere this mod does not draw, the typed marks still read as plain bold.
29 if (e.surface !== 'terminal' || e.props.isSummary === true || !REPLY_FLAGS.some(id => flags[id])) {
30 return next({ ...e, props: { ...e.props, text: toPlainMarkdown(e.props.text) } })
31 }
32 // Same text, same blocks: a scroll redraws a reply many times over, and a streaming reply keeps one parse.
33 const blocks = memoLatest('blocks', e.requestId, e.props.text, () => parseBlocks(e.props.text))
34 const code = blocks.flatMap(b => (b.type === 'code' ? [b] : []))
35 // Only a block long enough to fold has a fold to read; only a mermaid block has a diagram. One round trip.
36 const folded = code.flatMap((b, i) => (b.source.split('\n').length > FOLD_AT ? [i] : []))
37 const [folds, diagrams] = await Promise.all([
38 Promise.all(folded.map(i => read($, { ...FOLDS, id: `${e.requestId}:${i}` }))),
39 code.some(b => isMermaid(b.language)) ? read($, DIAGRAMS) : Promise.resolve({}),
40 ])
41 const openFolds = new Set(folded.filter((_, n) => folds[n] === true))
42 const env = cachedEnv()
43 const ctx: RenderCtx = {
44 t: $.ui.resolve(e),
45 flags,
46 columns: e.viewport?.columns ?? 80,
47 requestId: e.requestId,
48 openFolds,
49 diagrams,
50 env,
51 hrefForPath: path => (flags.caminhos && env !== null ? pathHref(path, env) : null),
52 onCopy: source => void copySource($, source, e.surface),
53 text: e.props.text,
54 onFold: (index, isOpen) => void update($, { ...FOLDS, id: `${e.requestId}:${index}` }, () => isOpen),
55 }
56 return renderReply(ctx, blocks)
57 }))
58}
59
60async function copySource($: EngineInterface, text: string, surface: RenderSurface): Promise<void> {
61 const copied = await copyText($, { text, surface })
62 toast($, copied.isCopied ? tr('Copied.', 'Copiado.') : tr(`Could not copy: ${copied.reason}.`, `Não deu para copiar: ${copied.reason}.`))
63}
64hooks/flags.ts 168 lines1import type { EngineInterface } from 'claude-code'
2
3import { languageVersion, redrawAll, setLanguage, tr, type Lang, type Text } from './i18n'
4import { storeGet, storeSet } from './io'
5import { asRecord } from './safe'
6
7export type FlagId =
8 | 'enfase' | 'coluna' | 'callouts' | 'tabelas' | 'deltas' | 'respiro'
9 | 'highlight' | 'copiar' | 'dobrar' | 'caminhos' | 'diagramas'
10 | 'ferramentas' | 'placar' | 'separador' | 'agentes'
11 | 'pendencias' | 'plano' | 'painel' | 'spinner'
12 | 'numeros' | 'selo' | 'diff' | 'erros' | 'commits' | 'foco'
13 | 'avisos' | 'som' | 'limite' | 'ausencia' | 'linha' | 'perguntas' | 'fixar' | 'explica' | 'agente'
14 | 'tema' | 'print' | 'demo' | 'atualizacao' | 'resumo'
15
16type FlagGroup = 'text' | 'code' | 'tools' | 'guide' | 'system'
17
18// `id` is the key the store keeps (the first words were Portuguese and stay, so nobody's choices are lost); `alias` is
19// its English name. Commands take either.
20type FlagSpec = { id: FlagId; alias: string; group: FlagGroup; label: Text; description: Text; isDefaultOn: boolean }
21
22export type Flags = Readonly<Record<FlagId, boolean>>
23
24export const FLAGS: readonly FlagSpec[] = [
25 { id: 'enfase', alias: 'emphasis', group: 'text', label: { en: 'Colored emphasis', pt: 'Ênfase colorida' }, description: { en: 'Claude\'s marks and automatic colors for numbers, paths and status', pt: 'Marcas do Claude e cores automáticas para números, caminhos e status' }, isDefaultOn: true },
26 { id: 'coluna', alias: 'column', group: 'text', label: { en: 'Reading column', pt: 'Coluna de leitura' }, description: { en: 'Prose kept to 100 columns', pt: 'Prosa limitada a 100 colunas' }, isDefaultOn: true },
27 { id: 'callouts', alias: 'callouts', group: 'text', label: { en: 'Callouts', pt: 'Callouts' }, description: { en: 'Boxes for decision, risk, done, summary, note and tip', pt: 'Caixas para decisão, risco, feito, resumo, nota e dica' }, isDefaultOn: true },
28 { id: 'tabelas', alias: 'tables', group: 'text', label: { en: 'Colored tables', pt: 'Tabelas coloridas' }, description: { en: 'Highlighted header, striped rows, numbers on the right', pt: 'Cabeçalho em destaque, linhas alternadas, números à direita' }, isDefaultOn: true },
29 { id: 'deltas', alias: 'deltas', group: 'text', label: { en: 'Automatic deltas', pt: 'Deltas automáticos' }, description: { en: '"from 87 to 106" gets (+19)', pt: '"de 87 para 106" ganha (+19)' }, isDefaultOn: true },
30 { id: 'respiro', alias: 'spacing', group: 'text', label: { en: 'Room between items', pt: 'Respiro entre itens' }, description: { en: 'A blank line between list items that wrap', pt: 'Linha em branco entre itens de lista que quebram linha' }, isDefaultOn: true },
31 { id: 'numeros', alias: 'numbers', group: 'text', label: { en: 'Human numbers', pt: 'Números humanos' }, description: { en: '"102761 ms" gets "(1m 42s)" beside it', pt: '"102761 ms" ganha "(1m 42s)" ao lado' }, isDefaultOn: true },
32 { id: 'highlight', alias: 'highlight', group: 'code', label: { en: 'Own highlighter', pt: 'Highlighter próprio' }, description: { en: 'Neon colors for ts, py, sh, json, yaml, swift, sql and diff', pt: 'Cores neon em ts, py, sh, json, yaml, swift, sql e diff' }, isDefaultOn: true },
33 { id: 'copiar', alias: 'copy', group: 'code', label: { en: 'One-click copy', pt: 'Copiar com um clique' }, description: { en: 'A copy button on every code block and on long replies', pt: 'Botão copiar em cada bloco de código e nas respostas longas' }, isDefaultOn: true },
34 { id: 'dobrar', alias: 'fold', group: 'code', label: { en: 'Fold what is long', pt: 'Dobrar o que é longo' }, description: { en: 'Blocks over 25 lines start folded', pt: 'Blocos com mais de 25 linhas aparecem dobrados' }, isDefaultOn: true },
35 { id: 'caminhos', alias: 'paths', group: 'code', label: { en: 'Clickable paths', pt: 'Caminhos clicáveis' }, description: { en: 'Paths open in the editor, at the line', pt: 'Caminhos abrem no editor, na linha' }, isDefaultOn: true },
36 { id: 'diagramas', alias: 'diagrams', group: 'code', label: { en: 'Diagrams', pt: 'Diagramas' }, description: { en: 'Mermaid blocks become pictures', pt: 'Blocos mermaid viram imagem' }, isDefaultOn: true },
37 { id: 'ferramentas', alias: 'tools', group: 'tools', label: { en: 'Tools by kind', pt: 'Ferramentas por categoria' }, description: { en: 'An icon and a color per kind; reads dimmed', pt: 'Ícone e cor por tipo; leituras esmaecidas' }, isDefaultOn: true },
38 { id: 'placar', alias: 'score', group: 'tools', label: { en: 'Test score', pt: 'Placar de testes' }, description: { en: 'The test summary above the Bash output', pt: 'Resumo dos testes acima da saída do Bash' }, isDefaultOn: true },
39 { id: 'separador', alias: 'rule', group: 'tools', label: { en: 'Turn rule', pt: 'Separador de turno' }, description: { en: 'A rule with the turn\'s time, duration and numbers', pt: 'Régua com hora, duração e números do turno' }, isDefaultOn: true },
40 { id: 'agentes', alias: 'agents', group: 'tools', label: { en: 'Agent cards', pt: 'Cartões de agente' }, description: { en: 'A background agent ends in a compact card', pt: 'Agente em segundo plano termina num cartão compacto' }, isDefaultOn: true },
41 { id: 'selo', alias: 'seal', group: 'tools', label: { en: 'Proof seal', pt: 'Selo de prova' }, description: { en: 'The turn\'s rule says whether what Claude claimed was tested', pt: 'A régua do turno diz se o que o Claude afirmou foi testado' }, isDefaultOn: true },
42 { id: 'diff', alias: 'diff', group: 'tools', label: { en: 'Readable diff', pt: 'Diff legível' }, description: { en: 'Edits with word highlights and line numbers', pt: 'Edições com destaque por palavra e números de linha' }, isDefaultOn: true },
43 { id: 'erros', alias: 'errors', group: 'tools', label: { en: 'Explained errors', pt: 'Erro explicado' }, description: { en: 'A failed command gets one sentence saying why (Haiku)', pt: 'Comando que falhou ganha uma frase dizendo por quê (Haiku)' }, isDefaultOn: false },
44 { id: 'commits', alias: 'commits', group: 'tools', label: { en: 'Commit cards', pt: 'Cartão de commit' }, description: { en: 'Commit, push and PR become a one-line card', pt: 'Commit, push e PR viram um cartão de uma linha' }, isDefaultOn: true },
45 { id: 'foco', alias: 'focus', group: 'tools', label: { en: 'Focus mode', pt: 'Modo foco' }, description: { en: 'Hides the tools that finished without error', pt: 'Esconde as ferramentas concluídas sem erro' }, isDefaultOn: false },
46 { id: 'pendencias', alias: 'pending', group: 'guide', label: { en: 'Needs you', pt: 'Precisa de você' }, description: { en: 'A band above the prompt with what asks for your decision', pt: 'Faixa acima do prompt com o que pede sua decisão' }, isDefaultOn: false },
47 { id: 'plano', alias: 'plan', group: 'guide', label: { en: 'Plan progress', pt: 'Progresso do plano' }, description: { en: 'A bar with the tasks while Claude works', pt: 'Barra com as tarefas enquanto o Claude trabalha' }, isDefaultOn: true },
48 { id: 'painel', alias: 'pane', group: 'guide', label: { en: 'Pane at start', pt: 'Painel ao iniciar' }, description: { en: 'Opens the session\'s side pane by itself', pt: 'Abre o painel lateral da sessão sozinho' }, isDefaultOn: true },
49 { id: 'spinner', alias: 'spinner', group: 'guide', label: { en: 'Spinner that says what runs', pt: 'Spinner que diz o que roda' }, description: { en: '"Thinking", "Writing" and what runs now ("Running the tests", "Editing x.ts")', pt: '"Pensando", "Escrevendo" e o que roda agora ("Rodando os testes", "Editando x.ts")' }, isDefaultOn: true },
50 { id: 'avisos', alias: 'notify', group: 'guide', label: { en: 'Notify when done', pt: 'Aviso quando termina' }, description: { en: 'A macOS notification at the end of a long turn or on a question', pt: 'Notificação do macOS no fim de um turno longo ou numa pergunta' }, isDefaultOn: true },
51 { id: 'som', alias: 'sound', group: 'guide', label: { en: 'Notification sound', pt: 'Som nos avisos' }, description: { en: 'A short sound with the notification', pt: 'Um som curto junto da notificação' }, isDefaultOn: false },
52 { id: 'ausencia', alias: 'away', group: 'guide', label: { en: 'While you were away', pt: 'Enquanto você estava fora' }, description: { en: 'What happened, once you start typing again', pt: 'Resumo do que aconteceu quando você volta a digitar' }, isDefaultOn: true },
53 { id: 'linha', alias: 'timeline', group: 'guide', label: { en: 'Timeline', pt: 'Linha do tempo' }, description: { en: 'The turns in the pane, a click scrolls to each', pt: 'Os turnos no painel, com clique para rolar até cada um' }, isDefaultOn: true },
54 { id: 'perguntas', alias: 'questions', group: 'guide', label: { en: 'Question header', pt: 'Cabeçalho de pergunta' }, description: { en: 'A colored line above Claude\'s questions', pt: 'Uma linha colorida acima das perguntas do Claude' }, isDefaultOn: true },
55 { id: 'fixar', alias: 'pin', group: 'guide', label: { en: 'Pin replies', pt: 'Fixar respostas' }, description: { en: '/realce pin keeps a reply in the pane', pt: '/realce fixar guarda uma resposta no painel' }, isDefaultOn: true },
56 { id: 'explica', alias: 'explain', group: 'guide', label: { en: 'Explain this', pt: 'Explica isso' }, description: { en: '/realce explain explains the selected text (Haiku)', pt: '/realce explica explica o trecho selecionado (Haiku)' }, isDefaultOn: true },
57 { id: 'tema', alias: 'theme', group: 'system', label: { en: 'Follow the theme', pt: 'Segue o tema' }, description: { en: 'Colors for light backgrounds, color blindness and ANSI, from Claude Code\'s theme', pt: 'Cores para fundo claro, daltônicos e ANSI, pelo tema do Claude Code' }, isDefaultOn: true },
58 { id: 'print', alias: 'print', group: 'system', label: { en: 'Pretty print', pt: 'Print bonito' }, description: { en: '/realce print saves the last reply as a picture', pt: '/realce print salva a última resposta como imagem' }, isDefaultOn: true },
59 { id: 'demo', alias: 'demo', group: 'system', label: { en: 'Demo', pt: 'Demonstração' }, description: { en: '/realce demo shows everything the mod does', pt: '/realce demo mostra tudo o que o mod faz' }, isDefaultOn: true },
60 { id: 'atualizacao', alias: 'updates', group: 'system', label: { en: 'Update notice', pt: 'Aviso de atualização' }, description: { en: 'Says when a new version is out on GitHub', pt: 'Avisa quando sair versão nova no GitHub' }, isDefaultOn: true },
61 { id: 'resumo', alias: 'summary', group: 'system', label: { en: 'Session summary', pt: 'Resumo da sessão' }, description: { en: '/realce summary copies a summary of the session in markdown', pt: '/realce resumo copia um resumo da sessão em markdown' }, isDefaultOn: true },
62 // Added after the 36 keys were given out: last in the table, so no earlier flag's key moves.
63 { id: 'limite', alias: 'limits', group: 'guide', label: { en: 'Usage and limits', pt: 'Uso e limites' }, description: { en: 'Usage in the pane; a band when a limit or the context nears its end', pt: 'Uso no painel; faixa quando um limite ou o contexto se aproxima do fim' }, isDefaultOn: true },
64 { id: 'agente', alias: 'agent', group: 'guide', label: { en: 'Agent in the pane', pt: 'Agente no painel' }, description: { en: 'Ask about the session in the pane; the answer goes to the chat in one press', pt: 'Pergunte sobre a sessão no painel; a resposta vai ao chat com um clique' }, isDefaultOn: true },
65]
66
67export const FLAG_GROUPS: readonly FlagGroup[] = ['text', 'code', 'tools', 'guide', 'system']
68
69export const GROUP_LABELS: Readonly<Record<FlagGroup, Text>> = {
70 text: { en: 'Text', pt: 'Texto' },
71 code: { en: 'Code', pt: 'Código' },
72 tools: { en: 'Tools', pt: 'Ferramentas' },
73 guide: { en: 'Guidance', pt: 'Orientação' },
74 system: { en: 'System', pt: 'Sistema' },
75}
76
77export const isFlagId = (id: string): id is FlagId => FLAGS.some(f => f.id === id)
78
79/** A flag by its id or its English name. */
80export const flagOf = (word: string): FlagSpec | undefined => FLAGS.find(f => f.id === word || f.alias === word)
81
82/** The name a command takes for the flag, in the language in force. */
83export const flagName = (f: FlagSpec): string => tr(f.alias, f.id)
84
85export const flagSpec = (id: FlagId): FlagSpec => FLAGS.find(f => f.id === id)!
86
87/** The advice for a feature turned off by `/realce off`: its own flag's `on` would turn on nothing. */
88export const MASTER_OFF = () => tr('Realce is off: /realce on turns it on.', 'Realce desligado: /realce on liga.')
89
90/** What a feature's command answers while its flag is off, or while the whole mod is. */
91export function featureOff(id: FlagId, isMasterOn = true): string {
92 if (!isMasterOn) return MASTER_OFF()
93 const f = flagSpec(id)
94 return tr(`Feature off: ${f.label.en}. /realce ${f.alias} on turns it on.`, `Recurso desligado: ${f.label.pt}. /realce ${f.id} on liga.`)
95}
96
97// Read from the store once per load and kept here: only this module changes them, and each change redraws the
98// screen itself. A store read in every drawing made each hooked row a reader of the store, redrawn on every write.
99let snapshot: { stored: Flags; isMasterOn: boolean } | null = null
100let version = 0
101
102/** Changes whenever a flag or the language does: part of every memoized drawing's key. */
103export const flagsVersion = () => version + languageVersion()
104
105/** For a change that repaints without a flag changing (the theme). */
106export function bumpVersion(): void {
107 version += 1
108}
109
110const parseStored = (stored: unknown): Flags => {
111 const set = asRecord(stored)
112 return Object.fromEntries(FLAGS.map(f => [f.id, typeof set[f.id] === 'boolean' ? set[f.id] : f.isDefaultOn])) as Record<FlagId, boolean>
113}
114
115const ALL_OFF = Object.fromEntries(FLAGS.map(f => [f.id, false])) as Flags
116
117async function load($: EngineInterface): Promise<{ stored: Flags; isMasterOn: boolean }> {
118 if (snapshot === null) {
119 const [stored, master, language] = await Promise.all([storeGet($, 'flags'), storeGet($, 'master'), storeGet($, 'language')])
120 snapshot = { stored: parseStored(stored), isMasterOn: (master ?? true) === true }
121 // The language the person picked with /realce language rides the same read; none means Claude Code's.
122 setLanguage(language === 'en' || language === 'pt' ? language : null)
123 }
124 return snapshot
125}
126
127/** The flags as set, master switch aside: what the flags pane shows. */
128export async function getStoredFlags($: EngineInterface): Promise<Flags> {
129 return (await load($)).stored
130}
131
132/** The flags in force: with the master switch off, every one is off. */
133export async function getFlags($: EngineInterface): Promise<Flags> {
134 const { stored, isMasterOn } = await load($)
135 return isMasterOn ? stored : ALL_OFF
136}
137
138export async function setFlag($: EngineInterface, id: FlagId, value: boolean): Promise<void> {
139 const current = await load($)
140 // From what the store holds now, not this session's copy: another session's change since this one loaded stays.
141 const stored = { ...parseStored(await storeGet($, 'flags')), [id]: value }
142 snapshot = { ...current, stored }
143 version += 1
144 await storeSet($, 'flags', stored)
145 // A flag changes drawings all over the transcript, not only the ones that read it, and the aids the model is taught.
146 redrawAll($)
147}
148
149export async function isMasterOn($: EngineInterface): Promise<boolean> {
150 return (await load($)).isMasterOn
151}
152
153export async function setMaster($: EngineInterface, value: boolean): Promise<void> {
154 snapshot = { ...(await load($)), isMasterOn: value }
155 version += 1
156 await storeSet($, 'master', value)
157 redrawAll($)
158}
159
160/** `/realce language`: the person's pick kept in the store, or none to follow Claude Code's language again. */
161export async function pickLanguage($: EngineInterface, next: Lang | null): Promise<void> {
162 await load($)
163 // None is kept as null rather than deleted: load() reads anything but en or pt as following Claude Code.
164 await storeSet($, 'language', next)
165 if (!setLanguage(next)) return
166 redrawAll($)
167}
168hooks/i18n.ts 82 lines1import type { EngineInterface } from 'claude-code'
2import { configs, redraw } from './io'
3
4// The interface's language: Claude Code's own `language` setting, or the one the person picked with
5// `/realce language`. English unless Portuguese is asked for. A leaf module: everything reads it, it reads nothing.
6
7export type Lang = 'en' | 'pt'
8
9/** A text in both languages, side by side where it is used. */
10export type Text = { readonly en: string; readonly pt: string }
11
12let fromConfig: Lang = 'en'
13let picked: Lang | null = null
14let changes = 0
15let isConfigRead = false
16
17/** The language every drawing and answer speaks now. */
18export const lang = (): Lang => picked ?? fromConfig
19
20/** True when the person picked the language with `/realce language`; false while it follows Claude Code's. */
21export const isPicked = (): boolean => picked !== null
22
23/** Changes whenever the language does: part of every memoized drawing's key, through flagsVersion. */
24export const languageVersion = () => changes
25
26/** The text in the language in force. */
27export const tr = (en: string, pt: string): string => (lang() === 'pt' ? pt : en)
28
29export const pick = (text: Text): string => text[lang()]
30
31/** The text a table gives `key` in the language in force; a key the table does not know reads as it came. */
32export const pickOr = (table: Readonly<Record<string, Text>>, key: string): string => (Object.hasOwn(table, key) ? pick(table[key]!) : key)
33
34/** Claude Code's `language` value ("Brazilian Portuguese", "Portuguese", "pt-BR") as the interface's language. */
35export function languageOf(value: unknown): Lang {
36 // `pt`, `pt-BR` and `pt_BR` too: `\b` would miss the underscore, a word character.
37 return typeof value === 'string' && /portugu|^pt([-_]|$)/i.test(value.trim()) ? 'pt' : 'en'
38}
39
40const moved = (before: Lang): boolean => {
41 if (lang() === before) return false
42 changes += 1
43 return true
44}
45
46/** The person's own pick (`/realce language`), or null to follow Claude Code again; true when the language moved. */
47export function setLanguage(next: Lang | null): boolean {
48 const before = lang()
49 picked = next
50 return moved(before)
51}
52
53/** Claude Code's setting changed or was read; true when the language moved. */
54function setConfigLanguage(next: Lang): boolean {
55 const before = lang()
56 fromConfig = next
57 isConfigRead = true
58 return moved(before)
59}
60
61/**
62 * Claude Code's language, read once: the first prompt.compose runs before session.start, and the session keeps the
63 * prompt it composed.
64 */
65export async function ensureLanguage($: EngineInterface): Promise<void> {
66 if (!isConfigRead) await refreshLanguage($)
67}
68
69/** Reads Claude Code's language (or takes the value a `config.set` carries) and redraws what speaks it. */
70export async function refreshLanguage($: EngineInterface, value?: unknown): Promise<boolean> {
71 const setting = value ?? (await configs($)).find(row => row.key === 'language')?.value
72 if (!setConfigLanguage(languageOf(setting))) return false
73 redrawAll($)
74 return true
75}
76
77/** A change every drawing and the aids the model is taught may speak: both are drawn again. */
78export function redrawAll($: EngineInterface): void {
79 redraw($, 'ui.render')
80 redraw($, 'prompt.section')
81}
82hooks/orient/explain.ts 41 lines1import type { EngineInterface } from 'claude-code'
2
3import { tr, type Text } from '../i18n'
4import { addEntry, settleEntry } from './ask'
5import { showAgentTab } from './pane/tab'
6import { detach, selected } from '../io'
7import { askHaiku } from '../screen/explainError'
8
9const MAX_CHARS = 4000
10// The explanation comes in the interface's language.
11const SYSTEM: Text = {
12 en: 'Explain in plain English, in at most 4 sentences, for someone who is not a specialist.',
13 pt: 'Explique em português simples, em até 4 frases, para quem não é especialista.',
14}
15
16async function runExplain($: EngineInterface, id: string, excerpt: string): Promise<void> {
17 const startedAt = await $.clock.now()
18 const answer = await askHaiku($, SYSTEM, excerpt, 400, 30_000)
19 const ms = (await $.clock.now()) - startedAt
20 await settleEntry($, id, 'text' in answer ? { status: 'ready', text: answer.text, ms } : { status: 'failed', text: answer.reason, ms })
21}
22
23/**
24 * `/realce explica [texto]`: the text given, or else the selection, explained in the pane's Agent tab by a fast model,
25 * off the command. The text works where nothing can be selected (outside fullscreen).
26 */
27export async function explainCommand($: EngineInterface, given: string): Promise<string> {
28 const text = given.trim() !== '' ? given.trim() : ((await selected($))?.text.trim() ?? '')
29 if (text === '') {
30 return tr(
31 'Select some text in the conversation (in fullscreen) or write what to explain: /realce explain <text>.',
32 'Selecione um trecho da conversa (em tela cheia) ou escreva o que explicar: /realce explica <texto>.',
33 )
34 }
35 const excerpt = [...text].slice(0, MAX_CHARS).join('')
36 const entry = await addEntry($, 'explain', excerpt, 'pending')
37 await showAgentTab($)
38 detach($, () => void runExplain($, entry.id, excerpt))
39 return tr('Explaining in the pane.', 'Explicando no painel.')
40}
41hooks/orient/ask.ts 231 lines1import { atom, read, update } from 'claude-code'
2import type { Args, EngineInterface, ModelForkResult, ModelUsage, On, ResultOf } from 'claude-code'
3
4import type { AskMode, ChatEntry } from '../../types'
5import { pick, tr, type Text } from '../i18n'
6import { detach, exec, fork, listAgents, spawn } from '../io'
7import { errorText } from '../safe'
8import { featureOff, getFlags, isMasterOn } from '../flags'
9
10const CHAT = atom({ plugin: 'realce', key: 'chat' } as const, [])
11
12const MAX_ENTRIES = 20
13// The pane's own last exchanges ride along with a question, so "and the second one?" has something to point at.
14const CONTEXT_EXCHANGES = 3
15const MAX_QUESTION = 4000
16const DESCRIPTION_CHARS = 40
17
18// A fork answers over the session's own transcript, tools included: unframed, it takes the question as the next step
19// of the task and reaches for a tool it is denied (measured 21-10).
20const SESSION_FRAME: Text = {
21 en: 'This is a side question from a pane beside the session, not a step of the task: do not continue the task and do not call any tool. Answer in English, briefly (at most 8 short lines), in markdown.',
22 pt: 'Esta é uma pergunta lateral de um painel ao lado da sessão, não um passo da tarefa: não continue a tarefa e não chame ferramenta. Responda em português, curto (no máximo 8 linhas curtas), em markdown.',
23}
24const TOOLS_FRAME: Text = {
25 en: 'A question from the realce pane about the current project. Read what you need and answer in English, briefly (at most 10 short lines), in markdown.',
26 pt: 'Uma pergunta do painel do realce sobre o projeto atual. Leia o que precisar e responda em português, curto (no máximo 10 linhas curtas), em markdown.',
27}
28// Tools mode runs the plugin's own agent, `agents/reader.md`, whose only tool is Read: the engine refuses any other
29// call in every permission mode, where a prompt, an agent's permission mode or the plugin's own hooks (skipped for the
30// calls of an agent it started) do not hold under bypassPermissions (measured 22-10). With no shell to list the
31// project, the agent gets its files in the prompt.
32const READER = 'realce:reader'
33const MAX_FILES = 400
34
35// A fork in the middle of a turn answers about the turn's next step, slowly and uncached: a question waits for the end.
36let isMainTurnRunning = false
37let isFlushing = false
38let counter = 0
39/** Whether a subagent is one the pane started: its entry carries its id, across a reload too. */
40export async function isPanelAgent($: EngineInterface, agentId: string): Promise<boolean> {
41 return (await read($, CHAT)).some(c => c.agentId === agentId)
42}
43
44/** Fresh tokens of a call: what it read uncached, wrote to the cache and generated; the cache's reads cost little. */
45const freshTokens = (u: ModelUsage) => u.input_tokens + (u.cache_creation_input_tokens ?? 0) + u.output_tokens
46
47function failureText(reply: Exclude<ModelForkResult, { isAnswered: true }>): string {
48 if (reply.reason === 'nothing-to-fork') return tr('Nothing to ask about yet: wait for the first turn to end.', 'Nada para consultar ainda: espere o primeiro turno terminar.')
49 if (reply.reason === 'api-error') return tr(`API error (${reply.status ?? 'no answer'}: ${reply.error}).`, `Erro da API (${reply.status ?? 'sem resposta'}: ${reply.error}).`)
50 if (reply.reason === 'empty-reply') return tr('The model answered nothing.', 'O modelo não respondeu nada.')
51 return tr('Cut short.', 'Interrompida.')
52}
53
54/** What the fork reads after the transcript: the frame, the pane's last exchanges and the question. */
55export function sessionPrompt(history: readonly ChatEntry[], question: string): string {
56 const earlier = history.map(c => tr(`Earlier in the pane — Q: ${c.question}\nA: ${c.text}`, `Antes no painel — P: ${c.question}\nR: ${c.text}`))
57 return [pick(SESSION_FRAME), ...earlier, tr(`Question: ${question}`, `Pergunta: ${question}`)].join('\n\n')
58}
59
60const isWaiting = (c: ChatEntry) => c.status === 'queued' || c.status === 'pending'
61
62/** Past 20 entries the oldest answered ones go: one still waiting keeps its place, or its answer would land nowhere. */
63function keepNewest(chat: ChatEntry[]): ChatEntry[] {
64 let excess = chat.length - MAX_ENTRIES
65 if (excess <= 0) return chat
66 const kept = chat.filter(c => isWaiting(c) || excess-- <= 0)
67 return kept.slice(-MAX_ENTRIES)
68}
69
70/** Changes one entry; one the history already dropped stays dropped. */
71export async function settleEntry($: EngineInterface, id: string, change: Partial<ChatEntry>): Promise<void> {
72 await update($, CHAT, chat => (chat.some(c => c.id === id) ? chat.map(c => (c.id === id ? { ...c, ...change } : c)) : chat))
73}
74
75/** A new entry at the end of the history, the oldest past 20 dropped; resolves it. */
76export async function addEntry($: EngineInterface, mode: ChatEntry['mode'], question: string, status: ChatEntry['status']): Promise<ChatEntry> {
77 const at = await $.clock.now()
78 const entry: ChatEntry = { id: `${at}-${counter++}`, mode, question: [...question].slice(0, MAX_QUESTION).join(''), status, text: '', at }
79 await update($, CHAT, chat => keepNewest([...chat, entry]))
80 return entry
81}
82
83async function answerFromSession($: EngineInterface, entry: ChatEntry): Promise<void> {
84 const history = (await read($, CHAT)).filter(c => c.status === 'ready' && c.mode !== 'explain' && c.id !== entry.id).slice(-CONTEXT_EXCHANGES)
85 const startedAt = await $.clock.now()
86 let reply: ModelForkResult
87 try {
88 reply = await fork($, { prompt: sessionPrompt(history, entry.question) })
89 } catch (error) {
90 await settleEntry($, entry.id, { status: 'failed', text: errorText(error) })
91 return
92 }
93 const ms = (await $.clock.now()) - startedAt
94 if (reply.isAnswered) await settleEntry($, entry.id, { status: 'ready', text: reply.text.trim(), ms, tokens: freshTokens(reply.usage) })
95 else await settleEntry($, entry.id, { status: 'failed', text: failureText(reply), ms })
96}
97
98/** The project's files as git sees them, the first 400, for an agent that can only read; null where git lists none. */
99async function projectFiles($: EngineInterface): Promise<string | null> {
100 const run = await exec($, ['git', 'ls-files', '--cached', '--others', '--exclude-standard'], { timeoutMs: 5000 }).catch(() => null)
101 if (run === null || run.exitCode !== 0) return null
102 const files = run.stdout.split('\n').filter(line => line !== '')
103 const more = files.length - MAX_FILES
104 return [...files.slice(0, MAX_FILES), ...(more > 0 ? [tr(`… and ${more} more`, `… e mais ${more}`)] : [])].join('\n')
105}
106
107/** What the reader gets: the frame, the project's files when git lists them, and the question. */
108export function toolsPrompt(files: string | null, question: string): string {
109 const listing = files === null ? tr('No file list: git does not list this folder.', 'Sem lista de arquivos: o git não lista esta pasta.') : tr(`The project's files:\n${files}`, `Os arquivos do projeto:\n${files}`)
110 return [pick(TOOLS_FRAME), listing, tr(`Question: ${question}`, `Pergunta: ${question}`)].join('\n\n')
111}
112
113async function answerWithTools($: EngineInterface, entry: ChatEntry): Promise<void> {
114 const short = [...entry.question.replace(/\s+/g, ' ')].slice(0, DESCRIPTION_CHARS).join('')
115 try {
116 const prompt = toolsPrompt(await projectFiles($), entry.question)
117 const started = await spawn($, { prompt, description: `Realce: ${short}`, subagentType: READER })
118 if ('deny' in started && started.deny !== undefined) {
119 await settleEntry($, entry.id, { status: 'failed', text: tr(`Refused: ${started.deny}`, `Recusado: ${started.deny}`) })
120 return
121 }
122 if (!('agentId' in started) || started.agentId === undefined) {
123 await settleEntry($, entry.id, { status: 'failed', text: tr('No agent started.', 'Nenhum agente começou.') })
124 return
125 }
126 await settleEntry($, entry.id, { agentId: started.agentId })
127 } catch (error) {
128 await settleEntry($, entry.id, { status: 'failed', text: errorText(error) })
129 }
130}
131
132/** The reader is the pane's alone: the model's own list of agent types leaves it out. */
133export function registerReader(on: On): void {
134 on('agent.offer', { agent: READER }, () => ({ isOffered: false }))
135}
136
137/** Answers the queued questions one by one, while no main turn runs. */
138async function flushQueue($: EngineInterface): Promise<void> {
139 if (isFlushing) return
140 isFlushing = true
141 try {
142 for (;;) {
143 if (isMainTurnRunning) return
144 const next = (await read($, CHAT)).find(c => c.status === 'queued')
145 if (next === undefined) return
146 // Turned off while it waited (the feature or the whole mod): it is answered with that, never asked.
147 if (!(await getFlags($)).agente) {
148 await settleEntry($, next.id, { status: 'failed', text: featureOff('agente', await isMasterOn($)) })
149 continue
150 }
151 await settleEntry($, next.id, { status: 'pending' })
152 await answerFromSession($, next)
153 }
154 } finally {
155 isFlushing = false
156 }
157}
158
159/**
160 * A question to the pane's agent: over the session's own transcript (`session`, a fork, no tools), or by a subagent
161 * that may read the project (`tools`). Answered off the caller's dispatch; a session question waits for a running
162 * main turn to end.
163 */
164export async function ask($: EngineInterface, question: string, mode: AskMode): Promise<void> {
165 const text = question.trim()
166 if (text === '') return
167 const waits = mode === 'session' && isMainTurnRunning
168 const entry = await addEntry($, mode, text, waits ? 'queued' : 'pending')
169 if (waits) return
170 detach($, () => void (mode === 'session' ? answerFromSession($, entry) : answerWithTools($, entry)))
171}
172
173export async function clearChat($: EngineInterface): Promise<void> {
174 // An answer still on its way finds its entry gone: settleEntry leaves it out, and a queued one is never asked.
175 await update($, CHAT, chat => (chat.length === 0 ? chat : []))
176}
177
178/**
179 * At each load: a fork or a Haiku call the old load was waiting on died with it, and so may a subagent's end; queued
180 * questions go on. A turn still running shows at its next step and holds them again.
181 */
182export async function askSessionStart($: EngineInterface): Promise<void> {
183 isMainTurnRunning = false
184 const chat = await read($, CHAT)
185 const waitingAgents = chat.filter(c => c.status === 'pending' && c.agentId !== undefined)
186 const running = waitingAgents.length === 0 ? new Set<string>() : new Set((await listAgents($)).filter(a => a.status === 'running' || a.status === 'pending' || a.status === 'waiting').map(a => a.id))
187 const lost = (c: ChatEntry) => c.status === 'pending' && (c.agentId === undefined || !running.has(c.agentId))
188 if (chat.some(lost)) {
189 const cut = tr('Cut short by a reload.', 'Interrompida por um recarregamento.')
190 const gone = tr('The agent is no longer running.', 'O agente não está mais rodando.')
191 await update($, CHAT, entries => entries.map(c => (lost(c) ? { ...c, status: 'failed' as const, text: c.agentId === undefined ? cut : gone } : c)))
192 }
193 if (chat.some(c => c.status === 'queued')) detach($, () => void flushQueue($))
194}
195
196/** Any step of the main loop's turn (the first, or the next one after a reload): questions wait for its end. */
197export function askTurnStep(e: Args<'turn.step'>): void {
198 if (e.agentId === undefined) isMainTurnRunning = true
199}
200
201/**
202 * A prompt the session took starts a turn before its first step: a question asked in between waits too. Not a slash
203 * command (most ask no model; one that does shows at its first step), nor a prompt a hook dropped.
204 */
205export function askPromptSubmit(e: Args<'prompt.submit'>, result: ResultOf['prompt.submit']): void {
206 if (e.isMeta !== true && e.command === undefined && result.drop === undefined) isMainTurnRunning = true
207}
208
209/** The main turn is over, however it ended (a hook beneath that failed too): nothing holds the questions. */
210export function askTurnEnd(e: Args<'turn.complete'>): void {
211 if (e.agentId === undefined) isMainTurnRunning = false
212}
213
214/** A main turn ended: the queue goes. A subagent the pane started ended: its answer lands in its entry. */
215export async function askTurnComplete($: EngineInterface, e: Args<'turn.complete'>): Promise<void> {
216 if (e.agentId === undefined) {
217 detach($, () => void flushQueue($))
218 return
219 }
220 const agentId = e.agentId
221 const chat = await read($, CHAT)
222 const entry = chat.find(c => c.agentId === agentId && c.status === 'pending')
223 if (entry === undefined) return
224 const ms = (await $.clock.now()) - entry.at
225 const answer = e.answer.trim()
226 if (e.isAborted) await settleEntry($, entry.id, { status: 'failed', text: tr('The agent was stopped.', 'O agente foi interrompido.'), ms })
227 else if (e.reason === 'refusal') await settleEntry($, entry.id, { status: 'failed', text: tr(`The agent refused: ${e.refusal.explanation ?? e.refusal.category ?? '?'}`, `O agente recusou: ${e.refusal.explanation ?? e.refusal.category ?? '?'}`), ms })
228 else if (e.reason === 'error') await settleEntry($, entry.id, { status: 'failed', text: tr('The agent stopped on an error.', 'O agente parou num erro.'), ms })
229 else await settleEntry($, entry.id, answer === '' ? { status: 'failed', text: tr('The agent answered nothing.', 'O agente não respondeu nada.'), ms } : { status: 'ready', text: answer, ms })
230}
231hooks/orient/pane/tab.ts 18 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { PaneTab } from '../../../types'
5import { openSessionPane } from '../paneToggle'
6
7const TAB = atom({ plugin: 'realce', key: 'tab' } as const, 'overview')
8
9export async function setTab($: EngineInterface, tab: PaneTab): Promise<void> {
10 await update($, TAB, current => (current === tab ? current : tab))
11}
12
13/** The session pane open on its Agent tab; `focus` hands it the keyboard, for a question typed next. */
14export async function showAgentTab($: EngineInterface, focus = false): Promise<void> {
15 await setTab($, 'agent')
16 await openSessionPane($, focus)
17}
18