@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
@@ -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
+ })