@mobius-os/mobius 0.2.8 → 0.2.9
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 +1 -1
- package/src/components/Chat.tsx +143 -133
- package/src/lib/entry-view.ts +348 -40
package/package.json
CHANGED
package/src/components/Chat.tsx
CHANGED
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
<
|
|
272
|
-
<Text color="cyan">• {
|
|
273
|
-
{view.
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
279
|
-
{
|
|
280
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
539
|
-
|
|
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, 历史靠终端自身滚动.)
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -1,28 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* jsonl entry → renderable view.
|
|
2
|
+
* jsonl entry → renderable view (TUI).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (
|
|
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';
|
|
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
|
-
|
|
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
|
-
|
|
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' &&
|
|
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
|
-
//
|
|
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
|
-
// ──
|
|
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
|
|
244
|
-
export function
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
|
|
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',
|
|
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
|
+
}
|