@mobius-os/mobius 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "dependencies": {
19
19
  "chalk": "^5.3.0",
20
20
  "cli-highlight": "2.1.11",
21
+ "extract-zip": "^2.0.1",
21
22
  "ink": "5.2.0",
22
23
  "marked": "12.0.2",
23
24
  "react": "18.3.1",
package/src/aimux.ts CHANGED
@@ -6,9 +6,11 @@
6
6
  * currently authenticated Mobius server. Nothing is started before login.
7
7
  */
8
8
  import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
9
- import { promises as fs, existsSync } from 'node:fs'
9
+ import { promises as fs, existsSync, createWriteStream } from 'node:fs'
10
10
  import os from 'node:os'
11
11
  import path from 'node:path'
12
+ import { Readable } from 'node:stream'
13
+ import extract from 'extract-zip'
12
14
  import { mobiusHome } from './config.js'
13
15
 
14
16
  export type AimuxState = 'starting' | 'connected' | 'failed' | 'stopped' | 'disabled'
@@ -22,6 +24,11 @@ export interface AimuxStatus {
22
24
  }
23
25
  export interface InstallProgress { phase: 'python' | 'venv' | 'install' | 'ready'; detail?: string }
24
26
 
27
+ /** aimux 的调用方式:venv 直接执行,或用内置 python 跑 `-m aimux`(Plan B 兜底)。 */
28
+ export type AimuxLauncher =
29
+ | { kind: 'exe'; path: string }
30
+ | { kind: 'module'; python: string }
31
+
25
32
  const AIMUX_PACKAGE = 'aimux'
26
33
  const WIN = process.platform === 'win32'
27
34
  const venvDir = () => path.join(mobiusHome(), 'aimux-venv')
@@ -76,21 +83,125 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
76
83
  return findPython() ?? installPython(onProgress)
77
84
  }
78
85
 
79
- export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string }> {
80
- if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true } }
86
+ // ── Plan B: 内置 python+aimux 运行时(本地 venv/pip 失败时的离线兜底)─────────
87
+ // 一个 zip 内含完整的 python-build-standalone(自带 ensurepip+pip)+ 预装 aimux;
88
+ // 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
89
+ // 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
90
+ // 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
91
+ const BUNDLE_VER = '1'
92
+ const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
93
+ const bundlePython = () => WIN
94
+ ? path.join(bundleDir(), 'python', 'python.exe')
95
+ : path.join(bundleDir(), 'python', 'bin', 'python3')
96
+
97
+ /** 当前平台对应的内置运行时包名;mac-arm64 走 mac-x64(Rosetta 2)。 */
98
+ export function bundleArch(): string | null {
99
+ const { platform, arch } = process
100
+ if (platform === 'linux' && arch === 'x64') return 'linux-x64'
101
+ if (platform === 'win32' && arch === 'x64') return 'win-x64'
102
+ if (platform === 'darwin' && (arch === 'x64' || arch === 'arm64')) return 'mac-x64'
103
+ return null
104
+ }
105
+
106
+ /** CDN 基址可用 MOBIUS_TUI_PYTHON_BUNDLE_URL 覆盖;文件名固定为 mobius-python-<arch>-v<N>.zip。 */
107
+ export const bundleBaseUrl = () => (process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL || 'https://serve.nutshellai.cn/publish/auto/mobius-tui').replace(/\/$/, '')
108
+ export const bundleUrl = (arch: string) => `${bundleBaseUrl()}/mobius-python-${arch}-v${BUNDLE_VER}.zip`
109
+
110
+ function bundleReady(): boolean {
111
+ return existsSync(bundlePython()) && spawnSync(bundlePython(), ['-c', 'import aimux'], { stdio: 'ignore', windowsHide: true }).status === 0
112
+ }
113
+
114
+ async function downloadBundle(arch: string, onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; zipPath?: string }> {
115
+ const url = bundleUrl(arch)
116
+ const zipPath = path.join(mobiusHome(), `python-bundle-v${BUNDLE_VER}.zip.tmp`)
117
+ await fs.mkdir(path.dirname(zipPath), { recursive: true }) // 首次安装 ~/.mobius 可能尚未创建
118
+ let res: Response
119
+ try { res = await fetch(url) } catch (e: any) { return { ok: false, error: `下载失败: ${e?.message ?? e}` } }
120
+ if (!res.ok) return { ok: false, error: `下载失败: HTTP ${res.status} (${url})` }
121
+ const total = Number(res.headers.get('content-length') || 0)
122
+ const ws = createWriteStream(zipPath)
123
+ let got = 0, last = 0
124
+ try {
125
+ const stream = Readable.fromWeb(res.body as any)
126
+ for await (const chunk of stream) {
127
+ ws.write(chunk as Buffer)
128
+ got += (chunk as Buffer).length
129
+ if (total && got - last > total * 0.03) { last = got; onProgress?.({ phase: 'install', detail: `下载内置运行时 ${Math.round((got / total) * 100)}%` }) }
130
+ }
131
+ if (!total && got) onProgress?.({ phase: 'install', detail: `下载内置运行时 ${(got / 1048576).toFixed(0)}MB` })
132
+ await new Promise<void>((resolve, reject) => { ws.end(() => resolve()); ws.on('error', reject) })
133
+ } catch (e: any) {
134
+ try { ws.destroy() } catch {}
135
+ try { await fs.unlink(zipPath) } catch {}
136
+ return { ok: false, error: `下载失败: ${e?.message ?? e}` }
137
+ }
138
+ return { ok: true, zipPath }
139
+ }
140
+
141
+ async function extractBundle(zipPath: string): Promise<{ ok: boolean; error?: string }> {
142
+ const staging = path.join(mobiusHome(), 'python-bundle.new')
143
+ const finalDir = bundleDir()
144
+ const stagingPython = WIN ? path.join(staging, 'python', 'python.exe') : path.join(staging, 'python', 'bin', 'python3')
145
+ await fs.rm(staging, { recursive: true, force: true }).catch(() => {})
146
+ await fs.mkdir(staging, { recursive: true })
147
+ try { await extract(zipPath, { dir: staging, defaultDirMode: 0o755, defaultFileMode: 0o644 }) }
148
+ catch (e: any) { await fs.rm(staging, { recursive: true, force: true }).catch(() => {}); return { ok: false, error: `解压失败: ${e?.message ?? e}` } }
149
+ if (!WIN) try { await fs.chmod(stagingPython, 0o755) } catch {} // 保险:确保可执行位(extract-zip 通常已还原)
150
+ await fs.rm(finalDir, { recursive: true, force: true }).catch(() => {})
151
+ await fs.rename(staging, finalDir)
152
+ return { ok: true }
153
+ }
154
+
155
+ export async function ensureFromBundle(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
156
+ if (bundleReady()) return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
157
+ const arch = bundleArch()
158
+ if (!arch) return { ok: false, error: `当前平台无内置运行时 (platform=${process.platform} arch=${process.arch})` }
159
+ onProgress?.({ phase: 'install', detail: `下载内置运行时 (${arch})…` })
160
+ const dl = await downloadBundle(arch, onProgress)
161
+ if (!dl.ok || !dl.zipPath) return { ok: false, error: dl.error }
162
+ onProgress?.({ phase: 'install', detail: '解压内置运行时…' })
163
+ const ex = await extractBundle(dl.zipPath)
164
+ try { await fs.unlink(dl.zipPath) } catch {}
165
+ if (!ex.ok) return { ok: false, error: ex.error }
166
+ if (!bundleReady()) return { ok: false, error: '内置运行时解压后仍无法 import aimux' }
167
+ return { ok: true, launcher: { kind: 'module', python: bundlePython() } }
168
+ }
169
+
170
+ /** 按 launcher 把 aimux 参数变成实际 spawn。 */
171
+ export function spawnLauncher(launcher: AimuxLauncher, args: string[]): ChildProcess {
172
+ return launcher.kind === 'exe'
173
+ ? spawn(launcher.path, args, { windowsHide: true })
174
+ : spawn(launcher.python, ['-m', 'aimux', ...args], { windowsHide: true })
175
+ }
176
+
177
+ /** test-only 导出: 暴露内部 downloadBundle 以便单测 mock fetch 验证流式下载+进度。 */
178
+ export const downloadBundleForTest = downloadBundle
179
+
180
+ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Promise<{ ok: boolean; error?: string; launcher?: AimuxLauncher }> {
181
+ // Fast-path:venv 里已有 aimux 可执行 → 直接用。
182
+ if (existsSync(aimuxExe()) && existsSync(venvPython())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
81
183
  const py = await pythonForAimux(onProgress)
82
- if (!py) return { ok: false, error: '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。' }
83
- onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
84
- let r = await run(py, ['-m', 'venv', venvDir()])
85
- if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
86
- if (r.code !== 0) return { ok: false, error: `venv 创建失败: ${r.stderr || r.stdout}` }
87
- onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
88
- r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
89
- if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
90
- })
91
- if (r.code !== 0) return { ok: false, error: `pip install 失败: ${r.stderr || r.stdout}` }
92
- if (!existsSync(aimuxExe())) return { ok: false, error: `aimux 可执行未生成: ${aimuxExe()}` }
93
- onProgress?.({ phase: 'ready' }); return { ok: true }
184
+ let venvError = '未找到 Python。请先安装 Python 3.10+(或安装 uv 后重试)。'
185
+ if (py) {
186
+ onProgress?.({ phase: 'venv', detail: `创建 Python 虚拟环境(${py})…` })
187
+ let r = await run(py, ['-m', 'venv', venvDir()])
188
+ if (r.code !== 0 && py === 'py') r = await run(py, ['-3', '-m', 'venv', venvDir()])
189
+ if (r.code === 0) {
190
+ onProgress?.({ phase: 'install', detail: `下载并安装 ${AIMUX_PACKAGE}…` })
191
+ r = await run(venvPython(), ['-m', 'pip', 'install', '--no-input', '--disable-pip-version-check', AIMUX_PACKAGE], line => {
192
+ if (/downloading|collecting|installing|using cached|%\s*\d|━|─/i.test(line)) onProgress?.({ phase: 'install', detail: line.slice(0, 120) })
193
+ })
194
+ if (r.code === 0 && existsSync(aimuxExe())) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: { kind: 'exe', path: aimuxExe() } } }
195
+ venvError = r.code === 0 ? `aimux 可执行未生成: ${aimuxExe()}` : `pip install 失败: ${r.stderr || r.stdout}`
196
+ } else {
197
+ venvError = `venv 创建失败: ${r.stderr || r.stdout}`
198
+ }
199
+ }
200
+ // ── Plan B 兜底:本地 python/venv 不可用 → 下载内置 python+aimux 运行时 ──
201
+ onProgress?.({ phase: 'install', detail: '本地 Python 不可用,改用内置运行时…' })
202
+ const bundle = await ensureFromBundle(onProgress)
203
+ if (bundle.ok && bundle.launcher) { onProgress?.({ phase: 'ready' }); return { ok: true, launcher: bundle.launcher } }
204
+ return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
94
205
  }
