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