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

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.

<sub>A real session, recorded with VHS from docs/demo/nebula-ui.tape (MP4).</sub>
/plugin marketplace add astrosteveo/nebula
/plugin install nebula-ui@nebula
| Part | Change |
|---|---|
| Prompt bar | Your prompts sit behind a thin accent bar instead of a grey box |
| Rich markdown | Code 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 commands | In 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 tools | Claude 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 output | Bash rows and Shell groups start open on what they printed; a click closes one, and switching this off in /nebula starts them closed |
| Explore groups | Folded reads and searches as one line (Shell when they're all commands); click to unfold into tool rows |
| Spinner | Two 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 |
| Notices | Startup notices with an ℹ icon |
| Mode chips | Footer modes as ◉ focus |
| Hint tail | ✦ /nebula at the end of the hint line |
| Background hint | A shorter ⇣ ctrl+b to run in background |
| Command output | What a slash command printed, behind an accent bar (red with ✗ failed for an error) instead of a dim ⎿ line |
| Dashboard band | Model, 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.
/ 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./plugin, /reload-plugins) keeps the engine's grey row: the API exposes no site for it.---) is read as a horizontal rule.See CHANGELOG.md for what each version added, changed and fixed.
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.
hooks/register.tsx 1623 lines1import { 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 lines1/**
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}
681hooks/dashboard.tsx 183 lines1import { 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}
183hooks/procs.tsx 480 lines1import { 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}
480hooks/theme.ts 32 lines1/** 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')
32types/index.d.ts 54 lines1/** 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