SLOPSHOPPER

cortex-bar

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…

newpanebandrowsguardcommand
★ 59v0.9.0MITupdated 2026-10-09lktiep/cortex-hub/mods/cortex-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cortex-bar
│ ┃ Cortex calls ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ 0 cortex calls │ ┃ Nothing yet. Run /cs to start a cortex ⏺ Read(src/auth.ts) │ ┃ session. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /cortex-bar │ ⎿ cortex-bar: cortex-bar: hidden (/cortex-bar shows it) │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cortex calls
◆ 0 cortex calls Nothing yet. Run /cs to start a cortex session.
README

cortex-bar

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.

In VS Code, Antigravity and Cursor

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.

Install

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

Commands

CommandWhat it does
/cortex-barShow or hide the band and the row tags (remembered)
/cortex-bar on / offShow / hide explicitly
/cortex-bar resetClear the call counts and the measures
/cortex-callsOpen 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.

What it measures

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.

  • Tokens in (~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.
  • Lookups are 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.
  • hit@k is the share of judged lookups (used or missed) whose best opened file was ranked k or better. It is how often the ranking put the right file where the agent looked.
  • Saved (~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.
  • Quality of other tools comes from their own output: the number of results and the top score of 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.

Colors

CategoryTools
sessionsession_start, session_end, changes, health, list_repos
knowledgeknowledge_search, knowledge_store
memorymemory_search, memory_store, memory_delete
codecode_search, code_context, code_impact, code_reindex, cypher, detect_changes
qualityquality_report, plan_quality, tool_stats
taskstask_*
otherany cortex tool not listed

Where the data comes from

  • Calls are timed in a tool.call hook on every mcp__<server>__cortex_* tool, whatever the MCP server is called. A new cortex_session_start starts the counts over.
  • Opened files come from a tool.call hook on Read, Edit, Write, NotebookEdit and Bash, after the tool ran; a denied or failed call does not count.
  • The /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.

Developing

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.

Source 2 files
hooks/register.js 541 lines
1// 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}
541
hooks/model.js 981 lines
1// 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