@mobius-os/mobius 0.3.25 → 0.3.27
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/App.tsx +6 -0
- package/src/components/Chat.tsx +32 -6
- package/src/components/ResumePicker.tsx +49 -9
- package/src/components/primitives.tsx +14 -3
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -21,10 +21,16 @@ import { ResumePicker } from './components/ResumePicker.js'
|
|
|
21
21
|
import { Screen } from './components/Screen.js'
|
|
22
22
|
import { startAimuxConnection, stopAimuxConnection, type AimuxStatus } from './aimux.js'
|
|
23
23
|
import { AimuxStatusLine } from './components/AimuxStatus.js'
|
|
24
|
+
import { useStableInput } from './components/primitives.js'
|
|
24
25
|
|
|
25
26
|
type Route = 'boot' | 'login' | 'prep' | 'chat' | 'resume'
|
|
26
27
|
|
|
27
28
|
export function App() {
|
|
29
|
+
// Keep stdin raw mode alive across route transitions. Without a persistent
|
|
30
|
+
// owner, Ink can briefly drop raw mode between an async picker unmount and
|
|
31
|
+
// the next Chat/Select mount, making the first arrows or typed characters
|
|
32
|
+
// appear ignored until a later key causes another render.
|
|
33
|
+
useStableInput(() => {})
|
|
28
34
|
const [route, setRoute] = useState<Route>('boot')
|
|
29
35
|
const [bootMsg, setBootMsg] = useState('初始化…')
|
|
30
36
|
const [client, setClient] = useState<MobiusClient | null>(null)
|
package/src/components/Chat.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
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,
|
|
11
|
+
import { Box, Text, 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'
|
|
@@ -24,7 +24,7 @@ import type { AnyEntry } from '../types.js'
|
|
|
24
24
|
import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
25
25
|
import type { AimuxStatus } from '../aimux.js'
|
|
26
26
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
27
|
-
import { isEscapeKeypress, isMouseInput, useMouseEvents } from './primitives.js'
|
|
27
|
+
import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
|
|
28
28
|
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
|
|
29
29
|
|
|
30
30
|
interface ChatProps {
|
|
@@ -112,6 +112,16 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
112
112
|
// (type:user / response_item.message[user] / event_msg.user_message) 合并成 1 条,
|
|
113
113
|
// 避免在累积视图里把同一条提问显示多次.
|
|
114
114
|
const dedupedEntries = useMemo(() => dedupeUserEntries(chat.entries), [chat.entries])
|
|
115
|
+
// Cache: entry index → rendered line count. Cleared when entries or columns change.
|
|
116
|
+
const entryLines = useRef<Map<number, number>>(new Map())
|
|
117
|
+
useEffect(() => { entryLines.current.clear() }, [dedupedEntries, terminal.columns])
|
|
118
|
+
const getEntryLines = useCallback((i: number) => {
|
|
119
|
+
const c = entryLines.current.get(i)
|
|
120
|
+
if (c !== undefined) return c
|
|
121
|
+
const n = entryScreenLines(viewsForEntry(dedupedEntries[i]), terminal.columns).length || 1
|
|
122
|
+
entryLines.current.set(i, n)
|
|
123
|
+
return n
|
|
124
|
+
}, [dedupedEntries, terminal.columns])
|
|
115
125
|
const viewportRows = terminal.isTty ? Math.max(9, terminal.rows - 1) : terminal.rows
|
|
116
126
|
const activityRows = (chat.typing ? 2 : 0) + (chat.error ? 1 : 0)
|
|
117
127
|
const helpRows = showHelp ? SLASH_COMMANDS.length + 3 : 0
|
|
@@ -149,7 +159,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
149
159
|
}, [client, ready.prefs.model])
|
|
150
160
|
const modelDisplay = modelLabel ?? ready.prefs.model ?? 'default'
|
|
151
161
|
|
|
152
|
-
|
|
162
|
+
useStableInput((_input, key) => {
|
|
153
163
|
// While a config/reconfig flow is open, this ChatScreen-level handler owns
|
|
154
164
|
// Esc so cancel is reliable even mid-list-loading (a per-component
|
|
155
165
|
// EscToCancel could be unmounted by the loading→loaded transition and drop
|
|
@@ -214,8 +224,24 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
214
224
|
useMouseEvents({
|
|
215
225
|
onWheel: (delta) => {
|
|
216
226
|
if (delta === 0) return
|
|
217
|
-
const
|
|
218
|
-
|
|
227
|
+
const targetLines = 2
|
|
228
|
+
let lines = 0
|
|
229
|
+
let next = scrollBack
|
|
230
|
+
const n = dedupedEntries.length
|
|
231
|
+
if (delta > 0) {
|
|
232
|
+
// scroll up (older): hide more entries from the tail
|
|
233
|
+
for (let i = n - 1 - next; i >= 0 && lines < targetLines; i--) {
|
|
234
|
+
lines += getEntryLines(i)
|
|
235
|
+
next++
|
|
236
|
+
}
|
|
237
|
+
} else {
|
|
238
|
+
// scroll down (newer): unhide entries from the tail
|
|
239
|
+
for (let i = n - next; i < n && lines < targetLines; i++) {
|
|
240
|
+
lines += getEntryLines(i)
|
|
241
|
+
next--
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
setScrollBack(Math.min(n, Math.max(0, next)))
|
|
219
245
|
},
|
|
220
246
|
onPress: (row, col) => {
|
|
221
247
|
const p = screenToSelPoint(row, col, transcriptModel, geometry)
|
|
@@ -772,7 +798,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
772
798
|
|
|
773
799
|
useEffect(() => () => resetPasteBurst(), [])
|
|
774
800
|
|
|
775
|
-
|
|
801
|
+
useStableInput((input, key) => {
|
|
776
802
|
if (isMouseInput(input)) return // mouse events must never become typed text
|
|
777
803
|
const now = Date.now()
|
|
778
804
|
const escape = isEscapeKeypress(input, key)
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /resume picker — list the ~32 most recently active sessions in the current
|
|
3
|
-
* project (aggregated across its issues,
|
|
4
|
-
*
|
|
3
|
+
* project (aggregated across its issues), sorted local-first then by last_active
|
|
4
|
+
* DESC. Sessions created on the current machine are visually marked.
|
|
5
5
|
*/
|
|
6
6
|
import React, { useEffect, useState } from 'react'
|
|
7
7
|
import { Box, Text } from 'ink'
|
|
8
8
|
import { Select } from './primitives.js'
|
|
9
9
|
import { MobiusClient } from '../api.js'
|
|
10
|
+
import { tuiAimuxIdentifier } from '../aimux.js'
|
|
10
11
|
import type { Project, Session } from '../types.js'
|
|
11
12
|
|
|
12
13
|
function relativeTime(iso?: string): string {
|
|
@@ -24,6 +25,24 @@ function relativeTime(iso?: string): string {
|
|
|
24
25
|
return new Date(iso).toISOString().slice(0, 10)
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Extract aimux_id from pc_client_metadata (object or JSON string). */
|
|
29
|
+
function sessionAimuxId(meta: unknown): string | null {
|
|
30
|
+
if (!meta) return null
|
|
31
|
+
if (typeof meta === 'string') {
|
|
32
|
+
try { meta = JSON.parse(meta) } catch { return null }
|
|
33
|
+
}
|
|
34
|
+
if (typeof meta === 'object' && meta !== null && typeof (meta as any).aimux_id === 'string') {
|
|
35
|
+
return (meta as any).aimux_id.trim() || null
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Optional: extract hostname hint from aimux_id (tui-<hostname> or desktop-<hostname>). */
|
|
41
|
+
function hostHint(aimuxId: string): string {
|
|
42
|
+
const m = aimuxId.match(/^(?:tui|desktop)-(.+)/)
|
|
43
|
+
return m ? m[1] : aimuxId
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
export function ResumePicker({ client, project, onPick, onBack }: {
|
|
28
47
|
client: MobiusClient
|
|
29
48
|
project: Project
|
|
@@ -34,7 +53,7 @@ export function ResumePicker({ client, project, onPick, onBack }: {
|
|
|
34
53
|
const [err, setErr] = useState<string | null>(null)
|
|
35
54
|
|
|
36
55
|
useEffect(() => {
|
|
37
|
-
client.listProjectSessions(project.id,
|
|
56
|
+
client.listProjectSessions(project.id, 64)
|
|
38
57
|
.then(setSessions)
|
|
39
58
|
.catch(e => setErr(e?.message ?? String(e)))
|
|
40
59
|
}, [client, project.id])
|
|
@@ -42,16 +61,37 @@ export function ResumePicker({ client, project, onPick, onBack }: {
|
|
|
42
61
|
if (err) return <Box paddingX={2}><Text color="red">加载会话失败: {err}</Text></Box>
|
|
43
62
|
if (sessions === null) return <Box paddingX={2}><Text color="cyan">加载历史会话…</Text></Box>
|
|
44
63
|
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
64
|
+
const myId = tuiAimuxIdentifier()
|
|
65
|
+
|
|
66
|
+
// Sort: local sessions first, then remote; each group by last_active DESC.
|
|
67
|
+
const sorted = [...sessions].sort((a, b) => {
|
|
68
|
+
const aLocal = sessionAimuxId(a.pc_client_metadata) === myId ? 0 : 1
|
|
69
|
+
const bLocal = sessionAimuxId(b.pc_client_metadata) === myId ? 0 : 1
|
|
70
|
+
if (aLocal !== bLocal) return aLocal - bLocal
|
|
71
|
+
return (b.last_active || '').localeCompare(a.last_active || '')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const localCount = sorted.filter(s => sessionAimuxId(s.pc_client_metadata) === myId).length
|
|
75
|
+
|
|
76
|
+
const items = sorted.map(s => {
|
|
77
|
+
const aid = sessionAimuxId(s.pc_client_metadata)
|
|
78
|
+
const isLocal = aid === myId
|
|
79
|
+
const host = aid ? hostHint(aid) : null
|
|
80
|
+
const marker = isLocal ? '💻 ' : host ? `🌐 ${host} ` : '🌐 ? '
|
|
81
|
+
const label = `${marker}${s.name}${s.issue_title ? ` · ${s.issue_title}` : ''}`
|
|
82
|
+
const time = relativeTime(s.last_active)
|
|
83
|
+
const desc = `${time} · ${s.message_count ?? 0} 条消息 · ${s.model ?? '?'}`
|
|
84
|
+
return { label, value: s.session_id, desc }
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const hint = localCount > 0
|
|
88
|
+
? `本机 ${localCount} 个 · 远程 ${sorted.length - localCount} 个,共 ${sorted.length} 个`
|
|
89
|
+
: `全部 ${sorted.length} 个(无本机会话)`
|
|
50
90
|
|
|
51
91
|
return (
|
|
52
92
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
53
93
|
<Text bold color="cyan">恢复历史会话({project.name})</Text>
|
|
54
|
-
<Text color="gray"
|
|
94
|
+
<Text color="gray">{hint}</Text>
|
|
55
95
|
<Box marginTop={1}>
|
|
56
96
|
{items.length === 0
|
|
57
97
|
? <Text color="gray">(暂无历史会话)</Text>
|
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
* Select (single-choice list + multi-choice with checkboxes), and a Spinner.
|
|
4
4
|
*/
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react'
|
|
6
|
-
import { Box, Text, useInput, useStdout, useStdin } from 'ink'
|
|
6
|
+
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
7
|
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
8
8
|
|
|
9
|
+
type InputHandler = (input: string, key: Key) => void
|
|
10
|
+
|
|
11
|
+
/** Keep one Ink listener while a component rerenders; read the latest handler through a ref. */
|
|
12
|
+
export function useStableInput(handler: InputHandler, options?: { isActive?: boolean }): void {
|
|
13
|
+
const handlerRef = useRef(handler)
|
|
14
|
+
handlerRef.current = handler
|
|
15
|
+
const stableRef = useRef<InputHandler | null>(null)
|
|
16
|
+
if (!stableRef.current) stableRef.current = (input, key) => handlerRef.current(input, key)
|
|
17
|
+
useInput(stableRef.current, options)
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
/** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
|
|
10
21
|
export function isEscapeKeypress(input: string, key: { escape?: boolean; ctrl?: boolean }): boolean {
|
|
11
22
|
return key.escape === true || input === '\x1b' || (key.ctrl === true && input === '[')
|
|
@@ -185,7 +196,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
185
196
|
edit(text, nextCursor)
|
|
186
197
|
})
|
|
187
198
|
|
|
188
|
-
|
|
199
|
+
useStableInput((input, key) => {
|
|
189
200
|
if (isMouseInput(input)) return
|
|
190
201
|
if (key.return) { props.onSubmit?.(); return }
|
|
191
202
|
if (key.upArrow) { props.onArrowUp?.(); return }
|
|
@@ -309,7 +320,7 @@ export function Select(props: SelectProps) {
|
|
|
309
320
|
|
|
310
321
|
useEffect(() => { setActive(a => Math.min(a, Math.max(0, items.length - 1))) }, [items.length])
|
|
311
322
|
|
|
312
|
-
|
|
323
|
+
useStableInput((input, key) => {
|
|
313
324
|
if (!items.length) return
|
|
314
325
|
if (isMouseInput(input)) return
|
|
315
326
|
if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
|