nebula-treasury 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +39 -0
  2. package/bin/nebula +11 -0
  3. package/package.json +65 -0
  4. package/src/commands/_agents.ts +14 -0
  5. package/src/commands/_unlock.ts +66 -0
  6. package/src/commands/chat-telegram.ts +398 -0
  7. package/src/commands/chat.tsx +1293 -0
  8. package/src/commands/drain.ts +90 -0
  9. package/src/commands/gateway-logs.ts +49 -0
  10. package/src/commands/gateway-run.ts +42 -0
  11. package/src/commands/gateway-start.ts +216 -0
  12. package/src/commands/gateway-status.ts +90 -0
  13. package/src/commands/gateway-stop.ts +133 -0
  14. package/src/commands/gateway.ts +101 -0
  15. package/src/commands/identity.ts +178 -0
  16. package/src/commands/init/cost.ts +40 -0
  17. package/src/commands/init/funding-gate.ts +64 -0
  18. package/src/commands/init/model-picker.ts +25 -0
  19. package/src/commands/init/operator-picker.ts +233 -0
  20. package/src/commands/init/telegram-step.ts +245 -0
  21. package/src/commands/init/wizard-state.ts +94 -0
  22. package/src/commands/init.ts +439 -0
  23. package/src/commands/logs.ts +37 -0
  24. package/src/commands/model.ts +48 -0
  25. package/src/commands/pairing-approve.ts +65 -0
  26. package/src/commands/pairing-clear.ts +39 -0
  27. package/src/commands/pairing-list.ts +55 -0
  28. package/src/commands/pairing-revoke.ts +49 -0
  29. package/src/commands/pairing.ts +81 -0
  30. package/src/commands/status.ts +44 -0
  31. package/src/commands/telegram-remove.ts +62 -0
  32. package/src/commands/telegram-setup.ts +64 -0
  33. package/src/commands/telegram-status.ts +87 -0
  34. package/src/commands/telegram.ts +44 -0
  35. package/src/config/load.ts +35 -0
  36. package/src/config/render.ts +99 -0
  37. package/src/index.ts +153 -0
  38. package/src/ui/app.tsx +673 -0
  39. package/src/ui/approval-summary.ts +32 -0
  40. package/src/ui/markdown-parse.ts +219 -0
  41. package/src/ui/markdown.tsx +37 -0
  42. package/src/ui/state.ts +181 -0
  43. package/src/util/bootstrap-mode.ts +25 -0
  44. package/src/util/bootstrap-progress-box.ts +378 -0
  45. package/src/util/cli-version.ts +28 -0
  46. package/src/util/format.ts +11 -0
  47. package/src/util/gateway-spawn.ts +125 -0
  48. package/src/util/gateway-version.ts +154 -0
  49. package/src/util/github-releases.ts +79 -0
  50. package/src/util/profile-key.ts +25 -0
  51. package/src/util/ref-resolver.ts +55 -0
  52. package/src/util/silence-console.ts +40 -0
  53. package/src/util/telegram-secrets.ts +218 -0