95
206
 
96
207
  export function tuiAimuxIdentifier(): string {
@@ -281,8 +392,13 @@ export async function startAimuxConnection(opts: { server: string; token: string
281
392
  phase: p.phase === 'ready' ? 'connecting' : p.phase,
282
393
  detail: p.detail || (p.phase === 'ready' ? 'AIMUX 已就绪,准备连接…' : p.phase),
283
394
  }))
284
- if (!ready.ok) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
285
- supervisor = new AimuxSupervisor({ server: opts.server, token: opts.token, identifier: tuiAimuxIdentifier(), onStatus })
395
+ if (!ready.ok || !ready.launcher) { onStatus({ state: 'failed', phase: 'idle', detail: ready.error }); return }
396
+ const identifier = tuiAimuxIdentifier()
397
+ const launcher = ready.launcher
398
+ supervisor = new AimuxSupervisor({
399
+ server: opts.server, token: opts.token, identifier, onStatus,
400
+ spawnProcess: () => spawnLauncher(launcher, ['reverse', 'connect', `${opts.server.replace(/\/$/, '')}/aimux_bridge`, '--identifier', identifier, '--token', opts.token, '--replace']),
401
+ })
286
402
  supervisor.start()
287
403
  })().finally(() => { installing = null })
288
404
  await installing
@@ -8,11 +8,11 @@
8
8
  * activity, composer, and a persistent context status line.
9
9
  */
10
10
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
11
- import { Box, Text, useInput, useStdout } from 'ink'
11
+ import { Box, Static, Text, useInput, useStdout } from 'ink'
12
12
  import { useChat } from '../hooks/useChat.js'
13
13
  import { MobiusClient } from '../api.js'
14
14
  import { renderMarkdownLines } from '../markdown.js'
15
- import { viewsForEntry, toolLabel, type EntryView } from '../lib/entry-view.js'
15
+ import { viewsForEntry, dedupeUserEntries, toolLabel, type EntryView } from '../lib/entry-view.js'
16
16
  import type { ReadyState } from './PrepScreen.js'
17
17
  import type { AnyEntry } from '../types.js'
18
18
  import type { AimuxStatus } from '../aimux.js'
@@ -36,7 +36,6 @@ interface TerminalSize {
36
36
  }
37
37
 
38
38
  const VERSION = '0.2.8'
39
- const CHROME_ROWS = 11
40
39
 
41
40
  const SLASH_COMMANDS = [
42
41
  { cmd: '/clear', desc: '清空当前对话,开启新会话' },
@@ -48,7 +47,6 @@ const SLASH_COMMANDS = [
48
47
  export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, aimuxStatus }: ChatProps) {
49
48
  const chat = useChat({ client, ready, resumeSessionId })
50
49
  const [showHelp, setShowHelp] = useState(false)
51
- const [scrollBack, setScrollBack] = useState(0)
52
50
  const [modelLabel, setModelLabel] = useState<string | null>(null)
53
51
  const terminal = useTerminalSize()
54
52
 
@@ -71,35 +69,19 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
71
69
  return
72
70
  }
73
71
  setShowHelp(false)
74
- setScrollBack(0)
75
72
  void chat.send(t)
76
73
  }, [chat, runSlash])
77
74
 
