Shows Cortex Hub tool calls above the prompt as a stacked bar, one color per category, with /cs session progress, tokens in and saved, and hit@k for code…

A Claude Code mod that draws this session's Cortex Hub tool calls above the prompt: a stacked bar with one color per category, the latest call, what the results cost and saved in tokens, how often the agent opened what a code lookup ranked, and where /cs stands. Each cortex tool row in the transcript gets a tag in its category's color.
◆ cortex ████████████████████████████████████████ 9 calls · last plan_quality 1.3s ✓
■ session 2 ■ knowledge 1 ■ memory 1 ■ code 3 ■ quality 1 ■ tasks 1
tokens ~3.3k in · ~9.8k saved est. │ hit@1 1/3 · hit@3 2/3 · hit@10 2/3
/cs ● session ● recall ● changes ● tasks │ ● build ● typecheck ○ lint ○ report
While a call is running the tail reads ⟳ code_search; a failed call turns it into ✗ and adds · 1 err. In a cortex project the band shows no cortex session yet — run /cs until the first cortex_session_start; elsewhere it stays out of the way until a cortex tool runs.
Under each cortex tool row:
● cortex-hub - cortex_code_search (MCP)(query: "hybrid search")
■ code · 233ms ✓ · ~700 tok · 3 files ~13k · used #2
~700 tok is what the result put into the context, 3 files ~13k the files it pointed at and their size, and used #2 says the agent went on to open the second of them (missed: it opened other files instead). A search or a check says how its result read instead: 2 found · top 0.61, nothing found, risk low, 7.5/10.
Needs Claude Code 2.1.287 or later in the terminal or the desktop app.
The Claude Code extension's chat panel (2.1.288) does not draw mod UI, so run Claude Code in the IDE's terminal instead: set "claudeCode.useTerminal": true in the IDE's settings, and the extension opens claude in an integrated terminal, where the band, the tags and the pane all show. In Antigravity the setting lives in its user settings.json, and its CLI is antigravity-ide (/Applications/Antigravity IDE.app/Contents/Resources/app/bin/ on macOS).
The mod also knows how to draw on the extension's surface, for when it does: there is no band above that prompt, so the newest cortex row carries the bar, the measures and the /cs checklist, and /cortex-calls puts them above its list.
From a clone, for one session:
claude --plugin-dir mods/cortex-bar
Or from this repo as a marketplace, which is also how the IDE extension gets it (it reads the same installed plugins as the CLI; it has no --plugin-dir):
claude plugin marketplace add lktiep/cortex-hub # or the path of a clone
claude plugin install cortex-bar@cortex-hub # --scope project to share it with the repo
Then start a new conversation in the extension (or the terminal).
| Command | What it does |
|---|---|
/cortex-bar | Show or hide the band and the row tags (remembered) |
/cortex-bar on / off | Show / hide explicitly |
/cortex-bar reset | Clear the call counts and the measures |
/cortex-calls | Open a pane with the measures and the last 50 calls |
◆ 10 cortex calls · 1 failed
tokens ~3.3k in · ~9.8k saved est. │ hit@1 1/3 · hit@3 2/3 · hit@10 2/3
Per tool ──────────────────────────────────────────────────────────────────────────
tool calls ok avg ~tok saved quality
■ knowledge_search 1 1 612ms ~600 · 1/1 found · last 2 found
■ code_search 2 2 1.3s ~1.4k ~9.8k hit@1 0/2 · hit@3 1/2 · …
■ plan_quality 1 1 1.5s ~225 · 0/1 passed · last 7.5/10
Calls newest first ────────────────────────────────────────────────────────────────
17:55:33 ✗ ■ code_reindex 30s ~0 MCP error…
17:54:57 ✓ ■ plan_quality 1.5s ~225 7.5/10 plan=1. A…
17:54:29 ✓ ■ code_search 1.3s ~700 3 files ~13k · used #2 query=whe…
Every column keeps its width, and only the last one in a row is cut, so nothing wraps. A narrow pane drops avg, then ~tok, then ok from the table, and the calls swap their tokens and arguments for the result. Where no pane can be placed (a claude -p run) the list comes back as text instead.
Everything is counted in this Claude Code session, from what passes through it; the hub's own cortex_tool_stats counts on the server, with its own baseline.
~3.3k in, ~700 tok): the characters of each cortex result over 4, the rule the hub's stats use. An estimate of what the result added to the context.code_search, code_context, code_impact and cypher. The mod reads the file paths in a lookup's result, up to ten, in the order they appear, and sizes each file on disk. A Read, Edit, Write or notebook edit of one of those files, or a Bash command naming it, is the agent using the lookup; the rank of the best one opened is used #n. A file can count for any of the last three lookups that returned it. A Read or Edit of a project file none of them returned is a stray, and a lookup that closes (three newer ones came after it) with strays and nothing used is missed; one with neither is unused and not judged.~9.8k saved est.) is, per lookup: the tokens of the files it pointed at (each capped at 25k, about what one Read shows), less what the result itself cost, less the files the agent opened anyway. A missed lookup saved nothing. It is an upper bound: it assumes that without the lookup the agent would have read every one of those files. cortex_tool_stats uses a fixed per-tool baseline instead, so the two differ.knowledge_search, the number of memory_search results, the risk_level of detect_changes (unknown is a failed lookup, not a pass), the score and verdict of plan_quality.The measures start over with a new cortex_session_start, /clear or /cortex-bar reset. They never hold up or change a tool's result: sizes are read in the background, and an output in a shape the parsers do not know is left unjudged.
| Category | Tools |
|---|---|
| session | session_start, session_end, changes, health, list_repos |
| knowledge | knowledge_search, knowledge_store |
| memory | memory_search, memory_store, memory_delete |
| code | code_search, code_context, code_impact, code_reindex, cypher, detect_changes |
| quality | quality_report, plan_quality, tool_stats |
| tasks | task_* |
| other | any cortex tool not listed |
tool.call hook on every mcp__<server>__cortex_* tool, whatever the MCP server is called. A new cortex_session_start starts the counts over.tool.call hook on Read, Edit, Write, NotebookEdit and Bash, after the tool ran; a denied or failed call does not count./cs checklist and the gates come from the markers .claude/hooks/ writes to .cortex/.session-state/, the same evidence the hooks gate on. A marker counts only when it holds the tool= line the tracker writes, so an empty or hand-made file stays ○. The mod rereads them shortly after cortex, Bash and edit tools and at the end of each turn, so build/typecheck/lint turn green when the gates pass and go back to ○ after the next write clears them. gate-off shows as ⚠ gates off.The argument shown for a call is its first non-empty query, name, target, title, repo, taskId, plan or content, cut to 48 characters. Fields whose name looks like a credential (key, token, secret, password, authorization) are never shown. Nothing is sent anywhere: the mod only reads what passes through this Claude Code session.
cd mods/cortex-bar
claude plugin validate .
claude plugin test
The first load (claude --plugin-dir mods/cortex-bar) generates the engine's types into .claude-plugin/types/ (ignored by git), which tsconfig.json extends. After that the hooks, plain JS with JSDoc types, typecheck strictly:
node_modules/.bin/tsc -p mods/cortex-bar/tsconfig.json --allowJs --checkJs --noEmit
hooks/model.js holds everything that decides what is drawn, as pure functions; hooks/register.js times calls, reads the markers and turns rows into elements. The tests mount every drawing on the surfaces that raise it: the band on terminal and desktop, the tool rows, folded groups and the pane on vscode as well.
hooks/register.js 541 lines1// cortex-bar: the Cortex Hub tool calls of this session, drawn above the prompt as a
2// stacked bar (one color per category) with where /cs stands, and a colored tag under each
3// cortex tool row. What it draws is model.js; this file times and measures the calls,
4// follows which files the agent opens after a lookup, reads the hooks' markers and turns
5// rows into elements.
6
7import {
8 LOOKUP_TOOLS,
9 MARKERS,
10 addCall,
11 applyUse,
12 bandModel,
13 categoryOf,
14 closeLookup,
15 emptyStats,
16 estimateTokens,
17 firstLine,
18 fitRow,
19 groupTag,
20 hitPaths,
21 newLookup,
22 paneModel,
23 qualityOf,
24 rowTag,
25 shortName,
26 summarizeArgs,
27 textProps,
28 withSizes,
29} from './model.js'
30
31const STATE_DIR = '.cortex/.session-state'
32const PANE_ID = 'cortex-calls'
33const RECENT_LIMIT = 50
34const REFRESH_DELAY_MS = 400
35/** How many finished calls keep their time for their tags. */
36const TIMED_LIMIT = 200
37const CORTEX_TOOL = /^mcp__.+__cortex_/
38/** How many of the newest lookups a file the agent opens can still count for. */
39const LOOKUP_WINDOW = 3
40/** How many lookups are kept for their tags. */
41const LOOKUP_LIMIT = 500
42/** Lookups judged even when their result names no file: an empty one is a miss too. */
43const ALWAYS_JUDGED = ['code_search', 'code_context']
44/**
45 * The surfaces that raise no AbovePrompt (Claude Code for VS Code, which Antigravity and
46 * Cursor run too, and the mobile app): there the newest cortex row carries the band.
47 */
48const NO_BAND = ['vscode', 'mobile']
49/** Cells the transcript indents a tool row's lines by, kept free so a tag never wraps. */
50const TRANSCRIPT_INDENT = 6
51
52/**
53 * @typedef {import('claude-code').EngineInterface} Engine
54 * @typedef {import('./model.js').Call} Call
55 * @typedef {import('./model.js').Running} Running
56 * @typedef {import('./model.js').Row} Row
57 * @typedef {import('./model.js').Verdict} Verdict
58 * @typedef {import('./model.js').Lookup} Lookup
59 * @typedef {import('./model.js').Use} Use
60 */
61
62// Module state lives as long as this load: a hot reload or a new session starts it over.
63let visible = true
64let stats = emptyStats()
65/** @type {Call[]} */
66let recent = []
67/** @type {Running[]} */
68let inFlight = []
69/** @type {string[] | null} */
70let markers = null
71/**
72 * Finished calls by tool_use_id, for the tags under their rows: kept apart from `recent`
73 * so a new session or a reset does not strip the times off the rows already drawn.
74 * @type {Map<string, Call>}
75 */
76const timed = new Map()
77let callIds = 0
78/** @type {import('claude-code').Timer | null} */
79let refreshTimer = null
80/** The project root, which repo-relative hit paths are under. */
81let root = ''
82/**
83 * Lookups by tool_use_id, oldest first. A reset closes them and starts a new epoch: the
84 * measures count the current epoch only, the tags keep showing the older ones.
85 * @type {Map<string, Lookup>}
86 */
87const lookups = new Map()
88let epoch = 0
89
90function resetCalls() {
91 stats = emptyStats()
92 recent = []
93 for (const [id, lookup] of lookups) lookups.set(id, closeLookup(lookup))
94 epoch += 1
95}
96
97/** The lookups the measures count. */
98function currentLookups() {
99 return [...lookups.values()].filter((lookup) => lookup.epoch === epoch)
100}
101
102/** The lookups a file opened now still counts for, oldest first. */
103function openLookups() {
104 return currentLookups().filter((lookup) => !lookup.closed)
105}
106
107/**
108 * The /cs markers in the project, or null when it has no state dir (not a cortex project).
109 * A marker counts only with the `tool=` evidence the tracker writes, which is what the
110 * gates require too; gate-off holds the reason someone gave instead.
111 * @param {Engine} $
112 * @returns {Promise<string[] | null>}
113 */
114async function readMarkers($) {
115 try {
116 const dir = (await $.session.root()) + '/' + STATE_DIR
117 if (!(await $.fs.exists(dir))) return null
118 const entries = await $.fs.list(dir)
119 const present = MARKERS.filter((name) =>
120 entries.some((entry) => entry.name === name && entry.kind === 'file' && entry.size > 0),
121 )
122 const texts = await Promise.all(present.map((name) => $.fs.read(dir + '/' + name)))
123 return present.filter((name, i) =>
124 name === 'gate-off' ? texts[i]?.trim() !== '' : Boolean(texts[i]?.includes('tool=')),
125 )
126 } catch {
127 return markers
128 }
129}
130
131/** @param {Engine} $ */
132async function refreshMarkers($) {
133 const found = await readMarkers($)
134 if (JSON.stringify(found) === JSON.stringify(markers)) return
135 markers = found
136 $.ui.invalidate('ui.render')
137}
138
139/**
140 * The tracker writes its marker after the tool returns, so look a moment later.
141 * @param {Engine} $
142 */
143function scheduleRefresh($) {
144 refreshTimer?.cancel()
145 refreshTimer = $.clock.after(REFRESH_DELAY_MS, () => {
146 refreshTimer = null
147 refreshMarkers($)
148 })
149}
150
151/**
152 * @param {import('claude-code').ToolCallResult} result
153 * @returns {Verdict}
154 */
155function verdictOf(result) {
156 if (result.deny !== undefined) return { ok: false, error: 'denied: ' + firstLine(result.deny) }
157 if (result.isError) return { ok: false, error: firstLine(result.text ?? 'error') }
158 return { ok: true, error: '' }
159}
160
161/**
162 * The result as the model read it: core sets `text`; a hook's own answer may carry a
163 * string `result` instead.
164 * @param {import('claude-code').ToolCallResult | undefined} result
165 */
166function resultText(result) {
167 if (!result || result.deny !== undefined) return ''
168 if (typeof result.text === 'string') return result.text
169 return typeof result.result === 'string' ? result.result : ''
170}
171
172/**
173 * Sizes the files a lookup returned, after the fact: the tool's answer never waits on it.
174 * @param {Engine} $
175 * @param {string} toolUseId
176 * @param {string[]} paths
177 */
178async function measureHits($, toolUseId, paths) {
179 const bytes = await Promise.all(
180 paths.map((path) =>
181 $.fs
182 .stat(path.startsWith('/') || root === '' ? path : root + '/' + path)
183 .then((stat) => (stat.kind === 'file' ? stat.size : 0))
184 .catch(() => 0),
185 ),
186 )
187 const lookup = lookups.get(toolUseId)
188 if (!lookup) return
189 lookups.set(toolUseId, withSizes(lookup, bytes))
190 $.ui.invalidate('ui.render')
191}
192
193/**
194 * A new lookup: the oldest open ones beyond the window close, and its hits get sized.
195 * @param {Engine} $
196 * @param {Call} call
197 * @param {string} text
198 */
199function startLookup($, call, text) {
200 const paths = hitPaths(text)
201 if (paths.length === 0 && !ALWAYS_JUDGED.includes(call.name)) return
202 lookups.set(
203 call.toolUseId,
204 newLookup({ toolUseId: call.toolUseId, name: call.name, epoch, returned: call.tokens, paths }),
205 )
206 const open = openLookups()
207 for (const old of open.slice(0, Math.max(0, open.length - LOOKUP_WINDOW))) {
208 lookups.set(old.toolUseId, closeLookup(old))
209 }
210 for (const oldest of lookups.keys()) {
211 if (lookups.size <= LOOKUP_LIMIT) break
212 lookups.delete(oldest)
213 }
214 if (paths.length > 0) measureHits($, call.toolUseId, paths)
215}
216
217/**
218 * A file the agent opened, counted for the open lookups.
219 * @param {Engine} $
220 * @param {Use} use
221 */
222function noteUse($, use) {
223 const open = openLookups()
224 if (open.length === 0) return
225 const after = applyUse(open, use)
226 let changed = false
227 after.forEach((lookup, i) => {
228 if (lookup === open[i]) return
229 lookups.set(lookup.toolUseId, lookup)
230 changed = true
231 })
232 if (changed) $.ui.invalidate('ui.render')
233}
234
235/**
236 * A path the way the hits name it: relative to the project root, or undefined outside it.
237 * @param {string} path
238 */
239function inProject(path) {
240 if (!path.startsWith('/')) return path.replace(/^\.\//, '')
241 return root !== '' && path.startsWith(root + '/') ? path.slice(root.length + 1) : undefined
242}
243
244/**
245 * @param {Engine} $
246 * @param {Running} started
247 * @param {Verdict} verdict
248 * @param {import('claude-code').ToolCallResult} [result]
249 */
250async function finishCall($, started, verdict, result) {
251 const endedAt = await $.clock.now().catch(() => started.at)
252 inFlight = inFlight.filter((call) => call.id !== started.id)
253 const text = resultText(result)
254 /** @type {Call} */
255 const call = {
256 toolUseId: started.toolUseId,
257 name: started.name,
258 category: categoryOf(started.name),
259 args: started.args,
260 at: endedAt,
261 ms: endedAt - started.at,
262 tokens: estimateTokens(text),
263 ...verdict,
264 }
265 // Measuring is best effort: a result in a shape the parsers do not expect is left
266 // unjudged, never an error in the tool's answer.
267 try {
268 const quality = verdict.ok ? qualityOf(started.name, text) : undefined
269 if (quality) call.quality = quality
270 if (verdict.ok && LOOKUP_TOOLS.includes(started.name)) startLookup($, call, text)
271 } catch {
272 // left unjudged
273 }
274 stats = addCall(stats, call)
275 recent = [...recent, call].slice(-RECENT_LIMIT)
276 timed.set(call.toolUseId, call)
277 for (const oldest of timed.keys()) {
278 if (timed.size <= TIMED_LIMIT) break
279 timed.delete(oldest)
280 }
281 $.ui.invalidate('ui.render')
282 scheduleRefresh($)
283}
284
285/**
286 * The pane, or the same list as text where no pane can be placed (a `-p` run).
287 * @param {Engine} $
288 * @returns {Promise<import('claude-code').CommandRunResult>}
289 */
290async function openCalls($) {
291 const opened = await $.ui.open({
292 id: PANE_ID,
293 title: 'Cortex calls',
294 focus: true,
295 closeOnEscape: true,
296 })
297 if (opened.isPlaced) return {}
298 const rows = paneModel({ stats, recent, inFlight, lookups: currentLookups() })
299 return { text: rows.map((row) => row.map((span) => span.text).join('')).join('\n') }
300}
301
302/**
303 * The band's rows for a transcript row on a surface without the band, when that row holds
304 * the newest cortex call; nothing anywhere else.
305 * @param {{ surface: string, viewport?: { columns: number } }} e
306 * @param {(string | undefined)[]} toolUseIds the cortex calls the row draws
307 * @returns {Row[]}
308 */
309function bandBelow(e, toolUseIds) {
310 if (!NO_BAND.includes(e.surface)) return []
311 const newest = inFlight[inFlight.length - 1] ?? recent[recent.length - 1]
312 if (!newest || !toolUseIds.includes(newest.toolUseId)) return []
313 const columns = Math.min(transcriptColumns(e) ?? 80, 100)
314 return bandModel({
315 stats,
316 recent,
317 inFlight,
318 markers,
319 lookups: currentLookups(),
320 columns,
321 maxRows: 4,
322 })
323}
324
325/**
326 * Cells a line under a transcript row has, less the transcript's own indent; unknown
327 * where the surface has not measured.
328 * @param {{ viewport?: { columns: number } }} e
329 */
330function transcriptColumns(e) {
331 return e.viewport ? Math.max(20, e.viewport.columns - TRANSCRIPT_INDENT) : undefined
332}
333
334/**
335 * One line per row. A Text shrinks and wraps when its row runs out of room, which breaks
336 * the columns, so each span sits in a Box that keeps its width; only a span marked
337 * `truncate` gives way, and it is cut short instead of wrapping. A row whose fixed spans
338 * are wider than `columns` is cut first.
339 * @param {import('claude-code').ElementConstructor<import('claude-code').BoxProps>} Box
340 * @param {import('claude-code').ElementConstructor<import('claude-code').TextProps>} Text
341 * @param {Row[]} rows
342 * @param {number | undefined} columns
343 */
344function drawRows(Box, Text, rows, columns) {
345 return rows.map((row) =>
346 Box({
347 flexDirection: 'row',
348 children: fitRow(row, columns).map((span) =>
349 Box({ flexShrink: span.truncate ? 1 : 0, children: [Text(textProps(span))] }),
350 ),
351 }),
352 )
353}
354
355/** @param {import('claude-code').On} on */
356export function register(on) {
357 on('session.start', async ($, e, next) => {
358 const result = await next(e)
359 await $.command.register({
360 name: 'cortex-bar',
361 description: 'Show or hide the Cortex tool-call bar above the prompt',
362 argumentHint: '[on|off|reset]',
363 immediate: true,
364 })
365 await $.command.register({
366 name: 'cortex-calls',
367 description: 'List the Cortex tool calls of this session',
368 immediate: true,
369 })
370 visible = (await $.store.get('visible')) !== false
371 root = await $.session.root().catch(() => '')
372 markers = await readMarkers($)
373 $.ui.invalidate('ui.render')
374 return result
375 })
376
377 // /clear starts a new conversation and session-init.sh wipes the markers with it.
378 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
379 const result = await next(e)
380 if (e.source === 'clear') resetCalls()
381 $.ui.invalidate('ui.render')
382 scheduleRefresh($)
383 return result
384 })
385
386 on('command.run', { command: ['cortex-bar', 'cortex-calls'] }, async ($, e) => {
387 if (e.command === 'cortex-calls') return openCalls($)
388 const arg = e.args.trim().toLowerCase()
389 if (arg === 'reset') {
390 resetCalls()
391 $.ui.invalidate('ui.render')
392 return { text: 'cortex-bar: call counts cleared' }
393 }
394 if (arg !== '' && arg !== 'on' && arg !== 'off') {
395 return { text: 'usage: /cortex-bar [on|off|reset]' }
396 }
397 visible = arg === '' ? !visible : arg === 'on'
398 await $.store.set('visible', visible)
399 $.ui.invalidate('ui.render')
400 return { text: visible ? 'cortex-bar: shown' : 'cortex-bar: hidden (/cortex-bar shows it)' }
401 })
402
403 // Every cortex tool, whatever the MCP server is named: mcp__<server>__cortex_<name>.
404 on('tool.call', { tool: CORTEX_TOOL }, async ($, e, next) => {
405 const name = shortName(e.tool)
406 if (name === 'session_start') resetCalls()
407 /** @type {Running} */
408 const started = {
409 id: ++callIds,
410 toolUseId: e.tool_use_id,
411 name,
412 args: summarizeArgs(e),
413 at: await $.clock.now(),
414 }
415 inFlight = [...inFlight, started]
416 $.ui.invalidate('ui.render')
417 /** @type {import('claude-code').ToolCallResult} */
418 let result
419 try {
420 result = await next(e)
421 } catch (err) {
422 await finishCall($, started, { ok: false, error: 'threw: ' + firstLine(err) })
423 throw err
424 }
425 await finishCall($, started, verdictOf(result), result)
426 return result
427 })
428
429 // A file opened after a lookup says whether the lookup found it: a Read or an edit of
430 // it, or a command that names it. Build, typecheck and lint run in Bash and writes clear
431 // the gates: only the markers know, so look again after any of these but a Read.
432 on(
433 'tool.call',
434 { tool: ['Read', 'Bash', 'Edit', 'Write', 'NotebookEdit'] },
435 async ($, e, next) => {
436 const result = await next(e)
437 if (e.tool !== 'Read' && markers !== null) scheduleRefresh($)
438 if (result.deny !== undefined || result.isError) return result
439 if (e.tool === 'Bash') {
440 noteUse($, { command: e.command })
441 } else {
442 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
443 // Only a file read or edited counts against a lookup; a new file is not a miss.
444 const stray = e.tool === 'Read' || e.tool === 'Edit' ? inProject(path) : undefined
445 noteUse($, stray === undefined ? { path } : { path, stray })
446 }
447 return result
448 },
449 )
450
451 on('turn.complete', async ($, e, next) => {
452 const result = await next(e)
453 await refreshMarkers($)
454 return result
455 })
456
457 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
458 const below = await next(e)
459 if (!visible || e.props.hasSurvey) return below
460 const rows = bandModel({
461 stats,
462 recent,
463 inFlight,
464 markers,
465 columns: e.props.bodyColumns,
466 maxRows: e.props.maxRows,
467 lookups: currentLookups(),
468 })
469 if (rows.length === 0) return below
470 const { Box, Text } = $.ui.resolve(e)
471 const band = Box({
472 key: 'cortex-bar',
473 flexDirection: 'column',
474 children: drawRows(Box, Text, rows, e.props.bodyColumns),
475 })
476 return below ? Box({ flexDirection: 'column', children: [below, band] }) : band
477 })
478
479 // Every surface draws the tool rows. Where there is no band, the newest cortex row
480 // carries it, so /cs shows its bar and checklist in the editor too.
481 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
482 const row = await next(e)
483 if (!visible || !CORTEX_TOOL.test(e.props.tool)) return row
484 const { Box, Text } = $.ui.resolve(e)
485 const tag = rowTag({
486 name: shortName(e.props.tool),
487 isRunning: e.props.isRunning,
488 isErrored: e.props.isErrored,
489 isInterrupted: e.props.isInterrupted,
490 call: timed.get(e.props.tool_use_id),
491 lookup: lookups.get(e.props.tool_use_id),
492 })
493 const below = [tag, ...bandBelow(e, [e.props.tool_use_id])]
494 return Box({
495 flexDirection: 'column',
496 children: [row, ...drawRows(Box, Text, below, transcriptColumns(e))],
497 })
498 })
499
500 // A folded run of calls draws one line and none of its ToolUse rows.
501 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
502 const group = await next(e)
503 const calls = e.props.calls.filter((call) => CORTEX_TOOL.test(call.tool))
504 if (!visible || e.props.isExpanded || calls.length === 0) return group
505 const { Box, Text } = $.ui.resolve(e)
506 const below = [
507 groupTag(calls.map((call) => shortName(call.tool))),
508 ...bandBelow(
509 e,
510 calls.map((call) => call.tool_use_id),
511 ),
512 ]
513 return Box({
514 flexDirection: 'column',
515 children: [group, ...drawRows(Box, Text, below, transcriptColumns(e))],
516 })
517 })
518
519 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
520 if (e.requestId !== PANE_ID) return next(e)
521 const { Box, Text } = $.ui.resolve(e)
522 // The bar and checklist head the pane where no band shows them; the bare "run /cs"
523 // hint is left out, the list says as much.
524 const columns = Math.max(20, e.props.bodyColumns - 2)
525 const current = currentLookups()
526 const summary = NO_BAND.includes(e.surface)
527 ? bandModel({ stats, recent, inFlight, markers, columns, maxRows: 2 })
528 : []
529 /** @type {Row[]} */
530 const rows = [
531 ...(summary.length > 1 ? [...summary, [{ text: ' ' }]] : []),
532 ...paneModel({ stats, recent, inFlight, lookups: current, columns }),
533 ]
534 return Box({
535 flexDirection: 'column',
536 paddingX: 1,
537 children: drawRows(Box, Text, rows, columns),
538 })
539 })
540}
541hooks/model.js 981 lines1// What cortex-bar draws, as plain data. Nothing here touches the mods API, so the tests
2// can check the bar and the pane without mounting anything, and register.js stays a thin
3// layer that turns rows of spans into Box and Text elements.
4
5/**
6 * @typedef {{ text: string, color?: string, bold?: boolean, dim?: boolean, truncate?: boolean }} Span
7 * @typedef {Span[]} Row
8 * @typedef {{ id: number, toolUseId: string, name: string, args: string, at: number }} Running
9 * @typedef {{ ok: boolean, error: string }} Verdict
10 * @typedef {{ kind: 'found' | 'risk known' | 'passed', ok: boolean, tag: string }} Quality
11 * @typedef {{ toolUseId: string, name: string, category: string, args: string, at: number, ms: number, tokens: number, quality?: Quality } & Verdict} Call
12 * @typedef {{ path: string, rank: number, tokens: number | null }} Hit
13 * @typedef {{ toolUseId: string, name: string, epoch: number, returned: number, hits: Hit[], used: number | null, opened: string[], strays: string[], closed: boolean }} Lookup
14 * @typedef {'used' | 'missed' | 'unused' | 'open'} LookupState
15 * @typedef {{ path: string, stray?: string } | { command: string }} Use
16 * @typedef {{ count: number, used: number, missed: number, at1: number, at3: number, at10: number, saved: number, measured: number }} LookupMetrics
17 * @typedef {{ name: string, isRunning: boolean, isErrored: boolean, isInterrupted: boolean, call?: Call, lookup?: Lookup }} ToolRow
18 * @typedef {{ calls: number, errors: number, ms: number, tokens: number, good: number, judged: number, kind: string, last: string }} ToolStats
19 * @typedef {{ total: number, errors: number, tokens: number, byCategory: Record<string, number>, byTool: Record<string, ToolStats>, okNames: string[] }} Stats
20 * @typedef {'done' | 'partial' | 'todo'} StepState
21 * @typedef {{ label: string, state: StepState }} Step
22 * @typedef {{ steps: Step[], gates: Step[] | null, gateOff: boolean }} Progress
23 * @typedef {{ stats: Stats, recent: Call[], inFlight: Running[], lookups?: Lookup[] }} Calls
24 */
25
26/**
27 * One color per category, in the order the bar stacks them. Raw rgb() colors, mid tones
28 * that read on a dark and a light theme; the states use the theme's own keys.
29 */
30export const CATEGORIES = [
31 { id: 'session', label: 'session', color: 'rgb(175,135,255)' },
32 { id: 'knowledge', label: 'knowledge', color: 'rgb(0,175,215)' },
33 { id: 'memory', label: 'memory', color: 'rgb(95,135,255)' },
34 { id: 'code', label: 'code', color: 'rgb(95,175,95)' },
35 { id: 'quality', label: 'quality', color: 'rgb(215,175,0)' },
36 { id: 'tasks', label: 'tasks', color: 'rgb(255,135,95)' },
37 { id: 'other', label: 'other', color: 'rgb(138,138,138)' },
38]
39
40const TITLE_COLOR = 'rgb(175,135,255)'
41const OTHER_COLOR = 'rgb(138,138,138)'
42const OK = 'success'
43const FAILED = 'error'
44const WAITING = 'warning'
45
46/**
47 * The markers .claude/hooks/track-quality.sh writes into .cortex/.session-state.
48 * They are the same evidence the commit gate reads, so the bar never disagrees with it.
49 */
50export const MARKERS = [
51 'session-started',
52 'knowledge-recalled',
53 'memory-recalled',
54 'changes-checked',
55 'tasks-checked',
56 'gate-build',
57 'gate-typecheck',
58 'gate-lint',
59 'quality-gates-passed',
60 'quality-reported',
61 'gate-off',
62]
63
64const SECRET_KEY = /key|token|secret|password|authorization/i
65/** What tool.call puts beside a tool's own arguments. */
66const RESERVED = ['tool', 'tool_use_id', 'agentId', 'consent']
67const SUMMARY_KEYS = ['query', 'name', 'target', 'title', 'repo', 'taskId', 'plan', 'content']
68
69/**
70 * `mcp__cortex-hub__cortex_code_search` → `code_search`
71 * @param {string} tool
72 */
73export function shortName(tool) {
74 const at = tool.lastIndexOf('__cortex_')
75 return at === -1 ? tool : tool.slice(at + '__cortex_'.length)
76}
77
78/** @param {string} name a short cortex tool name */
79export function categoryOf(name) {
80 if (/^(session_|changes$|health$|list_repos$)/.test(name)) return 'session'
81 if (name.startsWith('knowledge_')) return 'knowledge'
82 if (name.startsWith('memory_')) return 'memory'
83 if (/^(code_|cypher$|detect_changes$)/.test(name)) return 'code'
84 if (/^(quality_report|plan_quality|tool_stats)$/.test(name)) return 'quality'
85 if (name.startsWith('task_')) return 'tasks'
86 return 'other'
87}
88
89/** @param {string} category */
90function colorOf(category) {
91 return CATEGORIES.find((c) => c.id === category)?.color ?? OTHER_COLOR
92}
93
94/**
95 * One `key=value` hint for a call, never from a field that looks like a credential.
96 * @param {Record<string, unknown>} input the tool.call event: the tool's arguments
97 * @param {number} [max]
98 */
99export function summarizeArgs(input, max = 48) {
100 /** @param {string} key */
101 const rank = (key) => {
102 const i = SUMMARY_KEYS.indexOf(key)
103 return i === -1 ? SUMMARY_KEYS.length : i
104 }
105 /** @type {[string, string][]} */
106 const fields = []
107 for (const [key, value] of Object.entries(input)) {
108 if (RESERVED.includes(key) || SECRET_KEY.test(key)) continue
109 if (typeof value === 'string' && value.trim() !== '') fields.push([key, value])
110 }
111 const [best] = fields.sort(([a], [b]) => rank(a) - rank(b))
112 if (!best) return ''
113 const text = best[1].replace(/\s+/g, ' ').trim()
114 return best[0] + '=' + (text.length > max ? text.slice(0, max - 1) + '…' : text)
115}
116
117/**
118 * The first line of a message, cut to fit one row.
119 * @param {unknown} text
120 * @param {number} [max]
121 */
122export function firstLine(text, max = 60) {
123 const line = String(text).trim().split('\n')[0] ?? ''
124 return line.length > max ? line.slice(0, max - 1) + '…' : line
125}
126
127/** @param {number} ms */
128export function formatMs(ms) {
129 if (ms < 1000) return Math.max(0, Math.round(ms)) + 'ms'
130 return (ms / 1000).toFixed(ms < 10_000 ? 1 : 0) + 's'
131}
132
133/**
134 * Tokens in a text, by the same rule the hub's own usage stats use: four characters each.
135 * There is no tokenizer to ask, so every token figure the mod shows is this estimate.
136 * @param {string} text
137 */
138export function estimateTokens(text) {
139 return Math.ceil(text.length / 4)
140}
141
142/** @param {number} tokens `840`, `6.2k`, `120k`, `1.2M` */
143export function formatTokens(tokens) {
144 const n = Math.max(0, Math.round(tokens))
145 if (n < 1000) return String(n)
146 if (n < 9_950) return (n / 1000).toFixed(1) + 'k'
147 if (n < 999_500) return Math.round(n / 1000) + 'k'
148 return (n / 1_000_000).toFixed(1) + 'M'
149}
150
151/** @returns {Stats} */
152export function emptyStats() {
153 return { total: 0, errors: 0, tokens: 0, byCategory: {}, byTool: {}, okNames: [] }
154}
155
156/**
157 * Totals outlive the recent-calls list, which keeps only the newest calls.
158 * @param {Stats} stats
159 * @param {Call} call
160 * @returns {Stats}
161 */
162export function addCall(stats, call) {
163 const okNames =
164 call.ok && !stats.okNames.includes(call.name) ? [...stats.okNames, call.name] : stats.okNames
165 const tool = stats.byTool[call.name] ?? {
166 calls: 0,
167 errors: 0,
168 ms: 0,
169 tokens: 0,
170 good: 0,
171 judged: 0,
172 kind: '',
173 last: '',
174 }
175 const quality = call.quality
176 return {
177 total: stats.total + 1,
178 errors: stats.errors + (call.ok ? 0 : 1),
179 tokens: stats.tokens + call.tokens,
180 byCategory: {
181 ...stats.byCategory,
182 [call.category]: (stats.byCategory[call.category] ?? 0) + 1,
183 },
184 byTool: {
185 ...stats.byTool,
186 [call.name]: {
187 calls: tool.calls + 1,
188 errors: tool.errors + (call.ok ? 0 : 1),
189 ms: tool.ms + call.ms,
190 tokens: tool.tokens + call.tokens,
191 good: tool.good + (quality?.ok ? 1 : 0),
192 judged: tool.judged + (quality ? 1 : 0),
193 kind: quality?.kind ?? tool.kind,
194 last: quality?.tag ?? tool.last,
195 },
196 },
197 okNames,
198 }
199}
200
201/**
202 * How a result reads, for the tools whose output says so itself: how many documents a
203 * search found, the risk a diff got, the score a plan got. Undefined for the rest, and for
204 * an output in a shape this does not know.
205 * @param {string} name a short cortex tool name
206 * @param {string} text the result as the model read it
207 * @returns {Quality | undefined}
208 */
209export function qualityOf(name, text) {
210 if (name === 'knowledge_search' || name === 'memory_search') {
211 const heading = name === 'knowledge_search' ? /^### Result \d+:.*$/gm : /^### Memory \d+\b/gm
212 const found = text.match(heading) ?? []
213 const scores = found
214 .map((line) => /Score: (\d+(?:\.\d+)?)/.exec(line)?.[1])
215 .filter((score) => score !== undefined)
216 .map(Number)
217 if (found.length === 0) return { kind: 'found', ok: false, tag: 'nothing found' }
218 const top = scores.length > 0 ? ' · top ' + Math.max(...scores).toFixed(2) : ''
219 return { kind: 'found', ok: true, tag: found.length + ' found' + top }
220 }
221 if (name === 'detect_changes') {
222 const risk = /"risk_level":\s*"(\w+)"/.exec(text)?.[1]
223 return risk ? { kind: 'risk known', ok: risk !== 'unknown', tag: 'risk ' + risk } : undefined
224 }
225 if (name === 'plan_quality') {
226 const score = /Total Score:\s*(\d+(?:\.\d+)?)\/10\s+(APPROVED|NEEDS IMPROVEMENT)/.exec(text)
227 return score
228 ? { kind: 'passed', ok: score[2] === 'APPROVED', tag: score[1] + '/10' }
229 : undefined
230 }
231 return undefined
232}
233
234/** The tools that answer "where is it": what they return is judged by what gets opened. */
235export const LOOKUP_TOOLS = ['code_search', 'code_context', 'code_impact', 'cypher']
236/** How many results of a lookup count, as the retrieval benchmark counts them. */
237const HIT_LIMIT = 10
238/** One Read returns at most about this many tokens of a file, so a bigger file counts this. */
239const READ_CAP = 25_000
240/**
241 * A repo path with at least one folder and an extension: `apps/api/src/x.ts:12`,
242 * `[docs/x.md]`. What precedes it rules out the middle of a URL or of an absolute path.
243 */
244const PATH = /(?<![\w./:@-])(\/?(?:[\w.@-]+\/)+[\w.@-]*\.[A-Za-z][A-Za-z0-9]*)(?![\w/-])/g
245
246/**
247 * The files a lookup's result points to, in the order they first appear: that order is
248 * their rank.
249 * @param {string} text
250 * @param {number} [limit]
251 */
252export function hitPaths(text, limit = HIT_LIMIT) {
253 /** @type {string[]} */
254 const paths = []
255 for (const match of text.matchAll(PATH)) {
256 const path = (match[1] ?? '').replace(/^\.\//, '')
257 if (path !== '' && !paths.includes(path)) paths.push(path)
258 if (paths.length === limit) break
259 }
260 return paths
261}
262
263/**
264 * @param {{ toolUseId: string, name: string, epoch: number, returned: number, paths: string[] }} input
265 * @returns {Lookup}
266 */
267export function newLookup({ toolUseId, name, epoch, returned, paths }) {
268 return {
269 toolUseId,
270 name,
271 epoch,
272 returned,
273 hits: paths.map((path, i) => ({ path, rank: i + 1, tokens: null })),
274 used: null,
275 opened: [],
276 strays: [],
277 closed: false,
278 }
279}
280
281/**
282 * The hits with the size of each file, 0 for one that is not there (another repo's).
283 * @param {Lookup} lookup
284 * @param {number[]} bytes in the order of the hits
285 * @returns {Lookup}
286 */
287export function withSizes(lookup, bytes) {
288 return {
289 ...lookup,
290 hits: lookup.hits.map((hit, i) => ({
291 ...hit,
292 tokens: Math.min(READ_CAP, Math.ceil((bytes[i] ?? 0) / 4)),
293 })),
294 }
295}
296
297/**
298 * Whether `text` names `path` whole: on its own or at the end of a longer path.
299 * @param {string} text
300 * @param {string} path
301 */
302function mentions(text, path) {
303 for (let at = text.indexOf(path); at !== -1; at = text.indexOf(path, at + 1)) {
304 const before = text[at - 1] ?? ' '
305 const after = text[at + path.length] ?? ' '
306 if (!/[\w.@-]/.test(before) && !/[\w.@/-]/.test(after)) return true
307 }
308 return false
309}
310
311/**
312 * @param {Use} use
313 */
314function useText(use) {
315 return 'command' in use ? use.command : use.path
316}
317
318/**
319 * A file the agent opened after the lookup: a Read, an edit, or a command that names it.
320 * A hit sets `used` to the best rank opened so far. A file it did not return is a stray
321 * when the caller gives one (Reads and Edits inside the project); a command never is.
322 * @param {Lookup} lookup
323 * @param {Use} use
324 * @returns {Lookup}
325 */
326export function noteOpen(lookup, use) {
327 const text = useText(use)
328 const matched = lookup.hits.filter((hit) => mentions(text, hit.path))
329 if (matched.length === 0) {
330 const stray = 'stray' in use ? use.stray : undefined
331 if (stray === undefined || lookup.strays.includes(stray)) return lookup
332 return { ...lookup, strays: [...lookup.strays, stray] }
333 }
334 const best = Math.min(...matched.map((hit) => hit.rank))
335 return {
336 ...lookup,
337 used: lookup.used === null ? best : Math.min(lookup.used, best),
338 opened: [...new Set([...lookup.opened, ...matched.map((hit) => hit.path)])],
339 }
340}
341
342/**
343 * A file opened while several lookups are open: each one that returned it counts it, and
344 * only when none did is it a stray, of the newest.
345 * @param {Lookup[]} open oldest first
346 * @param {Use} use
347 * @returns {Lookup[]}
348 */
349export function applyUse(open, use) {
350 const text = useText(use)
351 const known = open.some((lookup) => lookup.hits.some((hit) => mentions(text, hit.path)))
352 if (known) {
353 const plain = 'path' in use ? { path: use.path } : use
354 return open.map((lookup) => noteOpen(lookup, plain))
355 }
356 return open.map((lookup, i) => (i === open.length - 1 ? noteOpen(lookup, use) : lookup))
357}
358
359/**
360 * @param {Lookup} lookup
361 * @returns {Lookup}
362 */
363export function closeLookup(lookup) {
364 return lookup.closed ? lookup : { ...lookup, closed: true }
365}
366
367/**
368 * used: a file it returned was opened. missed: it closed with only other files opened.
369 * unused: it closed with nothing opened. open: the agent may still act on it.
370 * @param {Lookup} lookup
371 * @returns {LookupState}
372 */
373export function lookupState(lookup) {
374 if (lookup.used !== null) return 'used'
375 if (!lookup.closed) return 'open'
376 return lookup.strays.length > 0 ? 'missed' : 'unused'
377}
378
379/**
380 * Tokens behind a lookup's hits, or null while their sizes are not known yet.
381 * @param {Lookup} lookup
382 */
383function tokensBehind(lookup) {
384 if (lookup.hits.some((hit) => hit.tokens === null)) return null
385 return lookup.hits.reduce((sum, hit) => sum + (hit.tokens ?? 0), 0)
386}
387
388/**
389 * What reading the hits' files instead would have cost, less what the lookup returned and
390 * the files that were opened anyway. An upper bound: it assumes every hit would have been
391 * opened to find the answer. A lookup the agent went past to other files saved nothing.
392 * @param {Lookup} lookup
393 */
394function savedBy(lookup) {
395 const behind = tokensBehind(lookup)
396 if (behind === null || lookup.hits.length === 0) return null
397 if (lookup.used === null && lookup.strays.length > 0) return 0
398 const opened = lookup.hits
399 .filter((hit) => lookup.opened.includes(hit.path))
400 .reduce((sum, hit) => sum + (hit.tokens ?? 0), 0)
401 return Math.max(0, behind - lookup.returned - opened)
402}
403
404/**
405 * hit@k over the lookups that were judged (used or missed), and the tokens they saved.
406 * @param {Lookup[]} lookups
407 * @returns {LookupMetrics}
408 */
409export function lookupMetrics(lookups) {
410 const metrics = { count: lookups.length, used: 0, missed: 0, at1: 0, at3: 0, at10: 0 }
411 let saved = 0
412 let measured = 0
413 for (const lookup of lookups) {
414 const state = lookupState(lookup)
415 if (state === 'missed') metrics.missed += 1
416 if (state === 'used' && lookup.used !== null) {
417 metrics.used += 1
418 if (lookup.used <= 1) metrics.at1 += 1
419 if (lookup.used <= 3) metrics.at3 += 1
420 if (lookup.used <= 10) metrics.at10 += 1
421 }
422 const by = savedBy(lookup)
423 if (by !== null) {
424 saved += by
425 measured += 1
426 }
427 }
428 return { ...metrics, saved, measured }
429}
430
431/**
432 * Where /cs stands. The cortex steps count a call seen in this session or a marker the
433 * hooks wrote (which covers calls made before the mod loaded). Build, typecheck and lint
434 * run in Bash, so only the markers know about them: `gates` is null without a state dir.
435 * @param {string[]} okNames
436 * @param {string[] | null} markers
437 * @returns {Progress}
438 */
439export function stepsFrom(okNames, markers) {
440 /** @param {string} name */
441 const seen = (name) => okNames.includes(name)
442 /** @param {string} marker */
443 const has = (marker) => markers != null && markers.includes(marker)
444 /** @param {boolean} done @returns {StepState} */
445 const state = (done) => (done ? 'done' : 'todo')
446 const knowledge = seen('knowledge_search') || has('knowledge-recalled')
447 const memory = seen('memory_search') || has('memory-recalled')
448 /** @type {StepState} */
449 const recall = knowledge && memory ? 'done' : knowledge || memory ? 'partial' : 'todo'
450 const steps = [
451 { label: 'session', state: state(seen('session_start') || has('session-started')) },
452 { label: 'recall', state: recall },
453 {
454 label: 'changes',
455 state: state(seen('changes') || seen('detect_changes') || has('changes-checked')),
456 },
457 { label: 'tasks', state: state(seen('task_pickup') || has('tasks-checked')) },
458 ]
459 if (markers == null) return { steps, gates: null, gateOff: false }
460 const allGates = has('quality-gates-passed')
461 const gates = [
462 { label: 'build', state: state(allGates || has('gate-build')) },
463 { label: 'typecheck', state: state(allGates || has('gate-typecheck')) },
464 { label: 'lint', state: state(allGates || has('gate-lint')) },
465 { label: 'report', state: state(seen('quality_report') || has('quality-reported')) },
466 ]
467 return { steps, gates, gateOff: has('gate-off') }
468}
469
470/**
471 * Split `width` cells between the categories in proportion to their calls. Every category
472 * that was used keeps at least one cell, and the cells left over go to the largest remainders.
473 * @param {Record<string, number>} byCategory
474 * @param {number} width
475 */
476export function barSegments(byCategory, width) {
477 const used = CATEGORIES.map((c) => ({ ...c, count: byCategory[c.id] ?? 0 })).filter(
478 (c) => c.count > 0,
479 )
480 const total = used.reduce((sum, c) => sum + c.count, 0)
481 if (total === 0 || width < used.length) return []
482 const cells = used.map((c) => {
483 const exact = (c.count / total) * width
484 return { id: c.id, color: c.color, exact, width: Math.max(1, Math.floor(exact)) }
485 })
486 let filled = cells.reduce((sum, c) => sum + c.width, 0)
487 while (filled > width) {
488 const widest = cells.reduce((a, b) => (b.width > a.width ? b : a))
489 widest.width -= 1
490 filled -= 1
491 }
492 while (filled < width) {
493 const behind = cells.reduce((a, b) => (b.exact - b.width > a.exact - a.width ? b : a))
494 behind.width += 1
495 filled += 1
496 }
497 return cells.map(({ id, color, width: w }) => ({ id, color, width: w }))
498}
499
500/** @type {Record<StepState, Span>} */
501const DOT = {
502 done: { text: '●', color: OK },
503 partial: { text: '◐', color: WAITING },
504 todo: { text: '○', dim: true },
505}
506
507/**
508 * @param {Step[]} items
509 * @param {string} [gap]
510 * @returns {Row}
511 */
512function stepSpans(items, gap = ' ') {
513 return items.flatMap((item) => [DOT[item.state], { text: ' ' + item.label + gap }])
514}
515
516/**
517 * `●●○○ tasks`: the dots alone, then the first step not done yet.
518 * @param {Step[]} items
519 * @returns {Row}
520 */
521function compactSteps(items) {
522 const next = items.find((item) => item.state !== 'done')
523 return [...items.map((item) => DOT[item.state]), { text: ' ' + (next?.label ?? 'done') + ' ' }]
524}
525
526/**
527 * @param {Call[]} recent
528 * @param {Running[]} inFlight
529 * @returns {Row}
530 */
531function lastCallSpans(recent, inFlight) {
532 const running = inFlight[inFlight.length - 1]
533 if (running) return [{ text: ' · ' }, { text: '⟳ ' + running.name, color: WAITING }]
534 const last = recent[recent.length - 1]
535 if (!last) return []
536 return [
537 { text: ' · last ' + last.name + ' ' + formatMs(last.ms) + ' ', dim: true },
538 last.ok ? { text: '✓', color: OK } : { text: '✗', color: FAILED },
539 ]
540}
541
542/**
543 * `■ code 3 ■ memory 1`, in the order the bar stacks them, for the categories used.
544 * @param {Record<string, number>} byCategory
545 * @param {string} [gap]
546 * @returns {Row}
547 */
548function legendSpans(byCategory, gap = ' ') {
549 return CATEGORIES.filter((c) => (byCategory[c.id] ?? 0) > 0).flatMap((c) => [
550 { text: '■ ', color: c.color },
551 { text: c.label + ' ' + byCategory[c.id] + gap },
552 ])
553}
554
555/** @param {Row} spans */
556const textLength = (spans) => spans.reduce((sum, s) => sum + [...s.text].length, 0)
557
558/**
559 * A row that cannot wrap. While its fixed spans fit, the truncating ones give way as the
560 * layout needs; past that they are left out and the row is cut short with an ellipsis.
561 * @param {Row} row
562 * @param {number | undefined} columns
563 * @returns {Row}
564 */
565export function fitRow(row, columns) {
566 if (columns === undefined || textLength(row.filter((s) => !s.truncate)) <= columns) return row
567 /** @type {Row} */
568 const out = []
569 let room = columns - 1
570 for (const span of row) {
571 if (span.truncate) continue
572 const chars = [...span.text]
573 if (chars.length > room) {
574 if (room > 0) out.push({ ...span, text: chars.slice(0, room).join('') })
575 break
576 }
577 out.push(span)
578 room -= chars.length
579 }
580 return [...out, { text: '…', dim: true }]
581}
582
583/**
584 * Text in a column `width` cells wide, cut with an ellipsis so a space always follows it.
585 * @param {string} text
586 * @param {number} width
587 */
588function cell(text, width) {
589 const chars = [...text]
590 return chars.length < width ? text.padEnd(width) : chars.slice(0, width - 2).join('') + '… '
591}
592
593/**
594 * @param {LookupMetrics} metrics
595 * @param {string} [gap]
596 */
597function hitText({ used, missed, at1, at3, at10 }, gap = ' · ') {
598 const judged = used + missed
599 return ['hit@1 ' + at1, 'hit@3 ' + at3, 'hit@10 ' + at10]
600 .map((part) => part + '/' + judged)
601 .join(gap)
602}
603
604/**
605 * `tokens ~6.2k in · ~88k saved est. │ hit@1 2/4 · …`: what the cortex results cost, what
606 * the lookups saved, and how often the agent opened what they ranked first.
607 * @param {Stats} stats
608 * @param {Lookup[]} lookups
609 * @returns {Row}
610 */
611function metricsSpans(stats, lookups) {
612 if (stats.tokens === 0 && lookups.length === 0) return []
613 const metrics = lookupMetrics(lookups)
614 /** @type {Row} */
615 const row = [{ text: 'tokens ', dim: true }, { text: '~' + formatTokens(stats.tokens) + ' in' }]
616 if (metrics.measured > 0) {
617 row.push(
618 { text: ' · ' },
619 { text: '~' + formatTokens(metrics.saved) + ' saved', color: OK },
620 { text: ' est.', dim: true },
621 )
622 }
623 if (metrics.used + metrics.missed > 0) {
624 row.push({ text: ' │ ', dim: true }, { text: hitText(metrics), truncate: true })
625 }
626 return row
627}
628
629/**
630 * The band above the prompt, as rows of spans: the stacked bar with its totals, a legend
631 * of the categories in use, the token and hit measures, and the /cs checklist. With fewer
632 * rows than that it keeps the bar, then the checklist, then the measures. Draws nothing in
633 * a project without cortex state until a cortex tool is called, and only a hint in one
634 * that has it but has not run /cs yet.
635 * @param {Calls & { markers: string[] | null, columns?: number, maxRows?: number }} input
636 * @returns {Row[]}
637 */
638export function bandModel({ stats, recent, inFlight, markers, lookups = [], columns, maxRows }) {
639 /** @type {Span} */
640 const title = { text: '◆ cortex ', color: TITLE_COLOR, bold: true }
641 const progress = stepsFrom(stats.okNames, markers)
642 const started = progress.steps[0]?.state === 'done'
643
644 if (!started && stats.total === 0 && inFlight.length === 0) {
645 if (markers == null) return []
646 return [[title, { text: 'no cortex session yet — run /cs', dim: true }]]
647 }
648
649 /** @type {Row} */
650 const tail = [{ text: ' ' + stats.total + (stats.total === 1 ? ' call' : ' calls') }]
651 if (stats.errors > 0) tail.push({ text: ' · ' + stats.errors + ' err', color: FAILED })
652 tail.push(...lastCallSpans(recent, inFlight))
653
654 const room = (columns ?? 80) - textLength([title]) - textLength(tail)
655 const width = Math.max(8, Math.min(40, room))
656 const segments = barSegments(stats.byCategory, width)
657 /** @type {Row} */
658 const bar =
659 segments.length > 0
660 ? segments.map((s) => ({ text: '█'.repeat(s.width), color: s.color }))
661 : [{ text: '░'.repeat(width), dim: true }]
662
663 const rows = [[title, ...bar, ...tail]]
664
665 const fits = (/** @type {Row} */ row) => columns === undefined || textLength(row) <= columns
666 const wideLegend = legendSpans(stats.byCategory)
667 const legend = fits(wideLegend) ? wideLegend : legendSpans(stats.byCategory, ' ')
668
669 /** @param {(items: Step[]) => Row} spans */
670 const checklistOf = (spans) => {
671 /** @type {Row} */
672 const row = [{ text: '/cs ', dim: true }, ...spans(progress.steps)]
673 if (progress.gates) row.push({ text: '│ ', dim: true }, ...spans(progress.gates))
674 if (progress.gateOff) row.push({ text: '⚠ gates off', color: WAITING })
675 return row
676 }
677 const checklist =
678 [checklistOf(stepSpans), checklistOf((items) => stepSpans(items, ' '))].find(fits) ??
679 checklistOf(compactSteps)
680
681 const metrics = metricsSpans(stats, lookups)
682 const limit = maxRows ?? 3
683 const showMetrics = metrics.length > 0 && limit >= 3
684 if (legend.length > 0 && limit >= (showMetrics ? 4 : 3)) rows.push(legend)
685 if (showMetrics) rows.push(metrics)
686 if (limit >= 2) rows.push(checklist)
687 return rows
688}
689
690/**
691 * What a recorded call says about its result, as separate parts: for a lookup, the files
692 * behind it and whether one was opened; for a search or a check, how its result read.
693 * @param {Call} call
694 * @param {Lookup | undefined} lookup
695 * @returns {Span[]}
696 */
697function detailParts(call, lookup) {
698 /** @type {Span[]} */
699 const parts = []
700 if (lookup) {
701 const count = lookup.hits.length
702 const behind = tokensBehind(lookup)
703 const size = behind !== null && count > 0 ? ' ~' + formatTokens(behind) : ''
704 parts.push({ text: count + (count === 1 ? ' file' : ' files') + size, dim: true })
705 const state = lookupState(lookup)
706 if (state === 'used') parts.push({ text: 'used #' + lookup.used, color: OK })
707 if (state === 'missed') parts.push({ text: 'missed', color: WAITING })
708 }
709 if (call.quality) {
710 /** @type {Span} */
711 const tag = { text: call.quality.tag }
712 if (!call.quality.ok) tag.color = WAITING
713 parts.push(tag)
714 }
715 return parts
716}
717
718/**
719 * Parts with a dim separator before each one.
720 * @param {Span[]} parts
721 * @param {string} separator
722 * @returns {Row}
723 */
724function joined(parts, separator) {
725 return parts.flatMap((part) => [{ text: separator, dim: true }, part])
726}
727
728/**
729 * The line under a cortex tool's row in the transcript: its category in its color, then
730 * how the call went, with its time, its tokens and how its result did when the mod
731 * recorded it. The row's own flags decide the state, so a call made before the mod
732 * loaded still gets its tag.
733 * @param {ToolRow} row
734 * @returns {Row}
735 */
736export function rowTag({ name, isRunning, isErrored, isInterrupted, call, lookup }) {
737 const category = categoryOf(name)
738 /** @type {Row} */
739 const tag = [{ text: '■ ' + category, color: colorOf(category) }]
740 if (isRunning) return [...tag, { text: ' · ', dim: true }, { text: '⟳', color: WAITING }]
741 if (isInterrupted) return [...tag, { text: ' · interrupted', dim: true }]
742 const failed = isErrored || (call !== undefined && !call.ok)
743 tag.push(
744 { text: call ? ' · ' + formatMs(call.ms) + ' ' : ' ', dim: true },
745 failed ? { text: '✗', color: FAILED } : { text: '✓', color: OK },
746 )
747 if (!call) return tag
748 /** @type {Span[]} */
749 const parts =
750 call.tokens > 0 ? [{ text: '~' + formatTokens(call.tokens) + ' tok', dim: true }] : []
751 return [...tag, ...joined([...parts, ...detailParts(call, lookup)], ' · ')]
752}
753
754/**
755 * The line under a folded group of tool calls that holds cortex calls: how many of each.
756 * @param {string[]} names the short names of the group's cortex calls
757 * @returns {Row}
758 */
759export function groupTag(names) {
760 /** @type {Record<string, number>} */
761 const counts = {}
762 for (const name of names) {
763 const category = categoryOf(name)
764 counts[category] = (counts[category] ?? 0) + 1
765 }
766 return legendSpans(counts)
767}
768
769/** @param {number} at */
770function clock(at) {
771 return new Date(at).toTimeString().slice(0, 8)
772}
773
774/**
775 * The quality column of the per-tool table: hit@k for a lookup tool, the share of good
776 * results and the last one for a tool whose output says how it went.
777 * @param {string} name
778 * @param {ToolStats} tool
779 * @param {LookupMetrics} metrics the tool's own lookups
780 */
781function qualityText(name, tool, metrics) {
782 if (LOOKUP_TOOLS.includes(name)) {
783 if (metrics.used + metrics.missed > 0) return hitText(metrics)
784 return metrics.count > 0 ? 'no result opened yet' : ''
785 }
786 if (tool.judged === 0) return ''
787 return tool.good + '/' + tool.judged + ' ' + tool.kind + ' · last ' + tool.last
788}
789
790/** Cells taken by the name column, wide enough for `knowledge_search`. */
791const NAME_WIDTH = 18
792/** Columns of the per-tool table after the name, right-aligned. */
793const TABLE = [
794 { title: 'calls', width: 6 },
795 { title: 'ok', width: 5 },
796 { title: 'avg', width: 8 },
797 { title: '~tok', width: 8 },
798 { title: 'saved', width: 8 },
799]
800/** What a narrow pane leaves out of the table first, so the quality column keeps room. */
801const TABLE_DROPS = ['avg', '~tok', 'ok']
802/** Cells the quality column keeps before the table drops a column: `hit@1 2/4 · hit@3 3/4`. */
803const QUALITY_WIDTH = 24
804/** Cells taken by the result column of a call row: `10 files ~31k · used #2`. */
805const DETAIL_WIDTH = 26
806/** Below this a call row leaves out its tokens and the result takes the rest of it. */
807const WIDE_CALLS = 86
808
809/**
810 * A section title with a dim rule to the edge.
811 * @param {string} title
812 * @param {number} columns
813 * @param {string} [note]
814 * @returns {Row}
815 */
816function section(title, columns, note = '') {
817 const used = title.length + (note ? note.length + 1 : 0) + 1
818 /** @type {Row} */
819 const row = [{ text: title, bold: true, color: TITLE_COLOR }]
820 if (note) row.push({ text: ' ' + note, dim: true })
821 row.push({ text: ' ' + '─'.repeat(Math.max(0, columns - used)), dim: true })
822 return row
823}
824
825/**
826 * One row per tool used, in the bar's category order, busiest first within one.
827 * @param {Stats} stats
828 * @param {Lookup[]} lookups
829 * @param {number} columns
830 * @returns {Row[]}
831 */
832function toolTable(stats, lookups, columns) {
833 const order = (/** @type {string} */ name) =>
834 CATEGORIES.findIndex((c) => c.id === categoryOf(name))
835 const names = Object.keys(stats.byTool).sort(
836 (a, b) => order(a) - order(b) || (stats.byTool[b]?.calls ?? 0) - (stats.byTool[a]?.calls ?? 0),
837 )
838 if (names.length === 0) return []
839 const tableWidth = (/** @type {typeof TABLE} */ shown) =>
840 2 + NAME_WIDTH + shown.reduce((sum, column) => sum + column.width, 0) + 2 + QUALITY_WIDTH
841 let shown = TABLE
842 for (const title of TABLE_DROPS) {
843 if (tableWidth(shown) > columns) shown = shown.filter((column) => column.title !== title)
844 }
845 const heading =
846 ' ' +
847 'tool'.padEnd(NAME_WIDTH) +
848 shown.map((column) => column.title.padStart(column.width)).join('') +
849 ' quality'
850 /** @type {Row[]} */
851 const rows = [section('Per tool', columns), [{ text: heading, dim: true, truncate: true }]]
852 for (const name of names) {
853 const tool = stats.byTool[name]
854 if (!tool) continue
855 const metrics = lookupMetrics(lookups.filter((lookup) => lookup.name === name))
856 const saved = metrics.measured > 0 ? '~' + formatTokens(metrics.saved) : '·'
857 /** @type {Record<string, Span>} */
858 const cells = {
859 calls: { text: String(tool.calls) },
860 ok:
861 tool.errors > 0
862 ? { text: String(tool.calls - tool.errors), color: FAILED }
863 : { text: String(tool.calls) },
864 avg: { text: formatMs(tool.ms / tool.calls), dim: true },
865 '~tok': { text: '~' + formatTokens(tool.tokens) },
866 saved: metrics.saved > 0 ? { text: saved, color: OK } : { text: saved, dim: true },
867 }
868 rows.push([
869 { text: '■ ', color: colorOf(categoryOf(name)) },
870 { text: cell(name, NAME_WIDTH) },
871 ...shown.map((column) => {
872 const span = cells[column.title] ?? { text: '' }
873 return { ...span, text: span.text.padStart(column.width) }
874 }),
875 { text: ' ' + qualityText(name, tool, metrics), truncate: true },
876 ])
877 }
878 return rows
879}
880
881/**
882 * Spans padded with spaces to `width` cells, so the column after them lines up.
883 * @param {Span[]} spans
884 * @param {number} width
885 * @returns {Row}
886 */
887function padded(spans, width) {
888 const room = width - textLength(spans)
889 return room > 0 ? [...spans, { text: ' '.repeat(room) }] : spans
890}
891
892/**
893 * The /cortex-calls pane: the totals and measures, a row per tool, then the calls:
894 * running ones first, then the recent ones, newest first. Every column has a fixed width
895 * and only the last one in a row is cut to fit.
896 * @param {Calls & { limit?: number, columns?: number }} input
897 * @returns {Row[]}
898 */
899export function paneModel({ stats, recent, inFlight, lookups = [], limit = 40, columns = 80 }) {
900 /** @type {Row} */
901 const header = [
902 { text: '◆ ', color: TITLE_COLOR, bold: true },
903 { text: stats.total + ' cortex calls', bold: true },
904 ]
905 if (stats.errors > 0) header.push({ text: ' · ' + stats.errors + ' failed', color: FAILED })
906 const rows = [header]
907
908 if (stats.total === 0 && inFlight.length === 0) {
909 rows.push([{ text: 'Nothing yet. Run /cs to start a cortex session.', dim: true }])
910 return rows
911 }
912
913 const metrics = metricsSpans(stats, lookups)
914 if (metrics.length > 0) rows.push(metrics)
915 const table = toolTable(stats, lookups, columns)
916 if (table.length > 0) rows.push([{ text: ' ' }], ...table)
917 rows.push([{ text: ' ' }], section('Calls', columns, 'newest first'))
918
919 const byId = new Map(lookups.map((lookup) => [lookup.toolUseId, lookup]))
920 for (const call of [...inFlight].reverse()) {
921 rows.push([
922 { text: 'running ', dim: true },
923 { text: ' ⟳ ', color: WAITING },
924 { text: '■ ', color: colorOf(categoryOf(call.name)) },
925 { text: cell(call.name, NAME_WIDTH) },
926 { text: ' ' + call.args, dim: true, truncate: true },
927 ])
928 }
929 const wide = columns >= WIDE_CALLS
930 for (const call of [...recent].reverse().slice(0, limit)) {
931 const lookup = byId.get(call.toolUseId)
932 const parts = detailParts(call, lookup)
933 const detail = joined(parts, ' · ').slice(1)
934 /** @type {Row} */
935 const row = [
936 { text: clock(call.at), dim: true },
937 call.ok ? { text: ' ✓ ', color: OK } : { text: ' ✗ ', color: FAILED },
938 { text: '■ ', color: colorOf(call.category) },
939 { text: cell(call.name, NAME_WIDTH) },
940 { text: formatMs(call.ms).padStart(7), dim: true },
941 ]
942 /** @type {Span} */
943 const last = call.error
944 ? { text: ' ' + call.error, color: FAILED, truncate: true }
945 : { text: ' ' + call.args, dim: true, truncate: true }
946 if (wide) {
947 row.push(
948 { text: ('~' + formatTokens(call.tokens)).padStart(8), dim: true },
949 { text: ' ' },
950 ...padded(detail, DETAIL_WIDTH),
951 last,
952 )
953 } else if (parts.length > 0 && call.ok) {
954 // The result says more than the arguments. Where it does not fit, the file count
955 // goes before the verdict does; what still does not fit is cut by fitRow.
956 const room = columns - textLength(row) - 2
957 const short = textLength(detail) > room && lookup ? parts.slice(1) : parts
958 row.push({ text: ' ' }, ...joined(short, ' · ').slice(1))
959 } else {
960 row.push(last)
961 }
962 rows.push(fitRow(row, columns))
963 }
964 return rows
965}
966
967/**
968 * A span as Text props, leaving out every prop it does not set.
969 * @param {Span} span
970 * @returns {import('claude-code').TextProps & { children: string }}
971 */
972export function textProps(span) {
973 /** @type {import('claude-code').TextProps & { children: string }} */
974 const props = { children: span.text }
975 if (span.color) props.color = span.color
976 if (span.bold) props.bold = true
977 if (span.dim) props.dimColor = true
978 if (span.truncate) props.wrap = 'truncate-end'
979 return props
980}
981