@promus/cli 0.24.17

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