package/src/ui/app.tsx ADDED
@@ -0,0 +1,673 @@
1
+ import { useKeyboard, useTerminalDimensions } from '@opentui/solid'
2
+ import { type SlashCommand, suggestForPrefix } from 'nebula-ai-core'
3
+ import { For, Show, createEffect, createSignal, onCleanup } from 'solid-js'
4
+ import { summarizeApprovalSubject } from './approval-summary'
5
+ import { MarkdownSegments } from './markdown'
6
+ import type { ChatState, TurnRow } from './state'
7
+
8
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const
9
+ const SPINNER_FRAME_MS = 80
10
+ const SCROLL_STEP = 8
11
+
12
+ // opentui's <span> accepts `fg` at runtime but the SpanProps type omits it,
13
+ // and every workaround we tried fails:
14
+ // - dynamic <Tag> wrapper (`const Tag = 'span' as any`): solid's JSX
15
+ // transform resolves Tag and crashes with `Comp is not a function`.
16
+ // - module-level function-typed alias (`const Sp = 'span' as unknown as
17
+ // (p) => JSX.Element`): same crash — runtime value is still a string,
18
+ // solid invokes it as a function in completeUpdates and throws.
19
+ // - module augmentation `interface SpanProps { fg?: string }`: opentui
20
+ // exports SpanProps in a way that doesn't merge.
21
+ // - inline ANSI `\x1b[38;2;…m`: opentui's <text> renders them literally.
22
+ // Direct `<span fg=…>` with `@ts-expect-error` is the only path that works.
23
+
24
+ interface AppProps {
25
+ state: ChatState
26
+ onSubmit: (text: string) => void | Promise<void>
27
+ onExit: () => void
28
+ /**
29
+ * v0.20.0: extra slash commands (Claude Code commands etc) appended to the
30
+ * autocomplete suggestions when typing `/`. Each entry is a `SlashCommand`
31
+ * with `surfaces:['tui']`. The bundled registry is always shown alongside.
32
+ */
33
+ extraSlashCommands?: readonly SlashCommand[]
34
+ }
35
+
36
+ /** Cap visible autocomplete rows so the popup doesn't push the input box off-screen. */
37
+ const SLASH_MENU_MAX_ROWS = 8
38
+
39
+ const PREFIX_GUTTER = ' '
40
+ const LABEL_WIDTH = 5
41
+ const BODY_INDENT = `${PREFIX_GUTTER}${' '.repeat(LABEL_WIDTH + 2)}`
42
+ const TOOL_RESULT_INDENT = `${BODY_INDENT} `
43
+
44
+ function pad5(s: string): string {
45
+ return s.padEnd(LABEL_WIDTH, ' ')
46
+ }
47
+
48
+ function renderPrefix(label: string): string {
49
+ return `${PREFIX_GUTTER}${pad5(label)} `
50
+ }
51
+
52
+ function formatUsage(usage: { total?: number; cached?: number } | null | undefined): string {
53
+ if (!usage) return ''
54
+ const total = usage.total ?? 0
55
+ const cached = usage.cached ?? 0
56
+ const totalK = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : `${total}`
57
+ const cachedK = cached >= 1000 ? `${(cached / 1000).toFixed(1)}k` : `${cached}`
58
+ return cached ? `${totalK} t (${cachedK} cached)` : `${totalK} t`
59
+ }
60
+
61
+ function formatBalance(balance: number | null | undefined): string {
62
+ if (balance == null) return ''
63
+ if (balance >= 100) return `${balance.toFixed(0)} Mantle`
64
+ if (balance >= 1) return `${balance.toFixed(2)} Mantle`
65
+ return `${balance.toFixed(3)} Mantle`
66
+ }
67
+
68
+ function balanceColor(
69
+ balance: number | null | undefined,
70
+ redBelow = 0.5,
71
+ yellowBelow = 1.5,
72
+ ): string {
73
+ if (balance == null) return '#9ca3af'
74
+ if (balance < redBelow) return '#fca5a5'
75
+ if (balance < yellowBelow) return '#fbbf24'
76
+ return '#9ca3af'
77
+ }
78
+
79
+ function formatElapsed(startedAt: number | null | undefined): string {
80
+ if (!startedAt) return ''
81
+ const sec = Math.floor((Date.now() - startedAt) / 1000)
82
+ if (sec < 60) return `${sec}s`
83
+ const m = Math.floor(sec / 60)
84
+ const s = sec % 60
85
+ return `${m}m${s.toString().padStart(2, '0')}s`
86
+ }
87
+
88
+ function UserRow(props: { text: string }) {
89
+ return (
90
+ <box flexDirection="row" marginBottom={1}>
91
+ <text fg="#67e8f9" flexShrink={0}>
92
+ {renderPrefix('you')}
93
+ </text>
94
+ <text wrapMode="word" flexGrow={1} fg="#e5e7eb">
95
+ {props.text}
96
+ </text>
97
+ </box>
98
+ )
99
+ }
100
+
101
+ function SystemRow(props: { text: string }) {
102
+ return (
103
+ <box flexDirection="row" marginBottom={1}>
104
+ <text fg="#9ca3af" flexShrink={0}>
105
+ {renderPrefix('sys')}
106
+ </text>
107
+ <text wrapMode="word" flexGrow={1} fg="#9ca3af">
108
+ {props.text}
109
+ </text>
110
+ </box>
111
+ )
112
+ }
113
+
114
+ function InboxRow(props: { text: string }) {
115
+ return (
116
+ <box flexDirection="row" marginBottom={1}>
117
+ <text fg="#fbbf24" flexShrink={0}>
118
+ {renderPrefix('inbox')}
119
+ </text>
120
+ <text wrapMode="word" flexGrow={1} fg="#fde68a">
121
+ {props.text}
122
+ </text>
123
+ </box>
124
+ )
125
+ }
126
+
127
+ function MarketRow(props: { text: string }) {
128
+ return (
129
+ <box flexDirection="row" marginBottom={1}>
130
+ <text fg="#c4b5fd" flexShrink={0}>
131
+ {renderPrefix('mkt')}
132
+ </text>
133
+ <text wrapMode="word" flexGrow={1} fg="#ddd6fe">
134
+ {props.text}
135
+ </text>
136
+ </box>
137
+ )
138
+ }
139
+
140
+ function TelegramInboxRow(props: { text: string }) {
141
+ return (
142
+ <box flexDirection="row" marginBottom={1}>
143
+ <text fg="#60a5fa" flexShrink={0}>
144
+ {renderPrefix('tg-in')}
145
+ </text>
146
+ <text wrapMode="word" flexGrow={1} fg="#bfdbfe">
147
+ {props.text}
148
+ </text>
149
+ </box>
150
+ )
151
+ }
152
+
153
+ function TelegramAssistantRow(props: { text: string }) {
154
+ return (
155
+ <box flexDirection="row" marginBottom={1}>
156
+ <text fg="#60a5fa" flexShrink={0}>
157
+ {renderPrefix('tg-out')}
158
+ </text>
159
+ <text wrapMode="word" flexGrow={1} fg="#dbeafe">
160
+ <MarkdownSegments text={props.text} />
161
+ </text>
162
+ </box>
163
+ )
164
+ }
165
+
166
+ function AssistantTextRow(props: { text: string; firstOfBlock: boolean }) {
167
+ return (
168
+ <box flexDirection="row" marginTop={props.firstOfBlock ? 0 : 1} marginBottom={1}>
169
+ <text fg="#86efac" flexShrink={0}>
170
+ {props.firstOfBlock ? renderPrefix('nebula') : BODY_INDENT}
171
+ </text>
172
+ <text wrapMode="word" flexGrow={1} fg="#e5e7eb">
173
+ <MarkdownSegments text={props.text} />
174
+ </text>
175
+ </box>
176
+ )
177
+ }
178
+
179
+ function ToolCallRow(props: {
180
+ toolName: string
181
+ args: string
182
+ firstOfBlock: boolean
183
+ autoEscalated?: boolean
184
+ }) {
185
+ return (
186
+ <box flexDirection="row">
187
+ <text fg="#86efac" flexShrink={0}>
188
+ {props.firstOfBlock ? renderPrefix('nebula') : BODY_INDENT}
189
+ </text>
190
+ <text wrapMode="word" flexGrow={1}>
191
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
192
+ <span fg={props.autoEscalated ? '#fbbf24' : '#c4b5fd'}>
193
+ {props.autoEscalated ? '↪ ' : '⏺ '}
194
+ </span>
195
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
196
+ <span fg="#e5e7eb">{props.toolName}</span>
197
+ <Show when={props.args}>
198
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
199
+ <span fg="#6b7280">{`(${props.args})`}</span>
200
+ </Show>
201
+ </text>
202
+ </box>
203
+ )
204
+ }
205
+
206
+ function ToolResultRow(props: { text: string; failed: boolean; autoEscalated?: boolean }) {
207
+ return (
208
+ <box flexDirection="row" marginBottom={1}>
209
+ <text flexShrink={0}>{TOOL_RESULT_INDENT}</text>
210
+ <text wrapMode="word" flexGrow={1}>
211
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
212
+ <span fg={props.failed ? '#fca5a5' : props.autoEscalated ? '#fbbf24' : '#4b5563'}>
213
+ {props.failed ? '✗ ' : props.autoEscalated ? '↳ ' : '⎿ '}
214
+ </span>
215
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
216
+ <span fg={props.failed ? '#fca5a5' : '#9ca3af'}>{props.text}</span>
217
+ </text>
218
+ </box>
219
+ )
220
+ }
221
+
222
+ /**
223
+ * Slash-command popup. Rendered between the spinner row and the input box
224
+ * when input starts with `/`. Mirrors the approval-modal layout pattern
225
+ * (flexShrink=0 so the scrollbox compresses to make room).
226
+ */
227
+ function SlashMenu(props: {
228
+ matches: readonly SlashCommand[]
229
+ selected: number
230
+ }) {
231
+ const visible = () => props.matches.slice(0, SLASH_MENU_MAX_ROWS)
232
+ return (
233
+ <box
234
+ flexDirection="column"
235
+ flexShrink={0}
236
+ borderStyle="rounded"
237
+ borderColor="#67e8f9"
238
+ paddingLeft={2}
239
+ paddingRight={2}
240
+ marginLeft={2}
241
+ marginRight={2}
242
+ marginTop={1}
243
+ >
244
+ <text fg="#67e8f9">{'commands (↑↓ select · tab/enter complete · esc dismiss)'}</text>
245
+ <For each={visible()}>
246
+ {(cmd, idx) => {
247
+ const isSelected = () => idx() === props.selected
248
+ return (
249
+ <text wrapMode="word">
250
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
251
+ <span fg={isSelected() ? '#86efac' : '#9ca3af'}>
252
+ {`${isSelected() ? '› ' : ' '}/${cmd.name}`}
253
+ </span>
254
+ <Show when={cmd.argHint}>
255
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
256
+ <span fg="#fbbf24">{` <${cmd.argHint}>`}</span>
257
+ </Show>
258
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
259
+ <span fg="#6b7280">{` ${cmd.description}`}</span>
260
+ </text>
261
+ )
262
+ }}
263
+ </For>
264
+ <Show when={props.matches.length > SLASH_MENU_MAX_ROWS}>
265
+ <text fg="#6b7280">{`+ ${props.matches.length - SLASH_MENU_MAX_ROWS} more (type to filter)`}</text>
266
+ </Show>
267
+ </box>
268
+ )
269
+ }
270
+
271
+ function ChatRowDispatch(props: { row: TurnRow }) {
272
+ const r = props.row
273
+ if (r.role === 'user') return <UserRow text={r.text} />
274
+ if (r.role === 'system') return <SystemRow text={r.text} />
275
+ if (r.role === 'assistant')
276
+ return <AssistantTextRow text={r.text} firstOfBlock={r.firstOfBlock === true} />
277
+ if (r.role === 'tool-call')
278
+ return (
279
+ <ToolCallRow
280
+ toolName={r.toolName ?? '(unknown)'}
281
+ args={r.args ?? ''}
282
+ firstOfBlock={r.firstOfBlock === true}
283
+ autoEscalated={r.autoEscalated === true}
284
+ />
285
+ )
286
+ if (r.role === 'tool-result')
287
+ return (
288
+ <ToolResultRow
289
+ text={r.text}
290
+ failed={r.failed === true}
291
+ autoEscalated={r.autoEscalated === true}
292
+ />
293
+ )
294
+ if (r.role === 'inbox') return <InboxRow text={r.text} />
295
+ if (r.role === 'market') return <MarketRow text={r.text} />
296
+ if (r.role === 'inbox-tg') return <TelegramInboxRow text={r.text} />
297
+ if (r.role === 'telegram-assistant') return <TelegramAssistantRow text={r.text} />
298
+ return null
299
+ }
300
+
301
+ export function ChatApp(props: AppProps) {
302
+ const dims = useTerminalDimensions()
303
+ const [spinnerFrame, setSpinnerFrame] = createSignal(0)
304
+ // Loose type: @opentui/core's ScrollBox class isn't re-exported via the
305
+ // jsx namespace, but the runtime instance has scrollBy + scrollTop.
306
+ let scrollboxRef: { scrollBy: (delta: number) => void; scrollTop: number } | null = null
307
+ // Only tick while we're actually waiting on the brain. Otherwise the signal
308
+ // would notify subscribers 12.5x/sec for nothing — wasteful in the renderer.
309
+ createEffect(() => {
310
+ if (props.state.status() !== 'thinking') {
311
+ setSpinnerFrame(0)
312
+ return
313
+ }
314
+ const id = setInterval(
315
+ () => setSpinnerFrame(f => (f + 1) % SPINNER_FRAMES.length),
316
+ SPINNER_FRAME_MS,
317
+ )
318
+ onCleanup(() => clearInterval(id))
319
+ })
320
+
321
+ // When the approval modal mounts, scrollbox flexGrow=1 compresses to give
322
+ // it room. opentui's stickyScroll reanchors against the new shorter
323
+ // viewport before content remeasures, sometimes landing at scrollTop=0.
324
+ // Force a re-snap to the bottom one tick after mount.
325
+ createEffect(() => {
326
+ const pending = props.state.pendingApproval()
327
+ if (!pending) return
328
+ queueMicrotask(() => {
329
+ if (!scrollboxRef) return
330
+ // Setting scrollTop to a large value clamps to scrollHeight inside opentui.
331
+ try {
332
+ scrollboxRef.scrollTop = Number.MAX_SAFE_INTEGER
333
+ } catch {
334
+ // Older opentui versions: scrollBy with a big delta lands at the bottom.
335
+ scrollboxRef.scrollBy?.(1_000_000)
336
+ }
337
+ })
338
+ })
339
+
340
+ // Recompute the slash autocomplete matches whenever input starts with `/`.
341
+ // Cleared on submit/exit/non-slash input. Pulls registry + caller-supplied
342
+ // extras (Claude Code commands).
343
+ function refreshSlashMatches(nextInput: string): void {
344
+ if (!nextInput.startsWith('/')) {
345
+ if (props.state.slashMatches().length > 0) props.state.setSlashMatches([])
346
+ return
347
+ }
348
+ const builtins = suggestForPrefix('tui', nextInput)
349
+ const extras = (props.extraSlashCommands ?? []).filter(cmd => {
350
+ const stripped = nextInput.replace(/^\/+/, '').toLowerCase()
351
+ return stripped.length === 0 || cmd.name.startsWith(stripped)
352
+ })
353
+ const merged = [...builtins]
354
+ for (const e of extras) {
355
+ if (!merged.some(b => b.name === e.name)) merged.push(e)
356
+ }
357
+ props.state.setSlashMatches(merged)
358
+ if (props.state.slashIndex() >= merged.length) props.state.setSlashIndex(0)
359
+ }
360
+
361
+ useKeyboard(evt => {
362
+ if (evt.ctrl && evt.name === 'c') {
363
+ evt.preventDefault()
364
+ props.onExit()
365
+ return
366
+ }
367
+ // Approval modal mode: swallow keys, route y/s/n to decision.
368
+ const pending = props.state.pendingApproval()
369
+ if (pending) {
370
+ if (evt.name === 'return') return
371
+ if (evt.sequence) {
372
+ const ch = evt.sequence.toLowerCase()
373
+ if (ch === 'y' || ch === '1') {
374
+ pending.resolve('allow-once')
375
+ props.state.setPendingApproval(null)
376
+ return
377
+ }
378
+ if (ch === 's' || ch === '2') {
379
+ pending.resolve('allow-session')
380
+ props.state.setPendingApproval(null)
381
+ return
382
+ }
383
+ if (ch === 'n' || ch === 'd' || ch === '3' || evt.name === 'escape') {
384
+ pending.resolve('deny')
385
+ props.state.setPendingApproval(null)
386
+ return
387
+ }
388
+ }
389
+ return
390
+ }
391
+ // stickyScroll auto-snaps to bottom on new rows; ctrl+u/d (vim-style
392
+ // half-page) and opt+u/d let the operator scroll back through past
393
+ // responses mid-conversation. Ctrl works in every terminal; Opt only
394
+ // works when the terminal is configured to send Opt as Meta/Alt
395
+ // (Ghostty needs `macos-option-as-alt = true`, iTerm2 "Option as Esc+",
396
+ // Terminal.app "Use Option as Meta key").
397
+ if ((evt.ctrl || evt.option) && (evt.name === 'u' || evt.name === 'd')) {
398
+ scrollboxRef?.scrollBy(evt.name === 'u' ? -SCROLL_STEP : SCROLL_STEP)
399
+ return
400
+ }
401
+ // Esc dismisses the slash menu first; only on a second press does it
402
+ // abort the current brain turn.
403
+ if (evt.name === 'escape') {
404
+ if (props.state.slashMatches().length > 0) {
405
+ props.state.setSlashMatches([])
406
+ props.state.setSlashIndex(0)
407
+ return
408
+ }
409
+ const abort = props.state.activeAbort()
410
+ if (abort && !abort.signal.aborted) {
411
+ abort.abort()
412
+ }
413
+ return
414
+ }
415
+ // Slash menu: ↑/↓ cycle selection, Tab completes, Enter submits the
416
+ // selection (when the menu is open). Only fires when matches are visible.
417
+ if (props.state.slashMatches().length > 0) {
418
+ if (evt.name === 'up') {
419
+ const len = props.state.slashMatches().length
420
+ props.state.setSlashIndex(i => (i - 1 + len) % len)
421
+ return
422
+ }
423
+ if (evt.name === 'down') {
424
+ const len = props.state.slashMatches().length
425
+ props.state.setSlashIndex(i => (i + 1) % len)
426
+ return
427
+ }
428
+ if (evt.name === 'tab') {
429
+ const cmd = props.state.slashMatches()[props.state.slashIndex()]
430
+ if (cmd) {
431
+ const next = `/${cmd.name}${cmd.argHint ? ' ' : ''}`
432
+ props.state.setInput(next)
433
+ refreshSlashMatches(next)
434
+ }
435
+ return
436
+ }
437
+ }
438
+ if (evt.name === 'return') {
439
+ const text = props.state.input().trim()
440
+ if (!text) return
441
+ // Mid-turn submit guard: refuse to fire a second brain.infer while one
442
+ // is in flight (concurrent infers clobber history). Tell the operator
443
+ // how to interrupt the current one.
444
+ if (props.state.status() === 'thinking') {
445
+ props.state.pushRow({
446
+ role: 'system',
447
+ text: 'turn in progress. press esc to interrupt before sending the next message.',
448
+ })
449
+ return
450
+ }
451
+ // If the slash menu is open and a single match exists with no args
452
+ // typed yet, complete to that command name before submitting. Otherwise
453
+ // submit verbatim — operator may have typed `/perms strict` in full.
454
+ let toSubmit = text
455
+ if (props.state.slashMatches().length === 1 && /^\/\S+$/.test(text)) {
456
+ const sole = props.state.slashMatches()[0]!
457
+ toSubmit = `/${sole.name}`
458
+ }
459
+ props.state.pushRow({ role: 'user', text: toSubmit })
460
+ props.state.setInput('')
461
+ props.state.setSlashMatches([])
462
+ props.state.setSlashIndex(0)
463
+ props.state.setStatus('thinking')
464
+ props.onSubmit(toSubmit)
465
+ return
466
+ }
467
+ if (evt.name === 'backspace' || evt.name === 'delete') {
468
+ props.state.setInput(prev => {
469
+ const next = prev.slice(0, -1)
470
+ refreshSlashMatches(next)
471
+ return next
472
+ })
473
+ return
474
+ }
475
+ if (evt.sequence && !evt.ctrl && !evt.meta && !evt.option && evt.sequence.length === 1) {
476
+ const ch = evt.sequence
477
+ props.state.setInput(prev => {
478
+ const next = prev + ch
479
+ refreshSlashMatches(next)
480
+ return next
481
+ })
482
+ }
483
+ })
484
+
485
+ return (
486
+ <box flexDirection="column" width={dims().width} height={dims().height}>
487
+ {/* Chat history — scrollable so it never crowds the input area. */}
488
+ <scrollbox
489
+ ref={(el: typeof scrollboxRef) => {
490
+ scrollboxRef = el
491
+ }}
492
+ flexGrow={1}
493
+ flexShrink={1}
494
+ stickyScroll
495
+ stickyStart="bottom"
496
+ contentOptions={{
497
+ flexDirection: 'column',
498
+ paddingLeft: 0,
499
+ paddingRight: 1,
500
+ paddingTop: 1,
501
+ paddingBottom: 1,
502
+ }}
503
+ >
504
+ <For each={props.state.rows()}>{row => <ChatRowDispatch row={row} />}</For>
505
+ </scrollbox>
506
+
507
+ {/* Approval modal — single-color rows (nested spans broke layout in v0.7.0). */}
508
+ <Show when={props.state.pendingApproval()}>
509
+ <box
510
+ flexDirection="column"
511
+ flexShrink={0}
512
+ borderStyle="rounded"
513
+ borderColor="#f59e0b"
514
+ paddingLeft={2}
515
+ paddingRight={2}
516
+ marginLeft={2}
517
+ marginRight={2}
518
+ marginTop={1}
519
+ >
520
+ <text fg="#fbbf24" wrapMode="word">
521
+ {`⚠ approval needed · ${props.state.pendingApproval()!.request.reason}`}
522
+ </text>
523
+ <text fg="#fde68a" wrapMode="word">
524
+ {summarizeApprovalSubject(props.state.pendingApproval()!.request)}
525
+ </text>
526
+ <text>
527
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
528
+ <span fg="#86efac">{'[y]'}</span>
529
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
530
+ <span fg="#9ca3af">{' allow once '}</span>
531
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
532
+ <span fg="#86efac">{'[s]'}</span>
533
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
534
+ <span fg="#9ca3af">{' allow session '}</span>
535
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
536
+ <span fg="#fca5a5">{'[n]'}</span>
537
+ {/* @ts-expect-error opentui SpanProps omits fg, runtime accepts it */}
538
+ <span fg="#9ca3af">{' deny'}</span>
539
+ </text>
540
+ </box>
541
+ </Show>
542
+
543
+ {/* v0.20.0: slash autocomplete popup. Pushed between spinner row and
544
+ input box so the operator sees command suggestions live as they
545
+ type. Mirrors approval-modal layout pattern (flexShrink=0 + the
546
+ scrollbox compresses). */}
547
+ <Show when={props.state.slashMatches().length > 0}>
548
+ <SlashMenu matches={props.state.slashMatches()} selected={props.state.slashIndex()} />
549
+ </Show>
550
+
551
+ {/* Status hint row above input. Always rendered (no Show wrapper) so
552
+ the row's height never collapses; spinner content swaps between a
553
+ spinner string and a single space (never empty — opentui's text
554
+ renderer chokes on truly-empty children). The elapsed counter
555
+ re-evaluates on every spinnerFrame tick (80ms), no extra timer. */}
556
+ <box flexDirection="row" flexShrink={0} paddingLeft={3} paddingRight={2} marginTop={1}>
557
+ <text fg="#67e8f9" flexGrow={1}>
558
+ {(() => {
559
+ if (props.state.status() !== 'thinking') return ' '
560
+ // re-read spinnerFrame so this expression is reactive
561
+ spinnerFrame()
562
+ const elapsed = formatElapsed(props.state.turnStartedAt())
563
+ const frame = SPINNER_FRAMES[spinnerFrame()]
564
+ return elapsed
565
+ ? `${frame} thinking… ${elapsed} (esc to interrupt)`
566
+ : `${frame} thinking… (esc to interrupt)`
567
+ })()}
568
+ </text>
569
+ </box>
570
+
571
+ {/* Input bar — minHeight=3 keeps the row visible when empty; box grows
572
+ as the wrapped text needs more rows. maxHeight caps runaway growth
573
+ on a paste of huge content so the chat history never gets shoved
574
+ off-screen. */}
575
+ <box
576
+ flexDirection="row"
577
+ flexShrink={0}
578
+ minHeight={3}
579
+ maxHeight={12}
580
+ borderStyle="rounded"
581
+ borderColor="#374151"
582
+ paddingLeft={1}
583
+ paddingRight={1}
584
+ marginLeft={2}
585
+ marginRight={2}
586
+ >
587
+ <text fg="#67e8f9" flexShrink={0}>
588
+ {'> '}
589
+ </text>
590
+ <text wrapMode="word" flexGrow={1} fg="#e5e7eb">
591
+ {`${props.state.input()}${props.state.status() === 'idle' ? '▋' : ''}`}
592
+ </text>
593
+ </box>
594
+
595
+ {/* Status footer. Each separator is paired with its value via a Show so
596
+ dropping a value also drops its leading separator (no orphans).
597
+ Hint text takes flexShrink=1 so on narrow terminals it compresses
598
+ before colliding with the left side. */}
599
+ <box flexDirection="row" flexShrink={0} paddingLeft={2} paddingRight={2}>
600
+ <text fg="#86efac" flexShrink={0}>
601
+ {props.state.identityLabel}
602
+ </text>
603
+ <text fg="#374151" flexShrink={0}>
604
+ {' · '}
605
+ </text>
606
+ {/* v0.22.0: perms label unifies with /yolo. When mode is 'off', show
607
+ "YOLO" in red so operators read it as a danger signal — modals are
608
+ disabled, dangerous tool calls run without prompting. Strict/prompt
609
+ keep the literal mode in gray for clarity. */}
610
+ <text fg={props.state.approvalsMode() === 'off' ? '#ef4444' : '#9ca3af'} flexShrink={0}>
611
+ {props.state.approvalsMode() === 'off' ? 'YOLO' : `perms: ${props.state.approvalsMode()}`}
612
+ </text>
613
+ {/* opentui's <Show> renders in resolution order, not JSX order; matching
614
+ here keeps intent obvious. Wallet first because EOA gas starves first. */}
615
+ <Show when={props.state.eoaBalance() != null}>
616
+ <text fg="#374151" flexShrink={0}>
617
+ {' · '}
618
+ </text>
619
+ <text fg="#6b7280" flexShrink={0}>
620
+ {'wallet '}
621
+ </text>
622
+ <text fg={balanceColor(props.state.eoaBalance(), 0.005, 0.02)} flexShrink={0}>
623
+ {formatBalance(props.state.eoaBalance())}
624
+ </text>
625
+ </Show>
626
+ <Show when={props.state.balance() != null}>
627
+ <text fg="#374151" flexShrink={0}>
628
+ {' · '}
629
+ </text>
630
+ <text fg="#6b7280" flexShrink={0}>
631
+ {'compute '}
632
+ </text>
633
+ <text fg={balanceColor(props.state.balance())} flexShrink={0}>
634
+ {formatBalance(props.state.balance())}
635
+ </text>
636
+ </Show>
637
+ {/* v0.24.4: hide the sandbox-billing balance segment on local-gateway
638
+ deploys. There's no Daytona reserve to surface for a daemon running
639
+ on the operator's own machine; chat-sandbox.tsx also skips the
640
+ getSandboxBillingReserve RPC for the same reason, so the signal
641
+ stays null even if the gate were missing — but gating here keeps
642
+ the statusbar deterministic for tests + future setters. */}
643
+ <Show when={!props.state.isLocalGateway && props.state.sandboxBalance() != null}>
644
+ <text fg="#374151" flexShrink={0}>
645
+ {' · '}
646
+ </text>
647
+ <text fg="#6b7280" flexShrink={0}>
648
+ {'sandbox '}
649
+ </text>
650
+ <text fg={balanceColor(props.state.sandboxBalance())} flexShrink={0}>
651
+ {formatBalance(props.state.sandboxBalance())}
652
+ </text>
653
+ </Show>
654
+ <Show when={props.state.activeJobCount() > 0}>
655
+ <text fg="#374151" flexShrink={0}>
656
+ {' · '}
657
+ </text>
658
+ <text fg="#fbbf24" flexShrink={0}>
659
+ {`${props.state.activeJobCount()} escrow`}
660
+ </text>
661
+ </Show>
662
+ <Show when={props.state.usage()}>
663
+ <text fg="#374151" flexShrink={0}>
664
+ {' · '}
665
+ </text>
666
+ <text fg="#9ca3af" flexShrink={0}>
667
+ {formatUsage(props.state.usage())}
668
+ </text>
669
+ </Show>
670
+ </box>
671
+ </box>
672
+ )
673
+ }