SLOPSHOPPER

realce

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

newpanebandspinnerrowsguard
★ 1v0.6.4MITupdated 2026-10-22Th3-W6rst/claude-realce
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · realce
│ ┃ Session ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 1: tab-overview 2: Agent 3: Files 3 4: Tu │ realce │ │ ┃ ● realce: realce: sess│ realce installed: /realce demo shows what │ │ ┃ Usage ⏺ Read(src/auth.ts) │ it does; /realce help lists the commands. │ │ ┃ Context ▰▰▰▰▰▱▱▱▱▱ 49% ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ 5 h ▰▰▰▱▱▱▱▱▱▱ 31% ✎ Edited /work/app/src/auth.ts │ ┃ ⏺ Bash(bun test) │ ┃ Failures (1) ⎿ 3 pass, 1 fail │ ┃ fail-toolu_06 │ ┃ 1 fail Done. I made refresh reject expired claims and added an audit call. │ ┃ Fix it → chat │ ┃ ── 42s · ⚠ no proof · 9 tools · 3 files ────────────────────────────… │ ┃ 3 · Files (3) · +14 −1 │ › /realce │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
1: tab-overview 2: Agent 3: Files 3 4: Turns Usage Context ▰▰▰▰▰▱▱▱▱▱ 49% 5 h ▰▰▰▱▱▱▱▱▱▱ 31% Failures (1) fail-toolu_06 1 fail Fix it → chat 3 · Files (3) · +14 −1
Pane · Realce
[x] Realce on Text a: [x] Colored emphasis b: [x] Reading column c: [x] Callouts d: [x] Colored tables e: [x] Automatic deltas f: [x] Room between items g: [x] Human numbers Code h: [x] Own highlighter i: [x] One-click copy j: [x] Fold what is long k: [x] Clickable paths l: [x] Diagrams Tools m: [x] Tools by kind n: [x] Test score o: [x] Turn rule p: [x] Agent cards q: [x] Proof seal r: [x] Readable diff s: [ ] Explained errors t: [x] Commit cards u: [ ] Focus mode Guidance v: [ ] Needs you w: [x] Plan progress x: [x] Pane at start y: [x] Spinner that says what runs z: [x] Notify when done 0: [ ] Notification sound 1: [x] While you were away 2: [x] Timeline 3: [x] Question header 4: [x] Pin replies 5: [x] Explain this [x] Usage and limits [x] Agent in the pane System 6: [x] Follow the theme 7: [x] Pretty print 8: [x] Demo 9: [x] Update notice [x] Session summary A letter or digit toggles · Tab and Enter too · Esc closes
Turn line
── 42s · ⚠ no proof · 9 tools · 3 files ───────────────────────────────────────────────────────────…
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
✎ Edited /work/app/src/auth.ts
README

realce

test

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:

  • colors by meaning in the replies, tables, decision and risk boxes, diagrams as pictures;
  • code with its own highlighter, and diffs with word highlights;
  • tools readable at a glance, and a proof seal that says whether what Claude claimed was tested;
  • a spinner that says what is running ("Running the tests (last: 25s)"), and, if you want it, why each failed command failed;
  • notices when Claude is done or asks something, and a session pane in tabs (overview, agent, files, turns) with an agent you ask about the session without stopping Claude, its answer one press away from the chat.

The interface follows Claude Code's language: English, or Portuguese for whoever set Claude Code to Portuguese (/realce language en|pt|auto picks one).

realce's demo

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