78
- const transcriptRows = Math.max(5, terminal.rows - CHROME_ROWS)
79
- const fitted = useMemo(
80
- () => fitTranscript(chat.entries, transcriptRows, terminal.columns, scrollBack),
81
- [chat.entries, transcriptRows, terminal.columns, scrollBack],
82
- )
83
- // Welcome card is for a truly fresh session only. Once there's any
84
- // conversation (or an in-flight user message) it disappears, handing the
85
- // compact header + bottom-anchored transcript the full height. Keeping it
86
- // while chatting left recent messages stranded mid-screen with a blank gap
87
- // above the composer.
88
- const showWelcome = chat.entries.length === 0 && chat.pendingUser === null && scrollBack === 0
89
-
90
- // In-app history pager. Ink redraws only the live frame, so the terminal's
91
- // own scrollback holds no past turns — older entries are unreachable unless we
92
- // page through them here. PageUp/PageDown move the viewport back/forward over
93
- // the transcript. While reading history (scrollBack > 0) we keep the view
94
- // pinned as new entries stream in; sending a message (onSubmit above) snaps
95
- // back to the latest so the conversation auto-follows again.
96
- const prevLenRef = useRef(chat.entries.length)
97
- useEffect(() => {
98
- const prev = prevLenRef.current
99
- const cur = chat.entries.length
100
- prevLenRef.current = cur
101
- if (cur > prev && scrollBack > 0) setScrollBack(s => s + (cur - prev))
102
- }, [chat.entries.length, scrollBack])
75
+ // Welcome card is for a truly fresh session only — once there's any
76
+ // conversation (or an in-flight message) it disappears. The transcript then
77
+ // streams into <Static> below and accumulates into the terminal scrollback
78
+ // (the terminal's own scrollback holds history; no in-app pager needed).
79
+ const showWelcome = chat.entries.length === 0 && chat.pendingUser === null
80
+
81
+ // 用户输入去重 (对齐 web viewer/rounds.ts buildRounds): codex 同一提问的 3 形态
82
+ // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
83
+ // 避免在累积视图里把同一条提问显示多次.
84
+ const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
103
85
 
104
86
  // Show the model's friendly label (e.g. "GPT-5.6-Sol") in the header/status
105
87
  // instead of its opaque key (e.g. "codex:mobiusdefaultaabb").
@@ -114,50 +96,30 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
114
96
  }, [client, ready.prefs.model])
115
97
  const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
116
98
 
