@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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { summarizeApprovalSubject } from './approval-summary'
|
|
3
|
+
|
|
4
|
+
describe('summarizeApprovalSubject', () => {
|
|
5
|
+
it('renders chain.send native with amount + recipient', () => {
|
|
6
|
+
expect(
|
|
7
|
+
summarizeApprovalSubject({
|
|
8
|
+
kind: 'chain.send',
|
|
9
|
+
amount: '0.001',
|
|
10
|
+
recipient: '0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec',
|
|
11
|
+
token: '0G',
|
|
12
|
+
reason: 'native/ERC-20 transfer',
|
|
13
|
+
}),
|
|
14
|
+
).toBe('send 0.001 0G to 0xC635…87Ec')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('renders chain.send ERC-20 with explicit token symbol', () => {
|
|
18
|
+
expect(
|
|
19
|
+
summarizeApprovalSubject({
|
|
20
|
+
kind: 'chain.send',
|
|
21
|
+
amount: '0.5',
|
|
22
|
+
recipient: '0xC635e6Eb223aE14143E23cEEa9440bC773dc87Ec',
|
|
23
|
+
token: 'USDCe',
|
|
24
|
+
reason: 'native/ERC-20 transfer',
|
|
25
|
+
}),
|
|
26
|
+
).toBe('send 0.5 USDCe to 0xC635…87Ec')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders chain.wrap as the arrow form (no recipient noise)', () => {
|
|
30
|
+
expect(
|
|
31
|
+
summarizeApprovalSubject({
|
|
32
|
+
kind: 'chain.send',
|
|
33
|
+
amount: '0.01',
|
|
34
|
+
token: '0G→W0G',
|
|
35
|
+
reason: 'wrap native to W0G',
|
|
36
|
+
}),
|
|
37
|
+
).toBe('0.01 0G→W0G')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('renders chain.unwrap', () => {
|
|
41
|
+
expect(
|
|
42
|
+
summarizeApprovalSubject({
|
|
43
|
+
kind: 'chain.send',
|
|
44
|
+
amount: '0.01',
|
|
45
|
+
token: 'W0G→0G',
|
|
46
|
+
reason: 'unwrap W0G to native',
|
|
47
|
+
}),
|
|
48
|
+
).toBe('0.01 W0G→0G')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders chain.swap with token-pair encoding', () => {
|
|
52
|
+
expect(
|
|
53
|
+
summarizeApprovalSubject({
|
|
54
|
+
kind: 'chain.swap',
|
|
55
|
+
amount: '0.005',
|
|
56
|
+
token: '0G→USDCe',
|
|
57
|
+
reason: 'JAINE swap execution',
|
|
58
|
+
}),
|
|
59
|
+
).toBe('swap 0.005 0G→USDCe')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders chain.swap with empty amt + tok', () => {
|
|
63
|
+
expect(
|
|
64
|
+
summarizeApprovalSubject({
|
|
65
|
+
kind: 'chain.swap',
|
|
66
|
+
reason: 'JAINE swap execution',
|
|
67
|
+
}),
|
|
68
|
+
).toBe('swap')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('renders stake.stake', () => {
|
|
72
|
+
expect(
|
|
73
|
+
summarizeApprovalSubject({
|
|
74
|
+
kind: 'chain.stake',
|
|
75
|
+
amount: '0.02',
|
|
76
|
+
token: '0G→stOG',
|
|
77
|
+
reason: 'Gimo stake',
|
|
78
|
+
}),
|
|
79
|
+
).toBe('0.02 0G→stOG')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders stake.unstake', () => {
|
|
83
|
+
expect(
|
|
84
|
+
summarizeApprovalSubject({
|
|
85
|
+
kind: 'chain.stake',
|
|
86
|
+
amount: '0.01',
|
|
87
|
+
token: 'stOG→0G (queued)',
|
|
88
|
+
reason: 'Gimo unstake',
|
|
89
|
+
}),
|
|
90
|
+
).toBe('0.01 stOG→0G (queued)')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('renders stake.claim with no amount', () => {
|
|
94
|
+
expect(
|
|
95
|
+
summarizeApprovalSubject({
|
|
96
|
+
kind: 'chain.stake',
|
|
97
|
+
token: 'claim queued 0G',
|
|
98
|
+
reason: 'Gimo claim',
|
|
99
|
+
}),
|
|
100
|
+
).toBe('claim queued 0G')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('renders chain.write with signature + recipient + value', () => {
|
|
104
|
+
expect(
|
|
105
|
+
summarizeApprovalSubject({
|
|
106
|
+
kind: 'chain.write',
|
|
107
|
+
recipient: '0x9e71d79f06f956d4d2666b5c93dafab721c84721',
|
|
108
|
+
command: 'transfer(address,uint256)',
|
|
109
|
+
amount: '1 wei',
|
|
110
|
+
reason: 'arbitrary state-changing call',
|
|
111
|
+
}),
|
|
112
|
+
).toBe('transfer(address,uint256) (value: 1 wei) on 0x9e71…4721')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('renders chain.write with no value', () => {
|
|
116
|
+
expect(
|
|
117
|
+
summarizeApprovalSubject({
|
|
118
|
+
kind: 'chain.write',
|
|
119
|
+
recipient: '0x9e71d79f06f956d4d2666b5c93dafab721c84721',
|
|
120
|
+
command: 'totalSupply()',
|
|
121
|
+
reason: 'arbitrary state-changing call',
|
|
122
|
+
}),
|
|
123
|
+
).toBe('totalSupply() on 0x9e71…4721')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('falls back to command for shell.run', () => {
|
|
127
|
+
expect(
|
|
128
|
+
summarizeApprovalSubject({
|
|
129
|
+
kind: 'shell.run',
|
|
130
|
+
command: 'rm -rf /tmp/foo',
|
|
131
|
+
reason: 'shell command execution',
|
|
132
|
+
}),
|
|
133
|
+
).toBe('rm -rf /tmp/foo')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('falls back to path for fs.write', () => {
|
|
137
|
+
expect(
|
|
138
|
+
summarizeApprovalSubject({
|
|
139
|
+
kind: 'fs.write',
|
|
140
|
+
path: '/tmp/x.txt',
|
|
141
|
+
reason: 'fs.write request',
|
|
142
|
+
}),
|
|
143
|
+
).toBe('/tmp/x.txt')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('falls back to (unspecified) when nothing usable', () => {
|
|
147
|
+
expect(
|
|
148
|
+
summarizeApprovalSubject({
|
|
149
|
+
kind: 'fs.patch',
|
|
150
|
+
reason: 'fs.patch request',
|
|
151
|
+
}),
|
|
152
|
+
).toBe('(unspecified)')
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PermissionRequest } from '@promus/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
|
+
* (`0G→W0G` / `W0G→0G`) 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 || '0G'
|
|
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.stake':
|
|
25
|
+
if (!amt) return tok || 'stake operation'
|
|
26
|
+
return `${amt} ${tok}`.trim()
|
|
27
|
+
case 'chain.write': {
|
|
28
|
+
const valuePart = amt ? ` (value: ${amt})` : ''
|
|
29
|
+
return `${req.command ?? '?'}${valuePart} on ${shortAddr(req.recipient)}`
|
|
30
|
+
}
|
|
31
|
+
default:
|
|
32
|
+
return req.command ?? req.path ?? '(unspecified)'
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -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,146 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
// Import from the .ts file (pure logic) so the test doesn't trigger the
|
|
3
|
+
// JSX transform on markdown.tsx. CI's bun runtime resolves react-jsx by
|
|
4
|
+
// default unless a per-file pragma or workspace tsconfig override applies,
|
|
5
|
+
// and pulling solid-js JSX into the test file isn't worth the coupling.
|
|
6
|
+
import { parseMarkdown } from './markdown-parse'
|
|
7
|
+
|
|
8
|
+
describe('parseMarkdown', () => {
|
|
9
|
+
it('renders plain text as a single segment', () => {
|
|
10
|
+
const segs = parseMarkdown('hello world')
|
|
11
|
+
expect(segs).toHaveLength(1)
|
|
12
|
+
expect(segs[0]?.text).toBe('hello world')
|
|
13
|
+
expect(segs[0]?.bold).toBeUndefined()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses **bold** as bold segment between plain', () => {
|
|
17
|
+
const segs = parseMarkdown('the **fast** fox')
|
|
18
|
+
const labels = segs.map(s => `${s.text}${s.bold ? '*' : ''}`)
|
|
19
|
+
expect(labels).toContain('fast*')
|
|
20
|
+
expect(segs.find(s => s.text === 'fast')?.bold).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('parses *italic* as italic segment', () => {
|
|
24
|
+
const segs = parseMarkdown('the *quick* fox')
|
|
25
|
+
expect(segs.find(s => s.text === 'quick')?.italic).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('parses `code` as code-colored segment', () => {
|
|
29
|
+
const segs = parseMarkdown('use `browser.snapshot` next')
|
|
30
|
+
const code = segs.find(s => s.text === 'browser.snapshot')
|
|
31
|
+
expect(code?.fg).toBeDefined()
|
|
32
|
+
expect(code?.fg).not.toBe('#e5e7eb')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders heading with bold + heading color, drops the # prefix', () => {
|
|
36
|
+
const segs = parseMarkdown('# Title\nbody')
|
|
37
|
+
const titleSeg = segs.find(s => s.text === 'Title')
|
|
38
|
+
expect(titleSeg?.bold).toBe(true)
|
|
39
|
+
expect(titleSeg?.fg).not.toBe('#e5e7eb')
|
|
40
|
+
// The leading '#' should not appear as text
|
|
41
|
+
expect(segs.some(s => s.text.startsWith('#'))).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('renders bullet lists with bullet glyph + content', () => {
|
|
45
|
+
const segs = parseMarkdown('- one\n- two')
|
|
46
|
+
const bullets = segs.filter(s => s.text.includes('•'))
|
|
47
|
+
expect(bullets.length).toBe(2)
|
|
48
|
+
// Bullet should be a SEPARATE segment from content (different style)
|
|
49
|
+
expect(segs.find(s => s.text === 'one')).toBeDefined()
|
|
50
|
+
expect(segs.find(s => s.text === 'two')).toBeDefined()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders fenced code block with code-block color, skips fence lines', () => {
|
|
54
|
+
const segs = parseMarkdown('```ts\nconst x = 1;\nconst y = 2;\n```')
|
|
55
|
+
const codeLines = segs.filter(s => s.text.includes('const'))
|
|
56
|
+
expect(codeLines.length).toBe(2)
|
|
57
|
+
expect(codeLines[0]?.fg).toBeDefined()
|
|
58
|
+
// Fence syntax (```ts and ```) should NOT appear in output
|
|
59
|
+
expect(segs.some(s => s.text.startsWith('```'))).toBe(false)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('parses inline code inside bold without breaking either', () => {
|
|
63
|
+
const segs = parseMarkdown('use **`foo`** now')
|
|
64
|
+
// The combined ** + `` is unusual; we accept either bold-with-code or plain-with-code, but no crash
|
|
65
|
+
expect(segs.length).toBeGreaterThan(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('preserves newlines between blocks', () => {
|
|
69
|
+
const segs = parseMarkdown('one\ntwo\nthree')
|
|
70
|
+
const newlines = segs.filter(s => s.text === '\n')
|
|
71
|
+
expect(newlines.length).toBe(2)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles the screenshot regression case (mixed bold + inline code + bullets)', () => {
|
|
75
|
+
const text = `**What I did successfully:**
|
|
76
|
+
- \`browser.navigate\` → done
|
|
77
|
+
- \`browser.type\` worked
|
|
78
|
+
|
|
79
|
+
**What failed:**
|
|
80
|
+
- \`browser.snapshot\` returned home`
|
|
81
|
+
const segs = parseMarkdown(text)
|
|
82
|
+
// Bold "What I did successfully:" present
|
|
83
|
+
expect(segs.find(s => s.text === 'What I did successfully:' && s.bold)).toBeDefined()
|
|
84
|
+
// Inline code segments
|
|
85
|
+
expect(segs.find(s => s.text === 'browser.navigate')).toBeDefined()
|
|
86
|
+
expect(segs.find(s => s.text === 'browser.snapshot')).toBeDefined()
|
|
87
|
+
// No literal ** or ` in output
|
|
88
|
+
expect(segs.some(s => s.text.includes('**'))).toBe(false)
|
|
89
|
+
expect(segs.some(s => s.text.includes('`'))).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// v0.22.0: brain emits GFM tables. Previously the renderer treated every
|
|
93
|
+
// `|...|` line as plain text, leaving the operator with literal pipes + a
|
|
94
|
+
// useless separator row. The new path detects header + `|---|---|` + data
|
|
95
|
+
// rows and emits aligned cells with a box-drawing divider.
|
|
96
|
+
describe('GFM tables', () => {
|
|
97
|
+
it('renders a 2-column table with header + data rows', () => {
|
|
98
|
+
const md = '| Mode | Behavior |\n|------|----------|\n| yolo | auto |\n| prompt | modal |'
|
|
99
|
+
const segs = parseMarkdown(md)
|
|
100
|
+
// Header row text should contain both column headers + box-drawing pipes
|
|
101
|
+
const headerSeg = segs.find(s => s.text.includes('Mode') && s.text.includes('Behavior'))
|
|
102
|
+
expect(headerSeg).toBeDefined()
|
|
103
|
+
expect(headerSeg?.bold).toBe(true)
|
|
104
|
+
expect(headerSeg?.text).toContain('│')
|
|
105
|
+
// Separator row should be present once
|
|
106
|
+
expect(segs.some(s => s.text.includes('─') && s.text.includes('┼'))).toBe(true)
|
|
107
|
+
// Data rows
|
|
108
|
+
expect(segs.some(s => s.text.includes('yolo') && s.text.includes('auto'))).toBe(true)
|
|
109
|
+
expect(segs.some(s => s.text.includes('prompt') && s.text.includes('modal'))).toBe(true)
|
|
110
|
+
// Cells are padded to a common width — column 0 should be 6 chars wide
|
|
111
|
+
// ("prompt") so "yolo" appears as "yolo " (4 + 2 spaces of padding).
|
|
112
|
+
const yoloRow = segs.find(s => s.text.includes('yolo'))
|
|
113
|
+
expect(yoloRow?.text).toMatch(/yolo\s{2,}/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('pads short cells so columns align', () => {
|
|
117
|
+
const md = '| col |\n|-----|\n| a |\n| longer cell |'
|
|
118
|
+
const segs = parseMarkdown(md)
|
|
119
|
+
const longRow = segs.find(s => s.text.includes('longer cell'))
|
|
120
|
+
const shortRow = segs.find(s => s.text.match(/│\s+a\s+│/))
|
|
121
|
+
expect(longRow).toBeDefined()
|
|
122
|
+
expect(shortRow).toBeDefined()
|
|
123
|
+
// Both rows should have identical visual width (same number of chars)
|
|
124
|
+
expect(shortRow?.text.length).toBe(longRow?.text.length)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('falls through to plain text when no separator row follows the header', () => {
|
|
128
|
+
const md = '| col1 | col2 |\nplain text below'
|
|
129
|
+
const segs = parseMarkdown(md)
|
|
130
|
+
// No table-style border characters
|
|
131
|
+
expect(segs.some(s => s.text.includes('│'))).toBe(false)
|
|
132
|
+
expect(segs.some(s => s.text.includes('┼'))).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('does not emit em-dashes in the separator row (project rule)', () => {
|
|
136
|
+
const md = '| col |\n|-----|\n| a |'
|
|
137
|
+
const segs = parseMarkdown(md)
|
|
138
|
+
// U+2014 (em-dash) must NOT appear anywhere in the rendered output.
|
|
139
|
+
// U+2013 (en-dash) also forbidden. Only ASCII hyphens or box-drawing
|
|
140
|
+
// U+2500 are allowed.
|
|
141
|
+
const joined = segs.map(s => s.text).join('')
|
|
142
|
+
expect(joined.includes('—')).toBe(false)
|
|
143
|
+
expect(joined.includes('–')).toBe(false)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -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>`: promus 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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import { createChatState } from './state'
|
|
3
|
+
|
|
4
|
+
describe('createChatState — v0.24.4 isLocalGateway', () => {
|
|
5
|
+
it('exposes isLocalGateway=true when the local-gateway flag is passed', () => {
|
|
6
|
+
const state = createChatState({
|
|
7
|
+
initialSystem: 'connected to local gateway (~/.promus/agents/abcd1234/gateway.sock)',
|
|
8
|
+
identityLabel: 'agent specter 0xabc',
|
|
9
|
+
approvalsMode: 'prompt',
|
|
10
|
+
isLocalGateway: true,
|
|
11
|
+
})
|
|
12
|
+
expect(state.isLocalGateway).toBe(true)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('defaults isLocalGateway=false when omitted (sandbox path)', () => {
|
|
16
|
+
const state = createChatState({
|
|
17
|
+
initialSystem: 'connected to sandbox 12345678 @ https://sandbox.example',
|
|
18
|
+
identityLabel: 'agent enigma 0xdef',
|
|
19
|
+
approvalsMode: 'prompt',
|
|
20
|
+
})
|
|
21
|
+
expect(state.isLocalGateway).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('treats explicit isLocalGateway=false as sandbox mode', () => {
|
|
25
|
+
const state = createChatState({
|
|
26
|
+
initialSystem: 'connected to sandbox 12345678 @ https://sandbox.example',
|
|
27
|
+
identityLabel: 'agent enigma 0xdef',
|
|
28
|
+
approvalsMode: 'off',
|
|
29
|
+
isLocalGateway: false,
|
|
30
|
+
})
|
|
31
|
+
expect(state.isLocalGateway).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('keeps sandboxBalance() null at construction so the statusbar Show gate hides the segment until setSandboxBalance fires', () => {
|
|
35
|
+
const localState = createChatState({
|
|
36
|
+
initialSystem: 'connected to local gateway',
|
|
37
|
+
identityLabel: 'agent specter 0xabc',
|
|
38
|
+
approvalsMode: 'off',
|
|
39
|
+
isLocalGateway: true,
|
|
40
|
+
})
|
|
41
|
+
// v0.24.4: chat-sandbox.tsx skips setSandboxBalance entirely for local
|
|
42
|
+
// gateway deploys. Re-affirm the default so any future setter regression
|
|
43
|
+
// surfaces here.
|
|
44
|
+
expect(localState.sandboxBalance()).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('seeds the initial system row from initialSystem (local-gateway label form)', () => {
|
|
48
|
+
const state = createChatState({
|
|
49
|
+
initialSystem: 'connected to local gateway (~/.promus/agents/abcd1234/gateway.sock)',
|
|
50
|
+
identityLabel: 'agent specter 0xabc',
|
|
51
|
+
approvalsMode: 'prompt',
|
|
52
|
+
isLocalGateway: true,
|
|
53
|
+
})
|
|
54
|
+
const first = state.rows()[0]
|
|
55
|
+
expect(first).toBeDefined()
|
|
56
|
+
if (!first) throw new Error('rows()[0] missing')
|
|
57
|
+
expect(first.role).toBe('system')
|
|
58
|
+
expect(first.text).toContain('local gateway')
|
|
59
|
+
expect(first.text).not.toContain('sandbox')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('seeds the initial system row from initialSystem (sandbox label form)', () => {
|
|
63
|
+
const state = createChatState({
|
|
64
|
+
initialSystem: 'connected to sandbox 12345678 @ https://sandbox.example',
|
|
65
|
+
identityLabel: 'agent enigma 0xdef',
|
|
66
|
+
approvalsMode: 'prompt',
|
|
67
|
+
})
|
|
68
|
+
const first = state.rows()[0]
|
|
69
|
+
expect(first).toBeDefined()
|
|
70
|
+
if (!first) throw new Error('rows()[0] missing')
|
|
71
|
+
expect(first.role).toBe('system')
|
|
72
|
+
expect(first.text).toContain('sandbox 12345678')
|
|
73
|
+
})
|
|
74
|
+
})
|