SLOPSHOPPER

nebula-ui

A modern restyle of Claude Code: prompt bars, rich markdown, clickable tool rows and a live dashboard band, each part switchable from /nebula

newpanebandspinnerrowsguard
v0.2.0no licenseupdated 2026-10-16astrosteveo/nebula/plugins/nebula-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · nebula-ui
│ ┃ Nebula UI ✕ │ ┃ ✦ Nebula UI ▌ fix the failing auth test and add an audit log call │ ┃ Press a number, or Enter on a row, to switch │ ┃ that part. Esc closes. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ 1: UserMessage │ ┃ 2: AssistantMessage open-undefined │ ┃ 3: ToolUse ⏺ Bash(bun test) │ ┃ 4: ToolGroup ⎿ 3 pass, 1 fail │ ┃ 5: Spinner │ ┃ 6: TurnDuration │ ┃ 7: InfoNotice Done. I made refresh reject expired claims and added an audit call. │ ┃ 8: SessionMode │ ┃ 9: PromptHint │ ┃ 0: ToolProgress ─── ✦ Worked for 42s ──────────────────────────────────────────────── │ ┃ a: AbovePrompt ──────────────────────────────────────────────── │ ┃ b: CommandOutput ────────────────────── │ ┃ c: ClickCommands │ ┃ d: ShellOpen › /nebula │ ┃ ▎ Nebula UI panel opened. │ ┃ t: theme │ ┃ w: frame-width │ ✻  … ✦ thinking Thinking… ✦ opus 5.5 │ ⎇ ? ±2 │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% │ 5h ▰▰▱▱▱▱ 31% │ $0.42 │ ⏱ 30m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ◉ auto-accept edits

Draws

Band
✦ opus 5.5 │ ⎇ ? ±2 │ ctx ▰▰▰▰▰▱▱▱▱▱ 49% │ 5h ▰▰▱▱▱▱ 31% │ $0.42 │ ⏱ 30m
Pane · Nebula UI
✦ Nebula UI Press a number, or Enter on a row, to switch that part. Esc closes. 1: UserMessage 2: AssistantMessage 3: ToolUse 4: ToolGroup 5: Spinner 6: TurnDuration 7: InfoNotice 8: SessionMode 9: PromptHint 0: ToolProgress a: AbovePrompt b: CommandOutput c: ClickCommands d: ShellOpen t: theme w: frame-width
Pane · Processes
Nothing started yet. Click a shell command in one of Claude's replies and choose run here.
Pane · nebula-run
No command to run.
Spinner
✻  … ✦ thinking Thinking…
Turn line
─── ✦ Worked for 42s ─────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────
Your message
▌ fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
open-undefined
Command output
▎ Nebula UI panel opened.
README

nebula-ui

A modern restyle of Claude Code's terminal UI, built only on the mods API. Every part can be switched on or off from /nebula, and the choices are kept across sessions.

nebula-ui in a real Claude Code session: a tour reply with a table, a callout and a runnable code frame; Claude starting a server and checking it with the process tools; /procs; and the /nebula panel flipping tool rows off and on

<sub>A real session, recorded with VHS from docs/demo/nebula-ui.tape (MP4).</sub>

Install

/plugin marketplace add astrosteveo/nebula
/plugin install nebula-ui@nebula

What it changes