117
- useInput((_input, key) => {
118
- // The composer ignores pageUp/pageDown, so binding them here can't clash
119
- // with text entry, history navigation, or the slash-command popup.
120
- const step = Math.max(1, fitted.entries.length)
121
- if (key.pageUp) setScrollBack(s => Math.min(chat.entries.length, s + step))
122
- else if (key.pageDown) setScrollBack(s => Math.max(0, s - step))
123
- })
124
-
125
99
  return (
126
- <Box
127
- flexDirection="column"
128
- width={terminal.isTty ? terminal.columns : undefined}
129
- height={terminal.isTty ? Math.max(16, terminal.rows - 1) : undefined}
130
- paddingX={1}
131
- overflowY="hidden"
132
- >
133
- <Box flexDirection="column" flexGrow={1} overflowY="hidden">
134
- {showWelcome
135
- ? <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
136
- : <CompactHeader ready={ready} sessionId={chat.sessionId} />}
137
-
138
- <Box flexGrow={1} flexDirection="column" justifyContent={showWelcome ? 'flex-start' : 'flex-end'} overflowY="hidden">
139
- {fitted.hiddenOlder > 0 || scrollBack > 0
140
- ? <Text dimColor> ↑ {fitted.hiddenOlder > 0 ? `还有 ${fitted.hiddenOlder} 条较早记录 · PageUp 向上翻页` : '已到最早记录 · PageDown 向下翻页'}</Text>
141
- : null}
142
- {fitted.entries.map((entry, index) => (
143
- <EntryBlock key={entry.__id ?? `entry-${index}`} entry={entry} />
144
- ))}
145
- {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
146
- {fitted.hiddenRecent > 0
147
- ? <Text dimColor> ↓ PageDown 向下翻页 · 较新 {fitted.hiddenRecent} 条</Text>
148
- : null}
149
- </Box>
150
-
151
- {chat.entries.length === 0 && chat.pendingUser === null && !showHelp
152
- ? <Box marginTop={1}><Text dimColor>输入问题开始协作,或输入 <Text color="cyan">/</Text> 查看命令。</Text></Box>
153
- : null}
100
+ <Box flexDirection="column" width={terminal.isTty ? terminal.columns : undefined} paddingX={1}>
101
+ {showWelcome ? (
102
+ <WelcomeCard ready={ready} columns={terminal.columns} resumed={Boolean(resumeSessionId)} modelDisplay={modelDisplay} />
103
+ ) : null}
154
104
 
155
- {showHelp ? <HelpBlock commands={SLASH_COMMANDS} /> : null}
156
- </Box>
105
+ {/* <Static> 累积输出: 每个 entry 永久打印进终端 scrollback, 不参与动态重绘.
106
+ 新 entry 只追加打印, 历史靠终端自身滚动, 不再需要 in-app 翻页/视窗裁剪. */}
107
+ <Static items={dedupedEntries}>
108
+ {(entry, index) => (
109
+ <EntryAccum key={entry.__id ?? `e${index}`} entry={entry} columns={terminal.columns} />
110
+ )}
111
+ </Static>
157
112
 
113
+ {chat.pendingUser !== null ? <UserLine text={chat.pendingUser} /> : null}
158
114
  {chat.typing ? <WorkingIndicator /> : null}
159
115
  {chat.error ? <Text color="red">⚠ {chat.error}</Text> : null}
160
116
 
117
+ {chat.entries.length === 0 && chat.pendingUser === null && !showHelp
118
+ ? <Box marginTop={1}><Text dimColor>输入问题开始协作,或输入 <Text color="cyan">/</Text> 查看命令。</Text></Box>
119
+ : null}
120
+
121
+ {showHelp ? <HelpBlock commands={SLASH_COMMANDS} /> : null}
122
+
161
123
  <Composer
162
124
  onSubmit={onSubmit}
163
125
  onStop={chat.stop}
@@ -230,25 +192,17 @@ function MetaRow({ label, value, hint, labelWidth }: { label: string; value: str
230
192
  )
231
193
  }
232
194
 
233
- function CompactHeader({ ready, sessionId }: { ready: ReadyState; sessionId: string | null }) {
234
- return (
235
- <Box justifyContent="space-between">
236
- <Text bold><Text dimColor>{'>_ '}</Text>Mobius</Text>
237
- <Text dimColor>{ready.project.name} › {ready.issue.title}{sessionId ? ` · ${sessionId.slice(0, 8)}` : ''}</Text>
238
- </Box>
239
- )
240
- }
241
-
242
- function EntryBlock({ entry }: { entry: AnyEntry }) {
195
+ function EntryAccum({ entry, columns }: { entry: AnyEntry; columns: number }) {
243
196
  const views = viewsForEntry(entry)
244
197
  return (
245
198
  <Box flexDirection="column">
246
- {views.map((view, index) => <ViewLine key={index} view={view} />)}
199
+ {views.map((view, index) => <ViewLine key={index} view={view} columns={columns} />)}
247
200
  </Box>
248
201
  )
249
202
  }
250
203
 
251
- function ViewLine({ view }: { view: EntryView }) {
204
+ function ViewLine({ view, columns }: { view: EntryView; columns: number }) {
205
+ const width = Math.max(20, columns - 4)
252
206
  switch (view.kind) {
253
207
  case 'skip':
254
208
  return null
@@ -266,30 +220,124 @@ function ViewLine({ view }: { view: EntryView }) {
266
220
  </Box>
267
221
  )
268
222
  }
269
- case 'tool_call':
223
+ case 'tool_call': {
224
+ // compact (≤2 行): 命令行 + 可选结果行 (已与 tool_result 合并).
225
+ const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
270
226
  return (
271
- <Text>
272
- <Text color="cyan">• {toolLabel(view.toolName)}</Text>
273
- {view.summary ? <Text dimColor> {view.summary}</Text> : null}
274
- </Text>
227
+ <Box marginTop={1} flexDirection="column">
228
+ <Text color="cyan">• {head}</Text>
229
+ {view.result ? (
230
+ <Text dimColor color={view.result.isError ? 'red' : undefined}>
231
+ {' └ '}{clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}
232
+ </Text>
233
+ ) : null}
234
+ </Box>
275
235
  )
276
- case 'tool_result':
236
+ }
237
+ case 'tool_result': {
238
+ // codex 式 head+ellipsis+tail (output_max_lines=5): tool 结果保留头尾,
239
+ // 中间省略行数; DIM 样式 + └/缩进前缀 (对齐 codex exec_cell/render.rs).
240
+ const lines = headTailLines(view.text, width - 4, 5)
277
241
  return (
278
- <Text dimColor color={view.isError ? 'red' : undefined}>
279
- {' └ '}{view.summary || '(无输出)'}
280
- </Text>
242
+ <Box flexDirection="column">
243
+ {lines.map((l, i) => (
244
+ <Text key={i} dimColor color={view.isError ? 'red' : undefined}>{i === 0 ? ' └ ' : ' '}{l}</Text>
245
+ ))}
246
+ </Box>
247
+ )
248
+ }
249
+ case 'code_edit':
250
+ return <CodeEditView view={view} />
251
+ case 'write_file':
252
+ return <WriteFileView view={view} />
253
+ case 'reasoning': {
254
+ const lines = clampLines(view.text, width - 4, 2)
255
+ return (
256
+ <Box marginTop={1} flexDirection="column">
257
+ {lines.map((line, i) => (
258
+ <Text key={i} dimColor color="magenta">{i === 0 ? ' ◇ ' : ' '}{line}</Text>
259
+ ))}
260
+ </Box>
281
261
  )
282
- case 'reasoning':
283
- return <Text dimColor color="magenta"> ◇ {view.text}</Text>
262
+ }
284
263
  case 'system':
285
- return <Text dimColor color="yellow"> {view.text}</Text>
264
+ return <Text dimColor color="yellow"> {clampLines(view.text, width - 2, 2)[0]}</Text>
286
265
  case 'error':
287
- return <Text color="red">⚠ {view.text}</Text>
266
+ return (
267
+ <Box marginTop={1} flexDirection="column">
268
+ {view.text.split('\n').map((line, i) => (
269
+ <Text key={i} color="red">{i === 0 ? '⚠ ' : ' '}{line}</Text>
270
+ ))}
271
+ </Box>
272
+ )
288
273
  default:
289
274
  return null
290
275
  }
291
276
  }
292
277
 
278
+ // 代码修改 (Edit/StrReplace/apply_patch) — full: 完整展示 old(−)/new(+) 改动原文.
279
+ function CodeEditView({ view }: { view: { filePath: string; oldString: string; newString: string } }) {
280
+ return (
281
+ <Box marginTop={1} flexDirection="column">
282
+ <Text color="magenta">✎ 编辑 {view.filePath || '(未指定文件)'}</Text>
283
+ {view.oldString ? view.oldString.split('\n').map((line, i) => (
284
+ <Text key={`o${i}`} color="red">{' − '}{line}</Text>
285
+ )) : null}
286
+ {view.newString ? view.newString.split('\n').map((line, i) => (
287
+ <Text key={`n${i}`} color="green">{' + '}{line}</Text>
288
+ )) : null}
289
+ </Box>
290
+ )
291
+ }
292
+
293
+ // 文件写入 (Write/create_file) — full: 完整展示写入内容原文.
294
+ function WriteFileView({ view }: { view: { filePath: string; content: string } }) {
295
+ return (
296
+ <Box marginTop={1} flexDirection="column">
297
+ <Text color="magenta">✎ 写入 {view.filePath || '(未指定文件)'}</Text>
298
+ {view.content.split('\n').map((line, i) => (
299
+ <Text key={i} color="green">{' + '}{line}</Text>
300
+ ))}
301
+ </Box>
302
+ )
303
+ }
304
+
305
+ // 把文本按宽度硬切成最多 maxLines 行 (超出则在末行加 …), 用于 compact 类的 ≤2 行硬约束.
306
+ function clampLines(text: string, width: number, maxLines: number): string[] {
307
+ if (!text) return ['']
308
+ const paras = text.replace(/\r\n/g, '\n').split('\n')
309
+ const wrapped: string[] = []
310
+ for (const para of paras) {
311
+ if (para === '') { wrapped.push(''); continue }
312
+ for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
313
+ }
314
+ if (wrapped.length <= maxLines) return wrapped
315
+ const trimmed = wrapped.slice(0, maxLines)
316
+ const last = trimmed[maxLines - 1]
317
+ trimmed[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + '…' : last + '…'
318
+ return trimmed
319
+ }
320
+
321
+ // codex 式 head + ellipsis + tail 截断 (参考 codex-rs/tui/src/exec_cell/render.rs
322
+ // 的 truncate_lines_middle): 保留输出头尾, 中间省略并报告省略行数. 长输出既能看
323
+ // 到结论 (成功/失败常在尾), 又不刷屏. maxLines 含省略行 (如 5 = 头2 + 省1 + 尾2).
324
+ function headTailLines(text: string, width: number, maxLines: number): string[] {
325
+ if (!text) return ['']
326
+ const paras = text.replace(/\r\n/g, '\n').split('\n')
327
+ const wrapped: string[] = []
328
+ for (const para of paras) {
329
+ if (para === '') { wrapped.push(''); continue }
330
+ for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
331
+ }
332
+ if (wrapped.length <= maxLines) return wrapped.slice(0, maxLines)
333
+ const budget = maxLines - 1 // 留 1 行给省略标记
334
+ const head = Math.max(1, Math.ceil(budget / 2))
335
+ const tail = Math.max(1, budget - head)
336
+ const omitted = wrapped.length - head - tail
337
+ if (omitted <= 0) return wrapped.slice(0, maxLines)
338
+ return [...wrapped.slice(0, head), `… +${omitted} 行`, ...wrapped.slice(wrapped.length - tail)]
339
+ }
340
+
293
341
  function UserLine({ text }: { text: string }) {
294
342
  const lines = text.split('\n')
295
343
  if (lines[0] !== undefined) lines[0] = `› ${lines[0]}`
@@ -535,43 +583,5 @@ function clickableUrl(url: string): string {
535
583
  return `\u001B]8;;${url}\u0007${url}\u001B]8;;\u0007`
536
584
  }
537
585
 
538
- function wrappedRows(text: string, columns: number): number {
539
- const width = Math.max(20, columns - 6)
540
- return text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / width)), 0)
541
- }
542
-
543
- function entryRows(entry: AnyEntry, columns: number): number {
544
- return viewsForEntry(entry).reduce((sum, view) => {
545
- if (view.kind === 'skip') return sum
546
- if (view.kind === 'tool_call') return sum + wrappedRows(`${toolLabel(view.toolName)} ${view.summary}`, columns)
547
- if (view.kind === 'tool_result') return sum + wrappedRows(view.summary, columns)
548
- return sum + wrappedRows(view.text, columns) + (view.kind === 'user' || view.kind === 'assistant' ? 1 : 0)
549
- }, 0)
550
- }
551
-
552
- function fitTranscript(entries: AnyEntry[], rowBudget: number, columns: number, scrollBack = 0): {
553
- entries: AnyEntry[]
554
- hiddenOlder: number
555
- hiddenRecent: number
556
- estimatedRows: number
557
- } {
558
- // `scrollBack` = how many of the most-recent entries are paged out of view
559
- // below the viewport (the user pressed PageUp). The visible window is then
560
- // fit from the tail of what remains, backward, until the row budget is full.
561
- const tail = Math.max(0, entries.length - scrollBack)
562
- const avail = tail === 0 ? [] : entries.slice(0, tail)
563
- let rows = 0
564
- let first = avail.length
565
- for (let index = avail.length - 1; index >= 0; index--) {
566
- const nextRows = entryRows(avail[index], columns)
567
- if (first < avail.length && rows + nextRows > rowBudget) break
568
- rows += nextRows
569
- first = index
570
- }
571
- return {
572
- entries: avail.slice(first),
573
- hiddenOlder: first, // entries older than the viewport
574
- hiddenRecent: entries.length - tail, // == scrollBack: entries newer than the viewport
575
- estimatedRows: rows,
576
- }
577
- }
586
+ // (fitTranscript / blockRows / wrappedRows 视窗裁剪 + in-app 翻页逻辑已移除:
587
+ // transcript 现由 <Static> 累积进终端 scrollback, 历史靠终端自身滚动.)
@@ -1,28 +1,53 @@
1
1
  /**
2
- * jsonl entry → renderable view.
2
+ * jsonl entry → renderable view (TUI).
3
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).
4
+ * 对齐 mobius web viewer (frontend/src/components/viewer/) 的过滤/合并/折叠思想,
5
+ * 但适配终端线性渲染 (无卡片点击展开):
6
+ * - 隐藏: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 7 类噪声.
7
+ * - 合并: tool_use + tool_result call_id 配对成一个 block (借鉴 web
8
+ * mergeBashToolResultItems, 简化版), 命令与结果不再分两行.
9
+ * - 折叠/展开分流: web 用卡片 open state + 点击展开; TUI 改成 —— web 默认"折叠"
10
+ * 的卡 (普通命令/Read/reasoning/system) 压成 ≤2 行摘要 (见 Chat.tsx clampLines),
11
+ * "展开"的卡 (assistant 文本/代码修改/error) 完整展示.
12
+ * - 不引入 web entry-extract.ts 的重机器 (diff/plan 卡片渲染); 代码修改只完整
13
+ * 显示 old_str→new_str / content 原文, 不渲染红绿 diff.
14
+ *
15
+ * 同时处理 Claude SDK entry 形态 (type:'user'|'assistant'|'system', message.content[])
16
+ * 与两种 Codex SDK 形态 (function_call/function_call_output 和
17
+ * custom_tool_call/custom_tool_call_output).
12
18
  */
13
19
  import type { AnyEntry } from '../types.js'
14
20
 
21
+ // compact (≤2 行): tool_call / reasoning / system
22
+ // full (完整): user / assistant / code_edit / write_file / error
15
23
  export type EntryView =
16
24
  | { kind: 'skip' }
17
25
  | { kind: 'user'; text: string }
18
26
  | { kind: 'assistant'; text: string }
19
27
  | { kind: 'reasoning'; text: string }
20
- | { kind: 'tool_call'; toolName: string; summary: string }
21
- | { kind: 'tool_result'; summary: string; isError: boolean }
28
+ | { kind: 'tool_call'; toolName: string; summary: string; result?: ToolResultView }
29
+ | { kind: 'tool_result'; text: string; isError: boolean }
30
+ | { kind: 'code_edit'; filePath: string; oldString: string; newString: string }
31
+ | { kind: 'write_file'; filePath: string; content: string }
22
32
  | { kind: 'system'; text: string }
23
33
  | { kind: 'error'; text: string }
24
34
 
25
- // ── copied verbatim from frontend entry-classify.ts ──────────────────────────
35
+ export interface ToolResultView {
36
+ text: string
37
+ isError: boolean
38
+ }
39
+
40
+ /**
41
+ * 配对合并后的渲染单元. mergeToolCalls 把 entries 序列合并:
42
+ * - tool_use entry → 'tool' block, 带上按 call_id 配对到的 result.
43
+ * - 纯 tool_result entry (内容已并入发起方) → 丢弃.
44
+ * - 其余 entry → 'entry' block.
45
+ */
46
+ export type Block =
47
+ | { kind: 'entry'; entry: AnyEntry }
48
+ | { kind: 'tool'; entry: AnyEntry; results: Map<string, ToolResultView> }
49
+
50
+ // ── copied verbatim from frontend entry-classify.ts (文本抽取) ──────────────
26
51
  export function assistantResponseText(content: any): string {
27
52
  if (typeof content === 'string') return content
28
53
  if (!Array.isArray(content)) return ''
@@ -62,13 +87,24 @@ function entryUserText(entry: AnyEntry): string {
62
87
 
63
88
  const ENV_CONTEXT_RE = /<environment_context\b[^>]*>[\s\S]*?<\/environment_context>/gi
64
89
 
65
- // Noise entries that carry no conversational value (system injections / metadata).
90
+ /**
91
+ * 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的 7 类
92
+ * - token_count : codex 每轮 token 用量统计 (event_msg)
93
+ * - environment_context : codex 每轮注入的 <environment_context> 纯系统 user 消息
94
+ * - session_meta : codex 会话首条元数据
95
+ * - turn_context : codex 每轮注入的本轮上下文元数据
96
+ * - turn_duration : Claude Code 每轮结束注入的 system 耗时统计
97
+ * - skill_listing : Claude Code 注入的可用 Skill 清单
98
+ * - agent_listing_delta : Claude Code 注入的可用 subagent 清单
99
+ * 注: context_compacted 不在此列 (对齐 web — 它保留为可见事件, TUI 显示成 system 行).
100
+ */
66
101
  export function isHiddenNoise(entry: AnyEntry): boolean {
67
- if (entry?.type === 'event_msg' && (entry?.payload?.type === 'token_count' || entry?.payload?.type === 'context_compacted')) return true
102
+ if (entry?.type === 'event_msg' && entry?.payload?.type === 'token_count') return true
68
103
  if (entry?.type === 'session_meta') return true
104
+ if (entry?.type === 'turn_context') return true
69
105
  if (entry?.type === 'system' && entry?.subtype === 'turn_duration') return true
70
106
  if (entry?.type === 'attachment' && (entry?.attachment?.type === 'skill_listing' || entry?.attachment?.type === 'agent_listing_delta')) return true
71
- // pure <environment_context> injection
107
+ // <environment_context> 注入: 剥掉后无任何人类提问文本才隐藏.
72
108
  const t = entryUserText(entry)
73
109
  if (t) {
74
110
  const stripped = t.replace(ENV_CONTEXT_RE, '')
@@ -77,7 +113,176 @@ export function isHiddenNoise(entry: AnyEntry): boolean {
77
113
  return false
78
114
  }
79
115
 
80
- // ── TUI-side projection ──────────────────────────────────────────────────────
116
+ // ── 字段提取 (轻量版, 不引入 web entry-extract.ts 重机器) ───────────────────
117
+ const EDIT_NAMES = ['Edit', 'edit_file', 'StrReplace', 'str_replace', 'apply_patch']
118
+ const WRITE_NAMES = ['Write', 'write_file', 'create_file']
119
+
120
+ function pickString(input: any, keys: string[]): string {
121
+ if (!input || typeof input !== 'object') return ''
122
+ for (const k of keys) {
123
+ const v = input[k]
124
+ if (typeof v === 'string') return v
125
+ }
126
+ return ''
127
+ }
128
+
129
+ /** Edit 代码修改: Claude tool_use.old_string/new_string, 或 Codex patch_apply_end.unified_diff. */
130
+ function extractEdit(entry: AnyEntry): { filePath: string; oldString: string; newString: string } | null {
131
+ if (entry?.type === 'assistant') {
132
+ const c = entry?.message?.content
133
+ if (Array.isArray(c)) {
134
+ for (const b of c) {
135
+ if (b?.type !== 'tool_use') continue
136
+ const name = typeof b.name === 'string' ? b.name : ''
137
+ if (!EDIT_NAMES.includes(name)) continue
138
+ const input = b.input && typeof b.input === 'object' ? b.input : {}
139
+ const oldS = pickString(input, ['old_string', 'old_str'])
140
+ const newS = pickString(input, ['new_string', 'new_str'])
141
+ const fp = pickString(input, ['file_path', 'path'])
142
+ if (oldS || newS) return { filePath: fp, oldString: oldS, newString: newS }
143
+ }
144
+ }
145
+ }
146
+ // Codex: event_msg patch_apply_end.changes[path].unified_diff (作为 newString 完整展示).
147
+ if (entry?.type === 'event_msg' && entry?.payload?.type === 'patch_apply_end') {
148
+ const changes = entry?.payload?.changes
149
+ if (changes && typeof changes === 'object' && !Array.isArray(changes)) {
150
+ for (const [fp, ch] of Object.entries(changes as any)) {
151
+ const diff = (ch as any)?.unified_diff
152
+ if (typeof diff === 'string' && diff.trim()) return { filePath: fp, oldString: '', newString: diff }
153
+ }
154
+ }
155
+ }
156
+ return null
157
+ }
158
+
159
+ /** Write 文件写入: tool_use.file_path + content (Claude / Codex). */
160
+ function extractWrite(entry: AnyEntry): { filePath: string; content: string } | null {
161
+ const fromInput = (input: any): { filePath: string; content: string } | null => {
162
+ const fp = pickString(input, ['file_path', 'path', 'filePath'])
163
+ const content = pickString(input, ['content'])
164
+ if (!fp || !content) return null
165
+ return { filePath: fp, content }
166
+ }
167
+ if (entry?.type === 'assistant') {
168
+ const c = entry?.message?.content
169
+ if (Array.isArray(c)) {
170
+ for (const b of c) {
171
+ if (b?.type !== 'tool_use') continue
172
+ const name = typeof b.name === 'string' ? b.name : ''
173
+ if (!WRITE_NAMES.includes(name)) continue
174
+ const r = fromInput(b.input)
175
+ if (r) return r
176
+ }
177
+ }
178
+ }
179
+ if (entry?.type === 'response_item') {
180
+ const p = entry.payload
181
+ if ((p?.type === 'function_call' || p?.type === 'custom_tool_call') && WRITE_NAMES.includes(p?.name)) {
182
+ let input = p?.input
183
+ if (typeof input === 'string') { try { input = JSON.parse(input) } catch { input = null } }
184
+ return fromInput(input)
185
+ }
186
+ }
187
+ return null
188
+ }
189
+
190
+ // 提取 entry 内所有 tool_use 的 call_id (Claude tool_use.id / Codex function_call.call_id).
191
+ function extractToolUseIds(entry: AnyEntry): string[] {
192
+ const ids: string[] = []
193
+ if (entry?.type === 'assistant') {
194
+ const c = entry?.message?.content
195
+ if (Array.isArray(c)) {
196
+ for (const b of c) {
197
+ if (b?.type === 'tool_use' && typeof b.id === 'string') ids.push(b.id)
198
+ }
199
+ }
200
+ }
201
+ if (entry?.type === 'response_item') {
202
+ const p = entry.payload
203
+ if ((p?.type === 'function_call' || p?.type === 'custom_tool_call') && typeof p?.call_id === 'string') ids.push(p.call_id)
204
+ }
205
+ return ids
206
+ }
207
+
208
+ // 提取 entry 内所有 tool_result 记录 (Claude user.tool_result / Codex function_call_output).
209
+ function extractToolResults(entry: AnyEntry): Array<ToolResultView & { toolUseId?: string }> {
210
+ const out: Array<ToolResultView & { toolUseId?: string }> = []
211
+ if (entry?.type === 'user') {
212
+ const c = entry?.message?.content
213
+ if (Array.isArray(c)) {
214
+ for (const b of c) {
215
+ if (b?.type !== 'tool_result') continue
216
+ const { text, isError } = extractToolResult(b)
217
+ out.push({ text, isError, toolUseId: typeof b.tool_use_id === 'string' ? b.tool_use_id : undefined })
218
+ }
219
+ }
220
+ }
221
+ if (entry?.type === 'response_item') {
222
+ const p = entry.payload
223
+ if (p?.type === 'function_call_output' || p?.type === 'custom_tool_call_output') {
224
+ const { text } = extractToolResult({ content: p?.output, is_error: false })
225
+ out.push({ text, isError: p?.status === 'failed' || p?.is_error === true, toolUseId: typeof p?.call_id === 'string' ? p.call_id : undefined })
226
+ }
227
+ }
228
+ return out
229
+ }
230
+
231
+ function isPureToolResultEntry(entry: AnyEntry): boolean {
232
+ if (entry?.type === 'response_item') {
233
+ const p = entry.payload
234
+ return p?.type === 'function_call_output' || p?.type === 'custom_tool_call_output'
235
+ }
236
+ if (entry?.type !== 'user') return false
237
+ const c = entry?.message?.content
238
+ return Array.isArray(c) && c.length > 0 && c.every((b: any) => b?.type === 'tool_result')
239
+ }
240
+
241
+ /**
242
+ * 把 entries 序列合并成 Block 序列: tool_use 按 call_id 配对其 tool_result,
243
+ * 纯 tool_result entry (已并入发起方) 丢弃. 返回的 Block 序列与卡片渲染同序.
244
+ */
245
+ export function mergeToolCalls(entries: AnyEntry[]): Block[] {
246
+ // call_id → tool_use 发起方 entry 的下标.
247
+ const useIndexById = new Map<string, number>()
248
+ entries.forEach((entry, index) => {
249
+ for (const id of extractToolUseIds(entry)) {
250
+ if (!useIndexById.has(id)) useIndexById.set(id, index)
251
+ }
252
+ })
253
+
254
+ // tool_use entry 下标 → (call_id → result).
255
+ const resultsByUseIndex = new Map<number, Map<string, ToolResultView>>()
256
+ const pureResultIndexes = new Set<number>()
257
+ entries.forEach((entry, index) => {
258
+ const records = extractToolResults(entry)
259
+ if (records.length === 0) return
260
+ let matched = 0
261
+ for (const r of records) {
262
+ const useIdx = r.toolUseId ? useIndexById.get(r.toolUseId) : undefined
263
+ if (useIdx == null) continue
264
+ const m = resultsByUseIndex.get(useIdx) || new Map<string, ToolResultView>()
265
+ m.set(r.toolUseId!, { text: r.text, isError: r.isError })
266
+ resultsByUseIndex.set(useIdx, m)
267
+ matched += 1
268
+ }
269
+ // 该 entry 是纯 tool_result 且全部配对成功 → 整条丢弃 (内容已并入发起方).
270
+ if (matched > 0 && matched === records.length && isPureToolResultEntry(entry)) pureResultIndexes.add(index)
271
+ })
272
+
273
+ const out: Block[] = []
274
+ entries.forEach((entry, index) => {
275
+ if (pureResultIndexes.has(index)) return
276
+ if (extractToolUseIds(entry).length > 0) {
277
+ out.push({ kind: 'tool', entry, results: resultsByUseIndex.get(index) || new Map<string, ToolResultView>() })
278
+ } else {
279
+ out.push({ kind: 'entry', entry })
280
+ }
281
+ })
282
+ return out
283
+ }
284
+
285
+ // ── TUI-side projection ─────────────────────────────────────────────────────
81
286
  function truncate(s: string, n: number): string {
82
287
  const one = s.replace(/\s+/g, ' ').trim()
83
288
  return one.length > n ? one.slice(0, n - 1) + '…' : one
@@ -157,6 +362,7 @@ function extractToolResult(content: any): { text: string; isError: boolean } {
157
362
  const TOOL_LABEL: Record<string, string> = {
158
363
  Bash: '运行命令', bash: '运行命令', shell: '运行命令', exec: '运行命令',
159
364
  exec_command: '运行命令', shell_command: '运行命令', run_terminal_cmd: '运行命令',
365
+ result: '结果',
160
366
  write_stdin: '输入命令',
161
367
  Read: '读取文件', read_file: '读取文件',
162
368
  Write: '写入文件', write_file: '写入文件', create_file: '创建文件',
@@ -240,8 +446,9 @@ function parseCustomToolCall(raw: any): { name: string; input: Record<string, an
240
446
  return { name: call[1], input }
241
447
  }
242
448
 
243
- /** Project one entry into zero or more renderable views. */
244
- export function viewsForEntry(entry: AnyEntry): EntryView[] {
449
+ /** Project one block into zero or more renderable views. */
450
+ export function viewsForBlock(block: Block): EntryView[] {
451
+ const entry = block.entry
245
452
  if (!entry || typeof entry !== 'object') return [{ kind: 'skip' }]
246
453
  if (isHiddenNoise(entry)) return [{ kind: 'skip' }]
247
454
  const type = entry.type
@@ -257,20 +464,42 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
257
464
  const textParts: string[] = []
258
465
  const thinkingParts: string[] = []
259
466
  let hasThinking = false
467
+ const flushText = () => {
468
+ if (textParts.length) { out.push({ kind: 'assistant', text: textParts.join('\n') }); textParts.length = 0 }
469
+ }
260
470
  for (const b of content) {
261
471
  if (!b) continue
262
472
  if (b.type === 'text' || b.type === 'output_text') {
263
473
  if (b.text) textParts.push(b.text)
264
474
  } 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) })
475
+ const name = typeof b.name === 'string' ? b.name : ''
476
+ const input = b.input && typeof b.input === 'object' ? b.input : {}
477
+ if (EDIT_NAMES.includes(name)) {
478
+ // 代码修改 → full (完整展示 old→new)
479
+ flushText()
480
+ out.push({
481
+ kind: 'code_edit',
482
+ filePath: pickString(input, ['file_path', 'path']),
483
+ oldString: pickString(input, ['old_string', 'old_str']),
484
+ newString: pickString(input, ['new_string', 'new_str']),
485
+ })
486
+ } else if (WRITE_NAMES.includes(name)) {
487
+ // 文件写入 → full (完整展示 content)
488
+ flushText()
489
+ out.push({ kind: 'write_file', filePath: pickString(input, ['file_path', 'path', 'filePath']), content: pickString(input, ['content']) })
490
+ } else {
491
+ // 普通工具 → compact (带配对的 result)
492
+ flushText()
493
+ const id = typeof b.id === 'string' ? b.id : ''
494
+ const result = block.kind === 'tool' && id ? block.results.get(id) : undefined
495
+ out.push({ kind: 'tool_call', toolName: name, summary: summarizeToolInput(name, b.input), result })
496
+ }
267
497
  } else if (b.type === 'thinking') {
268
- // model reasoning — shown like the web viewer (encrypted/empty thinking → fallback label)
269
498
  hasThinking = true
270
499
  if (typeof b.thinking === 'string' && b.thinking) thinkingParts.push(b.thinking)
271
500
  }
272
501
  }
273
- if (textParts.length) out.push({ kind: 'assistant', text: textParts.join('\n') })
502
+ flushText()
274
503
  if (thinkingParts.length) out.push({ kind: 'reasoning', text: thinkingParts.join('\n').trim() })
275
504
  else if (hasThinking) out.push({ kind: 'reasoning', text: '思考内容被隐藏' })
276
505
  return out.length ? out : [{ kind: 'skip' }]
@@ -278,25 +507,24 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
278
507
 
279
508
  if (type === 'user') {
280
509
  const content = entry.message?.content
281
- // tool_result wrapper (assistant's tool output fed back)
510
+ // tool_result wrapper (assistant's tool output fed back).
282
511
  if (Array.isArray(content) && content.some((b: any) => b?.type === 'tool_result')) {
283
512
  const out: EntryView[] = []
284
513
  for (const b of content) {
285
514
  if (b?.type === 'tool_result') {
286
515
  const { text, isError } = extractToolResult(b)
287
- out.push({ kind: 'tool_result', summary: truncate(text, 160), isError })
516
+ out.push({ kind: 'tool_result', text, isError })
288
517
  }
289
518
  }
290
519
  return out
291
520
  }
292
521
  const text = entryUserText(entry)
293
- return text ? [{ kind: 'user', text }] : [{ kind: 'skip' }]
522
+ return text ? [{ kind: 'user', text: stripUserFraming(text) }] : [{ kind: 'skip' }]
294
523
  }
295
524
 
296
525
  if (type === 'system') {
297
- const subtype = entry.subtype
298
- if (subtype === 'init') return [{ kind: 'skip' }]
299
- const text = entry.content || entry.message?.content || subtype
526
+ if (entry.subtype === 'init') return [{ kind: 'skip' }]
527
+ const text = entry.content || entry.message?.content || entry.subtype
300
528
  return text ? [{ kind: 'system', text: truncate(typeof text === 'string' ? text : JSON.stringify(text), 160) }] : [{ kind: 'skip' }]
301
529
  }
302
530
 
@@ -307,7 +535,7 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
307
535
  if (p.type === 'message') {
308
536
  const text = assistantResponseText(p.content)
309
537
  if (!text) return [{ kind: 'skip' }]
310
- return [{ kind: p.role === 'user' ? 'user' : 'assistant', text }]
538
+ return [{ kind: p.role === 'user' ? 'user' : 'assistant', text: p.role === 'user' ? stripUserFraming(text) : text }]
311
539
  }
312
540
  if (p.type === 'reasoning') {
313
541
  const enc = p.encrypted_content
@@ -316,23 +544,25 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
316
544
  : (reasoningSummaryText(p) || 'reasoning')
317
545
  return [{ kind: 'reasoning', text }]
318
546
  }
319
- if (p.type === 'function_call') {
547
+ if (p.type === 'function_call' || p.type === 'custom_tool_call') {
548
+ // Write → full
549
+ const write = extractWrite(entry)
550
+ if (write) return [{ kind: 'write_file', filePath: write.filePath, content: write.content }]
320
551
  let name = p.name || 'tool'
321
552
  let input: any = p.arguments
322
553
  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) }]
554
+ if (p.type === 'custom_tool_call') {
555
+ const nested = parseCustomToolCall(p.input)
556
+ if (nested) { name = nested.name; input = nested.input }
557
+ }
558
+ const id = typeof p.call_id === 'string' ? p.call_id : ''
559
+ const result = block.kind === 'tool' && id ? block.results.get(id) : undefined
560
+ return [{ kind: 'tool_call', toolName: name, summary: summarizeToolInput(name, input), result }]
332
561
  }
333
562
  if (p.type === 'function_call_output' || p.type === 'custom_tool_call_output') {
563
+ // 累积模式: tool 结果单独成行 (发起的 tool_use 命令行已在前一条 entry 累积).
334
564
  const { text } = extractToolResult({ content: p.output, is_error: false })
335
- return [{ kind: 'tool_result', summary: truncate(text, 160), isError: false }]
565
+ return [{ kind: 'tool_result', text, isError: p?.status === 'failed' || p?.is_error === true }]
336
566
  }
337
567
  return [{ kind: 'skip' }]
338
568
  }
@@ -340,12 +570,90 @@ export function viewsForEntry(entry: AnyEntry): EntryView[] {
340
570
  if (type === 'event_msg') {
341
571
  const ptype = entry.payload?.type
342
572
  if (ptype === 'error') return [{ kind: 'error', text: truncate(entry.payload?.message || '错误', 200) }]
573
+ if (ptype === 'context_compacted') return [{ kind: 'system', text: '◇ 上下文已压缩' }]
574
+ if (ptype === 'patch_apply_end') {
575
+ // Codex 代码修改完成 → full (完整展示 unified_diff)
576
+ const edit = extractEdit(entry)
577
+ if (edit) return [{ kind: 'code_edit', filePath: edit.filePath, oldString: edit.oldString, newString: edit.newString }]
578
+ }
343
579
  return [{ kind: 'skip' }]
344
580
  }
345
581
 
346
582
  return [{ kind: 'skip' }]
347
583
  }
348
584
 
585
+ /** 旧接口保留: 单 entry 投射 (无合并), 给 useChat 的 entryMatchesPendingUser 等用. */
586
+ export function viewsForEntry(entry: AnyEntry): EntryView[] {
587
+ return viewsForBlock({ kind: 'entry', entry })
588
+ }
589
+
349
590
  export function toolLabel(name: string): string {
350
591
  return TOOL_LABEL[name] ?? name
351
592
  }
593
+
594
+ // ── 用户输入去重 (对齐 web viewer/rounds.ts buildRounds) ──────────────────────
595
+ // codex 一次用户输入在 jsonl 里以 3 种形态出现 (type:user / response_item.message[role=user]
596
+ // / event_msg.user_message), 文本相同. 若与上一条用户输入文本相同, 且之间还没出现任何
597
+ // agent 输出, 则视为同一次输入的重复入口 → 丢弃, 避免 TUI 把同一条提问显示多次.
598
+ export function userTextOf(e: AnyEntry): string {
599
+ if (e?.type === 'event_msg' && e?.payload?.type === 'user_message') {
600
+ return String(e?.payload?.message || '').trim()
601
+ }
602
+ if (e?.type === 'response_item' && e?.payload?.type === 'message' && e?.payload?.role === 'user') {
603
+ const c = e?.payload?.content
604
+ if (typeof c === 'string') return c.trim()
605
+ if (Array.isArray(c)) return c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n').trim()
606
+ return ''
607
+ }
608
+ if (e?.type === 'user') {
609
+ const c = e?.message?.content
610
+ if (typeof c === 'string') return c.trim()
611
+ if (Array.isArray(c)) return c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n').trim()
612
+ return ''
613
+ }
614
+ return ''
615
+ }
616
+
617
+ // mobius 给 agent 的用户消息会注入一大段上下文框架: 从 "以下信息描述了你正在协助的
618
+ // 用户、当前Project、Issue/Research 与 Session" 到 "## 用户的问题" 之前都是 framing
619
+ // (用户/项目/Issue/Session/Research/Memory 等描述), 之后才是真实提问. TUI 显示用户
620
+ // 消息时隐藏 framing, 只显示 "## 用户的问题" 之后的内容 (兼容 【## 用户的问题】 写法).
621
+ const USER_QUESTION_MARKER = /(?:^|\n)\s*【?\s*##\s*用户的问题\s*】?\s*\r?\n/
622
+
623
+ export function stripUserFraming(text: string): string {
624
+ if (!text) return text
625
+ const m = text.match(USER_QUESTION_MARKER)
626
+ if (!m || m.index == null) return text
627
+ const after = text.slice(m.index + m[0].length).trim()
628
+ return after || text
629
+ }
630
+
631
+ function isAssistantOutput(e: AnyEntry): boolean {
632
+ if (e?.type === 'assistant') return true
633
+ if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
634
+ if (e?.type === 'response_item') {
635
+ const pt = e?.payload?.type
636
+ if (pt === 'function_call' || pt === 'function_call_output' || pt === 'custom_tool_call' || pt === 'custom_tool_call_output' || pt === 'reasoning') return true
637
+ if (pt === 'message') {
638
+ const role = e?.payload?.role
639
+ return !!role && role !== 'user'
640
+ }
641
+ }
642
+ return false
643
+ }
644
+
645
+ export function dedupeUserEntries(entries: AnyEntry[]): AnyEntry[] {
646
+ let lastUserText = ''
647
+ let seenAssistantAfter = false
648
+ return entries.filter((e) => {
649
+ const text = userTextOf(e)
650
+ if (text) {
651
+ if (text === lastUserText && !seenAssistantAfter) return false
652
+ lastUserText = text
653
+ seenAssistantAfter = false
654
+ return true
655
+ }
656
+ if (isAssistantOutput(e)) seenAssistantAfter = true
657
+ return true
658
+ })
659
+ }