@mobius-os/mobius 0.3.45 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobius-os/mobius",
3
- "version": "0.3.45",
3
+ "version": "0.3.46",
4
4
  "type": "module",
5
5
  "description": "Mobius terminal client. Reuses Mobius frontend TypeScript types and jsonl entry shapes.",
6
6
  "bin": {
package/src/api.ts CHANGED
@@ -9,6 +9,7 @@ import type {
9
9
  AnyEntry,
10
10
  AuthConfig,
11
11
  HistoryGroup,
12
+ HistoryPendingOpener,
12
13
  Issue,
13
14
  LoginResponse,
14
15
  Memory,
@@ -163,11 +164,16 @@ export class MobiusClient {
163
164
  }
164
165
 
165
166
  // ── agent-history (协议 ①②: 组元数据 + 整组条目) ──────────────────────────
166
- /** ① 全部组元数据, 一次给全. */
167
- async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[] }> {
167
+ /** ① 全部组元数据, 一次给全; 顺带返回挂起中的开轮卡 (pending). */
168
+ async listHistoryGroups(sessionId: string): Promise<{ session_version: number; groups: HistoryGroup[]; pending: HistoryPendingOpener[] }> {
168
169
  return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups`)
169
170
  }
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
+
171
177
  /** ② 某组全部条目 (全量, 无分页, 条目不可变). */
172
178
  async listHistoryGroupEntries(sessionId: string, groupId: string): Promise<{ group_id: string; version: number; entries: AnyEntry[] }> {
173
179
  return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/groups/${encodeURIComponent(groupId)}/entries`)
@@ -161,15 +161,32 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
161
161
  // (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
162
162
  // 避免在累积视图里把同一条提问显示多次.
163
163
  const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
164
- const pendingEntry = useMemo<AnyEntry | null>(() => chat.pendingUser === null ? null : ({
165
- type: 'user',
166
- __id: '__pending-user__',
167
- message: { role: 'user', content: chat.pendingUser },
168
- }), [chat.pendingUser])
169
- const transcriptEntries = useMemo(
170
- () => pendingEntry ? [...dedupedEntries, pendingEntry] : dedupedEntries,
171
- [dedupedEntries, pendingEntry],
172
- )
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])
173
190
 
174
191
  // Markdown parsing and wrapping are paid once per entry/terminal width. Keep
175
192
  // the two most recent widths so resize-back does not immediately reparse the
@@ -414,6 +431,8 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
414
431
  commands={SLASH_COMMANDS}
415
432
  onHeightChange={setComposerRows}
416
433
  inputActiveRef={chatInputActiveRef}
434
+ onPauseToDequeue={chat.pauseToDequeue}
435
+ hasPending={chat.pending.length > 0}
417
436
  />
418
437
  <StatusArea
419
438
  ready={ready}
@@ -499,7 +518,8 @@ function ScreenText({ row, text }: { row: ScreenRow; text: string }) {
499
518
  : tone === 'edit_header' || tone === 'reasoning' ? 'magenta'
500
519
  : tone === 'edit_new' ? 'green'
501
520
  : tone === 'system' ? 'yellow'
502
- : undefined
521
+ : tone === 'pending' ? 'yellow'
522
+ : undefined
503
523
  const dimColor = tone === 'tool_result' || tone === 'tool_error' || tone === 'reasoning' || tone === 'system'
504
524
  return <Text wrap="truncate-end" bold={tone === 'user'} dimColor={dimColor} color={color}>{text}</Text>
505
525
  }
@@ -614,9 +634,12 @@ interface ComposerProps {
614
634
  commands: { cmd: string; desc: string }[]
615
635
  onHeightChange?: (rows: number) => void
616
636
  inputActiveRef?: React.RefObject<boolean>
637
+ // 排队插队: 有挂起指令时空输入回车触发; hasPending 控制提示文案与触发开关.
638
+ onPauseToDequeue?: () => void
639
+ hasPending?: boolean
617
640
  }
618
641
 
619
- 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) {
620
643
  const [value, setValue] = useState('')
621
644
  const [cursor, setCursor] = useState(0)
622
645
  const [popupIdx, setPopupIdx] = useState(0)
@@ -837,6 +860,9 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
837
860
  onSubmit(submitted)
838
861
  edit('', 0)
839
862
  setHistIdx(null)
863
+ } else if (hasPending) {
864
+ // 空输入回车 = 插队: 打断当前 turn 并出队下一条排队指令.
865
+ void onPauseToDequeue?.()
840
866
  }
841
867
  return
842
868
  }
@@ -969,7 +995,10 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
969
995
  <Box justifyContent="space-between">
970
996
  {confirmQuit
971
997
  ? <Text color="yellowBright" bold>请再次按下Ctrl+C退出</Text>
972
- : <Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>}
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>}
973
1002
  <Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
974
1003
  </Box>