Requirements

  • Claude Code 2.1.304 or newer (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.
  • macOS for the notices, the sound, the print and the paths that open in the editor. The rest works on any system.
  • A terminal with 24-bit color (Ghostty, iTerm2, WezTerm, Kitty): echo $COLORTERM should answer truecolor.
  • Optional: mermaid-cli, for diagrams and for the PNG of /realce print (see Diagrams).

Install

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:

  • Everything comes on, except focus mode, the sound, explained errors (errors) and the "Needs you" band (pending): those four are a matter of taste, and the last two add lines to every turn.
  • The mod adds a short paragraph with the marks to the system prompt (only those of the features that are on).
  • Two features call Haiku on your account, about one to two thousand tokens each time: 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.

First steps

  1. Run /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.
  2. /realce opens the flags pane. /realce pane opens the session pane, which also opens by itself in a terminal 144 columns wide or more.
  3. Ask Claude: create sum.py with a sum function and a pytest test, and run the test. While the test runs, the spinner says "Running the tests" (and, the second time, how long the last run took). The line that closes the turn (the rule: time, duration, tools) gets the seal ✔ proved. If Claude edits and says it is done without testing, the seal is ⚠ no proof.
  4. /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.

In a session

A turn recorded in a real session

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 session where Claude fixes a failing test

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.

The same turn in the detailed transcript

Commands

CommandWhat it does
/realceOpens the flags pane: each feature has a key (a letter or digit), the last ones only Tab and Enter; Esc closes
/realce helpLists the commands
/realce doctorChecks what your machine gives realce (version, colors, macOS, fullscreen, editor, mermaid, Chrome) and what is missing
/realce on · /realce offTurns the whole mod on or off, keeping each flag's choice
/realce <flag> on · offTurns one feature on or off, for example /realce column off
/realce paneOpens 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 copyCopies Claude's last reply (the marks become plain markdown)
/realce summaryShows and copies a summary of the session in markdown: turns (time, duration, seal), tests, files, git, failures
/realce focusToggles 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 pinPins the selected reply, or the last one, in the pane; again, unpins it
/realce printSaves the last reply as HTML and PNG on the Desktop, in your theme's colors
/realce demoShows a reply with every feature, without calling the model
/realce statusWhat is off, language, theme, fullscreen, notices, updates and the cost of each drawing
/realce language en · pt · autoThe interface's language; auto follows Claude Code's

The pane's agent

The session pane's Agent tab answering about a failed test

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

Features and flags

GroupFlagFeatureWhat it doesDefault
Textemphasis (enfase)Colored emphasisClaude's marks and automatic colors for numbers, paths and statuson
Textcolumn (coluna)Reading columnProse kept to 100 columnson
TextcalloutsCalloutsBoxes for decision, risk, done, summary, note and tipon
Texttables (tabelas)Colored tablesHighlighted header, striped rows, numbers on the righton
TextdeltasAutomatic deltas"from 87 to 106" gets (+19)on
Textspacing (respiro)Room between itemsA blank line between list items that wrapon
Textnumbers (numeros)Human numbers"102761 ms" gets "(1m 42s)" beside iton
CodehighlightOwn highlighterNeon colors for ts, py, sh, json, yaml, swift, sql and diffon
Codecopy (copiar)One-click copyA copy button on every code block and on long replieson
Codefold (dobrar)Fold what is longBlocks over 25 lines start foldedon
Codepaths (caminhos)Clickable pathsPaths open in the editor, at the lineon
Codediagrams (diagramas)DiagramsMermaid blocks become pictureson
Toolstools (ferramentas)Tools by kindAn icon and a color per kind; reads dimmedon
Toolsscore (placar)Test scoreThe test summary above the Bash outputon
Toolsrule (separador)Turn ruleA rule with the turn's time, duration and numberson
Toolsagents (agentes)Agent cardsA background agent ends in a compact cardon
Toolsseal (selo)Proof sealThe turn's rule says whether what Claude claimed was testedon
ToolsdiffReadable diffEdits with word highlights and line numberson
Toolserrors (erros)Explained errorsA failed command gets one sentence saying why (Haiku)off
ToolscommitsCommit cardsCommit, push and PR become a one-line cardon
Toolsfocus (foco)Focus modeHides the tools that finished without erroroff
Guidancepending (pendencias)Needs youA band above the prompt with what asks for your decisionoff
Guidanceplan (plano)Plan progressA bar with the tasks while Claude workson
Guidancepane (painel)Pane at startOpens the session's side pane by itselfon
GuidancespinnerSpinner that says what runs"Thinking", "Writing" and what runs now ("Running the tests", "Editing x.ts")on
Guidancenotify (avisos)Notify when doneA macOS notification at the end of a long turn or on a questionon
Guidancesound (som)Notification soundA short sound with the notificationoff
Guidanceaway (ausencia)While you were awayWhat happened, once you start typing againon
Guidancetimeline (linha)TimelineThe turns in the pane, a click scrolls to eachon
Guidancequestions (perguntas)Question headerA colored line above Claude's questionson
Guidancepin (fixar)Pin replies/realce pin keeps a reply in the paneon
Guidanceexplain (explica)Explain this/realce explain explains the selected text (Haiku)on
Guidancelimits (limite)Usage and limitsUsage in the pane; a band when a limit or the context nears its endon
Guidanceagent (agente)Agent in the paneAsk about the session in the pane; the answer goes to the chat in one presson
Systemtheme (tema)Follow the themeColors for light backgrounds, color blindness and ANSI, from Claude Code's themeon
SystemprintPretty print/realce print saves the last reply as a pictureon
SystemdemoDemo/realce demo shows everything the mod doeson
Systemupdates (atualizacao)Update noticeSays when a new version is out on GitHubon
Systemsummary (resumo)Session summary/realce summary copies a summary of the session in markdownon

Marks Claude starts to use

MarkMeaningColor
Label: opening a lineSubtopicred
term mid-sentenceKey termmagenta
==fact==The most important fact of the replyyellow highlighter
{+text+}Went wellgreen
{-text-}Went wrongred on wine
{!text!}Needs your attention or decision; with pending on, it also goes up to the "Needs you" band above the promptyellow
> [!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.

Diagrams

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.

What it reads, runs and sends

  • Processes: 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.
  • Files: reads its own 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.
  • Settings: reads Claude Code's settings list ($.config.list) for its language and theme.
  • Store: its flags, the on/off switch, the language you picked, whether the welcome hint was shown, and when it last looked for an update and the version it found.
  • Session state ($.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.
  • Session and clipboard: reads the transcript and the selection (for copy, print, summary, explain and pin), the session's agents (the Overview's list, and whether a pane agent still runs after a reload), the session id (its perf file's name) and Claude Code's version (/realce doctor), and copies to the clipboard.
  • Network: one GET of its own 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).
  • Model: the Haiku, to explain a failed command (up to 20 each time the mod loads, with /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).
  • Prompt: one system prompt section that teaches Claude the marks; /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.
  • Environment: reads HOME and COLORTERM.

Every one of these calls goes through hooks/io.ts; claude plugin validate . lists them all.

Known limitations

  • Fullscreen ("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.
  • Notify when done: Claude Code does not say whether the window has focus; the notice goes out for turns longer than 1 minute and for questions asked after 1 minute of work. With no notification channel in the terminal (tmux, Terminal.app), it goes out as the macOS banner. The sound plays only with a notice that went out.
  • Proof seal: tests run by a command in the background do not count; the seal stays at "⚠ no proof". It reads pytest, jest, vitest, bun, mocha, cargo, go test, XCTest, Swift Testing, node --test, RSpec and Deno.
  • Rule after a background agent: Claude Code measures the turn an agent's notice opens from your last prompt, not that turn alone; its rule keeps the duration only.
  • Deltas: "from 87 to 106" and an arrow with a unit ("22 ms → 9 ms") get their difference; an arrow between bare numbers ("4 → 8") does not, since it is usually an input → output.
  • A diagram while the reply streams: until the reply is done, the mermaid block shows as code.
  • Questions: Claude Code does not let the question dialog be redrawn; the mod only adds a line above it.
  • Agent card: it depends on the notice's text; in a format the mod does not know, Claude Code's drawing stays.
  • ctrl+o: the detailed transcript keeps Claude Code's tool rows, groups and turn line, with the test score above a test run and the word diff for edits; focus mode and folding stay out of it. A tool group that holds more than its calls (an error, a recalled memory) keeps Claude Code's line too, with a failed command's sentence under it. A turn line that waits on background agents keeps Claude Code's words.
  • Other mods: the mod draws Claude's replies, tool rows and results, the turn's line and agent notices itself, in place of Claude Code's drawing, so another mod's drawing of the same item beneath it does not show while that feature's flag is on (/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.

License

MIT.

Source 74 files
hooks/register.tsx 19 lines
1import 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}
19
hooks/commands.ts 183 lines
1import 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}
183
hooks/events.ts 118 lines
1import 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}
118
hooks/orient/register.ts 25 lines
1import 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}
25
hooks/prompt.ts 101 lines
1import 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}
101
hooks/screen/register.ts 17 lines
1import 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}
17
hooks/text/register.ts 64 lines
1import { 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}
64
hooks/flags.ts 168 lines
1import 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}
168
hooks/i18n.ts 82 lines
1import 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}
82
hooks/orient/explain.ts 41 lines
1import 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}
41
hooks/orient/ask.ts 231 lines
1import { 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}
231
hooks/orient/pane/tab.ts 18 lines
1import { 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