@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.
- package/README.md +18 -0
- package/bin/promus +33 -0
- package/package.json +51 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_inft-ref.ts +43 -0
- package/src/commands/_unlock.ts +74 -0
- package/src/commands/admin-autotopup-tick.ts +73 -0
- package/src/commands/admin.test.ts +34 -0
- package/src/commands/admin.ts +32 -0
- package/src/commands/balance.test.ts +10 -0
- package/src/commands/balance.ts +112 -0
- package/src/commands/chat-sandbox.tsx +520 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1916 -0
- package/src/commands/deploy.ts +204 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +47 -0
- package/src/commands/gateway-run.ts +54 -0
- package/src/commands/gateway-start.ts +218 -0
- package/src/commands/gateway-status.ts +88 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/init/cost.test.ts +169 -0
- package/src/commands/init/cost.ts +154 -0
- package/src/commands/init/funding-gate.ts +67 -0
- package/src/commands/init/model-picker.ts +81 -0
- package/src/commands/init/operator-picker.ts +263 -0
- package/src/commands/init/resume.ts +136 -0
- package/src/commands/init/sandbox-provision.test.ts +497 -0
- package/src/commands/init/sandbox-provision.ts +1177 -0
- package/src/commands/init/telegram-step.ts +229 -0
- package/src/commands/init/wizard-state.ts +95 -0
- package/src/commands/init.ts +612 -0
- package/src/commands/inspect.ts +529 -0
- package/src/commands/ledger.ts +176 -0
- package/src/commands/logs.ts +86 -0
- package/src/commands/migrate-keystore.ts +155 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +114 -0
- package/src/commands/pairing-clear.ts +42 -0
- package/src/commands/pairing-list.ts +58 -0
- package/src/commands/pairing-revoke.ts +52 -0
- package/src/commands/pairing.test.ts +88 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/pause.ts +99 -0
- package/src/commands/profile.ts +184 -0
- package/src/commands/restore.ts +221 -0
- package/src/commands/resume.ts +181 -0
- package/src/commands/status.ts +119 -0
- package/src/commands/sync.ts +147 -0
- package/src/commands/telegram-remove.ts +65 -0
- package/src/commands/telegram-setup.ts +74 -0
- package/src/commands/telegram-status.ts +89 -0
- package/src/commands/telegram.test.ts +50 -0
- package/src/commands/telegram.ts +44 -0
- package/src/commands/topup.ts +303 -0
- package/src/commands/transfer.test.ts +111 -0
- package/src/commands/transfer.ts +520 -0
- package/src/commands/upgrade.test.ts +137 -0
- package/src/commands/upgrade.ts +690 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.test.ts +96 -0
- package/src/config/render.ts +110 -0
- package/src/index.ts +378 -0
- package/src/sandbox/client.test.ts +251 -0
- package/src/sandbox/client.ts +424 -0
- package/src/ui/app.tsx +677 -0
- package/src/ui/approval-summary.test.ts +154 -0
- package/src/ui/approval-summary.ts +34 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.test.ts +146 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.test.ts +74 -0
- package/src/ui/state.ts +198 -0
- package/src/util/bootstrap-mode.test.ts +40 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.test.ts +190 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/brain-secrets.ts +96 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.test.ts +16 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.test.ts +86 -0
- package/src/util/gateway-spawn.ts +128 -0
- package/src/util/gateway-version.test.ts +113 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.test.ts +116 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.test.ts +60 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.test.ts +77 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.test.ts +53 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.test.ts +227 -0
- 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
|
+
}
|