975
1004
  </Box>
@@ -19,7 +19,7 @@ import { SseConnection } from '../sse.js'
19
19
  import { updateIssuePreference } from '../config.js'
20
20
  import { tuiAimuxIdentifier, probeAimuxBridgeConnection } from '../aimux.js'
21
21
  import { viewsForEntry } from '../lib/entry-view.js'
22
- import type { AnyEntry } from '../types.js'
22
+ import type { AnyEntry, HistoryPendingOpener } from '../types.js'
23
23
  import type { ReadyState } from '../components/PrepScreen.js'
24
24
 
25
25
  export interface ChatApi {
@@ -31,12 +31,14 @@ export interface ChatApi {
31
31
  export interface ChatController {
32
32
  entries: AnyEntry[]
33
33
  pendingUser: string | null
34
+ pending: HistoryPendingOpener[]
34
35
  typing: boolean
35
36
  sending: boolean
36
37
  error: string | null
37
38
  sessionId: string | null
38
39
  send: (text: string) => Promise<void>
39
40
  stop: () => Promise<void>
41
+ pauseToDequeue: () => Promise<void>
40
42
  }
41
43
 
42
44
  let ID = 0
@@ -110,6 +112,7 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
110
112
  const [sessionId, setSessionId] = useState<string | null>(resumeSessionId ?? null)
111
113
  const [entries, setEntries] = useState<AnyEntry[]>([])
112
114
  const [pendingUser, setPendingUser] = useState<string | null>(null)
115
+ const [pending, setPending] = useState<HistoryPendingOpener[]>([])
113
116
  const [typing, setTyping] = useState(false)
114
117
  const [sending, setSending] = useState(false)
115
118
  const [error, setError] = useState<string | null>(null)
@@ -185,6 +188,8 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
185
188
  try {
186
189
  const data = await client.listHistoryGroups(sid)
187
190
  const groups: any[] = Array.isArray(data?.groups) ? data.groups : []
191
+ // 挂起中的开轮卡 (排队指令): /groups 是权威快照, 覆盖本地增量.
192
+ setPending(Array.isArray(data?.pending) ? data.pending : [])
188
193
  const local = groupSlotsRef.current
189
194
  const targets = local.size === 0 ? groups.slice(-BOOTSTRAP_GROUP_COUNT) : groups
190
195
  let changed = false
@@ -240,6 +245,9 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
240
245
  version: Number(group.version) || 1,
241
246
  entries: [],
242
247
  })
248
+ // 新组开轮 = 后端已把挂起的 pending_round_openers 一次性出队 (flushPendingOpenersToSink).
249
+ // 排队行随之清空; 随后 entries 事件会把这一整组内容补齐.
250
+ setPending([])
243
251
  },
244
252
  onEntries: ({ group_id, group_id_version, entries }) => {
245
253
  if (process.env.MOBIUS_TUI_DEBUG) console.error('[onEntries]', group_id, group_id_version, entries.length)
@@ -269,6 +277,16 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
269
277
  setPendingUser(null)
270
278
  }
271
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
+ })
288
+ setPendingUser(null)
289
+ },
272
290
  onSubscribed: () => {
273
291
  reconnectAttemptRef.current = 0
274
292
  // 订阅即对账 (首开 = bootstrap 拉末尾几组; 重连 = stateless 补差额).
@@ -521,5 +539,18 @@ export function useChat({ client, ready, resumeSessionId }: ChatApi): ChatContro
521
539
  pollNowRef.current?.()
522
540
  }, [sessionId, client, updateTyping])
523
541
 
524
- return { entries, pendingUser, typing, sending, error, sessionId, send, stop }
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 }
525
556
  }
@@ -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 的 7
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
 
@@ -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
@@ -19,6 +19,8 @@ export interface SseHandlers {
19
19
  onGroupCreated?: (group: any) => void
20
20
  /** ③ 组条目增量: version = 应用该批后的组版本 (调用方水位线判据). */
21
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
22
24
  onTyping?: (active: boolean) => void
23
25
  onError?: (message: string, category?: string) => void
24
26
  onClose?: () => void
@@ -117,6 +119,9 @@ export class SseConnection {
117
119
  entries: Array.isArray(p.entries) ? p.entries : [],
118
120
  })
119
121
  break
122
+ case 'pending_opener':
123
+ this.handlers.onPendingOpener?.(p.entry)
124
+ break
120
125
  case 'typing':
121
126
  this.handlers.onTyping?.(!!p.active)
122
127
  break
package/src/types.ts CHANGED
@@ -203,6 +203,16 @@ export interface HistoryGroup {
203
203
  entry_count: number
204
204
  }
205
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
+
206
216
  // ── SSE envelope events (GET /api/sessions/:id/events) ───────────────────────
207
217
  // Each SSE frame's data is a JSON object with an `event` discriminator.
208
218
  export type SseEvent =