@mobius-os/mobius 0.2.2
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 +174 -0
- package/bin/mobius-tui.js +12 -0
- package/package.json +29 -0
- package/src/App.tsx +145 -0
- package/src/aimux.ts +294 -0
- package/src/api.ts +206 -0
- package/src/components/AimuxStatus.tsx +45 -0
- package/src/components/Chat.tsx +519 -0
- package/src/components/Login.tsx +88 -0
- package/src/components/PrepScreen.tsx +368 -0
- package/src/components/ResumePicker.tsx +63 -0
- package/src/components/primitives.tsx +241 -0
- package/src/config.ts +159 -0
- package/src/hooks/useChat.ts +332 -0
- package/src/lib/entry-view.ts +351 -0
- package/src/main.tsx +13 -0
- package/src/markdown.ts +160 -0
- package/src/sse.ts +126 -0
- package/src/types.ts +216 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonl entry → renderable view.
|
|
3
|
+
*
|
|
4
|
+
* The pure helpers (assistantResponseText / assistantEntryText / entryUserText
|
|
5
|
+
* and the "hidden noise" predicates) are copied from the mobius web frontend's
|
|
6
|
+
* frontend/src/components/viewer/entry-classify.ts. The frontend's full
|
|
7
|
+
* entry-extract.ts (diff/plan machinery) is far heavier than a chat TUI needs,
|
|
8
|
+
* so instead we project each entry into a small EntryView union that the
|
|
9
|
+
* Transcript renders — handling BOTH the Claude SDK entry shape (type:
|
|
10
|
+
* 'user'|'assistant'|'system', message.content[]) and both Codex SDK shapes
|
|
11
|
+
* (function_call/function_call_output and custom_tool_call/custom_tool_call_output).
|
|
12
|
+
*/
|
|
13
|
+
import type { AnyEntry } from '../types.js'
|
|
14
|
+
|
|
15
|
+
export type EntryView =
|
|
16
|
+
| { kind: 'skip' }
|
|
17
|
+
| { kind: 'user'; text: string }
|
|
18
|
+
| { kind: 'assistant'; text: string }
|
|
19
|
+
| { kind: 'reasoning'; text: string }
|
|
20
|
+
| { kind: 'tool_call'; toolName: string; summary: string }
|
|
21
|
+
| { kind: 'tool_result'; summary: string; isError: boolean }
|
|
22
|
+
| { kind: 'system'; text: string }
|
|
23
|
+
| { kind: 'error'; text: string }
|
|
24
|
+
|
|
25
|
+
// ── copied verbatim from frontend entry-classify.ts ──────────────────────────
|
|
26
|
+
export function assistantResponseText(content: any): string {
|
|
27
|
+
if (typeof content === 'string') return content
|
|
28
|
+
if (!Array.isArray(content)) return ''
|
|
29
|
+
return content
|
|
30
|
+
.map((b: any) => {
|
|
31
|
+
if (!b || typeof b !== 'object') return ''
|
|
32
|
+
if (typeof b.text !== 'string') return ''
|
|
33
|
+
return b.type === 'text' || b.type === 'output_text' ? b.text : ''
|
|
34
|
+
})
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join('\n')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assistantEntryText(entry: AnyEntry): string {
|
|
40
|
+
if (entry?.type === 'assistant') return assistantResponseText(entry?.message?.content)
|
|
41
|
+
if (entry?.type === 'response_item' && entry?.payload?.type === 'message' && entry?.payload?.role === 'assistant') {
|
|
42
|
+
return assistantResponseText(entry?.payload?.content)
|
|
43
|
+
}
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function entryUserText(entry: AnyEntry): string {
|
|
48
|
+
if (entry?.type === 'response_item' && entry?.payload?.type === 'message' && entry?.payload?.role === 'user') {
|
|
49
|
+
const c = entry?.payload?.content
|
|
50
|
+
if (typeof c === 'string') return c
|
|
51
|
+
if (Array.isArray(c)) return c.map((b: any) => (typeof b === 'string' ? b : (b?.text ?? b?.input_text ?? ''))).filter(Boolean).join('\n')
|
|
52
|
+
return ''
|
|
53
|
+
}
|
|
54
|
+
if (entry?.type === 'user') {
|
|
55
|
+
const c = entry?.message?.content
|
|
56
|
+
if (typeof c === 'string') return c
|
|
57
|
+
if (Array.isArray(c)) return c.map((b: any) => (typeof b === 'string' ? b : (b?.text ?? ''))).filter(Boolean).join('\n')
|
|
58
|
+
return ''
|
|
59
|
+
}
|
|
60
|
+
return ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const ENV_CONTEXT_RE = /<environment_context\b[^>]*>[\s\S]*?<\/environment_context>/gi
|
|
64
|
+
|
|
65
|
+
// Noise entries that carry no conversational value (system injections / metadata).
|
|
66
|
+
export function isHiddenNoise(entry: AnyEntry): boolean {
|
|
67
|
+
if (entry?.type === 'event_msg' && (entry?.payload?.type === 'token_count' || entry?.payload?.type === 'context_compacted')) return true
|
|
68
|
+
if (entry?.type === 'session_meta') return true
|
|
69
|
+
if (entry?.type === 'system' && entry?.subtype === 'turn_duration') return true
|
|
70
|
+
if (entry?.type === 'attachment' && (entry?.attachment?.type === 'skill_listing' || entry?.attachment?.type === 'agent_listing_delta')) return true
|
|
71
|
+
// pure <environment_context> injection
|
|
72
|
+
const t = entryUserText(entry)
|
|
73
|
+
if (t) {
|
|
74
|
+
const stripped = t.replace(ENV_CONTEXT_RE, '')
|
|
75
|
+
if (stripped.trim().length === 0 && stripped !== t) return true
|
|
76
|
+
}
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── TUI-side projection ──────────────────────────────────────────────────────
|
|
81
|
+
function truncate(s: string, n: number): string {
|
|
82
|
+
const one = s.replace(/\s+/g, ' ').trim()
|
|
83
|
+
return one.length > n ? one.slice(0, n - 1) + '…' : one
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build a one-line summary of a tool call from its name + input. */
|
|
87
|
+
export function summarizeToolInput(name: string, input: any): string {
|
|
88
|
+
if (!input || typeof input !== 'object') return ''
|
|
89
|
+
const cmd = (s?: string) => truncate(s ?? '', 120)
|
|
90
|
+
switch (name) {
|
|
91
|
+
case 'Bash':
|
|
92
|
+
case 'shell':
|
|
93
|
+
case 'bash':
|
|
94
|
+
case 'exec':
|
|
95
|
+
case 'exec_command':
|
|
96
|
+
case 'shell_command':
|
|
97
|
+
case 'run_terminal_cmd':
|
|
98
|
+
return cmd(input.cmd ?? input.command ?? input.script)
|
|
99
|
+
case 'Read':
|
|
100
|
+
case 'read_file':
|
|
101
|
+
return input.file_path ?? input.path ?? ''
|
|
102
|
+
case 'Write':
|
|
103
|
+
case 'write_file':
|
|
104
|
+
case 'create_file':
|
|
105
|
+
return input.file_path ?? input.path ?? ''
|
|
106
|
+
case 'Edit':
|
|
107
|
+
case 'StrReplace':
|
|
108
|
+
case 'edit_file':
|
|
109
|
+
case 'update_plan':
|
|
110
|
+
return input.file_path ?? input.path ?? ''
|
|
111
|
+
case 'Glob':
|
|
112
|
+
case 'list_files':
|
|
113
|
+
return input.pattern ?? input.path ?? ''
|
|
114
|
+
case 'Grep':
|
|
115
|
+
case 'grep':
|
|
116
|
+
case 'search_file_content':
|
|
117
|
+
return input.pattern ?? input.query ?? ''
|
|
118
|
+
case 'Task':
|
|
119
|
+
case 'launch_subagent':
|
|
120
|
+
return input.description ?? ''
|
|
121
|
+
case 'WebFetch':
|
|
122
|
+
case 'web_fetch':
|
|
123
|
+
return input.url ?? input.prompt ?? ''
|
|
124
|
+
case 'WebSearch':
|
|
125
|
+
case 'web_search':
|
|
126
|
+
return input.query ?? ''
|
|
127
|
+
case 'TodoWrite':
|
|
128
|
+
case 'update_plan_plan':
|
|
129
|
+
return ''
|
|
130
|
+
default: {
|
|
131
|
+
const keys = Object.keys(input)
|
|
132
|
+
if (keys.length === 0) return ''
|
|
133
|
+
const k = keys[0]
|
|
134
|
+
return truncate(`${k}: ${typeof input[k] === 'string' ? input[k] : JSON.stringify(input[k])}`, 100)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Extract readable text + error flag from a tool_result block content. */
|
|
140
|
+
function extractToolResult(content: any): { text: string; isError: boolean } {
|
|
141
|
+
const isError = !!content?.is_error
|
|
142
|
+
let body = content?.content
|
|
143
|
+
if (typeof body === 'string') return { text: body, isError }
|
|
144
|
+
if (Array.isArray(body)) {
|
|
145
|
+
const t = body
|
|
146
|
+
.map((b: any) => (typeof b === 'string' ? b : (b?.text ?? '')))
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join('\n')
|
|
149
|
+
return { text: t, isError }
|
|
150
|
+
}
|
|
151
|
+
if (typeof body === 'object' && body) {
|
|
152
|
+
return { text: body.text ?? body.output ?? JSON.stringify(body), isError }
|
|
153
|
+
}
|
|
154
|
+
return { text: '', isError }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const TOOL_LABEL: Record<string, string> = {
|
|
158
|
+
Bash: '运行命令', bash: '运行命令', shell: '运行命令', exec: '运行命令',
|
|
159
|
+
exec_command: '运行命令', shell_command: '运行命令', run_terminal_cmd: '运行命令',
|
|
160
|
+
write_stdin: '输入命令',
|
|
161
|
+
Read: '读取文件', read_file: '读取文件',
|
|
162
|
+
Write: '写入文件', write_file: '写入文件', create_file: '创建文件',
|
|
163
|
+
Edit: '编辑文件', StrReplace: '编辑文件', edit_file: '编辑文件', apply_patch: '编辑文件',
|
|
164
|
+
Glob: '搜索文件', list_files: '列出文件',
|
|
165
|
+
Grep: '搜索内容', grep: '搜索内容', search_file_content: '搜索内容',
|
|
166
|
+
Task: '子任务', launch_subagent: '子任务',
|
|
167
|
+
WebFetch: '抓取网页', web_fetch: '抓取网页',
|
|
168
|
+
WebSearch: '网络搜索', web_search: '网络搜索',
|
|
169
|
+
TodoWrite: '更新计划', update_plan: '更新计划',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Mirrors the web viewer (header-summary.ts): encrypted reasoning gets a fixed
|
|
173
|
+
// label; otherwise show the summary text if any.
|
|
174
|
+
const ENCRYPTED_REASONING_LABEL = 'Reasoning (闭源模型的推理过程被加密,无法解码)'
|
|
175
|
+
|
|
176
|
+
function reasoningSummaryText(p: any): string {
|
|
177
|
+
const s = p?.summary
|
|
178
|
+
if (Array.isArray(s)) return s.map((x: any) => (typeof x === 'string' ? x : (x?.text ?? ''))).filter(Boolean).join('\n')
|
|
179
|
+
if (typeof s === 'string') return s
|
|
180
|
+
return ''
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Newer Codex versions wrap tool calls in a custom `exec` transport. Its
|
|
185
|
+
* payload.input is JavaScript such as:
|
|
186
|
+
*
|
|
187
|
+
* const r = await tools.exec_command({ cmd: "rg -n ...", workdir: "/repo" })
|
|
188
|
+
*
|
|
189
|
+
* Extract the nested tool name and its object argument so the TUI can render
|
|
190
|
+
* the same command summary as the web viewer. JSON is the common case; the
|
|
191
|
+
* small quoted-field fallback also handles JavaScript object keys and strings.
|
|
192
|
+
*/
|
|
193
|
+
function parseCustomToolCall(raw: any): { name: string; input: Record<string, any> } | null {
|
|
194
|
+
if (typeof raw !== 'string') return null
|
|
195
|
+
// Prefer the first tools.<name>(...) invocation. A custom wrapper may call
|
|
196
|
+
// Promise.all(...) or another helper before it, which is transport code and
|
|
197
|
+
// must not become the displayed tool name.
|
|
198
|
+
const call = /tools\.([A-Za-z_$][\w$]*)\s*\(/g.exec(raw)
|
|
199
|
+
|| /\b(exec_command|write_stdin|apply_patch)\s*\(/g.exec(raw)
|
|
200
|
+
if (!call) return null
|
|
201
|
+
const objectStart = raw.indexOf('{', call.index + call[0].length)
|
|
202
|
+
if (objectStart < 0) return { name: call[1], input: {} }
|
|
203
|
+
|
|
204
|
+
let quote = ''
|
|
205
|
+
let escaped = false
|
|
206
|
+
let depth = 0
|
|
207
|
+
let objectEnd = -1
|
|
208
|
+
for (let index = objectStart; index < raw.length; index++) {
|
|
209
|
+
const ch = raw[index]
|
|
210
|
+
if (quote) {
|
|
211
|
+
if (escaped) escaped = false
|
|
212
|
+
else if (ch === '\\') escaped = true
|
|
213
|
+
else if (ch === quote) quote = ''
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
if (ch === '"' || ch === "'" || ch === '`') { quote = ch; continue }
|
|
217
|
+
if (ch === '{') depth++
|
|
218
|
+
else if (ch === '}' && --depth === 0) { objectEnd = index; break }
|
|
219
|
+
}
|
|
220
|
+
if (objectEnd < 0) return { name: call[1], input: {} }
|
|
221
|
+
|
|
222
|
+
const source = raw.slice(objectStart, objectEnd + 1)
|
|
223
|
+
try {
|
|
224
|
+
const parsed = JSON.parse(source)
|
|
225
|
+
if (parsed && typeof parsed === 'object') return { name: call[1], input: parsed }
|
|
226
|
+
} catch { /* JavaScript object syntax falls through to quoted-field parsing. */ }
|
|
227
|
+
|
|
228
|
+
const input: Record<string, any> = {}
|
|
229
|
+
const patterns = [
|
|
230
|
+
/(?:^|[,{])\s*(cmd|command|script|workdir|cwd|path|file_path|query|pattern)\s*:\s*"((?:\\.|[^"\\])*)"/g,
|
|
231
|
+
/(?:^|[,{])\s*(cmd|command|script|workdir|cwd|path|file_path|query|pattern)\s*:\s*'((?:\\.|[^'\\])*)'/g,
|
|
232
|
+
]
|
|
233
|
+
for (const pattern of patterns) {
|
|
234
|
+
let field: RegExpExecArray | null
|
|
235
|
+
while ((field = pattern.exec(source))) {
|
|
236
|
+
try { input[field[1]] = JSON.parse(`"${field[2].replace(/"/g, '\\"')}"`) }
|
|
237
|
+
catch { input[field[1]] = field[2].replace(/\\n/g, '\n').replace(/\\r/g, '\r').replace(/\\t/g, '\t').replace(/\\([\\"'])/g, '$1') }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { name: call[1], input }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Project one entry into zero or more renderable views. */
|
|
244
|
+
export function viewsForEntry(entry: AnyEntry): EntryView[] {
|
|
245
|
+
if (!entry || typeof entry !== 'object') return [{ kind: 'skip' }]
|
|
246
|
+
if (isHiddenNoise(entry)) return [{ kind: 'skip' }]
|
|
247
|
+
const type = entry.type
|
|
248
|
+
|
|
249
|
+
// ── Claude SDK shapes ───────────────────────────────────────────────────
|
|
250
|
+
if (type === 'assistant') {
|
|
251
|
+
const content = entry.message?.content
|
|
252
|
+
if (!Array.isArray(content)) {
|
|
253
|
+
const text = assistantResponseText(content)
|
|
254
|
+
return text ? [{ kind: 'assistant', text }] : [{ kind: 'skip' }]
|
|
255
|
+
}
|
|
256
|
+
const out: EntryView[] = []
|
|
257
|
+
const textParts: string[] = []
|
|
258
|
+
const thinkingParts: string[] = []
|
|
259
|
+
let hasThinking = false
|
|
260
|
+
for (const b of content) {
|
|
261
|
+
if (!b) continue
|
|
262
|
+
if (b.type === 'text' || b.type === 'output_text') {
|
|
263
|
+
if (b.text) textParts.push(b.text)
|
|
264
|
+
} else if (b.type === 'tool_use') {
|
|
265
|
+
if (textParts.length) { out.push({ kind: 'assistant', text: textParts.join('\n') }); textParts.length = 0 }
|
|
266
|
+
out.push({ kind: 'tool_call', toolName: b.name, summary: summarizeToolInput(b.name, b.input) })
|
|
267
|
+
} else if (b.type === 'thinking') {
|
|
268
|
+
// model reasoning — shown like the web viewer (encrypted/empty thinking → fallback label)
|
|
269
|
+
hasThinking = true
|
|
270
|
+
if (typeof b.thinking === 'string' && b.thinking) thinkingParts.push(b.thinking)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (textParts.length) out.push({ kind: 'assistant', text: textParts.join('\n') })
|
|
274
|
+
if (thinkingParts.length) out.push({ kind: 'reasoning', text: thinkingParts.join('\n').trim() })
|
|
275
|
+
else if (hasThinking) out.push({ kind: 'reasoning', text: '思考内容被隐藏' })
|
|
276
|
+
return out.length ? out : [{ kind: 'skip' }]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (type === 'user') {
|
|
280
|
+
const content = entry.message?.content
|
|
281
|
+
// tool_result wrapper (assistant's tool output fed back)
|
|
282
|
+
if (Array.isArray(content) && content.some((b: any) => b?.type === 'tool_result')) {
|
|
283
|
+
const out: EntryView[] = []
|
|
284
|
+
for (const b of content) {
|
|
285
|
+
if (b?.type === 'tool_result') {
|
|
286
|
+
const { text, isError } = extractToolResult(b)
|
|
287
|
+
out.push({ kind: 'tool_result', summary: truncate(text, 160), isError })
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return out
|
|
291
|
+
}
|
|
292
|
+
const text = entryUserText(entry)
|
|
293
|
+
return text ? [{ kind: 'user', text }] : [{ kind: 'skip' }]
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (type === 'system') {
|
|
297
|
+
const subtype = entry.subtype
|
|
298
|
+
if (subtype === 'init') return [{ kind: 'skip' }]
|
|
299
|
+
const text = entry.content || entry.message?.content || subtype
|
|
300
|
+
return text ? [{ kind: 'system', text: truncate(typeof text === 'string' ? text : JSON.stringify(text), 160) }] : [{ kind: 'skip' }]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Codex SDK shapes (response_item) ────────────────────────────────────
|
|
304
|
+
if (type === 'response_item') {
|
|
305
|
+
const p = entry.payload
|
|
306
|
+
if (!p) return [{ kind: 'skip' }]
|
|
307
|
+
if (p.type === 'message') {
|
|
308
|
+
const text = assistantResponseText(p.content)
|
|
309
|
+
if (!text) return [{ kind: 'skip' }]
|
|
310
|
+
return [{ kind: p.role === 'user' ? 'user' : 'assistant', text }]
|
|
311
|
+
}
|
|
312
|
+
if (p.type === 'reasoning') {
|
|
313
|
+
const enc = p.encrypted_content
|
|
314
|
+
const text = typeof enc === 'string' && enc.length > 0
|
|
315
|
+
? ENCRYPTED_REASONING_LABEL
|
|
316
|
+
: (reasoningSummaryText(p) || 'reasoning')
|
|
317
|
+
return [{ kind: 'reasoning', text }]
|
|
318
|
+
}
|
|
319
|
+
if (p.type === 'function_call') {
|
|
320
|
+
let name = p.name || 'tool'
|
|
321
|
+
let input: any = p.arguments
|
|
322
|
+
if (typeof input === 'string') { try { input = JSON.parse(input) } catch { /* keep string */ } }
|
|
323
|
+
return [{ kind: 'tool_call', toolName: name, summary: summarizeToolInput(name, input) }]
|
|
324
|
+
}
|
|
325
|
+
if (p.type === 'custom_tool_call') {
|
|
326
|
+
const nested = parseCustomToolCall(p.input)
|
|
327
|
+
const name = nested?.name || p.name || 'tool'
|
|
328
|
+
const input = nested?.input
|
|
329
|
+
|| (p.input && typeof p.input === 'object' ? p.input : null)
|
|
330
|
+
|| (typeof p.input === 'string' && ['exec', 'exec_command', 'shell', 'bash'].includes(name) ? { command: p.input } : {})
|
|
331
|
+
return [{ kind: 'tool_call', toolName: name, summary: summarizeToolInput(name, input) }]
|
|
332
|
+
}
|
|
333
|
+
if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
|
|
334
|
+
const { text } = extractToolResult({ content: p.output, is_error: false })
|
|
335
|
+
return [{ kind: 'tool_result', summary: truncate(text, 160), isError: false }]
|
|
336
|
+
}
|
|
337
|
+
return [{ kind: 'skip' }]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (type === 'event_msg') {
|
|
341
|
+
const ptype = entry.payload?.type
|
|
342
|
+
if (ptype === 'error') return [{ kind: 'error', text: truncate(entry.payload?.message || '错误', 200) }]
|
|
343
|
+
return [{ kind: 'skip' }]
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return [{ kind: 'skip' }]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function toolLabel(name: string): string {
|
|
350
|
+
return TOOL_LABEL[name] ?? name
|
|
351
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobius terminal entry point.
|
|
3
|
+
*
|
|
4
|
+
* Run: npx tsx src/main.tsx (or `npm start`)
|
|
5
|
+
*
|
|
6
|
+
* exitOnCtrlC is disabled — the composer interprets Ctrl+C itself (stop the
|
|
7
|
+
* current generation while busy; quit when idle). Use /quit to exit explicitly.
|
|
8
|
+
*/
|
|
9
|
+
import React from 'react'
|
|
10
|
+
import { render } from 'ink'
|
|
11
|
+
import { App } from './App.js'
|
|
12
|
+
|
|
13
|
+
render(React.createElement(App), { exitOnCtrlC: false })
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal markdown renderer.
|
|
3
|
+
*
|
|
4
|
+
* The web frontend renders markdown with react-markdown + remark-gfm +
|
|
5
|
+
* rehype-highlight (the message content is a plain markdown string). In the
|
|
6
|
+
* terminal we instead parse the same markdown with `marked`'s lexer and render
|
|
7
|
+
* the token tree to ANSI via `chalk` (prose) and `cli-highlight` (fenced code).
|
|
8
|
+
* The resulting ANSI string is fed to an Ink <Text>; Ink strips ANSI only for
|
|
9
|
+
* layout measurement and passes the codes through to the terminal.
|
|
10
|
+
*/
|
|
11
|
+
import chalk from 'chalk'
|
|
12
|
+
import { highlight, supportsLanguage } from 'cli-highlight'
|
|
13
|
+
import { lexer, type Token, type Tokens } from 'marked'
|
|
14
|
+
|
|
15
|
+
export interface RenderedMarkdownLine {
|
|
16
|
+
text: string
|
|
17
|
+
code: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Highlight a fenced code block the same way Codex does: foreground syntax
|
|
22
|
+
* colors only, no language badge, border, fence characters, or background.
|
|
23
|
+
* Unknown and unlabelled languages stay plain instead of being guessed as bash.
|
|
24
|
+
*/
|
|
25
|
+
function renderCode(code: string, lang?: string): string {
|
|
26
|
+
const trimmed = code.replace(/\n$/, '')
|
|
27
|
+
const language = lang?.split(/[\s,]/, 1)[0]?.trim()
|
|
28
|
+
if (!language || !supportsLanguage(language)) return trimmed
|
|
29
|
+
try {
|
|
30
|
+
return highlight(trimmed, { language, ignoreIllegals: true })
|
|
31
|
+
} catch {
|
|
32
|
+
return trimmed
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderInline(tokens: Token[] | undefined): string {
|
|
37
|
+
if (!tokens || tokens.length === 0) return ''
|
|
38
|
+
return tokens.map((t) => renderInlineOne(t)).join('')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderInlineOne(t: Token): string {
|
|
42
|
+
// marked inline tokens carry their own nested `.tokens`.
|
|
43
|
+
const anyT = t as any
|
|
44
|
+
switch (t.type) {
|
|
45
|
+
case 'text':
|
|
46
|
+
return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(anyT.text)
|
|
47
|
+
case 'strong':
|
|
48
|
+
return chalk.bold(renderInline(anyT.tokens))
|
|
49
|
+
case 'em':
|
|
50
|
+
return chalk.italic(renderInline(anyT.tokens))
|
|
51
|
+
case 'del':
|
|
52
|
+
return chalk.dim.strikethrough(renderInline(anyT.tokens))
|
|
53
|
+
case 'codespan':
|
|
54
|
+
return chalk.cyanBright(anyT.text)
|
|
55
|
+
case 'link': {
|
|
56
|
+
const label = renderInline(anyT.tokens) || anyT.href
|
|
57
|
+
return anyT.href && label !== anyT.href ? `${chalk.cyan(label)} (${chalk.dim.underline(anyT.href)})` : chalk.cyan(label)
|
|
58
|
+
}
|
|
59
|
+
case 'image':
|
|
60
|
+
return chalk.magentaBright(`[图片: ${anyT.href || anyT.text}]`)
|
|
61
|
+
case 'br':
|
|
62
|
+
return '\n'
|
|
63
|
+
case 'escape':
|
|
64
|
+
case 'html':
|
|
65
|
+
return anyT.text ?? ''
|
|
66
|
+
default:
|
|
67
|
+
return anyT.text ?? renderInline(anyT.tokens)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Prevent a stray embedded reset from truncating the surrounding style span. */
|
|
72
|
+
function escapeAnsiReset(s: string): string {
|
|
73
|
+
return s
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderTable(t: Tokens.Table): string {
|
|
77
|
+
const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: toks?.text ?? '' }])
|
|
78
|
+
const header = t.header.map((h) => cell(h)).join(' | ')
|
|
79
|
+
const rows = t.rows.map((r) => r.map((c) => cell(c)).join(' | ')).join('\n')
|
|
80
|
+
return chalk.bold(header) + '\n' + chalk.dim('-'.repeat(Math.min(header.length, 80))) + '\n' + rows
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render markdown into visual lines so code can opt out of terminal wrapping. */
|
|
84
|
+
export function renderMarkdownLines(md: string): RenderedMarkdownLine[] {
|
|
85
|
+
if (!md) return []
|
|
86
|
+
const tokens = lexer(md, { gfm: true, breaks: false })
|
|
87
|
+
const out: RenderedMarkdownLine[] = []
|
|
88
|
+
for (const t of tokens) {
|
|
89
|
+
const isCode = t.type === 'code'
|
|
90
|
+
const rendered = isCode
|
|
91
|
+
? renderCode((t as any).text, (t as any).lang)
|
|
92
|
+
: renderBlock(t)
|
|
93
|
+
for (const text of rendered.split('\n')) out.push({ text, code: isCode })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Collapse repeated prose separators while preserving blank lines that are
|
|
97
|
+
// part of source code. Remove only non-code padding at the outer edges.
|
|
98
|
+
const normalized: RenderedMarkdownLine[] = []
|
|
99
|
+
for (const line of out) {
|
|
100
|
+
const previous = normalized.at(-1)
|
|
101
|
+
if (!line.code && line.text === '' && previous && !previous.code && previous.text === '') continue
|
|
102
|
+
normalized.push(line)
|
|
103
|
+
}
|
|
104
|
+
while (normalized[0] && !normalized[0].code && normalized[0].text === '') normalized.shift()
|
|
105
|
+
while (normalized.at(-1) && !normalized.at(-1)?.code && normalized.at(-1)?.text === '') normalized.pop()
|
|
106
|
+
return normalized
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Backward-compatible string projection used by non-Ink callers and tests. */
|
|
110
|
+
export function renderMarkdown(md: string): string {
|
|
111
|
+
return renderMarkdownLines(md).map(line => line.text).join('\n')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderBlock(t: Token): string {
|
|
115
|
+
const anyT = t as any
|
|
116
|
+
switch (t.type) {
|
|
117
|
+
case 'heading': {
|
|
118
|
+
const inner = renderInline(anyT.tokens)
|
|
119
|
+
return anyT.depth <= 2 ? chalk.bold.cyanBright(inner) : chalk.bold(inner)
|
|
120
|
+
}
|
|
121
|
+
case 'paragraph':
|
|
122
|
+
return renderInline(anyT.tokens)
|
|
123
|
+
case 'code': {
|
|
124
|
+
return renderCode(anyT.text, anyT.lang)
|
|
125
|
+
}
|
|
126
|
+
case 'blockquote': {
|
|
127
|
+
const inner = (anyT.tokens as Token[]).map(renderBlock).join('\n')
|
|
128
|
+
return inner.split('\n').map((l) => chalk.dim('│ ' + l)).join('\n')
|
|
129
|
+
}
|
|
130
|
+
case 'list': {
|
|
131
|
+
const items: string[] = (anyT.items as Token[]).map((it: any, i: number) => {
|
|
132
|
+
const marker = anyT.ordered ? `${(anyT.start ?? 1) + i}. ` : '• '
|
|
133
|
+
const body = renderListItemBody(it)
|
|
134
|
+
return body.split('\n').map((l, idx) => (idx === 0 ? chalk.cyan(marker) + l : ' ' + l)).join('\n')
|
|
135
|
+
})
|
|
136
|
+
return items.join('\n')
|
|
137
|
+
}
|
|
138
|
+
case 'hr':
|
|
139
|
+
return chalk.dim('─'.repeat(40))
|
|
140
|
+
case 'table':
|
|
141
|
+
return renderTable(t as Tokens.Table)
|
|
142
|
+
case 'space':
|
|
143
|
+
return ''
|
|
144
|
+
case 'html':
|
|
145
|
+
return chalk.dim(anyT.text ?? '')
|
|
146
|
+
default:
|
|
147
|
+
return anyT.text ?? renderInline(anyT.tokens)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function renderListItemBody(item: any): string {
|
|
152
|
+
// list_item token: nested block tokens (paragraph, list, text…)
|
|
153
|
+
if (item.tokens && item.tokens.length) {
|
|
154
|
+
return (item.tokens as Token[])
|
|
155
|
+
.map((tok) => (tok.type === 'text' ? renderInline((tok as any).tokens) : renderBlock(tok)))
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join('\n')
|
|
158
|
+
}
|
|
159
|
+
return item.text ?? ''
|
|
160
|
+
}
|
package/src/sse.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-Sent Events client for GET /api/sessions/:id/events.
|
|
3
|
+
*
|
|
4
|
+
* Node has no native EventSource, so we drive the same endpoint with a streaming
|
|
5
|
+
* `fetch` and parse SSE frames ourselves (the same approach the web frontend's
|
|
6
|
+
* search modal falls back to when EventSource can't carry the Authorization
|
|
7
|
+
* header). The token rides in a `?token=` query param.
|
|
8
|
+
*
|
|
9
|
+
* Frame grammar: blocks separated by a blank line; within a block, `event:` sets
|
|
10
|
+
* the name and one or more `data:` lines (joined with \n) carry the JSON payload.
|
|
11
|
+
* Lines starting with `:` are keepalive comments and are ignored.
|
|
12
|
+
*/
|
|
13
|
+
import type { AnyEntry } from './types.js'
|
|
14
|
+
|
|
15
|
+
export interface SseHandlers {
|
|
16
|
+
onOpen?: () => void
|
|
17
|
+
onSubscribed?: (session: any) => void
|
|
18
|
+
onHistoryEntries?: (entries: AnyEntry[], done: boolean) => void
|
|
19
|
+
onEntry?: (entry: AnyEntry) => void
|
|
20
|
+
onTyping?: (active: boolean) => void
|
|
21
|
+
onError?: (message: string, category?: string) => void
|
|
22
|
+
onClose?: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SseConnection {
|
|
26
|
+
private controller: AbortController | null = null
|
|
27
|
+
private closed = false
|
|
28
|
+
|
|
29
|
+
constructor(private url: string, private handlers: SseHandlers) {}
|
|
30
|
+
|
|
31
|
+
isClosed(): boolean { return this.closed }
|
|
32
|
+
|
|
33
|
+
async start(): Promise<void> {
|
|
34
|
+
this.controller = new AbortController()
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(this.url, {
|
|
37
|
+
method: 'GET',
|
|
38
|
+
headers: { Accept: 'text/event-stream' },
|
|
39
|
+
signal: this.controller.signal,
|
|
40
|
+
})
|
|
41
|
+
if (!res.ok || !res.body) {
|
|
42
|
+
this.handlers.onError?.(`SSE 连接失败 (HTTP ${res.status})`)
|
|
43
|
+
this.handlers.onClose?.()
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
this.handlers.onOpen?.()
|
|
47
|
+
const reader = res.body.getReader()
|
|
48
|
+
const decoder = new TextDecoder('utf-8')
|
|
49
|
+
let buffer = ''
|
|
50
|
+
// eslint-disable-next-line no-constant-condition
|
|
51
|
+
while (true) {
|
|
52
|
+
const { value, done } = await reader.read()
|
|
53
|
+
if (done) break
|
|
54
|
+
buffer += decoder.decode(value, { stream: true })
|
|
55
|
+
// Complete frames are separated by a blank line.
|
|
56
|
+
const frames = buffer.split(/\r?\n\r?\n/)
|
|
57
|
+
buffer = frames.pop() ?? ''
|
|
58
|
+
for (const frame of frames) this.handleFrame(frame)
|
|
59
|
+
}
|
|
60
|
+
// flush any trailing frame
|
|
61
|
+
if (buffer.trim()) this.handleFrame(buffer)
|
|
62
|
+
} catch (e: any) {
|
|
63
|
+
// AbortError = we closed it ourselves; "terminated" / socket-closed codes =
|
|
64
|
+
// the server (or a reverse proxy's idle timeout) dropped the stream mid-read.
|
|
65
|
+
// Both are expected for a long-lived SSE connection, not user-facing errors —
|
|
66
|
+
// the recursive status poll in useChat remains the source of truth, so we
|
|
67
|
+
// stay silent instead of flashing a misleading "SSE 读取错误: terminated".
|
|
68
|
+
const msg = e?.message ?? String(e)
|
|
69
|
+
const expectedClose =
|
|
70
|
+
e?.name === 'AbortError' ||
|
|
71
|
+
msg === 'terminated' ||
|
|
72
|
+
e?.code === 'UND_ERR_SOCKET' || e?.code === 'UND_ERR_CLOSED' ||
|
|
73
|
+
e?.code === 'ECONNRESET' || e?.code === 'EPIPE'
|
|
74
|
+
if (!expectedClose) this.handlers.onError?.(`SSE 读取错误: ${msg}`)
|
|
75
|
+
} finally {
|
|
76
|
+
this.closed = true
|
|
77
|
+
this.handlers.onClose?.()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
close(): void {
|
|
82
|
+
this.closed = true
|
|
83
|
+
try { this.controller?.abort() } catch { /* ignore */ }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private handleFrame(frame: string): void {
|
|
87
|
+
let eventName = 'message'
|
|
88
|
+
const dataLines: string[] = []
|
|
89
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
90
|
+
if (!line || line.startsWith(':')) continue // blank / keepalive comment
|
|
91
|
+
if (line.startsWith('event:')) {
|
|
92
|
+
eventName = line.slice(6).trim()
|
|
93
|
+
} else if (line.startsWith('data:')) {
|
|
94
|
+
dataLines.push(line.slice(5).replace(/^\s/, ''))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (dataLines.length === 0) return
|
|
98
|
+
const raw = dataLines.join('\n')
|
|
99
|
+
let payload: any
|
|
100
|
+
try { payload = JSON.parse(raw) } catch { return }
|
|
101
|
+
this.dispatch(eventName, payload)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private dispatch(eventName: string, p: any): void {
|
|
105
|
+
const ev = p?.event ?? eventName
|
|
106
|
+
switch (ev) {
|
|
107
|
+
case 'subscribed': this.handlers.onSubscribed?.(p.session); break
|
|
108
|
+
case 'jsonl_history':
|
|
109
|
+
this.handlers.onHistoryEntries?.(p.entries ?? [], !!p.done)
|
|
110
|
+
break
|
|
111
|
+
case 'jsonl_entry':
|
|
112
|
+
this.handlers.onEntry?.(p.entry)
|
|
113
|
+
break
|
|
114
|
+
case 'typing':
|
|
115
|
+
this.handlers.onTyping?.(!!p.active)
|
|
116
|
+
break
|
|
117
|
+
case 'error':
|
|
118
|
+
case 'server_error':
|
|
119
|
+
this.handlers.onError?.(p.message ?? p.error ?? '未知错误', p.category)
|
|
120
|
+
break
|
|
121
|
+
default:
|
|
122
|
+
// history / jsonl_meta / message / stream / etc. — currently unused by the TUI.
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|