PartChange
Prompt barYour prompts sit behind a thin accent bar instead of a grey box
Rich markdownCode fences in a frame with the language and line count on the top edge, highlighted; diff fences in diff colors; tables redrawn with a rounded frame, an accent header, alignment and inline styles; headings with glyphs; > [!NOTE]-style callouts; blockquotes and rules
Tool rows✓ ▸ Bash npm test 12 lines: a status glyph, the tool as a pill, its target trimmed to fit, and a summary (lines of output, files found, search results, an HTTP status). Click anywhere on the row to open what the tool returned: Bash output and a diff of each file the command changed, highlighted file contents for Read, the page for WebFetch, errors, or JSON. Under the row, only an error or the files changed stay, and a click there opens the row too
Clickable commandsIn a reply, a command in inline code is a link. A click puts /command or ! command in your prompt for your Enter (copied instead if you're typing); a shell command opens a dialog to run it here or copy it. Every code frame has ⧉ copy on its top border, and a frame that holds commands (a sh/bash/console fence, or one whose lines all read as commands) also has ▶ run, or ↵ prompt for a lone / command. ▶ run starts the block at once and draws its output in the same frame, under the code, with ■ stop on the border while it runs. Clicks reach the mod in fullscreen
Processes/procs lists every command you ran from a reply: status, runtime, its last lines with URLs as links (a server on 0.0.0.0 opens as localhost), and ■ stop (the whole process group: it's started with setsid), ↻ restart and ⤢ follow, a pane that streams one process's log. The band shows ⚙ N running. They stop when the session ends; after /reload-plugins one still running is marked detached and can still be stopped
Process toolsClaude can see them too: processes lists them or reads one's output, and process_control starts, stops or restarts one, so "check the dev server" needs no copied logs. The engine asks your permission for them as for any tool
Open shell outputBash rows and Shell groups start open on what they printed; a click closes one, and switching this off in /nebula starts them closed
Explore groupsFolded reads and searches as one line (Shell when they're all commands); click to unfold into tool rows
SpinnerTwo pills, what the turn is doing (✦ thinking, ✎ writing, ⚙ running tools) and its word (Kneading…) in Claude's color, before the engine's animated glyph, time and tokens
Turn divider─── ✦ Baked for 1m 4s ────── at the end of each turn
NoticesStartup notices with an ℹ icon
Mode chipsFooter modes as ◉ focus
Hint tail✦ /nebula at the end of the hint line
Background hintA shorter ⇣ ctrl+b to run in background
Command outputWhat a slash command printed, behind an accent bar (red with ✗ failed for an error) instead of a dim ⎿ line
Dashboard bandModel, git branch and changes, context gauge, rate-limit windows with reset times, cost and session age, above the prompt

Accent (in /nebula, key t): terminal follows your Claude Code theme (the default); claude is Claude's orange, also from your theme; nebula, ember, mint, rose and gold are fixed colors; mono uses your text color.

Frame width (in /nebula, key w): code and output frames fill the width (the default), fit their content, or hold to 80, 100 or 120 columns.

With the terminal accent, every color follows your theme: the mod paints with theme keys (permission as its accent). The fixed presets suit dark themes best.

Limits

  • The terminal surface only; the desktop app, VS Code and mobile draw as before.
  • The / command menu is the engine's own: its descriptions also go to the model as the list of skills, so nebula-ui leaves them as they are.
  • The echo of a local slash command (/plugin, /reload-plugins) keeps the engine's grey row: the API exposes no site for it.
  • The engine's own click-to-expand on a tool row can't be followed by a mod, so nebula-ui's rows expand with their own toggle.
  • A setext heading (text underlined with ---) is read as a horizontal rule.
  • Quotes, callouts and command output are drawn as wrapped text with inline styles (code, bold, links), so a list or code fence inside one shows as written.

Changes

See CHANGELOG.md for what each version added, changed and fixed.

Developing

CI runs claude plugin validate and claude plugin test on every push that touches the plugin, on Claude Code 2.1.300 (the gate) and latest (an early warning).

claude plugin validate plugins/nebula-ui and claude plugin test plugins/nebula-ui check it; claude --plugin-dir plugins/nebula-ui loads it from this folder and reloads it as it changes.

Source 6 files
hooks/register.tsx 1623 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Dash, Disabled, FrameWidth, Proc } from '../types'
5import {
6  cellWidth,
7  colorRuns,
8  fenceCommand,
9  fitColumns,
10  lastCells,
11  linkCommands,
12  parse,
13  printable,
14  printableColored,
15  rowWidth,
16  spans,
17  takeCells,
18  width,
19  wrapSpans,
20  wrapText,
21} from './markdown'
22import type { Block, Callout, Command, Span } from './markdown'
23import { drawBand, gather } from './dashboard'
24import {
25  FOLLOW_PANE,
26  PROCS_PANE,
27  TONE,
28  adopt,
29  clearProc,
30  describeOutput,
31  describeProcs,
32  drawFollow,
33  drawProcs,
34  isRunning,
35  outputLine,
36  restartProc,
37  startProc,
38  statusWord,
39  stopAll,
40  stopProc,
41} from './procs'
42import { ACCENT, THEMES, THEME_NAMES, isThemeName, setAccent } from './theme'
43import type { ThemeName } from './theme'
44
45const PANEL = 'nebula-ui'
46
47const disabled = atom({ plugin: 'nebula-ui', key: 'disabled' } as const, [] as Disabled)
48const dash = atom({ plugin: 'nebula-ui', key: 'dash' } as const, null as Dash | null)
49const procList = atom({ plugin: 'nebula-ui', key: 'procs' } as const, [] as Proc[])
50const pending = atom({ plugin: 'nebula-ui', key: 'pending' } as const, null as { command: string; origin: string | null } | null)
51const frameWidth = atom({ plugin: 'nebula-ui', key: 'frameWidth' } as const, 'fill' as FrameWidth)
52const theme = atom({ plugin: 'nebula-ui', key: 'theme' } as const, 'terminal' as ThemeName)
53
54/** Lines of a run's output a code frame shows; /procs keeps more. */
55const RUN_LINES = 12
56
57/** The frame widths the panel steps through, in order. */
58const FRAME_WIDTHS: FrameWidth[] = ['fill', 'fit', 80, 100, 120]
59
60const isFrameWidth = (value: unknown): value is FrameWidth =>
61  value === 'fill' || value === 'fit' || (typeof value === 'number' && value >= 20)
62
63/** The dialog a click on a shell command opens: run it here, or copy it. */
64const RUN_PANE = 'nebula-run'
65
66const SITES = [
67  { id: 'UserMessage', label: 'Prompt bar', about: 'your prompts behind an accent bar' },
68  { id: 'AssistantMessage', label: 'Rich markdown', about: 'framed code and tables, headings, callouts' },
69  { id: 'ToolUse', label: 'Tool rows', about: 'tool pills; click ▸ to open the output' },
70  { id: 'ToolGroup', label: 'Explore groups', about: 'folded reads and searches; click ▸ to unfold' },
71  { id: 'Spinner', label: 'Spinner', about: 'a pill saying what the turn is doing' },
72  { id: 'TurnDuration', label: 'Turn divider', about: 'a rule at the end of each turn' },
73  { id: 'InfoNotice', label: 'Notices', about: 'startup notices with an icon' },
74  { id: 'SessionMode', label: 'Mode chips', about: 'footer modes as chips' },
75  { id: 'PromptHint', label: 'Hint tail', about: 'a /nebula mark in the hint line' },
76  { id: 'ToolProgress', label: 'Background hint', about: 'a compact ctrl+b hint' },
77  { id: 'AbovePrompt', label: 'Dashboard band', about: 'model, branch, context, limits, cost above the prompt' },
78  { id: 'CommandOutput', label: 'Command output', about: 'what a slash command printed, behind an accent bar' },
79  { id: 'ClickCommands', label: 'Clickable commands', about: 'click a command in a reply: / and ! go to your prompt, a shell command opens a run dialog' },
80  { id: 'ShellOpen', label: 'Open shell output', about: 'Bash rows and Shell groups start open; a click still closes them' },
81] as const
82
83type Site = (typeof SITES)[number]['id']
84
85/** The panel's keys, one per part, in order. */
86const HOTKEYS = '1234567890abcdefghij'
87const isOn = async ($: EngineInterface, site: Site) => !(await read($, disabled)).includes(site)
88
89/**
90 * Sets the accent from the person's theme before a drawing: the read also makes the drawing redraw
91 * when the theme changes. Every render hook that paints the accent calls it first.
92 */
93const themed = async ($: EngineInterface) => {
94  setAccent(await read($, theme))
95}
96
97const stepTheme = async ($: EngineInterface) => {
98  const name = await update($, theme, now => THEME_NAMES[(THEME_NAMES.indexOf(now) + 1) % THEME_NAMES.length]!)
99  await $.store.set('theme', name)
100}
101
102const stepFrameWidth = async ($: EngineInterface) => {
103  const width = await update($, frameWidth, now => {
104    const at = FRAME_WIDTHS.indexOf(now)
105
106    return FRAME_WIDTHS[(at + 1) % FRAME_WIDTHS.length]!
107  })
108  await $.store.set('frameWidth', width)
109}
110
111const toggle = async ($: EngineInterface, site: Site) => {
112  const list = await update($, disabled, off =>
113    off.includes(site) ? off.filter(one => one !== site) : [...off, site],
114  )
115  await $.store.set('disabled', list)
116}
117
118/** What the engine's spinner draws in place of its word, which nebula-ui draws in a pill. */
119const BLANK_WORD = '\u00a0'
120
121/** The pill before the spinner line: what the turn is doing, by the spinner's mode. */
122const SPINNER_MODES = {
123  requesting: { glyph: '⇅', label: 'connecting', color: 'inactive' },
124  responding: { glyph: '✎', label: 'writing', color: 'success' },
125  thinking: {
126    glyph: '✦',
127    label: 'thinking',
128    get color() {
129      return ACCENT
130    },
131  },
132  'tool-input': { glyph: '⋯', label: 'preparing', color: 'suggestion' },
133  'tool-use': { glyph: '⚙', label: 'running tools', color: 'suggestion' },
134} as const
135
136/** Whether a plain object has its own entry under `key`: a tool named `constructor` has none. */
137const own = (record: object, key: string) => Object.prototype.hasOwnProperty.call(record, key)
138
139const NOUNS: Record<string, [string, string]> = {
140  Read: ['read', 'reads'],
141  Grep: ['search', 'searches'],
142  Glob: ['search', 'searches'],
143  LS: ['listing', 'listings'],
144  Bash: ['command', 'commands'],
145  WebFetch: ['fetch', 'fetches'],
146  WebSearch: ['web search', 'web searches'],
147}
148
149const TARGETS: Record<string, string> = {
150  Bash: 'command',
151  Read: 'file_path',
152  Edit: 'file_path',
153  Write: 'file_path',
154  NotebookEdit: 'notebook_path',
155  Grep: 'pattern',
156  Glob: 'pattern',
157  WebFetch: 'url',
158  WebSearch: 'query',
159  Agent: 'description',
160  Skill: 'skill',
161  ToolSearch: 'query',
162}
163
164const PATHLIKE = new Set(['Read', 'Edit', 'Write', 'NotebookEdit', 'WebFetch'])
165
166const oneLine = (text: string) => printable(text).split('\n')[0]!.replace(/\s+/g, ' ').trim()
167
168/** What the call acts on, in one line: Bash's command, Read's path, a search's pattern. */
169/** Leading `cd dir &&`, `cd dir;` and `NAME=value;` steps, which say where a command runs, not what it does. */
170const PREAMBLE = /^(?:cd\s+(?:"[^"]*"|'[^']*'|\S+)\s*(?:&&|;)\s*|[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s*;\s*)+/
171
172/** A shell command without its preamble, or as written when the preamble is all there is. */
173const withoutPreamble = (command: string) => command.replace(PREAMBLE, '') || command
174
175/** The process tool's name as a model calls it, whose row names the command it starts. */
176const PROCESS_TOOL = 'mcp__nebula-ui__process_control'
177
178const describe = (tool: string, input: unknown): string => {
179  if (typeof input !== 'object' || input === null) {
180    return ''
181  }
182  const fields = input as Record<string, unknown>
183  const named = tool === PROCESS_TOOL ? 'command' : own(TARGETS, tool) ? TARGETS[tool] : undefined
184  let value = named === undefined ? Object.values(fields).find(one => typeof one === 'string') : fields[named]
185  // A stop or restart names no command: its row names the process.
186  if (tool === PROCESS_TOOL && typeof value !== 'string') {
187    value = fields.id
188  }
189
190  if (typeof value !== 'string') {
191    return ''
192  }
193
194  return tool === 'Bash' ? withoutPreamble(oneLine(value)) : oneLine(value)
195}
196
197/** `mcp__server__tool` as `server · tool`; a built-in as itself. */
198const toolName = (tool: string) => {
199  const [prefix, server, ...rest] = tool.split('__')
200
201  return prefix === 'mcp' && server !== undefined && rest.length > 0 ? `${server} · ${rest.join('__')}` : tool
202}
203
204const tally = (calls: ReadonlyArray<{ tool: string }>) => {
205  const counts = new Map<string, { n: number; nouns: [string, string] }>()
206  for (const call of calls) {
207    const nouns: [string, string] = own(NOUNS, call.tool) ? NOUNS[call.tool]! : [`${call.tool} call`, `${call.tool} calls`]
208    const key = nouns[0]
209    const seen = counts.get(key)
210    counts.set(key, { n: (seen?.n ?? 0) + 1, nouns })
211  }
212
213  return [...counts.values()].map(({ n, nouns }) => `${n} ${n === 1 ? nouns[0] : nouns[1]}`).join(' · ')
214}
215
216const duration = (ms: number) => {
217  // A duration the engine did not measure reads as none, not as NaN.
218  const total = Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : 0
219  const hours = Math.floor(total / 3600)
220  const minutes = Math.floor((total % 3600) / 60)
221  const seconds = total % 60
222  if (hours > 0) {
223    return `${hours}h ${minutes}m`
224  }
225
226  return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
227}
228
229const CALLOUTS: Record<Callout, { glyph: string; color: string; title: string }> = {
230  note: { glyph: 'ℹ', color: 'suggestion', title: 'Note' },
231  tip: { glyph: '✦', color: 'success', title: 'Tip' },
232  important: {
233    glyph: '◆',
234    get color() {
235      return ACCENT
236    },
237    title: 'Important',
238  },
239  warning: { glyph: '▲', color: 'warning', title: 'Warning' },
240  caution: { glyph: '✗', color: 'error', title: 'Caution' },
241}
242
243const HEADINGS = ['◆', '◇', '›']
244
245
246const expanded = { plugin: 'nebula-ui', key: 'expanded' } as const
247
248/** Tools whose result the engine draws under the row as the point of the call (a diff, a plan, a subagent's answer): no expander. */
249const ENGINE_RESULTS = new Set([
250  'Edit',
251  'Write',
252  'NotebookEdit',
253  'MultiEdit',
254  'TodoWrite',
255  'Agent',
256  'Task',
257  'AskUserQuestion',
258  'ExitPlanMode',
259  'EnterPlanMode',
260])
261
262/** Lines an opened tool row shows before it says how many more there are. */
263const DETAIL_LINES = 40
264
265/** The most lines an opened row shows once its "show all" control is on. */
266const MAX_LINES = 2000
267
268type Ui = Elements['terminal']
269
270const plural = (n: number, word: string) => `${n} ${n === 1 ? word : `${word}s`}`
271
272/** The fewest lines a code frame names its length for; a shorter one is read at a glance. */
273const LINE_COUNT_FROM = 5
274
275const lineCount = (source: string) => plural(source === '' ? 0 : source.split('\n').length, 'line')
276
277/** The first `limit` lines of `text`, and a note naming how many were left out. `colored` keeps color codes for `colorRuns`. */
278const clip = (text: string, limit = DETAIL_LINES, colored = false) => {
279  const lines = (colored ? printableColored(text) : printable(text)).replace(/\n+$/, '').split('\n')
280  const hidden = lines.length - limit
281
282  return {
283    text: lines.slice(0, limit).join('\n'),
284    hidden: Math.max(0, hidden),
285    note: hidden > 0 ? `${plural(hidden, 'more line')}` : lineCount(lines.join('\n')),
286  }
287}
288
289const field = (value: unknown, key: string): unknown =>
290  typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[key] : undefined
291
292const text = (value: unknown) => (typeof value === 'string' ? printable(value) : '')
293
294type EditedFile = { path: string; diff: string; added: number; removed: number }
295
296/** The files a Bash command changed, as the engine recorded them (`bashEditDiff`), each as a unified diff. */
297const editedFiles = (output: unknown): EditedFile[] => {
298  const files = field(field(output, 'bashEditDiff'), 'files')
299  if (!Array.isArray(files)) {
300    return []
301  }
302
303  return files.flatMap(file => {
304    const path = text(field(file, 'filePath'))
305    const hunks = field(file, 'hunks')
306    if (path === '' || !Array.isArray(hunks)) {
307      return []
308    }
309    const lines = hunks.flatMap(hunk => {
310      const at = (key: string) => Number(field(hunk, key) ?? 0)
311      const body = field(hunk, 'lines')
312
313      return [
314        `@@ -${at('oldStart')},${at('oldLines')} +${at('newStart')},${at('newLines')} @@`,
315        ...(Array.isArray(body) ? body.map(line => printable(String(line))) : []),
316      ]
317    })
318    const body = lines.filter(line => !line.startsWith('@@'))
319
320    return [
321      {
322        path,
323        diff: lines.join('\n'),
324        added: body.filter(line => line.startsWith('+')).length,
325        removed: body.filter(line => line.startsWith('-')).length,
326      },
327    ]
328  })
329}
330
331const basename = (path: string) => path.split('/').pop() ?? path
332
333const changeNote = (file: EditedFile) => `${basename(file.path)} +${file.added} −${file.removed}`
334
335/** Lines the engine adds to a command's stderr about itself (`Shell cwd was reset to …`), not the command's own. */
336const ENGINE_NOTE = /^Shell cwd was reset to .*$/gm
337
338const withoutEngineNotes = (stderr: string) => stderr.replace(ENGINE_NOTE, '').trim()
339
340/** The text a result carries as content blocks (`{ type: 'text', text }`), alone or under `content`, as MCP tools return it. */
341const textOf = (value: unknown): string | null => {
342  const blocks = Array.isArray(value) ? value : field(value, 'content')
343  if (!Array.isArray(blocks)) {
344    return null
345  }
346  const texts = blocks.map(block => (field(block, 'type') === 'text' ? text(field(block, 'text')) : '')).filter(one => one !== '')
347
348  return texts.length > 0 ? texts.join('\n\n') : null
349}
350
351/** The links a WebSearch found, as `[title](url)` pairs. */
352const searchLinks = (output: unknown) => {
353  const results = field(output, 'results')
354
355  return (Array.isArray(results) ? results : []).flatMap(result => {
356    const content = field(result, 'content')
357
358    return (Array.isArray(content) ? content : []).flatMap(hit => {
359      const title = text(field(hit, 'title'))
360      const url = text(field(hit, 'url'))
361
362      return url === '' ? [] : [{ title: title === '' ? url : title, url }]
363    })
364  })
365}
366
367const filenames = (output: unknown) => {
368  const names = field(output, 'filenames')
369
370  return Array.isArray(names) ? names.filter((one): one is string => typeof one === 'string') : []
371}
372
373/** A dim word or two after a finished call's target: what it returned, at a glance. */
374const summary = (tool: string, output: unknown) => {
375  if (tool === 'Read') {
376    const lines = field(field(output, 'file'), 'numLines')
377
378    return typeof lines === 'number' ? plural(lines, 'line') : ''
379  }
380  if (tool === 'Bash') {
381    const out = `${text(field(output, 'stdout'))}${withoutEngineNotes(text(field(output, 'stderr')))}`.trim()
382    const files = editedFiles(output).length
383
384    return [out === '' ? 'no output' : lineCount(out), ...(files > 0 ? [plural(files, 'file') + ' changed'] : [])].join(' · ')
385  }
386  if (tool === 'Grep' || tool === 'Glob') {
387    const lines = field(output, 'numLines')
388    const files = field(output, 'numFiles')
389
390    return typeof lines === 'number' && lines > 0
391      ? plural(lines, 'line')
392      : typeof files === 'number'
393        ? plural(files, 'file')
394        : ''
395  }
396  if (tool === 'WebSearch') {
397    return plural(searchLinks(output).length, 'result')
398  }
399  if (tool === 'WebFetch') {
400    const code = field(output, 'code')
401
402    return typeof code === 'number' ? `${code} ${text(field(output, 'codeText'))}`.trim() : ''
403  }
404
405  return ''
406}
407
408/** Drawing pieces shared by replies and tool rows, sized to `columns`. */
409const kit = ({ Box, Text, Code }: Ui, columns: number, frameSize: FrameWidth = 'fill') => {
410  const line = (glyph: string, color: string) => (
411    <Box height={1} overflow="hidden">
412      <Text color={color}>{glyph.repeat(columns)}</Text>
413    </Box>
414  )
415
416  const styled = (run: Span) => {
417    switch (run.style) {
418      case 'code':
419        return <Text color={ACCENT}>{run.text}</Text>
420      case 'command':
421        return (
422          <Text weight="bold" color={ACCENT}>
423            {run.text}
424          </Text>
425        )
426      case 'bold':
427        return <Text weight="bold">{run.text}</Text>
428      case 'italic':
429        return <Text italic>{run.text}</Text>
430      case 'strike':
431        return <Text strikethrough>{run.text}</Text>
432      case 'link':
433        return (
434          <Text color="suggestion" underline>
435            {run.text}
436          </Text>
437        )
438      default:
439        return run.text
440    }
441  }
442
443  // Bars are drawn as text, one glyph per wrapped row: a bar is exactly as tall as what it marks,
444  // and nothing is placed absolutely (which the engine drew whole past a site partly scrolled out of view).
445  const barred = (rows: Span[][], color: string, options: { glyph?: string; head?: RenderChildren; isDim?: boolean } = {}) => {
446    const mark = <Text color={color}>{options.glyph ?? '▌'} </Text>
447
448    return (
449      <Box flexDirection="column">
450        {options.head !== undefined && (
451          <Text>
452            {mark}
453            {options.head}
454          </Text>
455        )}
456        {rows.map(row => (
457          <Text>
458            {mark}
459            <Text dimColor={options.isDim === true}>{row.map(styled)}</Text>
460          </Text>
461        ))}
462      </Box>
463    )
464  }
465
466  // A frame is a bordered Box, which the engine clips like any other when it is partly scrolled out of
467  // view (absolutely placed sides were drawn whole from the viewport's edge, spilling past the frame).
468  // Its label and note are laid over the top border: column-reverse puts the overlay above the Box, its
469  // -1 margin pulls it onto the border row, and as the later child it is painted last.
470  // `fill` stretches as a column child does, `fit` shrinks to the wider of the content and the top border's
471  // words, and a number of columns is held to what the row has room for.
472  const sized =
473    frameSize === 'fill'
474      ? {}
475      : frameSize === 'fit'
476        ? { alignSelf: 'flex-start' as const }
477        : { width: Math.min(frameSize, columns) }
478
479  const frame = (body: RenderChildren, label?: string, note?: string, action?: RenderChildren) => (
480    <Box flexDirection="column-reverse" {...sized}>
481      <Box flexDirection="column" borderStyle="round" borderColor="inactive" paddingX={1}>
482        {body}
483      </Box>
484      <Box flexDirection="row" justifyContent="space-between" marginX={2} marginBottom={-1} height={1} overflow="hidden">
485        <Text color={ACCENT}>{label === undefined ? '' : ` ${label} `}</Text>
486        <Box flexDirection="row">
487          <Text color="inactive">{note === undefined ? '' : ` ${note} `}</Text>
488          {action}
489        </Box>
490      </Box>
491    </Box>
492  )
493
494  // A blank line is drawn as a space: the engine's code element drops an empty line, and the frame would lose it.
495  const code = (source: string, options: { language?: string; path?: string; startLine?: number }) => (
496    <Code
497      source={source
498        .split('\n')
499        .map(line => (line === '' ? ' ' : line))
500        .join('\n')}
501      {...options}
502      format={options.language === 'diff' ? 'diff' : 'source'}
503    />
504  )
505
506  const inline = (line: string) => spans(line).map(styled)
507
508  // A table too wide for the reply is drawn as each row's cells, one under another, with their headers.
509  const stacked = (block: Extract<Block, { kind: 'table' }>) => (
510    <Box flexDirection="column" rowGap={1}>
511      {block.rows.map(values => (
512        <Box flexDirection="column">
513          {block.header.map((head, column) => {
514            const value = values[column] ?? ''
515
516            return value === ''
517              ? null
518              : wrapText(`${head}: ${value}`, columns - 4).map(row => <Text>{row.map(styled)}</Text>)
519          })}
520        </Box>
521      ))}
522    </Box>
523  )
524
525  // A table is drawn as lines of text: every cell wrapped to its column, each row as tall as its tallest
526  // cell, the dividers written into each line rather than placed beside it.
527  const table = (block: Extract<Block, { kind: 'table' }>) => {
528    const count = block.header.length
529    const natural = block.header.map((cell, column) =>
530      Math.max(width(cell), ...block.rows.map(row => width(row[column] ?? ''))),
531    )
532    const room = columns - 4 - (3 * count + 1)
533    const widths = fitColumns(natural, room)
534    // A table too wide for its columns at their narrowest is drawn a record at a time, not cut at the edge.
535    if (widths.reduce((sum, one) => sum + one, 0) > room) {
536      return stacked(block)
537    }
538    const border = (left: string, middle: string, right: string) => (
539      <Text color="inactive">{left + widths.map(one => '─'.repeat(one + 2)).join(middle) + right}</Text>
540    )
541    const pad = (row: Span[], column: number) => {
542      const room = widths[column]! - rowWidth(row)
543      const align = block.align[column] ?? 'left'
544      const before = align === 'right' ? room : align === 'center' ? Math.floor(room / 2) : 0
545
546      return [' '.repeat(Math.max(0, before)), ' '.repeat(Math.max(0, room - before))] as const
547    }
548    const lines = (values: string[], isHeader: boolean) => {
549      const cells = values.map((value, column) => wrapSpans(spans(value), widths[column]!))
550      const height = Math.max(1, ...cells.map(rows => rows.length))
551
552      return Array.from({ length: height }, (_, index) => (
553        <Text wrap="truncate-end">
554          {cells.map((rows, column) => {
555            const row = rows[index] ?? []
556            const [before, after] = pad(row, column)
557
558            return (
559              <Text>
560                <Text color="inactive">│ </Text>
561                {before}
562                {isHeader ? (
563                  <Text weight="bold" color={ACCENT}>
564                    {row.map(styled)}
565                  </Text>
566                ) : (
567                  row.map(styled)
568                )}
569                {after}{' '}
570              </Text>
571            )
572          })}
573          <Text color="inactive">│</Text>
574        </Text>
575      ))
576    }
577
578    return (
579      <Box flexDirection="column">
580        {border('╭', '┬', '╮')}
581        {lines(block.header, true)}
582        {border('├', '┼', '┤')}
583        {block.rows.flatMap(values => lines(values, false))}
584        {border('╰', '┴', '╯')}
585      </Box>
586    )
587  }
588
589  return { line, barred, frame, code, inline, styled, table }
590}
591
592/** What an opened tool row shows: the output a model read, framed and highlighted. */
593const detail = (
594  ui: Ui,
595  columns: number,
596  tool: string,
597  output: unknown,
598  isErrored: boolean,
599  frameSize: FrameWidth,
600  options: { showAll: boolean; toggle: () => void },
601) => {
602  const { Box, Text, Markdown, Button } = ui
603  const { frame, code } = kit(ui, columns - 2, frameSize)
604
605  if (typeof output === 'string') {
606    const shown = clip(output)
607
608    return frame(<Text color={isErrored ? 'error' : 'text'}>{shown.text}</Text>, isErrored ? 'error' : 'result', shown.note)
609  }
610  if (tool === 'Bash') {
611    const limit = options.showAll ? MAX_LINES : DETAIL_LINES
612    const raw = field(output, 'stdout')
613    const stdout = clip(typeof raw === 'string' ? raw : '', limit, true)
614    const stderr = clip(withoutEngineNotes(text(field(output, 'stderr'))), limit)
615
616    const files = editedFiles(output)
617    // The output as printed, its color codes drawn as colors; a blank line keeps its height.
618    const colored = (shown: string) => (
619      <Box flexDirection="column">
620        {shown.split('\n').map(line =>
621          line === '' ? (
622            <Text> </Text>
623          ) : (
624            <Text>
625              {colorRuns(line).map(run => (
626                <Text
627                  color={run.color}
628                  weight={run.bold ? 'bold' : undefined}
629                  dimColor={run.dim}
630                  italic={run.italic}
631                  underline={run.underline}
632                >
633                  {run.text}
634                </Text>
635              ))}
636            </Text>
637          ),
638        )}
639      </Box>
640    )
641    // Long output shows its first lines, and a control in the frame's border for the rest.
642    const more =
643      stdout.hidden > 0 || options.showAll ? (
644        <Button key="show-all" plain onPress={options.toggle}>
645          <Text color="suggestion">
646            {options.showAll ? `▴ first ${DETAIL_LINES} lines` : `▾ show all ${stdout.hidden + DETAIL_LINES} lines`}
647          </Text>
648        </Button>
649      ) : undefined
650
651    return (
652      <Box flexDirection="column">
653        {stdout.text !== '' && frame(colored(stdout.text), 'stdout', stdout.note, more)}
654        {stderr.text !== '' && frame(<Text color="error">{stderr.text}</Text>, 'stderr', stderr.note)}
655        {files.map(file =>
656          frame(code(file.diff, { language: 'diff', path: file.path }), basename(file.path), `+${file.added} −${file.removed}`),
657        )}
658        {stdout.text === '' && stderr.text === '' && files.length === 0 && <Text dimColor>no output</Text>}
659      </Box>
660    )
661  }
662  const file = field(output, 'file')
663  if (tool === 'Read' && typeof field(file, 'content') === 'string') {
664    const path = text(field(file, 'filePath'))
665    const start = field(file, 'startLine')
666    const shown = clip(text(field(file, 'content')))
667
668    return frame(
669      code(shown.text, { path, startLine: typeof start === 'number' ? start : 1 }),
670      path.split('/').pop(),
671      shown.note,
672    )
673  }
674  if (tool === 'WebFetch' && typeof field(output, 'result') === 'string') {
675    const shown = clip(text(field(output, 'result')))
676
677    return frame(<Markdown text={shown.text} />, text(field(output, 'url')), shown.note)
678  }
679  if (tool === 'Grep' && typeof field(output, 'content') === 'string') {
680    const shown = clip(text(field(output, 'content')))
681
682    return frame(code(shown.text, {}), 'matches', shown.note)
683  }
684  if ((tool === 'Grep' || tool === 'Glob') && filenames(output).length > 0) {
685    const shown = clip(filenames(output).join('\n'))
686
687    return frame(code(shown.text, {}), 'files', shown.note)
688  }
689  if (tool === 'WebSearch' && searchLinks(output).length > 0) {
690    const shown = clip(searchLinks(output).map(link => `- [${link.title}](${link.url})`).join('\n'))
691
692    return frame(<Markdown text={shown.text} />, 'results', shown.note)
693  }
694  const said = textOf(output)
695  if (said !== null) {
696    const shown = clip(said)
697
698    return frame(<Markdown text={shown.text} />, 'result', shown.note)
699  }
700  const shown = clip(JSON.stringify(output, null, 2) ?? '')
701
702  return frame(code(shown.text, { language: 'json' }), 'json', shown.note)
703}
704
705/** The origins of rows the person wrote: typed, from the phone, or a slash command's echo (unstamped). */
706const OWN_ORIGINS = new Set(['composer', 'bridge', 'unclassified'])
707
708/** Columns a tool row leaves free at the right, so a trimmed row never wraps. */
709const ROW_MARGIN = 4
710
711/** `text` cut to `room` columns with an ellipsis, at its end or, for a path, its middle. */
712const fit = (text: string, room: number, isPath: boolean) => {
713  if (cellWidth(text) <= room) {
714    return text
715  }
716  if (room <= 1) {
717    return room === 1 ? '…' : ''
718  }
719  if (!isPath) {
720    return `${takeCells(text, room - 1)[0]}…`
721  }
722  const head = Math.ceil((room - 1) / 2)
723
724  return `${takeCells(text, head)[0]}…${lastCells(text, room - 1 - head)}`
725}
726
727const taskTone = (status: string | undefined) => {
728  switch (status) {
729    case 'completed':
730      return { glyph: '✓', color: 'success' }
731    case 'failed':
732      return { glyph: '✗', color: 'error' }
733    case 'killed':
734    case 'stopped':
735      return { glyph: '■', color: 'warning' }
736    default:
737      return { glyph: '◆', color: 'suggestion' }
738  }
739}
740
741/** A pane's arguments for `$.ui.open`. */
742type PaneArgs = Parameters<EngineInterface['ui']['open']>[0]
743
744/**
745 * Opens a pane, and says why the engine held it back: an open that nothing asked for draws only from 144
746 * columns, and the reason is the one word a person is given.
747 */
748const openPane = async ($: EngineInterface, args: PaneArgs) => {
749  const opened = await $.ui.open(args)
750  if (!opened.isPlaced) {
751    $.ui.toast(`${args.title} is not shown: ${opened.reason}`)
752  }
753
754  return opened
755}
756
757/** Opens the run dialog for a shell command: Run here runs it, Copy copies it. */
758const openRun = async ($: EngineInterface, command: string) => {
759  await update($, pending, () => ({ command, origin: null }))
760  await openPane($, { id: RUN_PANE, title: 'Run a command', focus: true, closeOnEscape: true })
761}
762
763/** Takes the run the dialog holds, once: a second press of a button finds nothing left to run. */
764const takePending = async ($: EngineInterface) => {
765  const taken: { run: { command: string; origin: string | null } | null } = { run: null }
766  await update($, pending, now => {
767    taken.run = now
768
769    return null
770  })
771
772  return taken.run
773}
774
775/** Registers a slash command. One the engine refuses must not stop the rest of the session's start. */
776const registerCommand = async ($: EngineInterface, command: Parameters<EngineInterface['command']['register']>[0]) => {
777  try {
778    await $.command.register(command)
779  } catch (error) {
780    $.ui.log(`nebula-ui: /${command.name} not registered: ${String(error)}`, { to: 'debug' })
781  }
782}
783
784export const register: Register = on => {
785  on('session.start', async ($, e, next) => {
786    const saved = await $.store.get('disabled')
787    if (Array.isArray(saved)) {
788      await update($, disabled, () => saved.filter((one): one is string => typeof one === 'string'))
789    }
790    await registerCommand($, {
791      name: 'nebula',
792      description: 'Open the Nebula UI panel to switch each restyled part on or off',
793    })
794    const savedTheme = await $.store.get('theme')
795    if (isThemeName(savedTheme)) {
796      await update($, theme, () => savedTheme)
797    }
798    const savedWidth = await $.store.get('frameWidth')
799    if (isFrameWidth(savedWidth)) {
800      await update($, frameWidth, () => savedWidth)
801    }
802    await registerCommand($, { name: 'procs', description: 'Show the commands started from replies, with their output and a stop button' })
803    void gather($)
804    void adopt($)
805    $.clock.every(15_000, () => {
806      void gather($)
807      void adopt($)
808    })
809    // Tools for the model, last and on their own: a host that refuses them keeps everything above.
810    try {
811      await $.tool.register({
812        name: 'processes',
813        description:
814          "Lists the commands the user started from nebula-ui (a run button on a code frame, a command clicked in a reply, or process_control), with their status and last lines; with an id, that process's recent output. Use it to check on a dev server or watcher the user is running.",
815        inputSchema: {
816          type: 'object',
817          properties: {
818            id: { type: 'string', description: 'A process id from the list; leave out to list them all.' },
819            lines: { type: 'integer', minimum: 1, maximum: 200, description: 'Lines of output to return for an id (default 60).' },
820          },
821        },
822      })
823      await $.tool.register({
824        name: 'process_control',
825        description:
826          "Starts, stops or restarts a process in nebula-ui's process manager, where the user sees it in /procs and can stop it. start runs `command` with sh -c in the session's directory, in its own process group; stop ends the whole group; restart runs the same command again. Each one needs the permission a Bash command needs: without it the call is refused.",
827        inputSchema: {
828          type: 'object',
829          properties: {
830            action: { type: 'string', enum: ['start', 'stop', 'restart'] },
831            id: { type: 'string', description: 'The process to stop or restart.' },
832            command: { type: 'string', description: 'The shell command to start.' },
833          },
834          required: ['action'],
835        },
836      })
837    } catch (error) {
838      $.ui.log(`nebula-ui: process tools not registered: ${String(error)}`, { to: 'debug' })
839    }
840    // The engine keeps each command's listing for the session; listing them again drops the glyphs an
841    // earlier version of nebula-ui gave them, which also reached the model's list of skills.
842    void $.ui.invalidate('command.describe')
843
844    return next(e)
845  })
846
847  on('turn.complete', async ($, e, next) => {
848    const done = await next(e)
849    void gather($)
850
851    return done
852  })
853
854  // The engine pushes the context, limits and cost as they move: the band reads them then, not on a timer alone.
855  // A burst of moves reads once, since each read also asks git for the branch.
856  let measuredAt = 0
857  on('session.measure', async ($, e, next) => {
858    const now = Date.now()
859    if (now - measuredAt >= 2000) {
860      measuredAt = now
861      void gather($)
862    }
863
864    return next(e)
865  })
866
867  on('tool.call', async ($, e, next) => {
868    const ran = await next(e)
869    void gather($)
870
871    return ran
872  }).catch(($, e, next) => next(e))
873
874  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
875    await themed($)
876    if (e.surface !== 'terminal' || e.props.hasSurvey || !(await isOn($, 'AbovePrompt'))) {
877      return next(e)
878    }
879    const figures = await read($, dash)
880    const running = (await read($, procList)).filter(isRunning).length
881    const { Button, Text } = $.ui.resolve(e)
882    const chip =
883      running === 0 ? undefined : (
884        <Button key="procs" plain onPress={() => void openPane($, { id: PROCS_PANE, title: 'Processes', closeOnEscape: true })}>
885          <Text color="suggestion">⚙ {running} running</Text>
886        </Button>
887      )
888
889    return figures === null ? next(e) : drawBand($.ui.resolve(e), figures, chip)
890  })
891
892  on('tool.call', { tool: 'mcp__nebula-ui__processes' }, async ($, e) => {
893    const list = await read($, procList)
894    const now = await $.clock.now()
895    const id = typeof e.id === 'string' ? e.id : undefined
896    if (id === undefined) {
897      return { result: describeProcs(list, now) }
898    }
899    const proc = list.find(one => one.id === id)
900    const lines = typeof e.lines === 'number' ? Math.min(200, Math.max(1, Math.round(e.lines))) : 60
901
902    return proc === undefined ? { deny: `No process ${id}; call processes without an id to list them.` } : { result: describeOutput(proc, now, lines) }
903  }).catch(() => ({ deny: 'nebula-ui could not read its process list.' }))
904
905  // A start, stop or restart is a shell command, so it runs only when the engine's own verdict allows it. An
906  // answer in place of core skips the permission dialog, so an ask here refuses instead of running unasked.
907  on('tool.call', { tool: 'mcp__nebula-ui__process_control' }, async ($, e) => {
908    const verdict = await $.tool.check({
909      tool: 'mcp__nebula-ui__process_control',
910      input: { action: e.action, command: e.command, id: e.id },
911    })
912    if (verdict.decision !== 'allow') {
913      const ask = 'Starting, stopping or restarting a process needs your approval. Allow mcp__nebula-ui__process_control in your permissions, or run the command with Bash, which asks first.'
914
915      return { deny: verdict.decision === 'deny' ? (verdict.reason ?? 'Starting, stopping or restarting a process is not allowed here.') : ask }
916    }
917    const list = await read($, procList)
918    const id = typeof e.id === 'string' ? e.id : undefined
919    const proc = list.find(one => one.id === id)
920    switch (e.action) {
921      case 'start': {
922        if (typeof e.command !== 'string' || e.command.trim() === '') {
923          return { deny: 'start needs a command.' }
924        }
925        const started = await startProc($, e.command)
926
927        return { result: `Started ${started}: ${e.command}. The user sees it in /procs; read its output with processes.` }
928      }
929      case 'stop':
930        if (proc === undefined) {
931          return { deny: `No process ${id ?? '(no id given)'}.` }
932        }
933        if (!isRunning(proc)) {
934          return { result: `${proc.id} had already ${proc.status}.` }
935        }
936        await stopProc($, proc.id)
937
938        return { result: `Stopped ${proc.id} and its process group.` }
939      case 'restart': {
940        if (proc === undefined) {
941          return { deny: `No process ${id ?? '(no id given)'}.` }
942        }
943        const restarted = await restartProc($, proc.id)
944        if (restarted === null) {
945          return { deny: `No process ${proc.id} to restart.` }
946        }
947
948        return { result: `Restarted as ${restarted}: ${proc.command}.` }
949      }
950      default:
951        return { deny: 'action is start, stop or restart.' }
952    }
953  }).catch(() => ({ deny: 'Starting, stopping or restarting a process could not be checked, so it did not run.' }))
954
955  on('session.end', async ($, e, next) => {
956    // A process started from a reply ends with the session, its children too.
957    await stopAll($)
958
959    return next(e)
960  })
961
962  on('command.run', { command: 'procs' }, async $ => {
963    await openPane($, { id: PROCS_PANE, title: 'Processes', closeOnEscape: true })
964
965    return { text: 'Processes pane opened.' }
966  }).catch(() => ({ text: 'nebula-ui could not open the Processes pane.' }))
967
968  on('ui.render', { component: 'Pane', requestId: PROCS_PANE }, async ($, e) => {
969    await themed($)
970    if (e.surface !== 'terminal') {
971      const { Text } = $.ui.resolve(e)
972
973      return <Text dimColor>The Processes pane draws in the terminal.</Text>
974    }
975    const list = await read($, procList)
976    const now = (await read($, dash))?.now ?? (await $.clock.now())
977
978    return drawProcs($.ui.resolve(e), list, now, e.props.bodyColumns, {
979      stop: id => void stopProc($, id),
980      restart: id => void restartProc($, id),
981      follow: proc => void openPane($, { id: `${FOLLOW_PANE}${proc.id}`, title: fit(proc.command, 32, false), closeOnEscape: true }),
982      clear: id => void clearProc($, id),
983      clearEnded: () => void update($, procList, all => all.filter(isRunning)),
984    })
985  })
986
987  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
988    await themed($)
989    if (!e.requestId.startsWith(FOLLOW_PANE) || e.surface !== 'terminal') {
990      return next(e)
991    }
992    const id = e.requestId.slice(FOLLOW_PANE.length)
993    const proc = (await read($, procList)).find(one => one.id === id)
994    const now = (await read($, dash))?.now ?? 0
995
996    return drawFollow($.ui.resolve(e), proc, now, e.props.scroll.bodyRows, {
997      stop: () => void stopProc($, id),
998      restart: () =>
999        void restartProc($, id).then(fresh =>
1000          fresh === null ? undefined : openPane($, { id: `${FOLLOW_PANE}${fresh}`, title: e.props.title, closeOnEscape: true }),
1001        ),
1002    })
1003  })
1004
1005  on('ui.render', { component: 'Pane', requestId: RUN_PANE }, async ($, e) => {
1006    await themed($)
1007    const { Box, Text, Button } = $.ui.resolve(e)
1008    const asked = await read($, pending)
1009    const cwd = await $.session.cwd()
1010    if (asked === null) {
1011      return <Text dimColor>No command to run.</Text>
1012    }
1013    const close = async () => {
1014      await update($, pending, () => null)
1015      await $.ui.close({ id: RUN_PANE })
1016    }
1017
1018    return (
1019      <Box flexDirection="column" rowGap={1}>
1020        <Box flexDirection="column">
1021          <Text dimColor>Run in {cwd}:</Text>
1022          <Text weight="bold" color={ACCENT}>
1023            $ {asked.command}
1024          </Text>
1025        </Box>
1026        <Box flexDirection="row" gap={2}>
1027          <Button
1028            key="run"
1029            label="▶ run here"
1030            hotkey="r"
1031            variant="primary"
1032            autoFocus
1033            onPress={async () => {
1034              const chosen = await takePending($)
1035              await close()
1036              if (chosen !== null) {
1037                await startProc($, chosen.command, chosen.origin)
1038                await openPane($, { id: PROCS_PANE, title: 'Processes', closeOnEscape: true })
1039              }
1040            }}
1041          />
1042          <Button
1043            key="copy"
1044            label="⧉ copy"
1045            hotkey="c"
1046            onPress={async () => {
1047              const chosen = await takePending($)
1048              await close()
1049              if (chosen !== null) {
1050                const copied = await $.ui.copy({ text: chosen.command, surface: e.surface })
1051                $.ui.toast(copied.isCopied ? `Copied ${chosen.command}` : 'Could not copy')
1052              }
1053            }}
1054          />
1055          <Button key="cancel" label="cancel" hotkey="x" role="dismiss" onPress={close} />
1056        </Box>
1057      </Box>
1058    )
1059  })
1060
1061  on('command.run', { command: 'nebula' }, async $ => {
1062    await $.ui.open({ id: PANEL, title: 'Nebula UI', focus: true, closeOnEscape: true })
1063
1064    return { text: 'Nebula UI panel opened.' }
1065  }).catch(() => ({ text: 'nebula-ui could not open its panel.' }))
1066
1067  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e) => {
1068    await themed($)
1069    const { Box, Text, Button } = $.ui.resolve(e)
1070    const off = await read($, disabled)
1071    const width = await read($, frameWidth)
1072    const themeName = await read($, theme)
1073
1074    return (
1075      <Box flexDirection="column">
1076        <Text weight="bold" color={ACCENT}>
1077          ✦ Nebula UI
1078        </Text>
1079        <Text dimColor>Press a number, or Enter on a row, to switch that part. Esc closes.</Text>
1080        <Box flexDirection="column" marginTop={1}>
1081          {SITES.map((site, index) => {
1082            const isSiteOn = !off.includes(site.id)
1083
1084            return (
1085              <Button key={site.id} plain hotkey={HOTKEYS[index]} onPress={() => toggle($, site.id)}>
1086                <Text color={isSiteOn ? 'success' : 'inactive'}>{isSiteOn ? '◉' : '○'}</Text> {site.label}{' '}
1087                <Text dimColor>{site.about}</Text>
1088              </Button>
1089            )
1090          })}
1091        </Box>
1092        <Box marginTop={1}>
1093          <Button key="theme" plain hotkey="t" onPress={() => stepTheme($)}>
1094            <Text color={ACCENT}>●</Text> Accent <Text weight="bold">{themeName}</Text>{' '}
1095            <Text dimColor>{THEMES[themeName].about} (t to change: {THEME_NAMES.join(', ')})</Text>
1096          </Button>
1097        </Box>
1098        <Box>
1099          <Button key="frame-width" plain hotkey="w" onPress={() => stepFrameWidth($)}>
1100            <Text color={ACCENT}>▭</Text> Frame width <Text weight="bold">{String(width)}</Text>{' '}
1101            <Text dimColor>code and output frames: fill, fit to their content, or a width in columns (w to change)</Text>
1102          </Button>
1103        </Box>
1104      </Box>
1105    )
1106  })
1107
1108  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
1109    await themed($)
1110    if (e.surface !== 'terminal' || !(await isOn($, 'UserMessage'))) {
1111      return next(e)
1112    }
1113    const { Box, Text } = $.ui.resolve(e)
1114
1115    const isOwn = e.props.task === undefined && e.props.from === undefined && OWN_ORIGINS.has(e.props.origin.kind)
1116    if (isOwn) {
1117      const { barred } = kit($.ui.resolve(e), e.viewport?.columns ?? 80)
1118      const room = (e.viewport?.columns ?? 80) - 3
1119      // The prompt as typed: no markdown read into it, a leading slash command marked out.
1120      const rows = printable(e.props.text).split('\n').flatMap((line, index) => {
1121        const command = index === 0 ? /^(\/\S+)(.*)$/.exec(line) : null
1122        const runs: Span[] =
1123          command === null
1124            ? [{ text: line, style: 'plain' }]
1125            : [
1126                { text: command[1]!, style: 'command' },
1127                { text: command[2]!, style: 'plain' },
1128              ]
1129
1130        return wrapSpans(runs, room)
1131      })
1132
1133      return <Box marginTop={1}>{barred(rows, ACCENT)}</Box>
1134    }
1135    const drawn = await next(e)
1136    if (e.props.task !== undefined) {
1137      const tone = taskTone(e.props.task.status)
1138
1139      return (
1140        <Box flexDirection="row">
1141          <Text color={tone.color}>{tone.glyph} </Text>
1142          <Box flexGrow={1}>{drawn}</Box>
1143        </Box>
1144      )
1145    }
1146
1147    return drawn
1148  })
1149
1150  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
1151    await themed($)
1152    if (e.surface !== 'terminal' || e.props.isSummary === true || !(await isOn($, 'AssistantMessage'))) {
1153      return next(e)
1154    }
1155    const ui = $.ui.resolve(e)
1156    const { Box, Text, Markdown, Button } = ui
1157    const { line, barred, frame, code, inline, table } = kit($.ui.resolve(e), (e.viewport?.columns ?? 80) - 3, await read($, frameWidth))
1158    const isClickable = await isOn($, 'ClickCommands')
1159    const started = isClickable ? await read($, procList) : []
1160    // The time a run's status counts from: the dashboard's, refreshed with it (no clock call per redraw).
1161    const now = (await read($, dash))?.now ?? 0
1162    /** The latest process a code frame of this reply started, if any. */
1163    const procOf = (index: number) => started.find(proc => proc.origin === `${e.requestId}:${index}`)
1164
1165    const copy = async (text: string) => {
1166      const copied = await $.ui.copy({ text, surface: e.surface })
1167      const lines = text.split('\n').length
1168      $.ui.toast(copied.isCopied ? `Copied ${lines > 1 ? plural(lines, 'line') : fit(text, 60, false)}` : 'Could not copy')
1169    }
1170
1171    // A slash or ! command goes to the prompt box for the person's Enter (copied while they have a draft);
1172    // a shell command opens a dialog that runs it here, tracked in the Processes pane, or copies it.
1173    const use = async (command: Command | undefined) => {
1174      if (command === undefined) {
1175        return
1176      }
1177      if (command.kind === 'shell') {
1178        await openRun($, command.text)
1179
1180        return
1181      }
1182      const draft = (await $.prompt.read()).text
1183      if (draft.trim() !== '') {
1184        return copy(command.text)
1185      }
1186      const { isFilled } = await $.prompt.fill({ text: command.text, mode: 'replace' })
1187      $.ui.toast(isFilled ? `${fit(command.text, 60, false)} is in your prompt: Enter runs it` : 'Could not reach the prompt')
1188    }
1189    // What a block may take beside the reply's two-column gutter, one column spare.
1190    const room = (e.viewport?.columns ?? 80) - 3
1191
1192    // A fence copies; one that holds commands also runs (shell, through the run dialog) or goes to the prompt.
1193    const fenceActions = (block: Extract<Block, { kind: 'code' }>, index: number) => {
1194      const command = fenceCommand(block.language, block.source)
1195      const proc = procOf(index)
1196      // The fence's button is a deliberate run: it starts the command here, its output drawn in the frame.
1197      const run = () =>
1198        command?.kind === 'shell' ? void startProc($, command.text, `${e.requestId}:${index}`) : void use(command ?? undefined)
1199
1200      return [
hooks/markdown.ts 681 lines
1/**
2 * Splits a reply's markdown into the blocks nebula-ui draws itself (fences,
3 * headings, tables, quotes and callouts, rules) and runs of prose, which stay
4 * with the engine's own Markdown renderer so lists, links and paragraphs keep
5 * working. Also the text helpers the drawing shares: how many terminal cells a
6 * string takes, and what a tool or process printed without its control codes.
7 */
8
9export type Callout = 'note' | 'tip' | 'important' | 'warning' | 'caution'
10
11export type Align = 'left' | 'center' | 'right'
12
13export type Block =
14  | { kind: 'prose'; text: string }
15  | { kind: 'code'; language: string; source: string; isOpen: boolean }
16  | { kind: 'heading'; level: number; text: string }
17  | { kind: 'table'; align: Align[]; header: string[]; rows: string[][] }
18  | { kind: 'quote'; callout: Callout | null; title: string; text: string }
19  | { kind: 'rule' }
20
21export type Span = { text: string; style: 'plain' | 'code' | 'bold' | 'italic' | 'strike' | 'link' | 'command' }
22
23const FENCE = /^( {0,3})(`{3,}|~{3,})\s*([^\s`]*)/
24const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/
25const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
26const QUOTE = /^ {0,3}>\s?(.*)$/
27/** A list item, or a line indented under one: a fence indented after it belongs to the list. */
28const LIST_ITEM = /^\s*([-*+]|\d+[.)])\s|^\s+\S/
29const CALLOUT = /^\[!(note|tip|important|warning|caution)\]\s*(.*)$/i
30const TABLE_DIVIDER = /^ {0,3}\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
31const NUMERIC = /^[~≈<>+\-−$€£]*\s*[\d.,]+\s*(%|x|ms|s|m|h|k|kb|mb|gb|tb)?$/i
32
33const INLINE =
34  /(`+)(.+?)\1|\*\*(.+?)\*\*|__(.+?)__|~~(.+?)~~|\*(?!\s)(.+?)\*|(?<![\w])_(?!\s)(.+?)_(?![\w])|\[([^\]]+)\]\(([^)\s]+)\)/g
35
36/** Terminal color and cursor codes, and the other escape sequences a tool or process prints. */
37export const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g
38
39/** One color code, `ESC[` then numbers and `m`: the only escape sequence drawn, as a style. */
40const SGR_TOKEN = /(\u001b\[[0-9;]*m)/
41
42/** Text a tool or process printed, safe to draw: escape sequences gone, other control characters but tab and newline dropped. */
43export const printable = (text: string) =>
44  text.replace(ANSI, '').replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
45
46/** `printable`, keeping the color codes, which `colorRuns` reads as styles. */
47export const printableColored = (text: string) =>
48  text
49    .split(SGR_TOKEN)
50    .map((part, index) => (index % 2 === 1 ? part : printable(part)))
51    .join('')
52
53/** East Asian wide and fullwidth code points, and the emoji that take two cells, ascending so a search can stop early. */
54const WIDE: ReadonlyArray<readonly [number, number]> = [
55  [0x1100, 0x115f],
56  [0x231a, 0x231b],
57  [0x2329, 0x232a],
58  [0x23e9, 0x23ec],
59  [0x23f0, 0x23f0],
60  [0x23f3, 0x23f3],
61  [0x25fd, 0x25fe],
62  [0x2614, 0x2615],
63  [0x2648, 0x2653],
64  [0x267f, 0x267f],
65  [0x2693, 0x2693],
66  [0x26a1, 0x26a1],
67  [0x26aa, 0x26ab],
68  [0x26bd, 0x26be],
69  [0x26c4, 0x26c5],
70  [0x26ce, 0x26ce],
71  [0x26d4, 0x26d4],
72  [0x26ea, 0x26ea],
73  [0x26f2, 0x26f3],
74  [0x26f5, 0x26f5],
75  [0x26fa, 0x26fa],
76  [0x26fd, 0x26fd],
77  [0x2705, 0x2705],
78  [0x270a, 0x270b],
79  [0x2728, 0x2728],
80  [0x274c, 0x274c],
81  [0x274e, 0x274e],
82  [0x2753, 0x2755],
83  [0x2757, 0x2757],
84  [0x2795, 0x2797],
85  [0x27b0, 0x27b0],
86  [0x27bf, 0x27bf],
87  [0x2b1b, 0x2b1c],
88  [0x2b50, 0x2b50],
89  [0x2b55, 0x2b55],
90  [0x2e80, 0x303e],
91  [0x3041, 0x33ff],
92  [0x3400, 0x4dbf],
93  [0x4e00, 0x9fff],
94  [0xa000, 0xa4cf],
95  [0xa960, 0xa97f],
96  [0xac00, 0xd7a3],
97  [0xf900, 0xfaff],
98  [0xfe10, 0xfe19],
99  [0xfe30, 0xfe6f],
100  [0xff00, 0xff60],
101  [0xffe0, 0xffe6],
102  [0x16fe0, 0x18aff],
103  [0x1b000, 0x1b2ff],
104  [0x1f004, 0x1f004],
105  [0x1f0cf, 0x1f0cf],
106  [0x1f18e, 0x1f18e],
107  [0x1f191, 0x1f19a],
108  [0x1f200, 0x1f251],
109  [0x1f300, 0x1f64f],
110  [0x1f680, 0x1f6ff],
111  [0x1f7e0, 0x1f7eb],
112  [0x1f90c, 0x1f9ff],
113  [0x1fa70, 0x1faff],
114  [0x20000, 0x3fffd],
115]
116
117const isWide = (code: number) => {
118  for (const [from, to] of WIDE) {
119    if (code < from) {
120      return false
121    }
122    if (code <= to) {
123      return true
124    }
125  }
126
127  return false
128}
129
130/** Code points that take no cell of their own: combining marks, joiners, variation selectors, skin tones and tags. */
131const isZeroWidth = (code: number) =>
132  (code >= 0x0300 && code <= 0x036f) ||
133  (code >= 0x1ab0 && code <= 0x1aff) ||
134  (code >= 0x1dc0 && code <= 0x1dff) ||
135  (code >= 0x20d0 && code <= 0x20ff) ||
136  (code >= 0xfe00 && code <= 0xfe0f) ||
137  (code >= 0xfe20 && code <= 0xfe2f) ||
138  (code >= 0x200b && code <= 0x200f) ||
139  (code >= 0x2028 && code <= 0x202e) ||
140  (code >= 0x2060 && code <= 0x206f) ||
141  (code >= 0x1f3fb && code <= 0x1f3ff) ||
142  (code >= 0xe0020 && code <= 0xe007f) ||
143  (code >= 0xe0100 && code <= 0xe01ef)
144
145type Graphemes = { segment: (text: string) => Iterable<{ segment: string }> }
146
147let segmenter: Graphemes | null | undefined
148
149/** Grapheme clusters: what a reader takes for one character, an emoji sequence or a letter with its marks. */
150const graphemes = (text: string): string[] => {
151  if (segmenter === undefined) {
152    const Segmenter = (Intl as unknown as { Segmenter?: new (locale: undefined, options: { granularity: 'grapheme' }) => Graphemes })
153      .Segmenter
154    segmenter = Segmenter === undefined ? null : new Segmenter(undefined, { granularity: 'grapheme' })
155  }
156
157  return segmenter === null ? [...text] : [...segmenter.segment(text)].map(part => part.segment)
158}
159
160/** The cells one grapheme takes: two for a wide glyph, an emoji or a flag, one for other visible text, none for marks alone. */
161const cellsOf = (grapheme: string) => {
162  let visible = false
163  let wide = false
164  for (const char of grapheme) {
165    const code = char.codePointAt(0)!
166    if (isZeroWidth(code)) {
167      continue
168    }
169    visible = true
170    if (isWide(code) || (code >= 0x1f1e6 && code <= 0x1f1ff)) {
171      wide = true
172    }
173  }
174
175  return visible ? (wide ? 2 : 1) : 0
176}
177
178/** Terminal cells a string takes on screen: what a column must hold, which a code point count does not give for wide glyphs. */
179export const cellWidth = (text: string) => graphemes(text).reduce((sum, grapheme) => sum + cellsOf(grapheme), 0)
180
181/** How many leading graphemes fit in `room` cells: always one, so a wide glyph in a one-cell room still advances. */
182const headCount = (parts: string[], room: number) => {
183  let cells = 0
184  let count = 0
185  while (count < parts.length) {
186    const next = cellsOf(parts[count]!)
187    if (count > 0 && cells + next > room) {
188      break
189    }
190    cells += next
191    count++
192  }
193
194  return count
195}
196
197/** The leading part of `text` that fits `room` cells, and the rest. */
198export const takeCells = (text: string, room: number): [string, string] => {
199  const parts = graphemes(text)
200  const count = headCount(parts, room)
201
202  return [parts.slice(0, count).join(''), parts.slice(count).join('')]
203}
204
205/** The trailing part of `text` that fits `room` cells. */
206export const lastCells = (text: string, room: number) => {
207  const parts = graphemes(text).reverse()
208
209  return parts.slice(0, headCount(parts, room)).reverse().join('')
210}
211
212/** A line's inline markdown as styled runs: code, bold, italic, strike, link text. Not nested. */
213export const spans = (text: string): Span[] => {
214  const out: Span[] = []
215  let at = 0
216  for (const match of text.matchAll(INLINE)) {
217    if (match.index > at) {
218      out.push({ text: text.slice(at, match.index), style: 'plain' })
219    }
220    const [, , code, bold, boldU, strike, italic, italicU, link] = match
221    const run: Span =
222      code !== undefined
223        ? { text: code.trim(), style: 'code' }
224        : bold !== undefined || boldU !== undefined
225          ? { text: (bold ?? boldU)!, style: 'bold' }
226          : strike !== undefined
227            ? { text: strike, style: 'strike' }
228            : italic !== undefined || italicU !== undefined
229              ? { text: (italic ?? italicU)!, style: 'italic' }
230              : { text: link!, style: 'link' }
231    out.push(run)
232    at = match.index + match[0].length
233  }
234  if (at < text.length) {
235    out.push({ text: text.slice(at), style: 'plain' })
236  }
237
238  return out
239}
240
241/** Cells the text takes once its inline markers are drawn as styles. */
242export const width = (text: string) => spans(text).reduce((sum, run) => sum + cellWidth(run.text), 0)
243
244/** A table row's cells: split on pipes outside code spans, `\|` kept as a pipe. A backtick with no closing one is a plain character. */
245const rowCells = (line: string) => {
246  const row = line.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '')
247  const out: string[] = []
248  let cell = ''
249  let at = 0
250  while (at < row.length) {
251    const char = row[at]!
252    if (char === '\\' && row[at + 1] === '|') {
253      cell += '|'
254      at += 2
255    } else if (char === '`') {
256      const run = /^`+/.exec(row.slice(at))![0]
257      const close = row.indexOf(run, at + run.length)
258      if (close === -1) {
259        cell += run
260        at += run.length
261      } else {
262        cell += row.slice(at, close + run.length)
263        at = close + run.length
264      }
265    } else if (char === '|') {
266      out.push(cell.trim())
267      cell = ''
268      at++
269    } else {
270      cell += char
271      at++
272    }
273  }
274  out.push(cell.trim())
275
276  return out
277}
278
279const alignOf = (divider: string): Align => {
280  const left = divider.startsWith(':')
281  const right = divider.endsWith(':')
282
283  return left && right ? 'center' : right ? 'right' : 'left'
284}
285
286/** Each column's alignment: the divider's markers, else right for a column of numbers. */
287const alignments = (dividers: string[], rows: string[][]) =>
288  dividers.map((divider, column) => {
289    const marked = divider.startsWith(':') || divider.endsWith(':')
290    const values = rows.map(row => row[column] ?? '').filter(value => value !== '')
291    const isNumeric = values.length > 0 && values.every(value => NUMERIC.test(value))
292
293    return marked ? alignOf(divider) : isNumeric ? 'right' : 'left'
294  })
295
296const dedent = (lines: string[], indent: number) =>
297  lines.map(line => line.slice(Math.min(indent, line.length - line.trimStart().length)))
298
299/** A line that closes a fence opened with `marker`: the same fence character, at least as long, and nothing after it. */
300const closesFence = (line: string, marker: string) => {
301  const run = line.trim()
302
303  return run.length >= marker.length && [...run].every(char => char === marker[0])
304}
305
306export const parse = (text: string): Block[] => {
307  const lines = text.split('\n').map(line => line.replace(/\r$/, ''))
308  const blocks: Block[] = []
309  let prose: string[] = []
310
311  const flush = () => {
312    const joined = prose.join('\n').trim()
313    if (joined !== '') {
314      blocks.push({ kind: 'prose', text: joined })
315    }
316    prose = []
317  }
318
319  for (let i = 0; i < lines.length; i++) {
320    const line = lines[i]!
321
322    const fence = FENCE.exec(line)
323    if (fence !== null) {
324      const marker = fence[2]!
325      const body: string[] = []
326      i++
327      while (i < lines.length && !closesFence(lines[i]!, marker)) {
328        body.push(lines[i]!)
329        i++
330      }
331      const lastProse = prose.findLast(one => one.trim() !== '') ?? ''
332      if (fence[1]!.length > 0 && LIST_ITEM.test(lastProse)) {
333        prose.push(line, ...body, ...(i < lines.length ? [lines[i]!] : []))
334        continue
335      }
336      flush()
337      blocks.push({
338        kind: 'code',
339        language: fence[3]!.toLowerCase(),
340        source: dedent(body, fence[1]!.length).join('\n'),
341        isOpen: i >= lines.length,
342      })
343      continue
344    }
345
346    const next = lines[i + 1]
347    if (line.includes('|') && next !== undefined && TABLE_DIVIDER.test(next) && next.includes('-')) {
348      const header = rowCells(line)
349      const dividers = rowCells(next)
350      if (dividers.length === header.length) {
351        flush()
352        const rows: string[][] = []
353        i += 2
354        while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') {
355          const row = rowCells(lines[i]!)
356          rows.push(header.map((_, column) => row[column] ?? ''))
357          i++
358        }
359        i--
360        blocks.push({ kind: 'table', align: alignments(dividers, rows), header, rows })
361        continue
362      }
363    }
364
365    const heading = HEADING.exec(line)
366    if (heading !== null) {
367      flush()
368      blocks.push({ kind: 'heading', level: heading[1]!.length, text: heading[2]! })
369      continue
370    }
371
372    if (RULE.test(line)) {
373      flush()
374      blocks.push({ kind: 'rule' })
375      continue
376    }
377
378    if (QUOTE.test(line)) {
379      flush()
380      const body: string[] = []
381      while (i < lines.length) {
382        const quoted = QUOTE.exec(lines[i]!)
383        if (quoted === null) {
384          break
385        }
386        body.push(quoted[1]!)
387        i++
388      }
389      i--
390      const callout = CALLOUT.exec(body[0] ?? '')
391      if (callout !== null) {
392        body.shift()
393      }
394      blocks.push({
395        kind: 'quote',
396        callout: callout === null ? null : (callout[1]!.toLowerCase() as Callout),
397        title: callout?.[2] ?? '',
398        text: body.join('\n').trim(),
399      })
400      continue
401    }
402
403    prose.push(line)
404  }
405  flush()
406
407  return blocks
408}
409
410/**
411 * Column widths that fit `room` columns of content: each column's widest cell,
412 * the widest shrunk a column at a time (to no less than 6) until they fit.
413 */
414export const fitColumns = (natural: number[], room: number) => {
415  const widths = natural.map(one => Math.max(1, one))
416  let total = widths.reduce((sum, one) => sum + one, 0)
417  while (total > room) {
418    const widest = widths.indexOf(Math.max(...widths))
419    if (widths[widest]! <= 6) {
420      break
421    }
422    widths[widest] = widths[widest]! - 1
423    total--
424  }
425
426  return widths
427}
428
429/**
430 * Styled runs word-wrapped into rows of at most `room` cells, styles kept across the breaks: a word
431 * longer than a row is cut, spaces at a break are dropped. An empty input is one empty row.
432 */
433export const wrapSpans = (runs: Span[], room: number): Span[][] => {
434  const limit = Math.max(1, room)
435  const rows: Span[][] = [[]]
436  let used = 0
437  let hasWords = false
438
439  const add = (text: string, style: Span['style']) => {
440    if (text === '') {
441      return
442    }
443    const row = rows[rows.length - 1]!
444    const last = row[row.length - 1]
445    if (last !== undefined && last.style === style) {
446      last.text += text
447    } else {
448      row.push({ text, style })
449    }
450    used += cellWidth(text)
451  }
452
453  const breakRow = () => {
454    const row = rows[rows.length - 1]!
455    // A row ends where its last words do: trailing whitespace goes, in every run it reaches.
456    for (let at = row.length - 1; at >= 0; at--) {
457      row[at]!.text = row[at]!.text.replace(/\s+$/, '')
458      if (row[at]!.text !== '') {
459        break
460      }
461    }
462    rows.push([])
463    used = 0
464  }
465
466  for (const run of runs) {
467    for (const token of run.text.split(/(\s+)/)) {
468      if (token === '') {
469        continue
470      }
471      if (/^\s+$/.test(token)) {
472        if (!hasWords) {
473          // A line's own indentation stays.
474          const indent = limit - 1
475          if (indent > 0) {
476            add(takeCells(token, indent)[0], run.style)
477          }
478        } else if (used > 0 && used + cellWidth(token) <= limit) {
479          add(token, run.style)
480        } else if (used > 0) {
481          breakRow()
482        }
483        continue
484      }
485      hasWords = true
486      if (used > 0 && used + cellWidth(token) > limit) {
487        breakRow()
488      }
489      let rest = token
490      while (cellWidth(rest) > limit) {
491        const [head, tail] = takeCells(rest, limit)
492        add(head, run.style)
493        breakRow()
494        rest = tail
495      }
496      add(rest, run.style)
497    }
498  }
499
500  return rows.map(row => row.filter(run => run.text !== ''))
501}
502
503/** Text with newlines wrapped line by line; inline markdown read as styles unless `isPlain`. */
504export const wrapText = (text: string, room: number, isPlain = false): Span[][] =>
505  text.split('\n').flatMap(line => wrapSpans(isPlain ? [{ text: line, style: 'plain' }] : spans(line), room))
506
507/** Cells a row of runs takes. */
508export const rowWidth = (row: Span[]) => row.reduce((sum, run) => sum + cellWidth(run.text), 0)
509
510/** A command a reply names in inline code: a slash command or `! shell` for this session, or a shell command. */
511export type Command = { kind: 'slash' | 'bang' | 'shell'; text: string }
512
513/** First words that make inline code a shell command worth running (a path or a name is left alone). */
514const SHELL_WORDS = new Set(
515  (
516    'claude git gh npm npx pnpm yarn bun bunx deno node python python3 pip pip3 uv uvx cargo rustup go make cmake ' +
517    'docker podman kubectl brew apt sudo curl wget ssh scp rsync ls cd cat rg fd grep sed awk tsc eza bat code ' +
518    'just mise nvm export source chmod mkdir rm cp mv touch echo tar unzip systemctl journalctl pacman yay paru ' +
519    'sleep watch tail head find env time timeout xargs which kill pkill ps top htop du df less man ping jq yq ' +
520    'sqlite3 psql mysql redis-cli ruby gem bundle rails php composer java mvn gradle dotnet swift zig nix ' +
521    'terraform aws gcloud az firebase vercel netlify flyctl vite pytest jest vitest eslint prettier ruff black ' +
522    'mypy poetry pipx conda tmux zsh bash sh fish nvim vim nano http nc dig open xdg-open godot gdh'
523  ).split(' '),
524)
525
526/** Words that are also plain English, so inline code starting with one reads as a command only with shell syntax after it. */
527const AMBIGUOUS = new Set(
528  'export find time code open top go head less watch which man source env ping http dig touch kill tail make'.split(' '),
529)
530
531/** A flag or a piece of shell syntax, which make code read as a command whatever its first word. */
532const SHELL_SHAPE = /(?:^|\s)--?[a-z0-9]|&&|\|\||[|;>]|\$[A-Za-z_{(]/i
533
534/** `export NAME=value`, the one form of `export` that is a shell command in a sentence's clothes. */
535const EXPORT_ASSIGNMENT = /^export\s+[A-Za-z_]\w*=/
536
537/** A program's name: lowercase, as typed at a prompt (not `Name`, `fn(x)` or `a.b.c`). */
538const PROGRAM = /^[a-z][a-z0-9_+-]*$/
539
540/** Leading `NAME=value` assignments, which say where a command's environment comes from, not what it runs. */
541const ASSIGNMENTS = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/
542
543/** What a piece of inline code would run, or null when it reads as a name, a path or a value. */
544export const commandOf = (code: string): Command | null => {
545  const text = code.trim()
546  if (/^\/[a-z][\w:-]*(?:\s|$)/i.test(text)) {
547    return { kind: 'slash', text }
548  }
549  if (/^!\s*\S/.test(text)) {
550    return { kind: 'bang', text: `!${text.slice(1).trimStart()}` }
551  }
552  const body = text.replace(ASSIGNMENTS, '')
553  const first = body.split(/\s+/)[0] ?? ''
554  if (!/\s/.test(body)) {
555    return null
556  }
557  const isCommand =
558    EXPORT_ASSIGNMENT.test(body) ||
559    (SHELL_WORDS.has(first) && (!AMBIGUOUS.has(first) || SHELL_SHAPE.test(body))) ||
560    (PROGRAM.test(first) && SHELL_SHAPE.test(body))
561
562  return isCommand ? { kind: 'shell', text } : null
563}
564
565/** Where a command's link points: a host that never resolves, so nothing opens if a click reaches the terminal. */
566const COMMAND_HOST = 'https://nebula-ui.invalid/command/'
567
568/**
569 * Prose with each command in inline code made a link the mod answers, and the commands by their link.
570 * Code already inside a link's text is left as written.
571 */
572export const linkCommands = (prose: string) => {
573  const commands = new Map<string, Command>()
574  const text = prose.replace(/(\[?)`([^`\n]+)`/g, (whole, bracket: string, code: string) => {
575    const command = bracket === '' ? commandOf(code) : null
576    if (command === null) {
577      return whole
578    }
579    const href = `${COMMAND_HOST}${commands.size}`
580    commands.set(href, command)
581
582    return `[\`${code}\`](${href})`
583  })
584
585  return { text, commands }
586}
587
588/** Fence languages that hold shell commands. */
589const SHELL_FENCES = new Set(['sh', 'bash', 'shell', 'zsh', 'console', 'terminal', 'fish'])
590
591/**
592 * What a code fence would run, or null. A shell fence runs as it is shown, one script, with the `$ ` prompts
593 * of a transcript dropped and its comments and blank lines kept; a fence without a language counts when every
594 * line reads as a command; one `/` or `!` line goes to the prompt.
595 */
596export const fenceCommand = (language: string, source: string): Command | null => {
597  const lines = source.split('\n').map(line => line.replace(/^\s*\$\s+/, ''))
598  const first = lines.findIndex(line => line.trim() !== '')
599  if (first === -1) {
600    return null
601  }
602  let last = lines.length - 1
603  while (lines[last]!.trim() === '') {
604    last--
605  }
606  const script = lines.slice(first, last + 1)
607  const content = script.filter(line => line.trim() !== '' && !line.trim().startsWith('#'))
608  if (content.length === 0) {
609    return null
610  }
611  const single = content.length === 1 ? commandOf(content[0]!) : null
612  if (single !== null && single.kind !== 'shell') {
613    return single
614  }
615  if (SHELL_FENCES.has(language)) {
616    return { kind: 'shell', text: script.join('\n') }
617  }
618  if (language === '' && content.every(line => commandOf(line)?.kind === 'shell')) {
619    return { kind: 'shell', text: script.join('\n') }
620  }
621
622  return null
623}
624
625/** The style a color code sets, and the ones after it on the same line. */
626export type Tint = { color?: string; bold?: boolean; dim?: boolean; italic?: boolean; underline?: boolean }
627
628/** The eight basic ANSI colors as theme keys, so a tool's colors follow the person's theme. */
629const BASIC_COLORS = ['inactive', 'error', 'success', 'warning', 'suggestion', 'permission', 'suggestion', 'text']
630
631const applySgr = (tint: Tint, codes: number[]): Tint => {
632  let next: Tint = { ...tint }
633  for (const code of codes) {
634    if (code === 0) {
635      next = {}
636    } else if (code === 1) {
637      next = { ...next, bold: true }
638    } else if (code === 2) {
639      next = { ...next, dim: true }
640    } else if (code === 3) {
641      next = { ...next, italic: true }
642    } else if (code === 4) {
643      next = { ...next, underline: true }
644    } else if (code === 22) {
645      next = { ...next, bold: false, dim: false }
646    } else if (code === 23) {
647      next = { ...next, italic: false }
648    } else if (code === 24) {
649      next = { ...next, underline: false }
650    } else if (code === 39) {
651      next = { ...next, color: undefined }
652    } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
653      next = { ...next, color: BASIC_COLORS[code % 10] }
654    }
655  }
656
657  return next
658}
659
660/**
661 * A line of a command's output as runs, each with the style its color codes set: bold, dim, italic and
662 * underline, and the eight basic colors, bright or not. Other escape sequences and control codes are gone.
663 */
664export const colorRuns = (line: string): Array<{ text: string } & Tint> => {
665  const runs: Array<{ text: string } & Tint> = []
666  let tint: Tint = {}
667  for (const part of line.split(SGR_TOKEN)) {
668    const codes = /^\u001b\[([0-9;]*)m$/.exec(part)
669    if (codes !== null) {
670      tint = applySgr(tint, codes[1] === '' ? [0] : codes[1]!.split(';').map(Number))
671    } else {
672      const text = printable(part)
673      if (text !== '') {
674        runs.push({ ...tint, text })
675      }
676    }
677  }
678
679  return runs
680}
681
hooks/dashboard.tsx 183 lines
1import { atom, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderElement } from 'claude-code'
3
4import type { Dash } from '../types'
5import { ACCENT, tone } from './theme'
6
7export const dash = atom({ plugin: 'nebula-ui', key: 'dash' } as const, null as Dash | null)
8
9/** `git status` in the session's directory as branch, ahead, behind and changed files; null outside a repo. */
10async function gitState($: EngineInterface): Promise<Dash['git']> {
11  try {
12    const run = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { timeoutMs: 5000 })
13    if (run.exitCode !== 0) {
14      return null
15    }
16    const lines = run.stdout.split('\n')
17    const head = lines.find(line => line.startsWith('# branch.head '))?.slice('# branch.head '.length) ?? '?'
18    const counts = /^# branch\.ab \+(\d+) -(\d+)/m.exec(run.stdout)
19
20    return {
21      branch: head === '(detached)' ? 'detached' : head,
22      ahead: Number(counts?.[1] ?? 0),
23      behind: Number(counts?.[2] ?? 0),
24      changed: lines.filter(line => line !== '' && !line.startsWith('#')).length,
25    }
26  } catch {
27    return null
28  }
29}
30
31/** Reads the figures the band draws and stores them; never throws, a failed read keeps the last. */
32export async function gather($: EngineInterface) {
33  try {
34    const [usage, model, git, now] = await Promise.all([
35      $.session.usage(),
36      $.session.model(),
37      gitState($),
38      $.clock.now(),
39    ])
40    await update($, dash, () => ({
41      model,
42      context: {
43        percent: usage.context.percent ?? null,
44        tokens: usage.context.tokens ?? null,
45        window: usage.context.window,
46      },
47      limits: usage.rateLimits.map(limit => ({
48        kind: limit.kind,
49        percent: limit.percentUsed,
50        resetsAt: limit.resetsAt ?? null,
51      })),
52      cost: usage.cost?.usd ?? null,
53      startedAt: usage.startedAt,
54      now,
55      git,
56    }))
57  } catch (error) {
58    // The band keeps the figures it has.
59    $.ui.log(`nebula-ui: dashboard figures not read: ${String(error)}`, { to: 'debug' })
60  }
61}
62
63/** `claude-opus-5-5[1m]` as `opus 5.5 · 1m`; a dated id (`claude-haiku-4-5-20251001`) as `haiku 4.5`. */
64export const modelName = (id: string) => {
65  const suffix = /\[(.*)\]$/.exec(id)?.[1]
66  const short = id
67    .replace(/\[.*\]$/, '')
68    .replace(/^claude-/, '')
69    .replace(/-\d{8}$/, '')
70    .replace(/-(\d+)-(\d+)$/, ' $1.$2')
71    .replace(/-(\d+)$/, ' $1')
72
73  return suffix === undefined ? short : `${short} · ${suffix}`
74}
75
76const LIMITS: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
77
78const limitName = (kind: string) => LIMITS[kind] ?? kind.replace(/^seven_day_/, '7d ').replace(/_/g, ' ')
79
80/** A span of time as the band shows it: seconds under a minute, then minutes, hours and days. */
81const span = (ms: number) => {
82  if (!Number.isFinite(ms) || ms < 0) {
83    return '0s'
84  }
85  if (ms < 60_000) {
86    return `${Math.floor(ms / 1000)}s`
87  }
88  const minutes = Math.round(ms / 60_000)
89  const hours = Math.floor(minutes / 60)
90  const days = Math.floor(hours / 24)
91
92  return days > 0 ? `${days}d ${hours % 24}h` : hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
93}
94
95const gauge = (percent: number, cells: number) => {
96  const filled = Math.min(cells, Math.max(0, Math.round((percent / 100) * cells)))
97
98  return '▰'.repeat(filled) + '▱'.repeat(cells - filled)
99}
100
101/** Dollars as the band shows them: two places, and `<$0.01` for a turn cheaper than a cent. */
102const money = (usd: number) => (usd > 0 && usd < 0.01 ? '<0.01' : usd.toFixed(2))
103
104/**
105 * One row of figures, each behind a dim bar; the row wraps where the band is narrow, and a figure that
106 * starts a wrapped row carries its bar with it, so no bar is left at the end of one.
107 */
108export const drawBand = ({ Box, Text }: Elements['terminal'], d: Dash, extra?: RenderElement) => {
109  const items: RenderElement[] = [
110    <Text>
111      <Text color={ACCENT}>✦ </Text>
112      <Text weight="bold">{modelName(d.model)}</Text>
113    </Text>,
114  ]
115
116  if (d.git !== null) {
117    items.push(
118      <Text>
119        <Text color="suggestion">⎇ </Text>
120        {d.git.branch}
121        {d.git.changed > 0 && <Text color="warning"> ±{d.git.changed}</Text>}
122        {d.git.ahead > 0 && <Text color="success"> ↑{d.git.ahead}</Text>}
123        {d.git.behind > 0 && <Text color="error"> ↓{d.git.behind}</Text>}
124      </Text>,
125    )
126  }
127
128  const percent = d.context?.percent
129  if (percent !== null && percent !== undefined && Number.isFinite(percent)) {
130    items.push(
131      <Text>
132        <Text dimColor>ctx </Text>
133        <Text color={tone(percent)}>{gauge(percent, 10)}</Text> {Math.round(percent)}%
134      </Text>,
135    )
136  }
137
138  for (const limit of d.limits ?? []) {
139    if (!Number.isFinite(limit.percent)) {
140      continue
141    }
142    const resets = limit.resetsAt === null ? NaN : Date.parse(limit.resetsAt) - d.now
143    items.push(
144      <Text>
145        <Text dimColor>{limitName(limit.kind)} </Text>
146        <Text color={tone(limit.percent)}>{gauge(limit.percent, 6)}</Text> {Math.round(limit.percent)}%
147        {Number.isFinite(resets) && resets > 0 && <Text dimColor> ↻ {span(resets)}</Text>}
148      </Text>,
149    )
150  }
151
152  if (d.cost !== null && Number.isFinite(d.cost) && d.cost >= 0) {
153    items.push(
154      <Text>
155        <Text color="success">$</Text>
156        {money(d.cost)}
157      </Text>,
158    )
159  }
160
161  items.push(<Text dimColor>⏱ {span(d.now - d.startedAt)}</Text>)
162
163  if (extra !== undefined) {
164    items.push(extra)
165  }
166
167  return (
168    <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
169      {items.map((item, index) =>
170        index === 0 ? (
171          item
172        ) : (
173          // A row, not a text: the chip is a button, which the engine refuses inside a text.
174          <Box flexDirection="row">
175            <Text color="inactive">│ </Text>
176            {item}
177          </Box>
178        ),
179      )}
180    </Box>
181  )
182}
183
hooks/procs.tsx 480 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, HookStream, ProcessSpawnChunk, ProcessSpawnResult } from 'claude-code'
3
4import type { Proc } from '../types'
5import { printable } from './markdown'
6import { ACCENT } from './theme'
7
8export const procs = atom({ plugin: 'nebula-ui', key: 'procs' } as const, [] as Proc[])
9
10export const PROCS_PANE = 'nebula-procs'
11
12/** Lines kept of what a process printed. */
13const TAIL = 200
14
15/** Ended processes kept in the list, newest first. Running ones are always kept: a process is listed while it runs. */
16const KEPT = 30
17
18/** The longest line kept, and the longest one the model reads (a process can print a line without a newline). */
19const MAX_LINE = 1000
20const MODEL_LINE = 400
21
22/** The first thing the wrapper prints, on stderr: the process group to stop. */
23const GROUP_MARK = 'nebula-ui-pgid:'
24
25/** How long a stop waits for SIGTERM before SIGKILL. */
26const GRACE_MS = 3000
27
28/** How often streamed output is written to state, so a chatty process doesn't redraw on every piece. */
29const FLUSH_MS = 250
30
31type Live = { stream: HookStream<ProcessSpawnChunk, ProcessSpawnResult>; isStopping: boolean }
32
33/**
34 * The streams being read, by process id. Reading keeps a child alive and `return()` ends it; this is
35 * module memory, so a reload of the mod ends every loop (the groups may live on: see `adopt`).
36 */
37const live = new Map<string, Live>()
38
39async function patch($: EngineInterface, id: string, change: (proc: Proc) => Proc) {
40  await update($, procs, list => list.map(proc => (proc.id === id ? change(proc) : proc)))
41}
42
43/** Whether any process of the group is still alive (`kill -0` sends nothing). */
44async function isGroupAlive($: EngineInterface, pgid: number) {
45  try {
46    return (await $.process.run(['kill', '-0', '--', `-${pgid}`], { timeoutMs: 2000 })).exitCode === 0
47  } catch {
48    return false
49  }
50}
51
52async function signalGroup($: EngineInterface, pgid: number, signal: 'TERM' | 'KILL') {
53  try {
54    await $.process.run(['kill', `-${signal}`, '--', `-${pgid}`], { timeoutMs: 2000 })
55  } catch {
56    // Already gone.
57  }
58}
59
60/** The list kept: every running process, and the newest `KEPT` of those that ended, newest first. */
61const keepRecent = (list: Proc[]) => {
62  let ended = 0
63
64  return list.filter(proc => {
65    if (isRunning(proc)) {
66      return true
67    }
68    ended += 1
69
70    return ended <= KEPT
71  })
72}
73
74/**
75 * Runs `command` through `sh -c` in the session's directory, in a process group of its own (setsid), and
76 * reads what it prints into the list until it ends.
77 */
78export async function startProc($: EngineInterface, command: string, origin: string | null = null) {
79  const id = crypto.randomUUID().slice(0, 8)
80  const cwd = await $.session.cwd()
81  const startedAt = await $.clock.now()
82  const proc: Proc = { id, command, cwd, startedAt, endedAt: null, status: 'running', code: null, pgid: null, tail: [], origin }
83  await update($, procs, list => keepRecent([proc, ...list]))
84
85  const stream = $.process.spawn({
86    argv: ['setsid', '-w', 'sh', '-c', `printf '${GROUP_MARK}%s\\n' "$$" >&2; ${command}`],
87    cwd,
88    // A runtime that writes to a pipe holds its output back until it exits unless told not to.
89    env: { PYTHONUNBUFFERED: '1' },
90  })
91  const entry: Live = { stream, isStopping: false }
92  live.set(id, entry)
93
94  void (async () => {
95    const lines: string[] = []
96    let partial = ''
97    let flushedAt = 0
98    let trailing = false
99    const flush = async () => {
100      flushedAt = Date.now()
101      const tail = [...lines, ...(partial === '' ? [] : [partial])].slice(-TAIL)
102      await patch($, id, one => ({ ...one, tail }))
103    }
104    // Output that arrives inside the throttle window is written when the window ends, not at the next chunk.
105    const later = () => {
106      if (trailing) {
107        return
108      }
109      trailing = true
110      void (async () => {
111        await $.clock.sleep(Math.max(0, FLUSH_MS - (Date.now() - flushedAt)))
112        trailing = false
113        await flush()
114      })().catch(() => {
115        // The session ended first: the state is kept as the last flush left it.
116      })
117    }
118
119    try {
120      for await (const chunk of stream) {
121        let text = printable(chunk.text)
122        if (chunk.stream === 'stderr' && text.includes(GROUP_MARK)) {
123          const found = new RegExp(`${GROUP_MARK}(\\d+)\\n?`).exec(text)
124          if (found !== null) {
125            const pgid = Number(found[1])
126            text = text.replace(found[0], '')
127            await patch($, id, one => ({ ...one, pgid }))
128          }
129        }
130        const pieces = (partial + text).split('\n')
131        partial = (pieces.pop() ?? '').slice(0, MAX_LINE)
132        lines.push(...pieces.map(piece => piece.slice(0, MAX_LINE)))
133        lines.splice(0, Math.max(0, lines.length - TAIL))
134        if (Date.now() - flushedAt >= FLUSH_MS) {
135          await flush()
136        } else {
137          later()
138        }
139      }
140      // A stop ends the stream on purpose, and its result is then nothing: the process ended by the stop.
141      const result = (await stream.result) as ProcessSpawnResult | undefined
142      await flush()
143      const endedAt = await $.clock.now()
144      const code = result?.code ?? null
145      const signal = result?.signal ?? null
146      await patch($, id, one => ({
147        ...one,
148        endedAt,
149        code,
150        status: entry.isStopping || signal !== null ? 'stopped' : code === 0 ? 'exited' : 'failed',
151      }))
152    } catch (error) {
153      await flush().catch(() => undefined)
154      const endedAt = await $.clock.now()
155      await patch($, id, one => ({
156        ...one,
157        endedAt,
158        status: entry.isStopping ? 'stopped' : 'failed',
159        // A stop ends the stream on purpose, which is not an error of the process.
160        tail: entry.isStopping ? one.tail : [...one.tail, String(error)].slice(-TAIL),
161      }))
162    } finally {
163      live.delete(id)
164    }
165  })().catch(() => {
166    // Nothing reads an error from here: the process's state records how it ended.
167  })
168
169  return id
170}
171
172/** Stops a process and everything it started: SIGTERM to its group, SIGKILL after a grace period. */
173export async function stopProc($: EngineInterface, id: string) {
174  const proc = (await read($, procs)).find(one => one.id === id)
175  if (proc === undefined) {
176    return
177  }
178  const entry = live.get(id)
179  if (entry !== undefined) {
180    entry.isStopping = true
181  }
182  if (proc.pgid !== null) {
183    await signalGroup($, proc.pgid, 'TERM')
184    const pgid = proc.pgid
185    void (async () => {
186      await $.clock.sleep(GRACE_MS)
187      if (await isGroupAlive($, pgid)) {
188        await signalGroup($, pgid, 'KILL')
189      }
190    })().catch(() => undefined)
191  }
192  if (entry !== undefined) {
193    await entry.stream.return(undefined as never).catch(() => undefined)
194  } else {
195    const endedAt = await $.clock.now()
196    await patch($, id, one => ({ ...one, status: 'stopped', endedAt }))
197  }
198}
199
200/**
201 * Stops every process still running, at the session's end, so nothing is left behind untracked. A stop's
202 * kill timer would die with the session, so this waits out the grace period and kills what still lives.
203 */
204export async function stopAll($: EngineInterface) {
205  const running = (await read($, procs)).filter(isRunning)
206  const groups = running.flatMap(proc => (proc.pgid === null ? [] : [proc.pgid]))
207  await Promise.all(running.map(proc => stopProc($, proc.id)))
208  const survivors = async () => {
209    const alive: number[] = []
210    for (const pgid of groups) {
211      if (await isGroupAlive($, pgid)) {
212        alive.push(pgid)
213      }
214    }
215
216    return alive
217  }
218  for (let waited = 0; waited < GRACE_MS; waited += 100) {
219    if ((await survivors()).length === 0) {
220      return
221    }
222    await $.clock.sleep(100)
223  }
224  for (const pgid of await survivors()) {
225    await signalGroup($, pgid, 'KILL')
226  }
227}
228
229/**
230 * After a reload of the mod, a process listed as running has no loop reading it: its group may live on.
231 * One still alive is marked detached (it can still be stopped); one gone is marked ended.
232 */
233export async function adopt($: EngineInterface) {
234  for (const proc of await read($, procs)) {
235    if ((proc.status !== 'running' && proc.status !== 'detached') || live.has(proc.id)) {
236      continue
237    }
238    const isAlive = proc.pgid !== null && (await isGroupAlive($, proc.pgid))
239    if (proc.status === 'running' || !isAlive) {
240      const now = await $.clock.now()
241      await patch($, proc.id, one => {
242        // Judged on the state as it stands now: a process that ended during the check keeps its end.
243        if (one.status !== 'running' && one.status !== 'detached') {
244          return one
245        }
246
247        return isAlive ? { ...one, status: 'detached' } : { ...one, status: 'exited', endedAt: one.endedAt ?? now }
248      })
249    }
250  }
251}
252
253export async function clearProc($: EngineInterface, id: string) {
254  await update($, procs, list => list.filter(proc => proc.id !== id))
255}
256
257export const TONE: Record<Proc['status'], { glyph: string; color: string }> = {
258  running: { glyph: '◌', color: 'suggestion' },
259  detached: { glyph: '◍', color: 'warning' },
260  exited: { glyph: '✓', color: 'success' },
261  failed: { glyph: '✗', color: 'error' },
262  stopped: { glyph: '■', color: 'warning' },
263}
264
265const age = (ms: number) => {
266  const seconds = Math.max(0, Math.round(ms / 1000))
267  const minutes = Math.floor(seconds / 60)
268  const hours = Math.floor(minutes / 60)
269
270  return hours > 0 ? `${hours}h ${minutes % 60}m` : minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
271}
272
273export const statusWord = (proc: Proc, now: number) => {
274  switch (proc.status) {
275    case 'running':
276      return now > proc.startedAt ? `running ${age(now - proc.startedAt)}` : 'running'
277    case 'detached':
278      return 'running, output detached by a reload'
279    case 'exited':
280      return `exited after ${age((proc.endedAt ?? now) - proc.startedAt)}`
281    case 'failed':
282      return `failed${proc.code === null ? '' : ` with ${proc.code}`}`
283    case 'stopped':
284      return 'stopped'
285  }
286}
287
288export const isRunning = (proc: Proc) => proc.status === 'running' || proc.status === 'detached'
289
290/** The Processes pane: each process, newest first, with its last lines and its controls. */
291export const drawProcs = (
292  ui: Elements['terminal'],
293  list: Proc[],
294  now: number,
295  columns: number,
296  act: {
297    stop: (id: string) => void
298    restart: (id: string) => void
299    follow: (proc: Proc) => void
300    clear: (id: string) => void
301    clearEnded: () => void
302  },
303) => {
304  const { Box, Text, Button } = ui
305  if (list.length === 0) {
306    return (
307      <Box flexDirection="column">
308        <Text dimColor>Nothing started yet.</Text>
309        <Text dimColor>Click a shell command in one of Claude's replies and choose run here.</Text>
310      </Box>
311    )
312  }
313  const room = Math.max(10, columns - 4)
314
315  return (
316    <Box flexDirection="column" rowGap={1}>
317      {list.some(proc => !isRunning(proc)) && (
318        <Box flexDirection="row" justifyContent="flex-end">
319          <Button key="clear-ended" plain onPress={act.clearEnded}>
320            <Text dimColor>✕ clear ended</Text>
321          </Button>
322        </Box>
323      )}
324      {list.map(proc => {
325        const tone = TONE[proc.status]
326        const shown = proc.tail.filter(line => line.trim() !== '').slice(isRunning(proc) ? -8 : -3)
327
328        return (
329          <Box flexDirection="column">
330            <Box flexDirection="row" gap={1}>
331              <Box flexShrink={0}>
332                <Text color={tone.color}>{tone.glyph}</Text>
333              </Box>
334              <Box flexShrink={1} flexGrow={1} overflow="hidden">
335                <Text weight="bold" wrap="truncate-end">
336                  {proc.command}
337                </Text>
338              </Box>
339              <Box flexShrink={0} gap={1}>
340                <Text color={tone.color}>{statusWord(proc, now)}</Text>
341                {isRunning(proc) && (
342                  <Button key={`stop-${proc.id}`} plain onPress={() => act.stop(proc.id)}>
343                    <Text color="error">■ stop</Text>
344                  </Button>
345                )}
346                <Button key={`restart-${proc.id}`} plain onPress={() => act.restart(proc.id)}>
347                  <Text color="success">↻</Text>
348                </Button>
349                <Button key={`follow-${proc.id}`} plain onPress={() => act.follow(proc)}>
350                  <Text color={ACCENT}>⤢</Text>
351                </Button>
352                {!isRunning(proc) && (
353                  <Button key={`clear-${proc.id}`} plain onPress={() => act.clear(proc.id)}>
354                    <Text dimColor>✕</Text>
355                  </Button>
356                )}
357              </Box>
358            </Box>
359            {shown.map(line => (
360              <Box flexDirection="row">
361                <Text color={ACCENT}>▏ </Text>
362                {outputLine(ui, line.slice(0, room))}
363              </Box>
364            ))}
365          </Box>
366        )
367      })}
368    </Box>
369  )
370}
371
372/** Starts a process again with the command and frame it ran from, stopping it first if it still runs. Null when it is not listed. */
373export async function restartProc($: EngineInterface, id: string) {
374  const proc = (await read($, procs)).find(one => one.id === id)
375  if (proc === undefined) {
376    return null
377  }
378  if (isRunning(proc)) {
379    await stopProc($, id)
380    for (let waited = 0; live.has(id) && waited < 5000; waited += 100) {
381      await $.clock.sleep(100)
382    }
383  }
384
385  return startProc($, proc.command, proc.origin)
386}
387
388/** The processes as the model reads them: one line each, newest first, with their last lines. */
389export const describeProcs = (list: Proc[], now: number) =>
390  list.length === 0
391    ? 'No processes have been started from nebula-ui in this session.'
392    : list
393        .map(proc =>
394          [
395            `${proc.id}  ${proc.status}  ${statusWord(proc, now)}  in ${proc.cwd}`,
396            `  $ ${proc.command}`,
397            ...proc.tail
398              .filter(line => line.trim() !== '')
399              .slice(-3)
400              .map(line => `  | ${line.slice(0, MODEL_LINE)}`),
401          ].join('\n'),
402        )
403        .join('\n\n')
404
405/** One process's output as the model reads it: its status, then its last `lines` lines. */
406export const describeOutput = (proc: Proc, now: number, lines: number) =>
407  [
408    `${proc.id}  ${proc.status}  ${statusWord(proc, now)}  in ${proc.cwd}`,
409    `$ ${proc.command}`,
410    '',
411    ...(proc.tail.length === 0
412      ? ['(no output yet)']
413      : lines > 0
414        ? proc.tail.slice(-lines).map(line => line.slice(0, MODEL_LINE))
415        : []),
416  ].join('\n')
417
418const URL = /https?:\/\/[^\s)\]'"<>`]+/g
419
420/** A line of output with each URL a link (a server's 0.0.0.0 opened as localhost). Escape codes are not drawn. */
421export const outputLine = ({ Text, Link }: Elements['terminal'], line: string) => {
422  const parts: Array<string | { url: string }> = []
423  let at = 0
424  const plain = printable(line)
425  for (const found of plain.matchAll(URL)) {
426    parts.push(plain.slice(at, found.index), { url: found[0] })
427    at = found.index + found[0].length
428  }
429  parts.push(plain.slice(at))
430
431  return (
432    <Text wrap="truncate-end">
433      {parts.map(part =>
434        typeof part === 'string' ? part : <Link href={part.url.replace('//0.0.0.0', '//localhost')}>{part.url}</Link>,
435      )}
436    </Text>
437  )
438}
439
440/** The pane id prefix of a process's own pane. A colon is outside the engine's id rule, so it is a hyphen. */
441export const FOLLOW_PANE = 'nebula-proc-'
442
443/** A pane following one process: its status and controls, then as many of its last lines as fit. */
444export const drawFollow = (
445  ui: Elements['terminal'],
446  proc: Proc | undefined,
447  now: number,
448  rows: number,
449  act: { stop: () => void; restart: () => void },
450) => {
451  const { Box, Text, Button } = ui
452  if (proc === undefined) {
453    return <Text dimColor>That process is no longer listed.</Text>
454  }
455  const tone = TONE[proc.status]
456
457  return (
458    <Box flexDirection="column">
459      <Box flexDirection="row" gap={2}>
460        <Text color={tone.color}>
461          {tone.glyph} {statusWord(proc, now)}
462        </Text>
463        {isRunning(proc) && (
464          <Button key="stop" plain hotkey="s" onPress={act.stop}>
465            <Text color="error">■ stop</Text>
466          </Button>
467        )}
468        <Button key="restart" plain hotkey="r" onPress={act.restart}>
469          <Text color="success">↻ restart</Text>
470        </Button>
471      </Box>
472      <Text dimColor wrap="truncate-end">
473        $ {proc.command} · {proc.cwd}
474        {proc.pgid === null ? '' : ` · group ${proc.pgid}`}
475      </Text>
476      {proc.tail.slice(-Math.max(1, rows - 2)).map(line => outputLine(ui, line))}
477    </Box>
478  )
479}
480
hooks/theme.ts 32 lines
1/** The accent presets: a theme key that follows the person's Claude Code theme, or a fixed color. */
2export const THEMES = {
3  terminal: { accent: 'permission', about: "follows your Claude Code theme" },
4  claude: { accent: 'claude', about: "Claude's orange, from your Claude Code theme" },
5  nebula: { accent: '#b4befe', about: 'lavender' },
6  ember: { accent: '#fab387', about: 'orange' },
7  mint: { accent: '#94e2d5', about: 'teal' },
8  rose: { accent: '#f5c2e7', about: 'pink' },
9  gold: { accent: '#f9e2af', about: 'yellow' },
10  mono: { accent: 'text', about: "your terminal's text color" },
11} as const
12
13export type ThemeName = keyof typeof THEMES
14
15export const THEME_NAMES = Object.keys(THEMES) as ThemeName[]
16
17export const isThemeName = (value: unknown): value is ThemeName =>
18  typeof value === 'string' && Object.prototype.hasOwnProperty.call(THEMES, value)
19
20/**
21 * The color nebula-ui accents with. A live binding: every module that imports it reads the value the
22 * render hooks last set from the person's theme, so set it before drawing.
23 */
24export let ACCENT: string = THEMES.terminal.accent
25
26export const setAccent = (name: ThemeName) => {
27  ACCENT = THEMES[name].accent
28}
29
30/** A share as a gauge's color: calm, getting full, nearly out. */
31export const tone = (percent: number) => (percent < 60 ? 'success' : percent < 85 ? 'warning' : 'error')
32
types/index.d.ts 54 lines
1/** The restyled parts the person switched off, by render component name. */
2export type Disabled = string[]
3
4/** The figures the dashboard band draws, gathered outside the drawing. */
5export type Dash = {
6  model: string
7  context: { percent: number | null; tokens: number | null; window: number }
8  limits: { kind: string; percent: number; resetsAt: string | null }[]
9  cost: number | null
10  startedAt: number
11  /** When the figures were gathered, in `$.clock.now()`'s milliseconds. */
12  now: number
13  /** Null outside a git repository. */
14  git: { branch: string; ahead: number; behind: number; changed: number } | null
15}
16
17/** How wide code and output frames are drawn: the full width, their content's, or a number of columns. */
18export type FrameWidth = 'fill' | 'fit' | number
19
20/** A command nebula-ui started from a reply, and what became of it. */
21export type Proc = {
22  id: string
23  command: string
24  cwd: string
25  startedAt: number
26  endedAt: number | null
27  /** `detached`: still running after a reload of the mod, its output no longer read. */
28  status: 'running' | 'exited' | 'failed' | 'stopped' | 'detached'
29  code: number | null
30  /** The process group the command runs in (it is started with setsid), so a stop reaches its children. */
31  pgid: number | null
32  /** The last lines it printed, stdout and stderr together. */
33  tail: string[]
34  /** The code frame it was run from (`<message id>:<block>`), which draws its output; null from a dialog. */
35  origin: string | null
36}
37
38declare module 'claude-code' {
39  interface PluginState {
40    'nebula-ui': {
41      disabled: Disabled
42      /** Whether a tool row (by tool_use_id) or a group (`group:` + its requestId) is opened. */
43      expanded: StateFamily<boolean>
44      dash: Dash | null
45      procs: Proc[]
46      frameWidth: FrameWidth
47      /** The accent preset (hooks/theme.ts THEMES). */
48      theme: 'terminal' | 'claude' | 'nebula' | 'ember' | 'mint' | 'rose' | 'gold' | 'mono'
49      /** The command the run dialog asks about, null while it is closed. */
50      pending: { command: string; origin: string | null } | null
51    }
52  }
53}
54