@mobius-os/mobius 0.3.43 → 0.3.46
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/aimux.ts +1 -1
- package/src/api.ts +19 -0
- package/src/components/Chat.tsx +42 -14
- package/src/hooks/useChat.ts +166 -26
- package/src/lib/entry-view.ts +8 -1
- package/src/lib/screen-text.ts +6 -0
- package/src/sse.ts +18 -7
- package/src/types.ts +22 -3
package/package.json
CHANGED
package/src/aimux.ts
CHANGED
|
@@ -92,7 +92,7 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
|
|
|
92
92
|
// 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
|
|
93
93
|
// 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
|
|
94
94
|
const BUNDLE_VER = '3'
|
|
95
|
-
const BUNDLE_AIMUX_VERSION = '0.1.
|
|
95
|
+
const BUNDLE_AIMUX_VERSION = '0.1.29'
|
|
96
96
|
/** Version expected from the installed or bundled AIMUX runtime. */
|
|
97
97
|
export const AIMUX_VERSION = BUNDLE_AIMUX_VERSION
|
|
98
98
|
const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
|
package/src/api.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* bearer token (header `Authorization: Bearer <jwt>`); there is no cookie auth.
|
|
7
7
|
*/
|
|
8
8
|
import type {
|
|
9
|
+
AnyEntry,
|
|
9
10
|
AuthConfig,
|
|
11
|
+
HistoryGroup,
|
|
12
|
+
HistoryPendingOpener,
|
|
10
13
|
Issue,
|
|
11
14
|
LoginResponse,
|
|
12
15
|
Memory,
|
|
@@ -160,6 +163,22 @@ export class MobiusClient {
|
|
|
160
163
|
return this.request<SessionRuntimeStatus>(`/api/sessions/${encodeURIComponent(sessionId)}/status`, { signal })
|
|
161
164
|
}
|
|
162
165
|
|
|
166
|
+
// ── agent-history (协议 ①②: 组元数据 + 整组条目) ──────────────────────────
|
|
167
|
+
/** ① 全部组元数据, 一次给全; 顺带返回挂起中的开轮卡 (pending). */
|
|
168
|
+
async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[]; pending: HistoryPendingOpener[] }> {
|
|
169
|
+
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 打断当前 turn 并出队下一条排队指令 (不追加新 prompt). */
|
|
173
|
+
async pauseToDequeue(sessionId: string): Promise<{ ok: boolean }> {
|
|
174
|
+
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/pause-to-dequeue`, { method: 'POST', body: '{}' })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** ② 某组全部条目 (全量, 无分页, 条目不可变). */
|
|
178
|
+
async listHistoryGroupEntries(sessionId: string, groupId: string): Promise<{ group_id: string; version: number; entries: AnyEntry[] }> {
|
|
179
|
+
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups/${encodeURIComponent(groupId)}/entries`)
|
|
180
|
+
}
|
|
181
|
+
|
|
163
182
|
// ── preference lookups ────────────────────────────────────────────────────
|
|
164
183
|
async modelOptions(): Promise<SessionModelOption[]> {
|
|
165
184
|
const r = await this.request<any>('/api/sessions/model-options')
|
package/src/components/Chat.tsx
CHANGED
|
@@ -152,8 +152,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
152
152
|
|
|
153
153
|
// First query of a fresh session triggers the full backend bootstrap (lazy
|
|
154
154
|
// session creation, worker spawn, context load) before any output streams.
|
|
155
|
-
// Label that phase
|
|
156
|
-
// so it reads as startup rather than a stuck agent. Once the first assistant
|
|
155
|
+
// Label that phase as startup rather than a stuck agent. Once the first assistant
|
|
157
156
|
// output is observed (or the session is a resumed one with prior history),
|
|
158
157
|
// the indicator falls back to the normal Working label for every turn.
|
|
159
158
|
const firstQueryInFlight = !resumeSessionId && !chat.entries.some(isAssistantOutput)
|
|
@@ -162,15 +161,32 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
162
161
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
163
162
|
// 避免在累积视图里把同一条提问显示多次.
|
|
164
163
|
const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
|
|
165
|
-
const pendingEntry = useMemo<AnyEntry | null>(() =>
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
164
|
+
const pendingEntry = useMemo<AnyEntry | null>(() => {
|
|
165
|
+
// 忙时提交的指令已由排队行统一呈现, 乐观占位退役 (避免 "You:" 与 "排队" 双重显示).
|
|
166
|
+
if (chat.pending.length > 0) return null
|
|
167
|
+
return chat.pendingUser === null ? null : ({
|
|
168
|
+
type: 'user',
|
|
169
|
+
__id: '__pending-user__',
|
|
170
|
+
message: { role: 'user', content: chat.pendingUser },
|
|
171
|
+
})
|
|
172
|
+
}, [chat.pendingUser, chat.pending.length])
|
|
173
|
+
// 排队行 (合成条目): 显示最后一条挂起指令 + 总数, 紧跟对话末尾.
|
|
174
|
+
const pendingQueueEntry = useMemo<AnyEntry | null>(() => {
|
|
175
|
+
if (chat.pending.length === 0) return null
|
|
176
|
+
const last = chat.pending[chat.pending.length - 1]
|
|
177
|
+
return {
|
|
178
|
+
type: '__pending_queue__',
|
|
179
|
+
__id: '__pending-queue__',
|
|
180
|
+
text: last?.user_summary ?? '',
|
|
181
|
+
count: chat.pending.length,
|
|
182
|
+
}
|
|
183
|
+
}, [chat.pending])
|
|
184
|
+
const transcriptEntries = useMemo(() => {
|
|
185
|
+
const parts = [...dedupedEntries]
|
|
186
|
+
if (pendingEntry) parts.push(pendingEntry)
|
|
187
|
+
if (pendingQueueEntry) parts.push(pendingQueueEntry)
|
|
188
|
+
return parts
|
|
189
|
+
}, [dedupedEntries, pendingEntry, pendingQueueEntry])
|
|
174
190
|
|
|
175
191
|
// Markdown parsing and wrapping are paid once per entry/terminal width. Keep
|
|
176
192
|
// the two most recent widths so resize-back does not immediately reparse the
|
|
@@ -415,6 +431,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
415
431
|
commands={SLASH_COMMANDS}
|
|
416
432
|
onHeightChange={setComposerRows}
|
|
417
433
|
inputActiveRef={chatInputActiveRef}
|
|
434
|
+
onPauseToDequeue={chat.pauseToDequeue}
|
|
435
|
+
hasPending={chat.pending.length > 0}
|
|
418
436
|
/>
|
|
419
437
|
<StatusArea
|
|
420
438
|
ready={ready}
|
|
@@ -500,7 +518,8 @@ function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
|
|
|
500
518
|
: tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
|
|
501
519
|
: tone === 'edit_new' ? 'green'
|
|
502
520
|
: tone === 'system' ? 'yellow'
|
|
503
|
-
:
|
|
521
|
+
: tone === 'pending' ? 'yellow'
|
|
522
|
+
: undefined
|
|
504
523
|
const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
|
|
505
524
|
return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
|
|
506
525
|
}
|
|
@@ -615,9 +634,12 @@ interface ComposerProps {
|
|
|
615
634
|
commands: { cmd: string; desc: string }[]
|
|
616
635
|
onHeightChange?: (rows: number) => void
|
|
617
636
|
inputActiveRef?: React.RefObject<boolean>
|
|
637
|
+
// 排队插队: 有挂起指令时空输入回车触发; hasPending 控制提示文案与触发开关.
|
|
638
|
+
onPauseToDequeue?: () => void
|
|
639
|
+
hasPending?: boolean
|
|
618
640
|
}
|
|
619
641
|
|
|
620
|
-
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef }: ComposerProps) {
|
|
642
|
+
export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightChange, inputActiveRef, onPauseToDequeue, hasPending }: ComposerProps) {
|
|
621
643
|
const [value, setValue] = useState('')
|
|
622
644
|
const [cursor, setCursor] = useState(0)
|
|
623
645
|
const [popupIdx, setPopupIdx] = useState(0)
|
|
@@ -838,6 +860,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
838
860
|
onSubmit(submitted)
|
|
839
861
|
edit('', 0)
|
|
840
862
|
setHistIdx(null)
|
|
863
|
+
} else if (hasPending) {
|
|
864
|
+
// 空输入回车 = 插队: 打断当前 turn 并出队下一条排队指令.
|
|
865
|
+
void onPauseToDequeue?.()
|
|
841
866
|
}
|
|
842
867
|
return
|
|
843
868
|
}
|
|
@@ -970,7 +995,10 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
970
995
|
<Box justifyContent="space-between">
|
|
971
996
|
{confirmQuit
|
|
972
997
|
? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
|
|
973
|
-
: <Text dimColor>
|
|
998
|
+
: <Text dimColor>
|
|
999
|
+
{hasPending ? <Text color="yellowBright">空回车插队 · </Text> : null}
|
|
1000
|
+
{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}
|
|
1001
|
+
</Text>}
|
|
974
1002
|
<Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
|
|
975
1003
|
</Box>
|
|
976
1004
|
</Box>
|
package/src/hooks/useChat.ts
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* Lifecycle (per the TUI spec):
|
|
5
5
|
* - lazily create a session (POST /api/issues/:issueId/sessions) on the first
|
|
6
6
|
* submitted message, using the saved preferences;
|
|
7
|
-
* - open the SSE stream (GET /api/sessions/:id/events?token=)
|
|
8
|
-
*
|
|
7
|
+
* - open the SSE stream (GET /api/sessions/:id/events?token=); on subscribe
|
|
8
|
+
* bootstrap the transcript via ① groups + ② tail-group entries, then apply
|
|
9
|
+
* live `entries` batches (watermark + uuid dedup) as they arrive;
|
|
10
|
+
* - reconnects re-run the same ① negotiation (stateless reconciliation):
|
|
11
|
+
* groups whose version changed are refetched whole, transcript rebuilt;
|
|
9
12
|
* - keep the agent's busy state synchronized with the runtime status API.
|
|
10
13
|
* `/clear` remounts the hook (fresh session next time); `/resume` injects a
|
|
11
|
-
* pre-existing sessionId
|
|
14
|
+
* pre-existing sessionId.
|
|
12
15
|
*/
|
|
13
16
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
14
17
|
import { MobiusClient, ApiError } from '../api.js'
|
|
@@ -16,7 +19,7 @@ import { SseConnection } from '../sse.js'
|
|
|
16
19
|
import { updateIssuePreference } from '../config.js'
|
|
17
20
|
import { tuiAimuxIdentifier, probeAimuxBridgeConnection } from '../aimux.js'
|
|
18
21
|
import { viewsForEntry } from '../lib/entry-view.js'
|
|
19
|
-
import type { AnyEntry } from '../types.js'
|
|
22
|
+
import type { AnyEntry, HistoryPendingOpener } from '../types.js'
|
|
20
23
|
import type { ReadyState } from '../components/PrepScreen.js'
|
|
21
24
|
|
|
22
25
|
export interface ChatApi {
|
|
@@ -28,12 +31,14 @@ export interface ChatApi {
|
|
|
28
31
|
export interface ChatController {
|
|
29
32
|
entries: AnyEntry[]
|
|
30
33
|
pendingUser: string | null
|
|
34
|
+
pending: HistoryPendingOpener[]
|
|
31
35
|
typing: boolean
|
|
32
36
|
sending: boolean
|
|
33
37
|
error: string | null
|
|
34
38
|
sessionId: string | null
|
|
35
39
|
send: (text: string) => Promise<void>
|
|
36
40
|
stop: () => Promise<void>
|
|
41
|
+
pauseToDequeue: () => Promise<void>
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
let ID = 0
|
|
@@ -42,7 +47,7 @@ function nextId(): number { ID += 1; return ID }
|
|
|
42
47
|
/**
|
|
43
48
|
* Does this entry represent the user's just-submitted message? Used to retire the
|
|
44
49
|
* optimistic `pendingUser` placeholder once the real entry is observed — including
|
|
45
|
-
* via a reconnect's history
|
|
50
|
+
* via a reconnect's history reconciliation (the live `entries` path already clears it).
|
|
46
51
|
*
|
|
47
52
|
* Mobius may prepend injected context (project/issue framing) to a user turn, so we
|
|
48
53
|
* match the typed text as a suffix of the entry's normalized text rather than
|
|
@@ -66,6 +71,22 @@ function entryKey(entry: AnyEntry): string | null {
|
|
|
66
71
|
return typeof entry?.uuid === 'string' ? entry.uuid : null
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
// 首次 bootstrap 拉取的末尾组数 (旧 SSE 尾部回放的等价物; 更早的组按需不拉,
|
|
75
|
+
// TUI 是平铺字幕, 没有轮次展开概念, 末尾几组已覆盖活跃对话).
|
|
76
|
+
const BOOTSTRAP_GROUP_COUNT = 3
|
|
77
|
+
// A fresh session can spend several seconds creating the worker and loading
|
|
78
|
+
// context before /status reports alive=true. Keep the first-turn indicator
|
|
79
|
+
// visible during that bootstrap window instead of letting the short generic
|
|
80
|
+
// hint expire and leaving the user with no feedback.
|
|
81
|
+
const FIRST_TURN_BOOTSTRAP_GRACE_MS = 30_000
|
|
82
|
+
|
|
83
|
+
/** Mini group store: 组序 + 水位线 (version) + 组内条目. */
|
|
84
|
+
interface GroupSlot {
|
|
85
|
+
seq: number
|
|
86
|
+
version: number
|
|
87
|
+
entries: AnyEntry[]
|
|
88
|
+
}
|
|
89
|
+
|
|
69
90
|
// Retry transient gateway/transport errors so a brief 502/503/504 (a reverse-
|
|
70
91
|
// proxy blip, a backend worker recycling after a deploy, a transient upstream
|
|
71
92
|
// failure) doesn't immediately fail a message dispatch. 4xx errors are not
|
|
@@ -91,10 +112,13 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
91
112
|
const [sessionId, setSessionId] = useState<string | null>(resumeSessionId ?? null)
|
|
92
113
|
const [entries, setEntries] = useState<AnyEntry[]>([])
|
|
93
114
|
const [pendingUser, setPendingUser] = useState<string | null>(null)
|
|
115
|
+
const [pending, setPending] = useState<HistoryPendingOpener[]>([])
|
|
94
116
|
const [typing, setTyping] = useState(false)
|
|
95
117
|
const [sending, setSending] = useState(false)
|
|
96
118
|
const [error, setError] = useState<string | null>(null)
|
|
97
119
|
const sseRef = useRef<SseConnection | null>(null)
|
|
120
|
+
// agent-history mini group store (协议 ①②③ 的 TUI 侧消费形态).
|
|
121
|
+
const groupSlotsRef = useRef<Map<string, GroupSlot>>(new Map())
|
|
98
122
|
const pollNowRef = useRef<(() => void) | null>(null)
|
|
99
123
|
const typingRef = useRef(false)
|
|
100
124
|
const sendingRef = useRef(false)
|
|
@@ -103,7 +127,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
103
127
|
// SSE auto-reconnect state. A reverse proxy's idle timeout (or a server
|
|
104
128
|
// restart) drops the stream mid-session; without reconnect the TUI stops
|
|
105
129
|
// receiving new jsonl entries even though the web client keeps updating.
|
|
106
|
-
// On reconnect the
|
|
130
|
+
// On reconnect the stateless ①② reconciliation refills any missed entries.
|
|
107
131
|
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
108
132
|
const reconnectAttemptRef = useRef(0)
|
|
109
133
|
const aliveRef = useRef(true)
|
|
@@ -119,7 +143,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
119
143
|
const appendEntries = useCallback((newOnes: AnyEntry[]) => {
|
|
120
144
|
if (!newOnes.length) return
|
|
121
145
|
setEntries(prev => {
|
|
122
|
-
// De-duplicate by uuid so a live
|
|
146
|
+
// De-duplicate by uuid so a live `entries` batch that also appears in a
|
|
123
147
|
// reconnect's history replay is never shown twice.
|
|
124
148
|
const seen = new Set<string>()
|
|
125
149
|
for (const e of prev) { const k = entryKey(e); if (k) seen.add(k) }
|
|
@@ -145,6 +169,65 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
145
169
|
setEntries(out)
|
|
146
170
|
}, [])
|
|
147
171
|
|
|
172
|
+
// ── agent-history (协议 ①②③): bootstrap / 重连对账 ──────────────────────
|
|
173
|
+
|
|
174
|
+
/** 按组序摊平重建 transcript (uuid 去重由 setHistory 兜底). */
|
|
175
|
+
const rebuildEntriesFromGroups = useCallback(() => {
|
|
176
|
+
const slots = [...groupSlotsRef.current.values()].sort((a, b) => a.seq - b.seq)
|
|
177
|
+
const flat: AnyEntry[] = []
|
|
178
|
+
for (const s of slots) flat.push(...s.entries)
|
|
179
|
+
setHistory(flat)
|
|
180
|
+
}, [setHistory])
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Stateless 对账 (订阅时/重连时同一条路径):
|
|
184
|
+
* ① 拿全部组元数据 → 本地没有的或 version 变了的组 ② 整组重拉 → 重建 transcript.
|
|
185
|
+
* 首次 (本地空) 只拉末尾 BOOTSTRAP_GROUP_COUNT 组; 之后每次只补差额, 常态零请求.
|
|
186
|
+
*/
|
|
187
|
+
const reconcileHistory = useCallback(async (sid: string) => {
|
|
188
|
+
try {
|
|
189
|
+
const data = await client.listHistoryGroups(sid)
|
|
190
|
+
const groups: any[] = Array.isArray(data?.groups) ? data.groups : []
|
|
191
|
+
// 挂起中的开轮卡 (排队指令): /groups 是权威快照, 覆盖本地增量.
|
|
192
|
+
setPending(Array.isArray(data?.pending) ? data.pending : [])
|
|
193
|
+
const local = groupSlotsRef.current
|
|
194
|
+
const targets = local.size === 0 ? groups.slice(-BOOTSTRAP_GROUP_COUNT) : groups
|
|
195
|
+
let changed = false
|
|
196
|
+
for (const g of targets) {
|
|
197
|
+
const gid = String(g?.id ?? '')
|
|
198
|
+
if (!gid) continue
|
|
199
|
+
const ver = Number(g?.version) || 0
|
|
200
|
+
const cur = local.get(gid)
|
|
201
|
+
if (cur && cur.version >= ver) continue
|
|
202
|
+
try {
|
|
203
|
+
const r = await client.listHistoryGroupEntries(sid, gid)
|
|
204
|
+
local.set(gid, {
|
|
205
|
+
seq: Number(g?.seq) || (local.size + 1),
|
|
206
|
+
version: Number(r?.version) || 0,
|
|
207
|
+
entries: Array.isArray(r?.entries) ? r.entries : [],
|
|
208
|
+
})
|
|
209
|
+
changed = true
|
|
210
|
+
} catch { /* 单组失败不阻塞其余组 */ }
|
|
211
|
+
}
|
|
212
|
+
// 服务端已不存在的组 → 丢弃 (会话被删/重建的防御).
|
|
213
|
+
const alive = new Set(groups.map((g: any) => String(g?.id ?? '')))
|
|
214
|
+
for (const gid of [...local.keys()]) {
|
|
215
|
+
if (!alive.has(gid)) { local.delete(gid); changed = true }
|
|
216
|
+
}
|
|
217
|
+
if (changed) rebuildEntriesFromGroups()
|
|
218
|
+
// 对账补齐后, 若乐观占位已被真实条目覆盖 → 退掉 (断线期间整轮完成的场景).
|
|
219
|
+
setPendingUser(prev => {
|
|
220
|
+
if (prev === null) return prev
|
|
221
|
+
const slots = [...local.values()].sort((a, b) => a.seq - b.seq)
|
|
222
|
+
const flat: AnyEntry[] = []
|
|
223
|
+
for (const s of slots) flat.push(...s.entries)
|
|
224
|
+
return flat.some(e => entryMatchesPendingUser(e, prev)) ? null : prev
|
|
225
|
+
})
|
|
226
|
+
} catch (e) {
|
|
227
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[history-reconcile]', (e as Error)?.message ?? e)
|
|
228
|
+
}
|
|
229
|
+
}, [client, rebuildEntriesFromGroups])
|
|
230
|
+
|
|
148
231
|
// ── SSE connection ────────────────────────────────────────────────────────
|
|
149
232
|
const connect = useCallback((sid: string) => {
|
|
150
233
|
if (process.env.MOBIUS_TUI_DEBUG) console.error('[connect]', sid)
|
|
@@ -153,23 +236,62 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
153
236
|
if (reconnectTimerRef.current) { clearTimeout(reconnectTimerRef.current); reconnectTimerRef.current = null }
|
|
154
237
|
const url = `${client.server}/api/sessions/${encodeURIComponent(sid)}/events?token=${encodeURIComponent(client.token)}`
|
|
155
238
|
const conn = new SseConnection(url, {
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
239
|
+
onGroupCreated: (group) => {
|
|
240
|
+
if (!group || typeof group !== 'object') return
|
|
241
|
+
const gid = String(group.id ?? '')
|
|
242
|
+
if (!gid || groupSlotsRef.current.has(gid)) return // 元数据不可变, 已知即忽略
|
|
243
|
+
groupSlotsRef.current.set(gid, {
|
|
244
|
+
seq: Number(group.seq) || (groupSlotsRef.current.size + 1),
|
|
245
|
+
version: Number(group.version) || 1,
|
|
246
|
+
entries: [],
|
|
247
|
+
})
|
|
248
|
+
// 新组开轮 = 后端已把挂起的 pending_round_openers 一次性出队 (flushPendingOpenersToSink).
|
|
249
|
+
// 排队行随之清空; 随后 entries 事件会把这一整组内容补齐.
|
|
250
|
+
setPending([])
|
|
166
251
|
},
|
|
167
|
-
|
|
168
|
-
if (process.env.MOBIUS_TUI_DEBUG) console.error('[
|
|
169
|
-
|
|
252
|
+
onEntries: ({ group_id, group_id_version, entries }) => {
|
|
253
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntries]', group_id, group_id_version, entries.length)
|
|
254
|
+
const gid = String(group_id ?? '')
|
|
255
|
+
if (!gid) return
|
|
256
|
+
let slot = groupSlotsRef.current.get(gid)
|
|
257
|
+
if (!slot) {
|
|
258
|
+
// 事件早到且本地无该组 (错过 group_created): 建槽后按水位线对账.
|
|
259
|
+
slot = { seq: groupSlotsRef.current.size + 1, version: 0, entries: [] }
|
|
260
|
+
groupSlotsRef.current.set(gid, slot)
|
|
261
|
+
}
|
|
262
|
+
const version = Number(group_id_version) || 0
|
|
263
|
+
if (slot.entries.length > 0 && version <= slot.version) return // 水位线: ≤ 本地即丢弃
|
|
264
|
+
// uuid 去重保险丝: 与整组重拉/对账重叠的条目只留一份.
|
|
265
|
+
const known = new Set<string>()
|
|
266
|
+
for (const e of slot.entries) { const k = entryKey(e); if (k) known.add(k) }
|
|
267
|
+
const fresh = entries.filter(e => {
|
|
268
|
+
const k = entryKey(e)
|
|
269
|
+
if (k && known.has(k)) return false
|
|
270
|
+
if (k) known.add(k)
|
|
271
|
+
return true
|
|
272
|
+
})
|
|
273
|
+
slot.entries = slot.entries.concat(fresh)
|
|
274
|
+
slot.version = Math.max(slot.version, version)
|
|
275
|
+
if (fresh.length > 0) {
|
|
276
|
+
appendEntries(fresh)
|
|
277
|
+
setPendingUser(null)
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
onPendingOpener: (opener) => {
|
|
281
|
+
// 忙时提交的新指令被挂起: 追加到排队行 (uuid 去重), 乐观占位随之退役.
|
|
282
|
+
if (!opener || typeof opener !== 'object') return
|
|
283
|
+
const id = String(opener.id ?? '')
|
|
284
|
+
setPending(prev => {
|
|
285
|
+
if (id && prev.some(p => p.id === id)) return prev
|
|
286
|
+
return [...prev, { id, opener_ts: opener.opener_ts ?? null, user_summary: opener.user_summary ?? '' }]
|
|
287
|
+
})
|
|
170
288
|
setPendingUser(null)
|
|
171
289
|
},
|
|
172
|
-
onSubscribed: () => {
|
|
290
|
+
onSubscribed: () => {
|
|
291
|
+
reconnectAttemptRef.current = 0
|
|
292
|
+
// 订阅即对账 (首开 = bootstrap 拉末尾几组; 重连 = stateless 补差额).
|
|
293
|
+
void reconcileHistory(sid)
|
|
294
|
+
},
|
|
173
295
|
onTyping: (active) => {
|
|
174
296
|
// SSE is a low-latency hint, not the source of truth. A `true` event
|
|
175
297
|
// lights the indicator immediately; either edge requests a fresh
|
|
@@ -179,7 +301,11 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
179
301
|
workingHintUntilRef.current = Date.now() + 1_500
|
|
180
302
|
updateTyping(true)
|
|
181
303
|
} else {
|
|
182
|
-
|
|
304
|
+
// A fresh turn uses a longer bootstrap grace period. Some agents
|
|
305
|
+
// emit an early typing=false edge before their worker is observable;
|
|
306
|
+
// do not let that transient edge erase the first-turn indicator.
|
|
307
|
+
const remaining = workingHintUntilRef.current - Date.now()
|
|
308
|
+
if (remaining < 5_000) workingHintUntilRef.current = 0
|
|
183
309
|
}
|
|
184
310
|
pollNowRef.current?.()
|
|
185
311
|
},
|
|
@@ -204,7 +330,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
204
330
|
})
|
|
205
331
|
sseRef.current = conn
|
|
206
332
|
conn.start()
|
|
207
|
-
}, [client.server, client.token, appendEntries, setHistory, updateTyping])
|
|
333
|
+
}, [client.server, client.token, appendEntries, setHistory, updateTyping, reconcileHistory])
|
|
208
334
|
doConnectRef.current = connect
|
|
209
335
|
|
|
210
336
|
const ensureSseForSend = useCallback((sid: string): boolean => {
|
|
@@ -374,7 +500,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
374
500
|
setError(null)
|
|
375
501
|
setPendingUser(body)
|
|
376
502
|
statusEpochRef.current += 1
|
|
377
|
-
|
|
503
|
+
const firstTurn = !sessionId && entries.length === 0
|
|
504
|
+
workingHintUntilRef.current = Date.now() + (firstTurn ? FIRST_TURN_BOOTSTRAP_GRACE_MS : 2_000)
|
|
378
505
|
sendingRef.current = true
|
|
379
506
|
updateTyping(true)
|
|
380
507
|
setSending(true)
|
|
@@ -400,7 +527,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
400
527
|
setSending(false)
|
|
401
528
|
pollNowRef.current?.()
|
|
402
529
|
}
|
|
403
|
-
}, [sending, ensureSession, ensureSseForSend, client, updateTyping])
|
|
530
|
+
}, [sending, sessionId, entries.length, ensureSession, ensureSseForSend, client, updateTyping])
|
|
404
531
|
|
|
405
532
|
const stop = useCallback(async () => {
|
|
406
533
|
if (!sessionId) return
|
|
@@ -412,5 +539,18 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
|
|
|
412
539
|
pollNowRef.current?.()
|
|
413
540
|
}, [sessionId, client, updateTyping])
|
|
414
541
|
|
|
415
|
-
|
|
542
|
+
// 打断当前 turn 并出队下一条排队指令 (空输入回车 / 插队). 不追加新 prompt,
|
|
543
|
+
// 后端对 claude-code/codex 发一次 C-c, deepseek harness 是空实现. 排队行会在
|
|
544
|
+
// 新组开轮 (group_created) 时被清空, 这里只需触发并刷新状态轮询.
|
|
545
|
+
const pauseToDequeue = useCallback(async () => {
|
|
546
|
+
if (!sessionId) return
|
|
547
|
+
statusEpochRef.current += 1
|
|
548
|
+
try { await client.pauseToDequeue(sessionId) } catch (e: any) {
|
|
549
|
+
const msg = e instanceof ApiError ? e.message : `插队失败: ${e?.message ?? e}`
|
|
550
|
+
setError(msg)
|
|
551
|
+
}
|
|
552
|
+
pollNowRef.current?.()
|
|
553
|
+
}, [sessionId, client])
|
|
554
|
+
|
|
555
|
+
return { entries, pendingUser, pending, typing, sending, error, sessionId, send, stop, pauseToDequeue }
|
|
416
556
|
}
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type EntryView =
|
|
|
31
31
|
| { kind: 'write_file'; filePath: string; content: string }
|
|
32
32
|
| { kind: 'system'; text: string }
|
|
33
33
|
| { kind: 'error'; text: string }
|
|
34
|
+
| { kind: 'pending'; text: string; count: number }
|
|
34
35
|
|
|
35
36
|
export interface ToolResultView {
|
|
36
37
|
text: string
|
|
@@ -113,7 +114,7 @@ function extractLocalCommandParts(entry: AnyEntry): LocalCommandPart[] {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
/**
|
|
116
|
-
* 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的
|
|
117
|
+
* 整卡隐藏的噪声: 对齐 web entry-classify.ts isHiddenJsonlNoiseEntry 的 8 类
|
|
117
118
|
* - token_count : codex 每轮 token 用量统计 (event_msg)
|
|
118
119
|
* - environment_context : codex 每轮注入的 <environment_context> 纯系统 user 消息
|
|
119
120
|
* - session_meta : codex 会话首条元数据
|
|
@@ -121,10 +122,12 @@ function extractLocalCommandParts(entry: AnyEntry): LocalCommandPart[] {
|
|
|
121
122
|
* - turn_duration : Claude Code 每轮结束注入的 system 耗时统计
|
|
122
123
|
* - skill_listing : Claude Code 注入的可用 Skill 清单
|
|
123
124
|
* - agent_listing_delta : Claude Code 注入的可用 subagent 清单
|
|
125
|
+
* - mcp_tool_call_end : MCP 工具调用完成生命周期标记
|
|
124
126
|
* 注: context_compacted 不在此列 (对齐 web — 它保留为可见事件, TUI 显示成 system 行).
|
|
125
127
|
*/
|
|
126
128
|
export function isHiddenNoise(entry: AnyEntry): boolean {
|
|
127
129
|
if (entry?.type === 'event_msg' && entry?.payload?.type === 'token_count') return true
|
|
130
|
+
if (entry?.type === 'event_msg' && entry?.payload?.type === 'mcp_tool_call_end') return true
|
|
128
131
|
if (entry?.type === 'session_meta') return true
|
|
129
132
|
if (entry?.type === 'turn_context') return true
|
|
130
133
|
if (entry?.type === 'system' && entry?.subtype === 'turn_duration') return true
|
|
@@ -515,6 +518,10 @@ function parseCustomToolCall(raw: any): { name: string; input: Record<string, an
|
|
|
515
518
|
export function viewsForBlock(block: Block): EntryView[] {
|
|
516
519
|
const entry = block.entry
|
|
517
520
|
if (!entry || typeof entry !== 'object') return [{ kind: 'skip' }]
|
|
521
|
+
// 排队行 (合成条目): 显示忙时挂起的用户指令, 与 web 排队卡片同形.
|
|
522
|
+
if (entry.type === '__pending_queue__') {
|
|
523
|
+
return [{ kind: 'pending', text: String(entry.text ?? ''), count: Number(entry.count) || 1 }]
|
|
524
|
+
}
|
|
518
525
|
if (isHiddenNoise(entry)) return [{ kind: 'skip' }]
|
|
519
526
|
const type = entry.type
|
|
520
527
|
|
package/src/lib/screen-text.ts
CHANGED
|
@@ -118,6 +118,7 @@ export type ScreenRowTone =
|
|
|
118
118
|
| 'reasoning'
|
|
119
119
|
| 'system'
|
|
120
120
|
| 'error'
|
|
121
|
+
| 'pending'
|
|
121
122
|
|
|
122
123
|
export interface ScreenRow {
|
|
123
124
|
/** Visible text used for geometry, hit-testing, and clipboard extraction. */
|
|
@@ -204,6 +205,11 @@ export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
|
|
|
204
205
|
const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
|
|
205
206
|
return { marginTop: true, rows: fit(rows, 'error') }
|
|
206
207
|
}
|
|
208
|
+
case 'pending': {
|
|
209
|
+
const summary = clampLines(view.text, width - 6, 1)[0] || '(无内容)'
|
|
210
|
+
const tail = view.count > 1 ? ` · 等 ${view.count} 条指令` : ''
|
|
211
|
+
return { marginTop: true, rows: fit([`⚡ 排队 · ${summary}${tail}`], 'pending') }
|
|
212
|
+
}
|
|
207
213
|
default:
|
|
208
214
|
return { marginTop: false, rows: [] }
|
|
209
215
|
}
|
package/src/sse.ts
CHANGED
|
@@ -15,8 +15,12 @@ import type { AnyEntry } from './types.js'
|
|
|
15
15
|
export interface SseHandlers {
|
|
16
16
|
onOpen?: () => void
|
|
17
17
|
onSubscribed?: (session: any) => void
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
/** ③ 新组元数据 (开轮卡本身随随后的 entries 事件到达). */
|
|
19
|
+
onGroupCreated?: (group: any) => void
|
|
20
|
+
/** ③ 组条目增量: version = 应用该批后的组版本 (调用方水位线判据). */
|
|
21
|
+
onEntries?: (payload: { group_id: string; group_id_version: number; entries: AnyEntry[] }) => void
|
|
22
|
+
/** 挂起开轮卡 (忙时提交、尚未出队的用户指令): { id, opener_ts, user_summary }. */
|
|
23
|
+
onPendingOpener?: (pending: { id: string; opener_ts: string | null; user_summary: string }) => void
|
|
20
24
|
onTyping?: (active: boolean) => void
|
|
21
25
|
onError?: (message: string, category?: string) => void
|
|
22
26
|
onClose?: () => void
|
|
@@ -105,11 +109,18 @@ export class SseConnection {
|
|
|
105
109
|
const ev = p?.event ?? eventName
|
|
106
110
|
switch (ev) {
|
|
107
111
|
case 'subscribed': this.handlers.onSubscribed?.(p.session); break
|
|
108
|
-
case '
|
|
109
|
-
this.handlers.
|
|
112
|
+
case 'group_created':
|
|
113
|
+
this.handlers.onGroupCreated?.(p.group)
|
|
110
114
|
break
|
|
111
|
-
case '
|
|
112
|
-
this.handlers.
|
|
115
|
+
case 'entries':
|
|
116
|
+
this.handlers.onEntries?.({
|
|
117
|
+
group_id: String(p.group_id ?? ''),
|
|
118
|
+
group_id_version: Number(p.group_id_version) || 0,
|
|
119
|
+
entries: Array.isArray(p.entries) ? p.entries : [],
|
|
120
|
+
})
|
|
121
|
+
break
|
|
122
|
+
case 'pending_opener':
|
|
123
|
+
this.handlers.onPendingOpener?.(p.entry)
|
|
113
124
|
break
|
|
114
125
|
case 'typing':
|
|
115
126
|
this.handlers.onTyping?.(!!p.active)
|
|
@@ -119,7 +130,7 @@ export class SseConnection {
|
|
|
119
130
|
this.handlers.onError?.(p.message ?? p.error ?? '未知错误', p.category)
|
|
120
131
|
break
|
|
121
132
|
default:
|
|
122
|
-
// history /
|
|
133
|
+
// history / message / stream / etc. — currently unused by the TUI.
|
|
123
134
|
break
|
|
124
135
|
}
|
|
125
136
|
}
|
package/src/types.ts
CHANGED
|
@@ -193,14 +193,33 @@ export interface ResourceAccess {
|
|
|
193
193
|
// ════════════════════════════════════════════════════════════════════════════
|
|
194
194
|
export type AnyEntry = Record<string, any>
|
|
195
195
|
|
|
196
|
+
// ── agent-history 组元数据 (协议 ① 的载荷) ───────────────────────────────────
|
|
197
|
+
export interface HistoryGroup {
|
|
198
|
+
id: string
|
|
199
|
+
seq: number
|
|
200
|
+
opener_ts: string | null
|
|
201
|
+
user_summary: string
|
|
202
|
+
version: number
|
|
203
|
+
entry_count: number
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 挂起中的开轮卡 (pending_round_openers): 忙时提交、尚未出队的用户指令.
|
|
208
|
+
* /groups 端点返回的 `pending` 数组与 SSE `pending_opener` 事件的 `entry` 字段同形.
|
|
209
|
+
*/
|
|
210
|
+
export interface HistoryPendingOpener {
|
|
211
|
+
id: string
|
|
212
|
+
opener_ts: string | null
|
|
213
|
+
user_summary: string
|
|
214
|
+
}
|
|
215
|
+
|
|
196
216
|
// ── SSE envelope events (GET /api/sessions/:id/events) ───────────────────────
|
|
197
217
|
// Each SSE frame's data is a JSON object with an `event` discriminator.
|
|
198
218
|
export type SseEvent =
|
|
199
219
|
| { event: 'subscribed'; session: Session }
|
|
200
220
|
| { event: 'history'; messages: any[]; total?: number }
|
|
201
|
-
| { event: '
|
|
202
|
-
| { event: '
|
|
203
|
-
| { event: 'jsonl_entry'; session_id: string; entry: AnyEntry }
|
|
221
|
+
| { event: 'group_created'; session_id: string; group: HistoryGroup }
|
|
222
|
+
| { event: 'entries'; session_id: string; group_id: string; group_id_version: number; entries: AnyEntry[] }
|
|
204
223
|
| { event: 'typing'; active: boolean }
|
|
205
224
|
| { event: 'error'; message?: string; category?: string }
|
|
206
225
|
| { event: 'server_error'; message?: string }
|