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.
- package/README.md +39 -0
- package/bin/nebula +11 -0
- package/package.json +65 -0
- package/src/commands/_agents.ts +14 -0
- package/src/commands/_unlock.ts +66 -0
- package/src/commands/chat-telegram.ts +398 -0
- package/src/commands/chat.tsx +1293 -0
- package/src/commands/drain.ts +90 -0
- package/src/commands/gateway-logs.ts +49 -0
- package/src/commands/gateway-run.ts +42 -0
- package/src/commands/gateway-start.ts +216 -0
- package/src/commands/gateway-status.ts +90 -0
- package/src/commands/gateway-stop.ts +133 -0
- package/src/commands/gateway.ts +101 -0
- package/src/commands/identity.ts +178 -0
- package/src/commands/init/cost.ts +40 -0
- package/src/commands/init/funding-gate.ts +64 -0
- package/src/commands/init/model-picker.ts +25 -0
- package/src/commands/init/operator-picker.ts +233 -0
- package/src/commands/init/telegram-step.ts +245 -0
- package/src/commands/init/wizard-state.ts +94 -0
- package/src/commands/init.ts +439 -0
- package/src/commands/logs.ts +37 -0
- package/src/commands/model.ts +48 -0
- package/src/commands/pairing-approve.ts +65 -0
- package/src/commands/pairing-clear.ts +39 -0
- package/src/commands/pairing-list.ts +55 -0
- package/src/commands/pairing-revoke.ts +49 -0
- package/src/commands/pairing.ts +81 -0
- package/src/commands/status.ts +44 -0
- package/src/commands/telegram-remove.ts +62 -0
- package/src/commands/telegram-setup.ts +64 -0
- package/src/commands/telegram-status.ts +87 -0
- package/src/commands/telegram.ts +44 -0
- package/src/config/load.ts +35 -0
- package/src/config/render.ts +99 -0
- package/src/index.ts +153 -0
- package/src/ui/app.tsx +673 -0
- package/src/ui/approval-summary.ts +32 -0
- package/src/ui/markdown-parse.ts +219 -0
- package/src/ui/markdown.tsx +37 -0
- package/src/ui/state.ts +181 -0
- package/src/util/bootstrap-mode.ts +25 -0
- package/src/util/bootstrap-progress-box.ts +378 -0
- package/src/util/cli-version.ts +28 -0
- package/src/util/format.ts +11 -0
- package/src/util/gateway-spawn.ts +125 -0
- package/src/util/gateway-version.ts +154 -0
- package/src/util/github-releases.ts +79 -0
- package/src/util/profile-key.ts +25 -0
- package/src/util/ref-resolver.ts +55 -0
- package/src/util/silence-console.ts +40 -0
- package/src/util/telegram-secrets.ts +218 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PermissionRequest } from 'nebula-ai-core'
|
|
2
|
+
import { shortAddr } from '../util/format'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Body line for the approval modal. Friendly text for value-moving onchain
|
|
6
|
+
* kinds; falls back to command/path for shell.run / fs.write / code.execute.
|
|
7
|
+
*
|
|
8
|
+
* Why the `'→'` sniff in chain.send: chain.wrap and chain.unwrap reuse
|
|
9
|
+
* `chain.send` as their permission kind but encode the operation in `token`
|
|
10
|
+
* (`MNT→WMNT` / `WMNT→MNT`) and have no recipient to display.
|
|
11
|
+
*/
|
|
12
|
+
export function summarizeApprovalSubject(req: PermissionRequest): string {
|
|
13
|
+
const amt = req.amount ?? ''
|
|
14
|
+
const tok = req.token ?? ''
|
|
15
|
+
switch (req.kind) {
|
|
16
|
+
case 'chain.send': {
|
|
17
|
+
if (tok.includes('→')) return `${amt} ${tok}`.trim()
|
|
18
|
+
const tokenLabel = tok || 'MNT'
|
|
19
|
+
return `send ${amt} ${tokenLabel} to ${shortAddr(req.recipient)}`
|
|
20
|
+
}
|
|
21
|
+
case 'chain.swap':
|
|
22
|
+
if (!amt && !tok) return 'swap'
|
|
23
|
+
return `swap ${amt} ${tok}`.trim()
|
|
24
|
+
case 'chain.write': {
|
|
25
|
+
const valuePart = amt ? ` (value: ${amt})` : ''
|
|
26
|
+
const onPart = req.recipient ? ` on ${shortAddr(req.recipient)}` : ''
|
|
27
|
+
return `${req.command ?? '?'}${valuePart}${onPart}`
|
|
28
|
+
}
|
|
29
|
+
default:
|
|
30
|
+
return req.command ?? req.path ?? '(unspecified)'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown parser for the assistant chat rows. Pure logic only,
|
|
3
|
+
* no JSX, so tests can import without dragging in the JSX runtime (CI's bun
|
|
4
|
+
* defaults to react-jsx and fails to resolve `react/jsx-dev-runtime` when
|
|
5
|
+
* a .tsx file is imported by a test).
|
|
6
|
+
*
|
|
7
|
+
* Subset the brain actually emits: `**bold**`, `*italic*`, `` `code` ``,
|
|
8
|
+
* `# headings`, `- bullet lists`, `1. numbered lists`, fenced code blocks,
|
|
9
|
+
* GFM tables (`| col | col |` + `|---|---|` separator).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface MdSegment {
|
|
13
|
+
text: string
|
|
14
|
+
fg?: string
|
|
15
|
+
bold?: boolean
|
|
16
|
+
italic?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const MD_COLORS = {
|
|
20
|
+
text: '#e5e7eb',
|
|
21
|
+
code: '#fda4af',
|
|
22
|
+
heading: '#fbbf24',
|
|
23
|
+
bullet: '#94a3b8',
|
|
24
|
+
codeBlock: '#f9a8d4',
|
|
25
|
+
tableBorder: '#6b7280',
|
|
26
|
+
tableHeader: '#fbbf24',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a single line's inline markup (`**bold**`, `*italic*`, `` `code` ``)
|
|
31
|
+
* into a flat list of segments. Caller handles the line-level structure.
|
|
32
|
+
*/
|
|
33
|
+
function parseInline(line: string, baseFg: string = MD_COLORS.text): MdSegment[] {
|
|
34
|
+
const out: MdSegment[] = []
|
|
35
|
+
let i = 0
|
|
36
|
+
let plain = ''
|
|
37
|
+
const flushPlain = () => {
|
|
38
|
+
if (plain) {
|
|
39
|
+
out.push({ text: plain, fg: baseFg })
|
|
40
|
+
plain = ''
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
while (i < line.length) {
|
|
44
|
+
if (line[i] === '`') {
|
|
45
|
+
const end = line.indexOf('`', i + 1)
|
|
46
|
+
if (end > i) {
|
|
47
|
+
flushPlain()
|
|
48
|
+
out.push({ text: line.slice(i + 1, end), fg: MD_COLORS.code })
|
|
49
|
+
i = end + 1
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (line[i] === '*' && line[i + 1] === '*') {
|
|
54
|
+
const end = line.indexOf('**', i + 2)
|
|
55
|
+
if (end > i + 2) {
|
|
56
|
+
flushPlain()
|
|
57
|
+
out.push({ text: line.slice(i + 2, end), fg: baseFg, bold: true })
|
|
58
|
+
i = end + 2
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (line[i] === '*' && line[i + 1] !== '*' && line[i + 1] !== ' ') {
|
|
63
|
+
const end = line.indexOf('*', i + 1)
|
|
64
|
+
if (end > i + 1 && line[end - 1] !== ' ' && line[end + 1] !== '*') {
|
|
65
|
+
flushPlain()
|
|
66
|
+
out.push({ text: line.slice(i + 1, end), fg: baseFg, italic: true })
|
|
67
|
+
i = end + 1
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
plain += line[i]
|
|
72
|
+
i++
|
|
73
|
+
}
|
|
74
|
+
flushPlain()
|
|
75
|
+
return out
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// GFM table separator row: `|---|---|` (optionally with alignment colons).
|
|
79
|
+
// Allows single-column tables (`|---|`), multi-column (`|---|---|`), and
|
|
80
|
+
// missing leading/trailing pipes (`---|---`).
|
|
81
|
+
const TABLE_SEPARATOR_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
|
|
82
|
+
|
|
83
|
+
function parseTableRow(line: string): string[] {
|
|
84
|
+
const trimmed = line.trim().replace(/^\|/, '').replace(/\|$/, '')
|
|
85
|
+
return trimmed.split('|').map(c => c.trim())
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Detect a GFM table starting at `lines[startIdx]`. Returns the parsed rows
|
|
90
|
+
* (header included as row 0) plus the index AFTER the last data row, or null
|
|
91
|
+
* if no table block matches.
|
|
92
|
+
*/
|
|
93
|
+
function detectTable(lines: string[], startIdx: number): { rows: string[][]; end: number } | null {
|
|
94
|
+
const header = lines[startIdx]
|
|
95
|
+
if (header === undefined) return null
|
|
96
|
+
if (!/^\s*\|.+\|?\s*$/.test(header)) return null
|
|
97
|
+
const sep = lines[startIdx + 1]
|
|
98
|
+
if (!sep || !TABLE_SEPARATOR_RE.test(sep)) return null
|
|
99
|
+
|
|
100
|
+
const rows: string[][] = [parseTableRow(header)]
|
|
101
|
+
let i = startIdx + 2
|
|
102
|
+
while (i < lines.length) {
|
|
103
|
+
const ln = lines[i]
|
|
104
|
+
if (ln === undefined || !/^\s*\|.+\|?\s*$/.test(ln)) break
|
|
105
|
+
rows.push(parseTableRow(ln))
|
|
106
|
+
i++
|
|
107
|
+
}
|
|
108
|
+
return { rows, end: i }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Render a parsed table as flat segments. Uses box-drawing characters for the
|
|
113
|
+
* separator under the header row; columns are padded to the widest cell. First
|
|
114
|
+
* row is rendered bold + heading color so it stands out.
|
|
115
|
+
*/
|
|
116
|
+
function renderTable(rows: string[][], out: MdSegment[], pushNewline: () => void): void {
|
|
117
|
+
if (rows.length === 0) return
|
|
118
|
+
const colCount = Math.max(...rows.map(r => r.length))
|
|
119
|
+
const widths = new Array(colCount).fill(0) as number[]
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
for (let c = 0; c < row.length; c++) {
|
|
122
|
+
widths[c] = Math.max(widths[c]!, row[c]!.length)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (let r = 0; r < rows.length; r++) {
|
|
126
|
+
pushNewline()
|
|
127
|
+
const row = rows[r]!
|
|
128
|
+
const cells: string[] = []
|
|
129
|
+
for (let c = 0; c < colCount; c++) {
|
|
130
|
+
const cell = (row[c] ?? '').padEnd(widths[c]!, ' ')
|
|
131
|
+
cells.push(cell)
|
|
132
|
+
}
|
|
133
|
+
const lineText = `│ ${cells.join(' │ ')} │`
|
|
134
|
+
out.push({
|
|
135
|
+
text: lineText,
|
|
136
|
+
fg: r === 0 ? MD_COLORS.tableHeader : MD_COLORS.text,
|
|
137
|
+
bold: r === 0,
|
|
138
|
+
})
|
|
139
|
+
if (r === 0) {
|
|
140
|
+
pushNewline()
|
|
141
|
+
const sep = `├${widths.map(w => '─'.repeat(w + 2)).join('┼')}┤`
|
|
142
|
+
out.push({ text: sep, fg: MD_COLORS.tableBorder })
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse the full text into a flat list of segments separated by newlines.
|
|
149
|
+
* Block-level structure is encoded as styled prefixes in the segments
|
|
150
|
+
* (heading -> bold colored line; bullet -> "• " + content; table -> aligned
|
|
151
|
+
* cells with box-drawing separator).
|
|
152
|
+
*/
|
|
153
|
+
export function parseMarkdown(text: string): MdSegment[] {
|
|
154
|
+
if (!text) return []
|
|
155
|
+
const out: MdSegment[] = []
|
|
156
|
+
const lines = text.split('\n')
|
|
157
|
+
let inFence = false
|
|
158
|
+
let firstLine = true
|
|
159
|
+
|
|
160
|
+
const pushNewline = () => {
|
|
161
|
+
if (!firstLine) out.push({ text: '\n', fg: MD_COLORS.text })
|
|
162
|
+
firstLine = false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let i = 0
|
|
166
|
+
while (i < lines.length) {
|
|
167
|
+
const rawLine = lines[i]!
|
|
168
|
+
if (rawLine.trim().startsWith('```')) {
|
|
169
|
+
inFence = !inFence
|
|
170
|
+
i++
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (inFence) {
|
|
174
|
+
pushNewline()
|
|
175
|
+
out.push({ text: rawLine, fg: MD_COLORS.codeBlock })
|
|
176
|
+
i++
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
const headingMatch = rawLine.match(/^(#{1,6})\s+(.*)$/)
|
|
180
|
+
if (headingMatch) {
|
|
181
|
+
pushNewline()
|
|
182
|
+
const inner = parseInline(headingMatch[2]!, MD_COLORS.heading)
|
|
183
|
+
for (const seg of inner) {
|
|
184
|
+
out.push({ ...seg, fg: seg.fg ?? MD_COLORS.heading, bold: true })
|
|
185
|
+
}
|
|
186
|
+
i++
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
const table = detectTable(lines, i)
|
|
190
|
+
if (table) {
|
|
191
|
+
renderTable(table.rows, out, pushNewline)
|
|
192
|
+
i = table.end
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
const bulletMatch = rawLine.match(/^(\s*)([-*])\s+(.*)$/)
|
|
196
|
+
if (bulletMatch) {
|
|
197
|
+
pushNewline()
|
|
198
|
+
out.push({ text: `${bulletMatch[1]}• `, fg: MD_COLORS.bullet })
|
|
199
|
+
out.push(...parseInline(bulletMatch[3]!))
|
|
200
|
+
i++
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const numberedMatch = rawLine.match(/^(\s*)(\d+)\.\s+(.*)$/)
|
|
204
|
+
if (numberedMatch) {
|
|
205
|
+
pushNewline()
|
|
206
|
+
out.push({
|
|
207
|
+
text: `${numberedMatch[1]}${numberedMatch[2]}. `,
|
|
208
|
+
fg: MD_COLORS.bullet,
|
|
209
|
+
})
|
|
210
|
+
out.push(...parseInline(numberedMatch[3]!))
|
|
211
|
+
i++
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
pushNewline()
|
|
215
|
+
out.push(...parseInline(rawLine))
|
|
216
|
+
i++
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { For } from 'solid-js'
|
|
2
|
+
import { parseMarkdown } from './markdown-parse'
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
parseMarkdown,
|
|
6
|
+
MD_COLORS,
|
|
7
|
+
type MdSegment,
|
|
8
|
+
} from './markdown-parse'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Render parsed markdown segments as opentui spans inside an existing
|
|
12
|
+
* `<text>` block. Caller owns the wrapping `<text>` (so wrapMode + flexGrow
|
|
13
|
+
* stay configurable).
|
|
14
|
+
*
|
|
15
|
+
* Why custom rather than opentui's built-in `<markdown>`: nebula already
|
|
16
|
+
* renders assistant text inside a row that has a fixed-width prefix
|
|
17
|
+
* gutter; switching to `<markdown>` would break the indent and gutter
|
|
18
|
+
* alignment because it owns its own layout. A custom renderer that emits
|
|
19
|
+
* spans keeps the existing AssistantTextRow flow intact.
|
|
20
|
+
*/
|
|
21
|
+
export function MarkdownSegments(props: { text: string }) {
|
|
22
|
+
const segments = () => parseMarkdown(props.text)
|
|
23
|
+
return (
|
|
24
|
+
<For each={segments()}>
|
|
25
|
+
{seg => {
|
|
26
|
+
// opentui's SpanProps type omits fg/bold/italic but the runtime
|
|
27
|
+
// accepts them. Cast through an object spread to bypass the check.
|
|
28
|
+
const styles = {
|
|
29
|
+
...(seg.fg ? { fg: seg.fg } : {}),
|
|
30
|
+
...(seg.bold ? { bold: true } : {}),
|
|
31
|
+
...(seg.italic ? { italic: true } : {}),
|
|
32
|
+
} as Record<string, unknown>
|
|
33
|
+
return <span {...styles}>{seg.text}</span>
|
|
34
|
+
}}
|
|
35
|
+
</For>
|
|
36
|
+
)
|
|
37
|
+
}
|
package/src/ui/state.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PermissionDecision,
|
|
3
|
+
PermissionMode,
|
|
4
|
+
PermissionRequest,
|
|
5
|
+
SlashCommand,
|
|
6
|
+
} from 'nebula-ai-core'
|
|
7
|
+
import { createSignal } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
export type TurnRole =
|
|
10
|
+
| 'user'
|
|
11
|
+
| 'assistant'
|
|
12
|
+
| 'system'
|
|
13
|
+
| 'tool-call'
|
|
14
|
+
| 'tool-result'
|
|
15
|
+
| 'inbox'
|
|
16
|
+
| 'market'
|
|
17
|
+
| 'inbox-tg'
|
|
18
|
+
| 'telegram-assistant'
|
|
19
|
+
|
|
20
|
+
export interface TurnRow {
|
|
21
|
+
id: string
|
|
22
|
+
role: TurnRole
|
|
23
|
+
text: string
|
|
24
|
+
// tool-call rows: tool name + formatted args (rendered as `name(args)`)
|
|
25
|
+
toolName?: string
|
|
26
|
+
args?: string
|
|
27
|
+
// tool-result rows: failure flag drives icon + color
|
|
28
|
+
failed?: boolean
|
|
29
|
+
// v0.21.2: drives the ↪ prefix so operators see the SAME logical fetch was
|
|
30
|
+
// escalated, not a fresh brain decision.
|
|
31
|
+
autoEscalated?: boolean
|
|
32
|
+
// True only for the first row in an "nebula block" (assistant + tool-call rows
|
|
33
|
+
// that share the same speaker turn). Computed once at push time so the For
|
|
34
|
+
// loop renderer doesn't re-walk neighbors on every state mutation.
|
|
35
|
+
firstOfBlock?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PendingApproval {
|
|
39
|
+
request: PermissionRequest
|
|
40
|
+
resolve: (decision: PermissionDecision) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface CreateChatStateOpts {
|
|
44
|
+
initialSystem: string
|
|
45
|
+
identityLabel: string
|
|
46
|
+
approvalsMode: PermissionMode
|
|
47
|
+
// v0.24.4: true when the TUI talks to a local gateway daemon over a unix
|
|
48
|
+
// socket (`~/.nebula/agents/<id>/gateway.sock`) instead of a remote Daytona
|
|
49
|
+
// sandbox endpoint. Drives statusbar copy (drops the "sandbox X" prefix on
|
|
50
|
+
// the system line) and hides the sandbox-billing balance segment (which is
|
|
51
|
+
// meaningless for local deploys — there is no billing reserve to surface).
|
|
52
|
+
// Defaults to false so existing call sites that don't pass it (i.e. nothing
|
|
53
|
+
// today, since both call sites set it explicitly) keep sandbox semantics.
|
|
54
|
+
isLocalGateway?: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createChatState(opts: CreateChatStateOpts) {
|
|
58
|
+
const [rows, setRows] = createSignal<TurnRow[]>([
|
|
59
|
+
{ id: 'sys-0', role: 'system', text: opts.initialSystem },
|
|
60
|
+
])
|
|
61
|
+
const [input, setInput] = createSignal('')
|
|
62
|
+
const [status, setStatus] = createSignal<'idle' | 'thinking' | 'error'>('idle')
|
|
63
|
+
const [usage, setUsage] = createSignal<{ total?: number; cached?: number } | null>(null)
|
|
64
|
+
const [pendingApproval, setPendingApproval] = createSignal<PendingApproval | null>(null)
|
|
65
|
+
const [approvalsMode, setApprovalsMode] = createSignal<PermissionMode>(opts.approvalsMode)
|
|
66
|
+
|
|
67
|
+
// Mantle Compute ledger balance, in Mantle. Refreshed at chat init and after each
|
|
68
|
+
// per-turn auto-sync. null = not yet fetched / fetch failed.
|
|
69
|
+
const [balance, setBalance] = createSignal<number | null>(null)
|
|
70
|
+
// Agent EOA balance, in Mantle. Pays gas for chain writes (agent.message
|
|
71
|
+
// inbox.send, sync's updateSlots anchor). Typically starves before the
|
|
72
|
+
// compute ledger in long sessions (~0.001 Mantle/send at 4 gwei).
|
|
73
|
+
const [eoaBalance, setEoaBalance] = createSignal<number | null>(null)
|
|
74
|
+
// v0.22.0: Mantle Sandbox billing reserve, in Mantle. Sandbox-deployed agents only —
|
|
75
|
+
// local-mode TUI stays null and the statusline `<Show>` hides the segment.
|
|
76
|
+
// Auto-topup refills this when it dips below the configured threshold; the
|
|
77
|
+
// statusline mirror lets operators see the same balance without leaving TUI.
|
|
78
|
+
const [sandboxBalance, setSandboxBalance] = createSignal<number | null>(null)
|
|
79
|
+
// ms epoch when current turn started (status flipped to 'thinking'). The
|
|
80
|
+
// spinner row reads this and renders elapsed seconds. Cleared on idle.
|
|
81
|
+
const [turnStartedAt, setTurnStartedAt] = createSignal<number | null>(null)
|
|
82
|
+
|
|
83
|
+
// In-flight escrow job count surfaced in the statusbar. The marketplace
|
|
84
|
+
// wiring that populated this lived in the (removed) comms plugin; the signal
|
|
85
|
+
// is retained so the statusbar segment and the sandbox 'market' rows keep
|
|
86
|
+
// their type contract.
|
|
87
|
+
const [activeJobCount, setActiveJobCount] = createSignal(0)
|
|
88
|
+
void setActiveJobCount
|
|
89
|
+
|
|
90
|
+
// Per-turn AbortController. Set when handleSubmit kicks off brain.infer;
|
|
91
|
+
// cleared (set to null) after the turn ends or is aborted. The keyboard
|
|
92
|
+
// handler reads it to wire Esc → abort.
|
|
93
|
+
const [activeAbort, setActiveAbort] = createSignal<AbortController | null>(null)
|
|
94
|
+
|
|
95
|
+
// v0.20.0: slash-command autocomplete popup state. `slashMatches` is the
|
|
96
|
+
// filtered list of commands matching the current input prefix; populated
|
|
97
|
+
// when input starts with `/`, cleared otherwise. `slashIndex` tracks the
|
|
98
|
+
// selected row inside `slashMatches`. Both reset to defaults on submit.
|
|
99
|
+
const [slashMatches, setSlashMatches] = createSignal<SlashCommand[]>([])
|
|
100
|
+
const [slashIndex, setSlashIndex] = createSignal(0)
|
|
101
|
+
|
|
102
|
+
// Status-change subscribers. Phase 12 telegram-dispatch hooks here so it
|
|
103
|
+
// can drain its queue when the brain returns to idle from a stdin turn.
|
|
104
|
+
type StatusListener = (next: 'idle' | 'thinking' | 'error') => void
|
|
105
|
+
const statusListeners = new Set<StatusListener>()
|
|
106
|
+
const onStatusChange = (cb: StatusListener): (() => void) => {
|
|
107
|
+
statusListeners.add(cb)
|
|
108
|
+
return () => statusListeners.delete(cb)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Wrap status setter so the turn-start timestamp tracks status changes
|
|
112
|
+
// automatically. Every code path that flips to 'thinking' starts the
|
|
113
|
+
// clock; every flip to idle/error stops it. Removes the burden from
|
|
114
|
+
// call sites.
|
|
115
|
+
const setStatusTracked: typeof setStatus = next => {
|
|
116
|
+
const prev = status()
|
|
117
|
+
const result = setStatus(next)
|
|
118
|
+
const after = status()
|
|
119
|
+
if (prev !== 'thinking' && after === 'thinking') setTurnStartedAt(Date.now())
|
|
120
|
+
else if (prev === 'thinking' && after !== 'thinking') setTurnStartedAt(null)
|
|
121
|
+
if (prev !== after) {
|
|
122
|
+
for (const cb of statusListeners) {
|
|
123
|
+
try {
|
|
124
|
+
cb(after)
|
|
125
|
+
} catch {
|
|
126
|
+
// listener errors must not break status updates
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let idCounter = 1
|
|
134
|
+
const nextId = () => `row-${idCounter++}`
|
|
135
|
+
|
|
136
|
+
const pushRow = (row: Omit<TurnRow, 'id' | 'firstOfBlock'>) => {
|
|
137
|
+
setRows(prev => {
|
|
138
|
+
const last = prev[prev.length - 1] ?? null
|
|
139
|
+
const isAssistantBlock = row.role === 'assistant' || row.role === 'tool-call'
|
|
140
|
+
const continuesBlock =
|
|
141
|
+
last?.role === 'assistant' || last?.role === 'tool-call' || last?.role === 'tool-result'
|
|
142
|
+
const firstOfBlock = isAssistantBlock && !continuesBlock
|
|
143
|
+
return [...prev, { ...row, id: nextId(), firstOfBlock }]
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
rows,
|
|
149
|
+
input,
|
|
150
|
+
status,
|
|
151
|
+
usage,
|
|
152
|
+
pendingApproval,
|
|
153
|
+
approvalsMode,
|
|
154
|
+
balance,
|
|
155
|
+
eoaBalance,
|
|
156
|
+
sandboxBalance,
|
|
157
|
+
turnStartedAt,
|
|
158
|
+
activeAbort,
|
|
159
|
+
activeJobCount,
|
|
160
|
+
slashMatches,
|
|
161
|
+
slashIndex,
|
|
162
|
+
setInput,
|
|
163
|
+
setStatus: setStatusTracked,
|
|
164
|
+
setUsage,
|
|
165
|
+
setPendingApproval,
|
|
166
|
+
setApprovalsMode,
|
|
167
|
+
setBalance,
|
|
168
|
+
setEoaBalance,
|
|
169
|
+
setSandboxBalance,
|
|
170
|
+
setTurnStartedAt,
|
|
171
|
+
setActiveAbort,
|
|
172
|
+
setSlashMatches,
|
|
173
|
+
setSlashIndex,
|
|
174
|
+
pushRow,
|
|
175
|
+
onStatusChange,
|
|
176
|
+
identityLabel: opts.identityLabel,
|
|
177
|
+
isLocalGateway: opts.isLocalGateway ?? false,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type ChatState = ReturnType<typeof createChatState>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { BootstrapMode } from 'nebula-ai-gateway'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the sandbox bootstrap mode from operator env.
|
|
5
|
+
*
|
|
6
|
+
* Default is `'npm'` since v0.21.20 (~10x faster cold start: `bun add -g
|
|
7
|
+
* nebula-ai-cli@<ver>` finishes in ~30-60s vs ~5-8min for `git clone +
|
|
8
|
+
* bun install`). The npm path was shipped in v0.21.15 and lived as opt-in
|
|
9
|
+
* for several releases before this flip.
|
|
10
|
+
*
|
|
11
|
+
* Resolution order:
|
|
12
|
+
* 1. `NEBULA_BOOTSTRAP_MODE=git|npm` — explicit operator override, wins.
|
|
13
|
+
* 2. `NEBULA_BOOTSTRAP_REF` set without explicit mode → 'git'. The REF env
|
|
14
|
+
* is a git-mode concept (branch tip / commit SHA); auto-implying git
|
|
15
|
+
* preserves the existing "deploy main", "deploy <sha>" dev workflows.
|
|
16
|
+
* 3. Otherwise → 'npm'.
|
|
17
|
+
*
|
|
18
|
+
* Callers pass `opts.mode` directly to bypass this resolver entirely.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveBootstrapMode(env: NodeJS.ProcessEnv = process.env): BootstrapMode {
|
|
21
|
+
if (env.NEBULA_BOOTSTRAP_MODE === 'git') return 'git'
|
|
22
|
+
if (env.NEBULA_BOOTSTRAP_MODE === 'npm') return 'npm'
|
|
23
|
+
if (env.NEBULA_BOOTSTRAP_REF) return 'git'
|
|
24
|
+
return 'npm'
|
|
25
|
+
}
|