@mobius-os/mobius 0.3.34 → 0.3.38
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/scripts/build-python-bundles.sh +2 -2
- package/src/App.tsx +18 -2
- package/src/aimux.ts +17 -8
- package/src/components/Chat.tsx +19 -5
- package/src/components/ConfigFlow.tsx +30 -6
- package/src/components/Login.tsx +5 -3
- package/src/components/PrepScreen.tsx +98 -25
- package/src/components/primitives.tsx +23 -4
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/markdown.ts +34 -10
- package/tests/aimux.test.tsx +17 -6
- package/tests/flow.test.tsx +25 -4
- package/tests/ui.test.tsx +128 -9
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import React, { useEffect, useState } from 'react'
|
|
13
13
|
import { Box, Text } from 'ink'
|
|
14
14
|
import { MobiusClient, getMe, login, ApiError } from './api.js'
|
|
15
|
-
import { loadLogin, saveLogin, type LoginRecord } from './config.js'
|
|
15
|
+
import { clearLogin, loadLogin, saveLogin, type LoginRecord } from './config.js'
|
|
16
16
|
import { LoginScreen } from './components/Login.js'
|
|
17
17
|
import { PrepScreen, type ReadyState } from './components/PrepScreen.js'
|
|
18
18
|
import { ChatScreen } from './components/Chat.js'
|
|
@@ -150,6 +150,21 @@ export function App() {
|
|
|
150
150
|
void stopAimuxConnection().finally(() => process.exit(0))
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
+
async function onLogout() {
|
|
154
|
+
if (process.env.MOBIUS_TUI_DEBUG) console.error('[route] logout')
|
|
155
|
+
try {
|
|
156
|
+
await clearLogin()
|
|
157
|
+
await stopAimuxConnection()
|
|
158
|
+
} finally {
|
|
159
|
+
setClient(null)
|
|
160
|
+
setUserId(null)
|
|
161
|
+
setReady(null)
|
|
162
|
+
setResumeSessionId(null)
|
|
163
|
+
setAimuxStatus({ state: 'stopped', phase: 'idle', detail: '登录后自动连接' })
|
|
164
|
+
setRoute('login')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
153
168
|
// ── render ─────────────────────────────────────────────────────────────────
|
|
154
169
|
// The chat screen already pins itself to the terminal height, so render it
|
|
155
170
|
// bare — a <Screen> wrapper would clip its transcript in short terminals and
|
|
@@ -168,6 +183,7 @@ export function App() {
|
|
|
168
183
|
onClear={onClear}
|
|
169
184
|
onResume={onResume}
|
|
170
185
|
onQuit={onQuit}
|
|
186
|
+
onLogout={() => { void onLogout() }}
|
|
171
187
|
onReconfigure={onReconfigure}
|
|
172
188
|
onConfigCancel={onConfigCancel}
|
|
173
189
|
aimuxStatus={aimuxStatus}
|
|
@@ -178,7 +194,7 @@ export function App() {
|
|
|
178
194
|
if (route === 'boot') {
|
|
179
195
|
node = <Box paddingX={2} paddingY={1}><Text color="cyan">{bootMsg}</Text></Box>
|
|
180
196
|
} else if (route === 'login' || !client) {
|
|
181
|
-
node = <LoginScreen onSuccess={onLoginSuccess} />
|
|
197
|
+
node = <LoginScreen onSuccess={onLoginSuccess} initialServer={prefill.server} initialUsername={prefill.username} />
|
|
182
198
|
} else if (route === 'resume' && ready) {
|
|
183
199
|
node = <Box flexDirection="column"><AimuxStatusLine status={aimuxStatus} compact /><ResumePicker client={client} project={ready.project} onPick={onResumed} onBack={() => setRoute('chat')} /></Box>
|
|
184
200
|
} else {
|
package/src/aimux.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* currently authenticated Mobius server. Nothing is started before login.
|
|
7
7
|
*/
|
|
8
8
|
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
9
10
|
import { promises as fs, existsSync, createWriteStream } from 'node:fs'
|
|
10
11
|
import os from 'node:os'
|
|
11
12
|
import path from 'node:path'
|
|
@@ -88,8 +89,8 @@ async function pythonForAimux(onProgress?: (p: InstallProgress) => void): Promis
|
|
|
88
89
|
// 解压到 ~/.mobius/python-bundle/ 后用 `<python> -m aimux` 运行,彻底绕开宿主机
|
|
89
90
|
// 系统 python(如被精简掉 ensurepip 的容器镜像)。aimux 全部依赖为纯 Python,
|
|
90
91
|
// 故三平台可共用同一套打包产物,分别按 arch 发布到 CDN。
|
|
91
|
-
const BUNDLE_VER = '
|
|
92
|
-
const BUNDLE_AIMUX_VERSION = '0.1.
|
|
92
|
+
const BUNDLE_VER = '3'
|
|
93
|
+
const BUNDLE_AIMUX_VERSION = '0.1.23'
|
|
93
94
|
const bundleDir = () => path.join(mobiusHome(), 'python-bundle')
|
|
94
95
|
const bundlePython = () => WIN
|
|
95
96
|
? path.join(bundleDir(), 'python', 'python.exe')
|
|
@@ -300,16 +301,24 @@ export async function ensureAimux(onProgress?: (p: InstallProgress) => void): Pr
|
|
|
300
301
|
return { ok: false, error: `${venvError};内置运行时也失败: ${bundle.error}` }
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
export function tuiAimuxIdentifier(): string {
|
|
304
|
-
const host =
|
|
305
|
-
|
|
304
|
+
export function tuiAimuxIdentifier(hostname = os.hostname(), cwd = process.cwd()): string {
|
|
305
|
+
const host = hostname.toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32)
|
|
306
|
+
// One machine may run several Mobius TUIs for different projects. A
|
|
307
|
+
// hostname-only identifier makes every reverse client register with the
|
|
308
|
+
// same name and --replace continuously evicts its siblings ("client
|
|
309
|
+
// replaced"). The normalized cwd hash is stable across restarts/resume but
|
|
310
|
+
// unique for the common multi-project case.
|
|
311
|
+
const workspace = createHash('sha256').update(path.resolve(cwd)).digest('hex').slice(0, 10)
|
|
312
|
+
return `tui-${host || 'pc'}-${workspace}`
|
|
306
313
|
}
|
|
307
314
|
|
|
308
315
|
/**
|
|
309
316
|
* Build the reverse-connect command in one place. The TUI can launch AIMUX
|
|
310
317
|
* through either a venv executable or bundled Python; both paths must request
|
|
311
|
-
*
|
|
312
|
-
* keyboard focus from the TUI.
|
|
318
|
+
* a fully headless Windows shell or every remote command flashes a console and
|
|
319
|
+
* steals keyboard focus from the TUI. Keep the old --silent-shell path
|
|
320
|
+
* available for older AIMUX bundles; current bundles use the no-console v2
|
|
321
|
+
* implementation (the historical spelling --slient-v2 is intentional).
|
|
313
322
|
*/
|
|
314
323
|
export function reverseConnectArgs(
|
|
315
324
|
server: string,
|
|
@@ -322,7 +331,7 @@ export function reverseConnectArgs(
|
|
|
322
331
|
'--identifier', identifier,
|
|
323
332
|
'--token', token,
|
|
324
333
|
'--replace',
|
|
325
|
-
...(platform === 'win32' ? ['--
|
|
334
|
+
...(platform === 'win32' ? ['--slient-v2'] : []),
|
|
326
335
|
]
|
|
327
336
|
}
|
|
328
337
|
|
package/src/components/Chat.tsx
CHANGED
|
@@ -27,7 +27,8 @@ import { ConfigFlow, ReconfigFlow, type ConfigResult } from './ConfigFlow.js'
|
|
|
27
27
|
import type { AimuxStatus } from '../aimux.js'
|
|
28
28
|
import { AimuxStatusLine, aimuxStatusText } from './AimuxStatus.js'
|
|
29
29
|
import { isEscapeKeypress, isMouseInput, useMouseEvents, useStableInput } from './primitives.js'
|
|
30
|
-
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary } from '../lib/delete-keys.js'
|
|
30
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousCursorBoundary, nextCursorBoundary, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
|
|
31
|
+
import { useCursorKeyCapture } from '../lib/cursor-keys.js'
|
|
31
32
|
|
|
32
33
|
interface ChatProps {
|
|
33
34
|
client: MobiusClient
|
|
@@ -37,6 +38,7 @@ interface ChatProps {
|
|
|
37
38
|
onClear: () => void
|
|
38
39
|
onResume: () => void
|
|
39
40
|
onQuit: () => void
|
|
41
|
+
onLogout: () => void
|
|
40
42
|
onReconfigure: (result: ConfigResult) => void
|
|
41
43
|
onConfigCancel: (sessionId: string | null) => void
|
|
42
44
|
aimuxStatus?: AimuxStatus
|
|
@@ -57,11 +59,12 @@ const SLASH_COMMANDS = [
|
|
|
57
59
|
{ cmd: '/resume', desc: '恢复一个历史会话' },
|
|
58
60
|
{ cmd: '/model', desc: '更换模型并开启新会话(保留当前任务)' },
|
|
59
61
|
{ cmd: '/config', desc: '重新选择项目、任务和模型' },
|
|
62
|
+
{ cmd: '/logout', desc: '断开当前连接并返回登录界面' },
|
|
60
63
|
{ cmd: '/help', desc: '显示帮助' },
|
|
61
64
|
{ cmd: '/quit', desc: '退出 TUI' },
|
|
62
65
|
]
|
|
63
66
|
|
|
64
|
-
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
67
|
+
export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear, onResume, onQuit, onLogout, onReconfigure, onConfigCancel, aimuxStatus }: ChatProps) {
|
|
65
68
|
const chat = useChat({ client, ready, resumeSessionId })
|
|
66
69
|
const [showHelp, setShowHelp] = useState(false)
|
|
67
70
|
// null means "follow the tail". A concrete anchor identifies the exact row
|
|
@@ -91,10 +94,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
91
94
|
case '/help': setShowHelp(s => !s); return true
|
|
92
95
|
case '/model': setConfigOpen(true); return true
|
|
93
96
|
case '/config': setReconfigOpen(true); return true
|
|
97
|
+
case '/logout': onLogout(); return true
|
|
94
98
|
case '/quit': case '/exit': onQuit(); return true
|
|
95
99
|
default: return false
|
|
96
100
|
}
|
|
97
|
-
}, [onClear, onResume, onQuit])
|
|
101
|
+
}, [onClear, onResume, onQuit, onLogout])
|
|
98
102
|
|
|
99
103
|
const onSubmit = useCallback((text: string) => {
|
|
100
104
|
const t = text.trim()
|
|
@@ -201,10 +205,11 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
201
205
|
// the keypress). configOpen/reconfigOpen/sessionId are read from handlerRef
|
|
202
206
|
// (see above) because Ink keeps the originally-registered callback and would
|
|
203
207
|
// otherwise see a stale closure.
|
|
204
|
-
if (handlerRef.current.configOpen
|
|
208
|
+
if (handlerRef.current.configOpen) {
|
|
205
209
|
if (isEscapeKeypress(_input, key)) onConfigCancel(handlerRef.current.sessionId)
|
|
206
210
|
return
|
|
207
211
|
}
|
|
212
|
+
if (handlerRef.current.reconfigOpen) return // ReconfigFlow owns hierarchical Esc navigation.
|
|
208
213
|
if (key.pageUp) scrollRows(-pageRows)
|
|
209
214
|
else if (key.pageDown) scrollRows(pageRows)
|
|
210
215
|
}, { interactive: false })
|
|
@@ -310,6 +315,7 @@ export function ChatScreen({ client, ready, webUserId, resumeSessionId, onClear,
|
|
|
310
315
|
<ReconfigFlow
|
|
311
316
|
client={client}
|
|
312
317
|
onDone={(result) => onReconfigure(result)}
|
|
318
|
+
onCancel={() => onConfigCancel(chat.sessionId)}
|
|
313
319
|
/>
|
|
314
320
|
</Box>
|
|
315
321
|
)
|
|
@@ -589,6 +595,13 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
589
595
|
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
590
596
|
edit(text, nextCursor)
|
|
591
597
|
})
|
|
598
|
+
useCursorKeyCapture(true, (intent) => {
|
|
599
|
+
const current = valueRef.current
|
|
600
|
+
const at = clampCursor(current, cursorRef.current)
|
|
601
|
+
const next = intent === 'home' ? 0 : intent === 'end' ? current.length
|
|
602
|
+
: intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
|
|
603
|
+
moveCursor(next)
|
|
604
|
+
})
|
|
592
605
|
|
|
593
606
|
const filtered = useMemo(() => {
|
|
594
607
|
const match = /^(\w*)$/.exec(value.slice(1))
|
|
@@ -790,6 +803,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
790
803
|
edit(text, nextCursor)
|
|
791
804
|
return
|
|
792
805
|
}
|
|
806
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) return
|
|
793
807
|
if (key.leftArrow) { moveCursor(previousCursorBoundary(current, at)); return }
|
|
794
808
|
if (key.rightArrow) { moveCursor(nextCursorBoundary(current, at)); return }
|
|
795
809
|
|
|
@@ -885,7 +899,7 @@ export function Composer({ onSubmit, onStop, onQuit, typing, commands, onHeightC
|
|
|
885
899
|
})}
|
|
886
900
|
</Box>
|
|
887
901
|
<Box justifyContent="space-between">
|
|
888
|
-
<Text dimColor>{(stdout.columns ?? 80) >=
|
|
902
|
+
<Text dimColor>{(stdout.columns ?? 80) >= 72 ? 'Enter 发送 · Shift+Enter / Alt+Enter / Ctrl+J 换行' : 'Enter 发送 · Alt+Enter / Ctrl+J 换行'}</Text>
|
|
889
903
|
<Text dimColor>{wrapped.length > maxRows ? `${visualCursor + 1}/${wrapped.length} 行` : `${wrapped.length} 行`}</Text>
|
|
890
904
|
</Box>
|
|
891
905
|
</Box>
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import React, { useEffect, useRef, useState } from 'react'
|
|
23
23
|
import { Box, Text } from 'ink'
|
|
24
|
-
import { Select, TextInput, Spinner, type SelectItem } from './primitives.js'
|
|
24
|
+
import { isEscapeKeypress, Select, TextInput, Spinner, useStableInput, type SelectItem } from './primitives.js'
|
|
25
25
|
import { MobiusClient } from '../api.js'
|
|
26
26
|
import {
|
|
27
27
|
bindCwdToProject, cwd, getCwdPreference, loadDir2Project, loadProjectsCache,
|
|
@@ -118,8 +118,8 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
118
118
|
: <Select
|
|
119
119
|
items={models.map(o => ({
|
|
120
120
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
121
|
-
value: o.key,
|
|
122
121
|
desc: o.sub,
|
|
122
|
+
value: o.key,
|
|
123
123
|
}))}
|
|
124
124
|
onSelect={key => void pickModel(key)}
|
|
125
125
|
/>}
|
|
@@ -134,9 +134,10 @@ export function ConfigFlow({ client, issue, onDone }: {
|
|
|
134
134
|
|
|
135
135
|
type ReconfigStep = 'projects' | 'issues' | 'models' | 'creating'
|
|
136
136
|
|
|
137
|
-
export function ReconfigFlow({ client, onDone }: {
|
|
137
|
+
export function ReconfigFlow({ client, onDone, onCancel }: {
|
|
138
138
|
client: MobiusClient
|
|
139
139
|
onDone: (r: ConfigResult) => void
|
|
140
|
+
onCancel: () => void
|
|
140
141
|
}) {
|
|
141
142
|
const [step, setStep] = useState<ReconfigStep>('projects')
|
|
142
143
|
const [projects, setProjects] = useState<Project[] | null>(null)
|
|
@@ -151,6 +152,29 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
151
152
|
const doneRef = useRef(false)
|
|
152
153
|
const thisCwd = cwd()
|
|
153
154
|
|
|
155
|
+
function goBack() {
|
|
156
|
+
// The focused TextInput owns Esc while a create form is open. Returning
|
|
157
|
+
// here prevents the same raw key from also navigating the underlying step.
|
|
158
|
+
if (createMode !== null) return
|
|
159
|
+
if (step === 'models') {
|
|
160
|
+
setIssue(null)
|
|
161
|
+
setStep('issues')
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
if (step === 'issues') {
|
|
165
|
+
setProject(null)
|
|
166
|
+
setIssue(null)
|
|
167
|
+
setStep('projects')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (step === 'projects') onCancel()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Own Esc across both loaded lists and their async loading frames.
|
|
174
|
+
useStableInput((input, key) => {
|
|
175
|
+
if (isEscapeKeypress(input, key)) goBack()
|
|
176
|
+
})
|
|
177
|
+
|
|
154
178
|
useEffect(() => () => { doneRef.current = true }, [])
|
|
155
179
|
|
|
156
180
|
// Load projects on mount.
|
|
@@ -320,7 +344,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
320
344
|
: <Select
|
|
321
345
|
items={[
|
|
322
346
|
{ label: '➕ 创建新项目', value: '__create__' },
|
|
323
|
-
...projects.map(p => ({ label: p.name,
|
|
347
|
+
...projects.map(p => ({ label: p.name, desc: p.description, value: p.id })),
|
|
324
348
|
]}
|
|
325
349
|
initialActive={projects.length > 0 ? 1 : 0}
|
|
326
350
|
onSelect={v => v === '__create__' ? setCreateMode('project') : pickProject(projects!.find(p => p.id === v)!)}
|
|
@@ -344,7 +368,7 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
344
368
|
: <Select
|
|
345
369
|
items={[
|
|
346
370
|
{ label: '➕ 创建新任务', value: '__create__' },
|
|
347
|
-
...issues.map(i => ({ label: i.title,
|
|
371
|
+
...issues.map(i => ({ label: i.title, desc: i.description, value: i.id })),
|
|
348
372
|
]}
|
|
349
373
|
initialActive={1}
|
|
350
374
|
onSelect={v => v === '__create__' ? setCreateMode('issue') : pickIssue(issues!.find(i => i.id === v)!)} />}
|
|
@@ -365,8 +389,8 @@ export function ReconfigFlow({ client, onDone }: {
|
|
|
365
389
|
: <Select
|
|
366
390
|
items={models.map(o => ({
|
|
367
391
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
368
|
-
value: o.key,
|
|
369
392
|
desc: o.sub,
|
|
393
|
+
value: o.key,
|
|
370
394
|
}))}
|
|
371
395
|
onSelect={key => void pickModel(key)}
|
|
372
396
|
/>}
|
package/src/components/Login.tsx
CHANGED
|
@@ -15,12 +15,14 @@ import { saveLogin, type LoginRecord } from '../config.js'
|
|
|
15
15
|
|
|
16
16
|
const DEFAULT_SERVER = ''
|
|
17
17
|
|
|
18
|
-
export function LoginScreen({ onSuccess, onError }: {
|
|
18
|
+
export function LoginScreen({ onSuccess, onError, initialServer = DEFAULT_SERVER, initialUsername = '' }: {
|
|
19
19
|
onSuccess: (rec: LoginRecord) => void
|
|
20
20
|
onError?: (msg: string) => void
|
|
21
|
+
initialServer?: string
|
|
22
|
+
initialUsername?: string
|
|
21
23
|
}) {
|
|
22
|
-
const [server, setServer] = useState(
|
|
23
|
-
const [username, setUsername] = useState(
|
|
24
|
+
const [server, setServer] = useState(initialServer)
|
|
25
|
+
const [username, setUsername] = useState(initialUsername)
|
|
24
26
|
const [password, setPassword] = useState('')
|
|
25
27
|
const [pwdRequired, setPwdRequired] = useState<boolean | null>(null)
|
|
26
28
|
const [focus, setFocus] = useState(0) // 0 server, 1 user, 2 password
|
|
@@ -230,10 +230,75 @@ function toItems(arr: { id: string; name: string; description?: string }[]): Sel
|
|
|
230
230
|
return arr.map(s => ({ label: s.name, value: s.id, desc: s.description }))
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
// Search-first picker used by the project and issue screens. The search field
|
|
234
|
+
// owns the initial focus so a user can type immediately; Down hands control to
|
|
235
|
+
// the normal Select for keyboard navigation. Enter in the search field chooses
|
|
236
|
+
// the first matching row, which keeps the common "type a unique name, Enter"
|
|
237
|
+
// workflow to one step.
|
|
238
|
+
function SearchableSelect({
|
|
239
|
+
items,
|
|
240
|
+
createItem,
|
|
241
|
+
title,
|
|
242
|
+
placeholder,
|
|
243
|
+
onSelect,
|
|
244
|
+
onCreate,
|
|
245
|
+
onQuit,
|
|
246
|
+
}: {
|
|
247
|
+
items: SelectItem[]
|
|
248
|
+
createItem?: SelectItem
|
|
249
|
+
title: string
|
|
250
|
+
placeholder: string
|
|
251
|
+
onSelect: (value: string) => void
|
|
252
|
+
onCreate?: () => void
|
|
253
|
+
onQuit?: () => void
|
|
254
|
+
}) {
|
|
255
|
+
const [query, setQuery] = useState('')
|
|
256
|
+
const [focus, setFocus] = useState<'search' | 'list'>('search')
|
|
257
|
+
const needle = query.trim().toLocaleLowerCase()
|
|
258
|
+
const matches = (item: SelectItem) => {
|
|
259
|
+
if (!needle) return true
|
|
260
|
+
return `${item.label}\n${item.desc ?? ''}\n${item.value}`.toLocaleLowerCase().includes(needle)
|
|
261
|
+
}
|
|
262
|
+
const filtered = items.filter(matches)
|
|
263
|
+
const createMatches = createItem && matches(createItem)
|
|
264
|
+
const visibleItems = createItem && (!needle || createMatches)
|
|
265
|
+
? [createItem, ...filtered]
|
|
266
|
+
: filtered
|
|
267
|
+
|
|
268
|
+
function choose(value: string) {
|
|
269
|
+
if (value === createItem?.value) onCreate?.()
|
|
270
|
+
else onSelect(value)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return (
|
|
274
|
+
<Box flexDirection="column">
|
|
275
|
+
<Text bold color="cyan">{title}</Text>
|
|
276
|
+
<Box marginTop={1} flexDirection="column">
|
|
277
|
+
<TextInput
|
|
278
|
+
value={query}
|
|
279
|
+
onChange={setQuery}
|
|
280
|
+
focused={focus === 'search'}
|
|
281
|
+
prompt="搜索:"
|
|
282
|
+
placeholder={placeholder}
|
|
283
|
+
onArrowDown={() => setFocus('list')}
|
|
284
|
+
onArrowUp={() => setFocus('list')}
|
|
285
|
+
onSubmit={() => { if (visibleItems.length) choose(visibleItems[0].value) }}
|
|
286
|
+
onEscape={() => onQuit?.()}
|
|
287
|
+
/>
|
|
288
|
+
{query.trim() && !filtered.length
|
|
289
|
+
? <Text color="yellow">没有匹配的项目,请修改搜索</Text>
|
|
290
|
+
: focus === 'search' && query.trim() ? <Text color="gray">匹配 {filtered.length} 项 · ↓进入列表</Text> : null}
|
|
291
|
+
{focus === 'list' && !visibleItems.length
|
|
292
|
+
? <Text color="yellow">没有匹配的项目,请按 Esc 修改搜索</Text>
|
|
293
|
+
: <Select
|
|
294
|
+
items={visibleItems}
|
|
295
|
+
focused={focus === 'list'}
|
|
296
|
+
onBack={() => setFocus('search')}
|
|
297
|
+
onSelect={choose}
|
|
298
|
+
/>}
|
|
299
|
+
</Box>
|
|
300
|
+
</Box>
|
|
301
|
+
)
|
|
237
302
|
}
|
|
238
303
|
|
|
239
304
|
// ── Project picker ───────────────────────────────────────────────────────────
|
|
@@ -264,22 +329,25 @@ function ProjectPicker({ cwd, projects, statusMsg, onPick, onCreate, onQuit }: {
|
|
|
264
329
|
)
|
|
265
330
|
}
|
|
266
331
|
|
|
267
|
-
const items: SelectItem[] =
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}),
|
|
273
|
-
]
|
|
332
|
+
const items: SelectItem[] = projects.map(p => ({
|
|
333
|
+
label: p.name,
|
|
334
|
+
desc: p.description,
|
|
335
|
+
value: p.id,
|
|
336
|
+
}))
|
|
274
337
|
return (
|
|
275
338
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
|
276
|
-
<Text bold color="cyan">选择当前路径的绑定项目</Text>
|
|
277
339
|
<Text color="gray">{cwd}</Text>
|
|
278
|
-
<
|
|
279
|
-
|
|
280
|
-
|
|
340
|
+
<SearchableSelect
|
|
341
|
+
title="选择当前路径的绑定项目"
|
|
342
|
+
placeholder="输入项目名或描述"
|
|
343
|
+
items={items}
|
|
344
|
+
createItem={{ label: '➕ 创建新项目', value: '__create__', desc: '绑定到当前路径' }}
|
|
345
|
+
onSelect={v => onPick(projects.find(p => p.id === v)!)}
|
|
346
|
+
onCreate={() => setMode('create')}
|
|
347
|
+
onQuit={onQuit}
|
|
348
|
+
/>
|
|
281
349
|
{statusMsg ? <Text color="yellow">{statusMsg}</Text> : null}
|
|
282
|
-
<Text color="gray"
|
|
350
|
+
<Text color="gray">输入关键词筛选 · ↓进入列表 · Esc 退出</Text>
|
|
283
351
|
</Box>
|
|
284
352
|
)
|
|
285
353
|
}
|
|
@@ -306,18 +374,23 @@ function IssuePicker({ issues, onPick, onCreate }: {
|
|
|
306
374
|
</Box>
|
|
307
375
|
)
|
|
308
376
|
}
|
|
309
|
-
const items: SelectItem[] =
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
377
|
+
const items: SelectItem[] = issues.map(i => ({
|
|
378
|
+
label: i.title,
|
|
379
|
+
desc: i.description,
|
|
380
|
+
value: i.id,
|
|
381
|
+
}))
|
|
313
382
|
return (
|
|
314
383
|
<Box flexDirection="column">
|
|
315
|
-
<Text bold color="cyan">选择任务(Issue)</Text>
|
|
316
384
|
<Text color="gray">偏好设置将保存在所选任务内部</Text>
|
|
317
385
|
<Box marginTop={1}>
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
386
|
+
<SearchableSelect
|
|
387
|
+
title="选择任务(Issue)"
|
|
388
|
+
placeholder="输入任务标题或描述"
|
|
389
|
+
items={items}
|
|
390
|
+
createItem={{ label: issues.length ? '➕ 创建新任务' : '➕ 创建新任务(尚无任务)', value: '__create__' }}
|
|
391
|
+
onSelect={v => onPick(issues.find(i => i.id === v)!)}
|
|
392
|
+
onCreate={() => setMode('create-name')}
|
|
393
|
+
/>
|
|
321
394
|
</Box>
|
|
322
395
|
</Box>
|
|
323
396
|
)
|
|
@@ -332,8 +405,8 @@ function ModelPicker({ options, defaultKey, onSelect }: {
|
|
|
332
405
|
if (!options.length) return <Text color="gray">加载模型列表…</Text>
|
|
333
406
|
const items: SelectItem[] = options.map(o => ({
|
|
334
407
|
label: `${o.label}${o.key === defaultKey ? ' (默认)' : ''}`,
|
|
335
|
-
value: o.key,
|
|
336
408
|
desc: o.sub,
|
|
409
|
+
value: o.key,
|
|
337
410
|
}))
|
|
338
411
|
return (
|
|
339
412
|
<Box flexDirection="column">
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react'
|
|
6
6
|
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
|
-
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
7
|
+
import { useDeleteKeyCapture, applyDeleteIntent, clampCursor, previousWordBoundary, nextWordBoundary } from '../lib/delete-keys.js'
|
|
8
|
+
import { useCursorKeyCapture } from '../lib/cursor-keys.js'
|
|
8
9
|
|
|
9
10
|
/** Return false when a mounted listener deliberately did not consume the input. */
|
|
10
11
|
type InputHandler = (input: string, key: Key) => void | false
|
|
@@ -318,6 +319,14 @@ export function TextInput(props: TextInputProps) {
|
|
|
318
319
|
const { text, cursor: nextCursor } = applyDeleteIntent(valueRef.current, cursorRef.current, intent)
|
|
319
320
|
edit(text, nextCursor)
|
|
320
321
|
})
|
|
322
|
+
useCursorKeyCapture(focused, (intent) => {
|
|
323
|
+
const current = valueRef.current
|
|
324
|
+
const at = clampCursor(current, cursorRef.current)
|
|
325
|
+
const next = intent === 'home' ? 0 : intent === 'end' ? current.length
|
|
326
|
+
: intent === 'backward-word' ? previousWordBoundary(current, at) : nextWordBoundary(current, at)
|
|
327
|
+
cursorRef.current = next
|
|
328
|
+
setCursor(next)
|
|
329
|
+
})
|
|
321
330
|
|
|
322
331
|
useStableInput((input, key) => {
|
|
323
332
|
if (isMouseInput(input)) return
|
|
@@ -343,6 +352,7 @@ export function TextInput(props: TextInputProps) {
|
|
|
343
352
|
edit(text, nextCursor)
|
|
344
353
|
return
|
|
345
354
|
}
|
|
355
|
+
if (key.ctrl && (key.leftArrow || key.rightArrow)) return
|
|
346
356
|
if (key.leftArrow) { setCursor(c => Math.max(0, c - 1)); return }
|
|
347
357
|
if (key.rightArrow) { setCursor(c => Math.min(value.length, c + 1)); return }
|
|
348
358
|
if (key.ctrl && input === 'a') { setCursor(0); return }
|
|
@@ -421,6 +431,12 @@ export interface SelectItem {
|
|
|
421
431
|
desc?: string
|
|
422
432
|
}
|
|
423
433
|
|
|
434
|
+
/** Keep a picker explanation on the item's main row; Select truncates that row. */
|
|
435
|
+
export function inlineSelectLabel(label: string, detail?: string): string {
|
|
436
|
+
const oneLine = detail?.replace(/\s*\n\s*/g, ' ⏎ ').replace(/[ \t]+/g, ' ').trim()
|
|
437
|
+
return oneLine ? `${label} - ${oneLine}` : label
|
|
438
|
+
}
|
|
439
|
+
|
|
424
440
|
export interface SelectProps {
|
|
425
441
|
items: SelectItem[]
|
|
426
442
|
mode?: 'single' | 'multi'
|
|
@@ -491,17 +507,20 @@ export function Select(props: SelectProps) {
|
|
|
491
507
|
const isActive = realIdx === active
|
|
492
508
|
const checked = mode === 'multi' ? selectedSet.has(it.value) : false
|
|
493
509
|
const marker = mode === 'multi' ? (checked ? '☑' : '☐') : isActive ? '❯' : ' '
|
|
510
|
+
// Keep picker rows compact: descriptions belong on the highlighted
|
|
511
|
+
// row only. Unfocused rows show just their label so a long list does
|
|
512
|
+
// not turn every item into a multi-line block.
|
|
513
|
+
const rowLabel = isActive ? inlineSelectLabel(it.label, it.desc) : it.label
|
|
494
514
|
return (
|
|
495
|
-
<Box key={it.value}
|
|
515
|
+
<Box key={it.value}>
|
|
496
516
|
<Text
|
|
497
517
|
color={isActive ? 'black' : undefined}
|
|
498
518
|
backgroundColor={isActive ? 'cyan' : undefined}
|
|
499
519
|
bold={isActive}
|
|
500
520
|
wrap="truncate-end"
|
|
501
521
|
>
|
|
502
|
-
{marker} {
|
|
522
|
+
{marker} {rowLabel}
|
|
503
523
|
</Text>
|
|
504
|
-
{isActive && it.desc ? <Text color="gray" wrap="truncate-end"> {it.desc}</Text> : null}
|
|
505
524
|
</Box>
|
|
506
525
|
)
|
|
507
526
|
})}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Raw terminal cursor-key capture for keys Ink does not expose in `Key`. */
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
import { useStdin } from 'ink'
|
|
5
|
+
|
|
6
|
+
export type CursorIntent = 'home' | 'end' | 'backward-word' | 'forward-word'
|
|
7
|
+
|
|
8
|
+
// Home/End vary by terminal/application mode. Ctrl+Left/Right are the xterm
|
|
9
|
+
// CSI modifier-5 forms; the shorter 5D/5C forms are emitted by some tmux and
|
|
10
|
+
// ConPTY bridges. Longest sequences come first so prefixes cannot win early.
|
|
11
|
+
const CURSOR_SEQUENCES: ReadonlyArray<readonly [string, CursorIntent]> = [
|
|
12
|
+
['\x1b[1;5D', 'backward-word'],
|
|
13
|
+
['\x1b[1;5C', 'forward-word'],
|
|
14
|
+
['\x1b[5D', 'backward-word'],
|
|
15
|
+
['\x1b[5C', 'forward-word'],
|
|
16
|
+
['\x1b[1~', 'home'],
|
|
17
|
+
['\x1b[7~', 'home'],
|
|
18
|
+
['\x1b[4~', 'end'],
|
|
19
|
+
['\x1b[8~', 'end'],
|
|
20
|
+
['\x1b[H', 'home'],
|
|
21
|
+
['\x1b[F', 'end'],
|
|
22
|
+
['\x1bOH', 'home'],
|
|
23
|
+
['\x1bOF', 'end'],
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export function classifyCursorSequence(raw: string): { intent: CursorIntent; length: number } | null {
|
|
27
|
+
for (const [sequence, intent] of CURSOR_SEQUENCES) {
|
|
28
|
+
if (raw.startsWith(sequence)) return { intent, length: sequence.length }
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isCursorPrefix(raw: string): boolean {
|
|
34
|
+
return CURSOR_SEQUENCES.some(([sequence]) => sequence.startsWith(raw))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Capture physical Home/End and Ctrl+arrow bytes without relying on Ink's lossy key object. */
|
|
38
|
+
export function useCursorKeyCapture(
|
|
39
|
+
enabled: boolean,
|
|
40
|
+
onCursor: (intent: CursorIntent) => void,
|
|
41
|
+
): void {
|
|
42
|
+
const { internal_eventEmitter } = useStdin()
|
|
43
|
+
const enabledRef = useRef(enabled)
|
|
44
|
+
const onCursorRef = useRef(onCursor)
|
|
45
|
+
enabledRef.current = enabled
|
|
46
|
+
onCursorRef.current = onCursor
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!internal_eventEmitter) return
|
|
50
|
+
let buf = ''
|
|
51
|
+
const handler = (chunk: unknown) => {
|
|
52
|
+
buf += String(chunk)
|
|
53
|
+
while (buf) {
|
|
54
|
+
const match = classifyCursorSequence(buf)
|
|
55
|
+
if (match) {
|
|
56
|
+
buf = buf.slice(match.length)
|
|
57
|
+
if (enabledRef.current) onCursorRef.current(match.intent)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (isCursorPrefix(buf)) break
|
|
61
|
+
// Ordinary text is handled by Ink's useInput; only retain a possible
|
|
62
|
+
// incomplete escape prefix so split terminal sequences still match.
|
|
63
|
+
const nextEsc = buf.indexOf('\x1b', buf.startsWith('\x1b') ? 1 : 0)
|
|
64
|
+
buf = nextEsc >= 0 ? buf.slice(nextEsc) : ''
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
internal_eventEmitter.on('input', handler)
|
|
68
|
+
return () => { internal_eventEmitter.off('input', handler) }
|
|
69
|
+
}, [internal_eventEmitter])
|
|
70
|
+
}
|
package/src/lib/delete-keys.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function nextCursorBoundary(text: string, cursor: number): number {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/** Backward-word boundary: skip trailing whitespace, then the word before it. */
|
|
62
|
-
function
|
|
62
|
+
export function previousWordBoundary(text: string, at: number): number {
|
|
63
63
|
let i = at
|
|
64
64
|
while (i > 0 && /\s/.test(text[i - 1])) i--
|
|
65
65
|
while (i > 0 && !/\s/.test(text[i - 1])) i--
|
|
@@ -67,7 +67,7 @@ function backwardWordBoundary(text: string, at: number): number {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/** Forward-word boundary: skip leading whitespace, then the word after it. */
|
|
70
|
-
function
|
|
70
|
+
export function nextWordBoundary(text: string, at: number): number {
|
|
71
71
|
let i = at
|
|
72
72
|
while (i < text.length && /\s/.test(text[i])) i++
|
|
73
73
|
while (i < text.length && !/\s/.test(text[i])) i++
|
|
@@ -93,12 +93,12 @@ export function applyDeleteIntent(
|
|
|
93
93
|
return { text: text.slice(0, at) + text.slice(next), cursor: at }
|
|
94
94
|
}
|
|
95
95
|
case 'backward-word': {
|
|
96
|
-
const start =
|
|
96
|
+
const start = previousWordBoundary(text, at)
|
|
97
97
|
if (start === at) return { text, cursor: at }
|
|
98
98
|
return { text: text.slice(0, start) + text.slice(at), cursor: start }
|
|
99
99
|
}
|
|
100
100
|
case 'forward-word': {
|
|
101
|
-
const end =
|
|
101
|
+
const end = nextWordBoundary(text, at)
|
|
102
102
|
if (end === at) return { text, cursor: at }
|
|
103
103
|
return { text: text.slice(0, at) + text.slice(end), cursor: at }
|
|
104
104
|
}
|
package/src/markdown.ts
CHANGED
|
@@ -12,6 +12,30 @@ import chalk from 'chalk'
|
|
|
12
12
|
import { highlight, supportsLanguage } from 'cli-highlight'
|
|
13
13
|
import { lexer, type Token, type Tokens } from 'marked'
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Decode HTML entities that marked's lexer injects into text tokens.
|
|
17
|
+
* marked encodes `' " < > &` as `' " < > &` even when
|
|
18
|
+
* only lexing (not rendering to HTML). The TUI renders to a terminal so
|
|
19
|
+
* we must reverse that encoding ourselves.
|
|
20
|
+
*/
|
|
21
|
+
const HTML_ENTITY_RE = /&(?:#(x?)([0-9a-fA-F]+)|(amp|lt|gt|quot|#39));/g
|
|
22
|
+
function decodeHtmlEntities(s: string): string {
|
|
23
|
+
return s.replace(HTML_ENTITY_RE, (_, hex: string | undefined, num: string, named: string | undefined) => {
|
|
24
|
+
if (named) {
|
|
25
|
+
switch (named) {
|
|
26
|
+
case 'amp': return '&'
|
|
27
|
+
case 'lt': return '<'
|
|
28
|
+
case 'gt': return '>'
|
|
29
|
+
case 'quot': return '"'
|
|
30
|
+
case '#39': return "'"
|
|
31
|
+
default: return _
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const code = parseInt(num, hex ? 16 : 10)
|
|
35
|
+
return String.fromCodePoint(code)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
15
39
|
export interface RenderedMarkdownLine {
|
|
16
40
|
text: string
|
|
17
41
|
code: boolean
|
|
@@ -43,7 +67,7 @@ function renderInlineOne(t: Token): string {
|
|
|
43
67
|
const anyT = t as any
|
|
44
68
|
switch (t.type) {
|
|
45
69
|
case 'text':
|
|
46
|
-
return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(anyT.text)
|
|
70
|
+
return anyT.tokens ? renderInline(anyT.tokens) : escapeAnsiReset(decodeHtmlEntities(anyT.text))
|
|
47
71
|
case 'strong':
|
|
48
72
|
return chalk.bold(renderInline(anyT.tokens))
|
|
49
73
|
case 'em':
|
|
@@ -51,20 +75,20 @@ function renderInlineOne(t: Token): string {
|
|
|
51
75
|
case 'del':
|
|
52
76
|
return chalk.dim.strikethrough(renderInline(anyT.tokens))
|
|
53
77
|
case 'codespan':
|
|
54
|
-
return chalk.cyanBright(anyT.text)
|
|
78
|
+
return chalk.cyanBright(decodeHtmlEntities(anyT.text))
|
|
55
79
|
case 'link': {
|
|
56
80
|
const label = renderInline(anyT.tokens) || anyT.href
|
|
57
81
|
return anyT.href && label !== anyT.href ? `${chalk.cyan(label)} (${chalk.dim.underline(anyT.href)})` : chalk.cyan(label)
|
|
58
82
|
}
|
|
59
83
|
case 'image':
|
|
60
|
-
return chalk.magentaBright(`[图片: ${anyT.href || anyT.text}]`)
|
|
84
|
+
return chalk.magentaBright(`[图片: ${anyT.href || decodeHtmlEntities(anyT.text)}]`)
|
|
61
85
|
case 'br':
|
|
62
86
|
return '\n'
|
|
63
87
|
case 'escape':
|
|
64
88
|
case 'html':
|
|
65
|
-
return anyT.text
|
|
89
|
+
return anyT.text ? decodeHtmlEntities(anyT.text) : ''
|
|
66
90
|
default:
|
|
67
|
-
return anyT.text
|
|
91
|
+
return anyT.text ? decodeHtmlEntities(anyT.text) : renderInline(anyT.tokens)
|
|
68
92
|
}
|
|
69
93
|
}
|
|
70
94
|
|
|
@@ -74,7 +98,7 @@ function escapeAnsiReset(s: string): string {
|
|
|
74
98
|
}
|
|
75
99
|
|
|
76
100
|
function renderTable(t: Tokens.Table): string {
|
|
77
|
-
const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: toks?.text ?? '' }])
|
|
101
|
+
const cell = (toks: any) => renderInline(toks?.tokens ?? [{ type: 'text', text: decodeHtmlEntities(toks?.text ?? '') }])
|
|
78
102
|
const header = t.header.map((h) => cell(h)).join(' | ')
|
|
79
103
|
const rows = t.rows.map((r) => r.map((c) => cell(c)).join(' | ')).join('\n')
|
|
80
104
|
return chalk.bold(header) + '\n' + chalk.dim('-'.repeat(Math.min(header.length, 80))) + '\n' + rows
|
|
@@ -121,7 +145,7 @@ function renderBlock(t: Token): string {
|
|
|
121
145
|
case 'paragraph':
|
|
122
146
|
return renderInline(anyT.tokens)
|
|
123
147
|
case 'code': {
|
|
124
|
-
return renderCode(anyT.text, anyT.lang)
|
|
148
|
+
return renderCode(decodeHtmlEntities(anyT.text), anyT.lang)
|
|
125
149
|
}
|
|
126
150
|
case 'blockquote': {
|
|
127
151
|
const inner = (anyT.tokens as Token[]).map(renderBlock).join('\n')
|
|
@@ -142,9 +166,9 @@ function renderBlock(t: Token): string {
|
|
|
142
166
|
case 'space':
|
|
143
167
|
return ''
|
|
144
168
|
case 'html':
|
|
145
|
-
return chalk.dim(anyT.text ?? '')
|
|
169
|
+
return chalk.dim(decodeHtmlEntities(anyT.text ?? ''))
|
|
146
170
|
default:
|
|
147
|
-
return anyT.text
|
|
171
|
+
return anyT.text ? decodeHtmlEntities(anyT.text) : renderInline(anyT.tokens)
|
|
148
172
|
}
|
|
149
173
|
}
|
|
150
174
|
|
|
@@ -156,5 +180,5 @@ function renderListItemBody(item: any): string {
|
|
|
156
180
|
.filter(Boolean)
|
|
157
181
|
.join('\n')
|
|
158
182
|
}
|
|
159
|
-
return item.text
|
|
183
|
+
return item.text ? decodeHtmlEntities(item.text) : ''
|
|
160
184
|
}
|
package/tests/aimux.test.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import os from 'node:os'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { render } from 'ink-testing-library'
|
|
9
9
|
import { AimuxStatusLine } from '../src/components/AimuxStatus.js'
|
|
10
|
-
import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode } from '../src/aimux.js'
|
|
10
|
+
import { AimuxSupervisor, probeAimuxBridgeConnection, bundleArch, bundleUrl, spawnLauncher, ensureFromBundle, downloadBundleForTest, reverseConnectArgs, aimuxLogPath, bundleHealthCheckCode, tuiAimuxIdentifier } from '../src/aimux.js'
|
|
11
11
|
|
|
12
12
|
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
|
13
13
|
let pass = 0, fail = 0
|
|
@@ -78,11 +78,11 @@ async function testBundleArchAndUrl() {
|
|
|
78
78
|
const arch = bundleArch()
|
|
79
79
|
ok(arch === 'linux-x64' || arch === 'win-x64' || arch === 'mac-x64', `bundleArch returns a supported arch on this host (${arch})`)
|
|
80
80
|
const before = bundleUrl('linux-x64')
|
|
81
|
-
ok(before.includes('mobius-python-linux-x64-
|
|
81
|
+
ok(before.includes('mobius-python-linux-x64-v3') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
|
|
82
82
|
const saved = process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL
|
|
83
83
|
process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = 'https://example.test/cdn/'
|
|
84
84
|
try {
|
|
85
|
-
ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-
|
|
85
|
+
ok(bundleUrl('win-x64') === 'https://example.test/cdn/mobius-python-win-x64-v3.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
|
|
86
86
|
} finally { if (saved === undefined) delete process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL; else process.env.MOBIUS_TUI_PYTHON_BUNDLE_URL = saved }
|
|
87
87
|
}
|
|
88
88
|
|
|
@@ -136,17 +136,27 @@ function testReverseConnectArgs() {
|
|
|
136
136
|
console.log('\n[AIMUX 6] reverse connect Windows shell visibility')
|
|
137
137
|
const win = reverseConnectArgs('https://mobius.test/', 'tui-win', 'jwt-test', 'win32')
|
|
138
138
|
const linux = reverseConnectArgs('https://mobius.test/', 'tui-linux', 'jwt-test', 'linux')
|
|
139
|
-
ok(win.includes('--
|
|
140
|
-
ok(!linux.includes('--
|
|
139
|
+
ok(win.includes('--slient-v2'), 'Windows reverse connection always requests the no-console shell mode')
|
|
140
|
+
ok(!linux.includes('--slient-v2'), 'non-Windows reverse connection does not receive the Windows-only flag')
|
|
141
141
|
ok(win[2] === 'https://mobius.test/aimux_bridge', 'reverse connection normalizes the bridge URL')
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
function testAimuxIdentifierScopesWorkspace() {
|
|
145
|
+
console.log('\n[AIMUX 6a] reverse client identifier workspace isolation')
|
|
146
|
+
const first = tuiAimuxIdentifier('same-host', '/work/project-a')
|
|
147
|
+
const firstAgain = tuiAimuxIdentifier('same-host', '/work/project-a')
|
|
148
|
+
const second = tuiAimuxIdentifier('same-host', '/work/project-b')
|
|
149
|
+
ok(first === firstAgain, 'identifier is stable for the same host and workspace')
|
|
150
|
+
ok(first !== second, 'different workspaces on one host do not replace each other')
|
|
151
|
+
ok(/^tui-same-host-[a-f0-9]{10}$/.test(first), 'identifier remains bridge-safe and recognizable')
|
|
152
|
+
}
|
|
153
|
+
|
|
144
154
|
function testBundleHealthCheck() {
|
|
145
155
|
console.log('\n[AIMUX 6b] bundle dependency health check')
|
|
146
156
|
const win = bundleHealthCheckCode('win32')
|
|
147
157
|
const linux = bundleHealthCheckCode('linux')
|
|
148
158
|
ok(win.includes('aimux.bridge_client') && win.includes('win32_setctime'), 'Windows bundle probe imports the real bridge path and its platform dependency')
|
|
149
|
-
ok(win.includes("aimux.__version__ == '0.1.
|
|
159
|
+
ok(win.includes("aimux.__version__ == '0.1.23'"), 'bundle probe rejects stale AIMUX versions')
|
|
150
160
|
ok(!linux.includes('win32_setctime'), 'non-Windows bundle probe does not require the Windows-only package')
|
|
151
161
|
}
|
|
152
162
|
|
|
@@ -199,6 +209,7 @@ async function main() {
|
|
|
199
209
|
await testBundleArchAndUrl()
|
|
200
210
|
await testSpawnLauncher()
|
|
201
211
|
testReverseConnectArgs()
|
|
212
|
+
testAimuxIdentifierScopesWorkspace()
|
|
202
213
|
testBundleHealthCheck()
|
|
203
214
|
await testEnsureFromBundleReady()
|
|
204
215
|
await testDownloadBundleStream()
|
package/tests/flow.test.tsx
CHANGED
|
@@ -68,9 +68,9 @@ function mockFetch(url: string, init?: RequestInit): Response {
|
|
|
68
68
|
}
|
|
69
69
|
// issues
|
|
70
70
|
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'POST') return json({ id: IID, project_id: PID, title: '命令行任务' }) // create issue
|
|
71
|
-
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务' }]) // list issues
|
|
71
|
+
if (url.includes('/api/projects/') && url.includes('/issues') && method === 'GET') return json([{ id: IID, project_id: PID, title: '命令行任务', description: '任务说明' }]) // list issues
|
|
72
72
|
// projects
|
|
73
|
-
if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲' }]) // list projects
|
|
73
|
+
if (url.includes('/api/projects') && method === 'GET') return json([{ id: PID, name: '已有项目甲', description: '项目说明' }]) // list projects
|
|
74
74
|
if (url.endsWith('/api/projects') && method === 'POST') return json({ id: PID, name: '测试项目PTY' }) // create project (exact)
|
|
75
75
|
// preference lookups
|
|
76
76
|
if (url.includes('/sessions/model-options')) return json([{ key: 'codex', label: 'GPT-5.5', title: 'GPT-5.5', sub: 'Codex', backend: 'tmux-codex' }])
|
|
@@ -165,13 +165,26 @@ async function main() {
|
|
|
165
165
|
stdin.write('\r')
|
|
166
166
|
ok(await waitFor(lastFrame, '重新配置'), '/config opens the full reconfig flow')
|
|
167
167
|
ok(await waitFor(lastFrame, '选择项目'), '/config shows project picker first')
|
|
168
|
-
|
|
168
|
+
ok(await waitFor(lastFrame, '已有项目甲 - 项目说明'), '/config keeps the project explanation on its main row')
|
|
169
|
+
// Pick the first project (created above), then verify Esc walks back one
|
|
170
|
+
// level at a time instead of closing the entire config flow.
|
|
169
171
|
stdin.write('\r'); await delay(400)
|
|
170
172
|
ok(await waitFor(lastFrame, '选择任务'), '/config shows issue picker after project')
|
|
171
|
-
|
|
173
|
+
ok((lastFrame() ?? '').includes('命令行任务 - 任务说明'), '/config keeps the issue explanation on its main row')
|
|
174
|
+
stdin.write('\x1b'); await delay(180)
|
|
175
|
+
ok(await waitFor(lastFrame, '选择项目'), 'Esc from issue selection returns to project selection')
|
|
176
|
+
stdin.write('\r'); await delay(400)
|
|
177
|
+
ok(await waitFor(lastFrame, '选择任务'), 'project selection can be re-entered after Esc')
|
|
178
|
+
|
|
179
|
+
// Pick the issue and verify the model step also returns to the issue step.
|
|
172
180
|
stdin.write('\r'); await delay(400)
|
|
173
181
|
ok(await waitFor(lastFrame, '选择模型'), '/config shows model picker after issue')
|
|
174
182
|
ok(await waitFor(lastFrame, 'GPT-5.5'), '/config model list rendered')
|
|
183
|
+
ok((lastFrame() ?? '').includes('GPT-5.5 (默认) - Codex'), '/config keeps the model explanation on its main row')
|
|
184
|
+
stdin.write('\x1b'); await delay(180)
|
|
185
|
+
ok(await waitFor(lastFrame, '选择任务'), 'Esc from model selection returns to issue selection')
|
|
186
|
+
stdin.write('\r'); await delay(400)
|
|
187
|
+
ok(await waitFor(lastFrame, '选择模型'), 'issue selection can be re-entered after Esc')
|
|
175
188
|
stdin.write('\r'); await delay(700) // pick codex → create session
|
|
176
189
|
ok(await waitFor(lastFrame, '输入问题'), '/config creates a fresh session and returns to chat')
|
|
177
190
|
ok((lastFrame() ?? '').includes('?session=sess-1'), 'reconfigured chat is attached to the new session')
|
|
@@ -191,6 +204,14 @@ async function main() {
|
|
|
191
204
|
ok(await waitFor(lastFrame, '输入问题'), '/model creates a fresh session and returns to chat')
|
|
192
205
|
ok((lastFrame() ?? '').includes('?session=sess-1'), '/model new session attached')
|
|
193
206
|
snap('7-after-model', lastFrame() ?? '')
|
|
207
|
+
|
|
208
|
+
// ── /logout ─────────────────────────────────────────────────────────────
|
|
209
|
+
await delay(400)
|
|
210
|
+
stdin.write('/logout'); await delay(150)
|
|
211
|
+
stdin.write('\r')
|
|
212
|
+
ok(await waitFor(lastFrame, 'Mobius 登录'), '/logout returns to the login form')
|
|
213
|
+
ok(!fs.existsSync(path.join(TMP_HOME, 'login.json')), '/logout clears the persisted login token')
|
|
214
|
+
ok((lastFrame() ?? '').includes('http://mock.local') && (lastFrame() ?? '').includes('tester'), '/logout keeps server and username available for the next login')
|
|
194
215
|
snap('6-after-config', lastFrame() ?? '')
|
|
195
216
|
} finally {
|
|
196
217
|
unmount()
|
package/tests/ui.test.tsx
CHANGED
|
@@ -162,7 +162,7 @@ async function testChat() {
|
|
|
162
162
|
})
|
|
163
163
|
try {
|
|
164
164
|
const { stdin, lastFrame, unmount } = render(
|
|
165
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
165
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
166
166
|
)
|
|
167
167
|
await delay(40)
|
|
168
168
|
const initialFrame = lastFrame() ?? ''
|
|
@@ -226,7 +226,7 @@ async function testResumedWorkingStatus() {
|
|
|
226
226
|
})
|
|
227
227
|
try {
|
|
228
228
|
const { stdin, lastFrame, unmount } = render(
|
|
229
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
229
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />
|
|
230
230
|
)
|
|
231
231
|
await delay(120)
|
|
232
232
|
ok((lastFrame() ?? '').includes('Working ('), 'resuming an already-running session restores Working without a new typing event')
|
|
@@ -261,6 +261,16 @@ function testMarkdownCodeRendering() {
|
|
|
261
261
|
|
|
262
262
|
const unlabelled = renderMarkdownLines('```\necho $HOME\n```')
|
|
263
263
|
ok(unlabelled.length === 1 && unlabelled[0].text === 'echo $HOME' && unlabelled[0].code, 'unlabelled code stays plain instead of being guessed as bash')
|
|
264
|
+
|
|
265
|
+
// HTML entity decoding — marked's lexer encodes ', ", <, >, & even when
|
|
266
|
+
// only tokenising, so the TUI renderer must decode them back.
|
|
267
|
+
const entities = renderMarkdownLines("What's \"cool\"? 1 < 2 & 3 > 1")
|
|
268
|
+
const entityText = entities.map(r => r.text).join('\n')
|
|
269
|
+
ok(entityText.includes("What's"), "' decoded back to apostrophe")
|
|
270
|
+
ok(entityText.includes('"cool"'), "" decoded back to double-quote")
|
|
271
|
+
ok(entityText.includes('1 < 2'), "< decoded back to <")
|
|
272
|
+
ok(entityText.includes('3 > 1'), "> decoded back to >")
|
|
273
|
+
ok(entityText.includes('& 3'), "& decoded back to &")
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
function testFirstUserEntryDedupe() {
|
|
@@ -297,11 +307,18 @@ async function testPrepRender() {
|
|
|
297
307
|
ok(frame.includes('选择当前路径的绑定项目'), 'project picker title shown')
|
|
298
308
|
ok(frame.includes('已有项目A') && frame.includes('已有项目B'), 'existing projects listed')
|
|
299
309
|
ok(frame.includes('创建新项目'), 'create-new option present')
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
ok(frame.includes('已有项目B
|
|
310
|
+
// Only the highlighted row carries its description; unfocused rows stay
|
|
311
|
+
// compact and show their names alone.
|
|
312
|
+
ok(!frame.includes('已有项目A - 第一行') && !frame.includes('已有项目B - 单行描述'), 'unfocused project rows omit their descriptions')
|
|
313
|
+
stdin.write('\x1b[B'); await delay(15) // move focus from search to the list
|
|
314
|
+
stdin.write('\x1b[B'); await delay(15) // highlight the first project
|
|
315
|
+
const selectedFrame = lastFrame() ?? ''
|
|
316
|
+
ok(selectedFrame.includes('已有项目A - 第一行 ⏎ 第二行'), 'selected multi-line description stays on the main row')
|
|
317
|
+
ok(!selectedFrame.includes('已有项目B - 单行描述'), 'unselected project description is omitted')
|
|
318
|
+
ok(!frame.includes('\n 第一行') && !frame.includes('\n 单行描述'), 'project explanations do not render as an additional row')
|
|
303
319
|
ok(!frame.includes('加载项目列表…'), 'completed project load does not leave a stale loading message')
|
|
304
320
|
|
|
321
|
+
stdin.write('\x1b[A'); await delay(15) // return to the create row
|
|
305
322
|
stdin.write('\r')
|
|
306
323
|
await delay(30)
|
|
307
324
|
const createFrame = lastFrame() ?? ''
|
|
@@ -313,6 +330,56 @@ async function testPrepRender() {
|
|
|
313
330
|
} finally { restoreFetch() }
|
|
314
331
|
}
|
|
315
332
|
|
|
333
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
334
|
+
// TEST 5b — Project and issue pickers filter by the search field
|
|
335
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
336
|
+
async function testPrepSearch() {
|
|
337
|
+
console.log('\n[UI 5b] Prep picker search filtering')
|
|
338
|
+
const client = new MobiusClient('http://mock.local', 'mock-jwt-token')
|
|
339
|
+
installMock((url) => {
|
|
340
|
+
if (url.includes('/api/projects') && !url.includes('/issues') && !url.includes('/skills') && !url.includes('/memories')) {
|
|
341
|
+
return jsonResponse([
|
|
342
|
+
{ id: 'p1', name: '前端平台', description: '用户界面与组件' },
|
|
343
|
+
{ id: 'p2', name: '数据管线', description: '批处理任务' },
|
|
344
|
+
])
|
|
345
|
+
}
|
|
346
|
+
if (url.includes('/api/projects/p2/issues')) {
|
|
347
|
+
return jsonResponse([
|
|
348
|
+
{ id: 'i1', project_id: 'p2', title: '修复导入超时', description: '处理批处理任务' },
|
|
349
|
+
{ id: 'i2', project_id: 'p2', title: '更新监控面板', description: '前端界面' },
|
|
350
|
+
])
|
|
351
|
+
}
|
|
352
|
+
if (url.includes('/sessions/model-options')) return jsonResponse([])
|
|
353
|
+
if (url.includes('/sessions/default-model')) return jsonResponse({ model: 'codex' })
|
|
354
|
+
if (url.includes('/skills') || url.includes('/memories')) return jsonResponse([])
|
|
355
|
+
return jsonResponse({ error: 'no mock' }, 404)
|
|
356
|
+
})
|
|
357
|
+
try {
|
|
358
|
+
const { lastFrame, stdin, unmount } = render(<PrepScreen client={client} onReady={() => {}} />)
|
|
359
|
+
await delay(120)
|
|
360
|
+
stdin.write('数据')
|
|
361
|
+
await delay(40)
|
|
362
|
+
let frame = lastFrame() ?? ''
|
|
363
|
+
ok(frame.includes('数据管线') && !frame.includes('前端平台'), 'project search keeps matching project and hides non-matches')
|
|
364
|
+
ok(!frame.includes('创建新项目'), 'project search hides the create row while searching')
|
|
365
|
+
stdin.write('\r')
|
|
366
|
+
await delay(160)
|
|
367
|
+
ok((lastFrame() ?? '').includes('选择任务(Issue)'), 'matching project opens its issue picker')
|
|
368
|
+
|
|
369
|
+
stdin.write('超时')
|
|
370
|
+
await delay(40)
|
|
371
|
+
frame = lastFrame() ?? ''
|
|
372
|
+
ok(frame.includes('修复导入超时') && !frame.includes('更新监控面板'), 'issue search matches title and hides other issues')
|
|
373
|
+
stdin.write('\r')
|
|
374
|
+
await delay(120)
|
|
375
|
+
ok((lastFrame() ?? '').includes('选择模型') || (lastFrame() ?? '').includes('加载模型列表'), 'matching issue is selected with Enter')
|
|
376
|
+
unmount()
|
|
377
|
+
// This test deliberately selects a project; remove its persisted cwd
|
|
378
|
+
// binding so later picker tests still start on the project screen.
|
|
379
|
+
try { fs.rmSync(path.join(TMP_HOME, 'dir2project.json'), { force: true }) } catch { /* ignore */ }
|
|
380
|
+
} finally { restoreFetch() }
|
|
381
|
+
}
|
|
382
|
+
|
|
316
383
|
// ════════════════════════════════════════════════════════════════════════════
|
|
317
384
|
// TEST 6 — Select viewport: a long list must not overflow the terminal
|
|
318
385
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -482,6 +549,56 @@ async function testComposerDeleteKeys() {
|
|
|
482
549
|
unmount()
|
|
483
550
|
}
|
|
484
551
|
|
|
552
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
553
|
+
// TEST 8d — Home/End and Ctrl+Left/Right cursor movement
|
|
554
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
555
|
+
async function testCursorNavigationKeys() {
|
|
556
|
+
console.log('\n[UI 8d] Home/End + Ctrl-arrow cursor navigation')
|
|
557
|
+
|
|
558
|
+
let textInputSubmitted = ''
|
|
559
|
+
function TextHarness() {
|
|
560
|
+
const [v, setV] = React.useState('alpha beta')
|
|
561
|
+
return <TextInput value={v} onChange={setV} focused onSubmit={() => { textInputSubmitted = v }} />
|
|
562
|
+
}
|
|
563
|
+
const textInput = render(<TextHarness />)
|
|
564
|
+
await delay(20)
|
|
565
|
+
textInput.stdin.write('\x1b[H'); await delay(15) // Home
|
|
566
|
+
textInput.stdin.write('^'); await delay(15)
|
|
567
|
+
textInput.stdin.write('\x1b[F'); await delay(15) // End
|
|
568
|
+
textInput.stdin.write('$'); await delay(15)
|
|
569
|
+
textInput.stdin.write('\x1b[1;5D'); await delay(15) // Ctrl+Left
|
|
570
|
+
textInput.stdin.write('|'); await delay(15)
|
|
571
|
+
textInput.stdin.write('\x1b[1;5C'); await delay(15) // Ctrl+Right
|
|
572
|
+
textInput.stdin.write('!'); await delay(15)
|
|
573
|
+
textInput.stdin.write('\r'); await delay(20)
|
|
574
|
+
textInput.unmount()
|
|
575
|
+
ok(textInputSubmitted === '^alpha |beta$!', `TextInput cursor keys edit at expected boundaries (got ${JSON.stringify(textInputSubmitted)})`)
|
|
576
|
+
|
|
577
|
+
const composerSubmitted: string[] = []
|
|
578
|
+
const composer = render(
|
|
579
|
+
<Composer
|
|
580
|
+
onSubmit={v => composerSubmitted.push(v)}
|
|
581
|
+
onStop={() => {}}
|
|
582
|
+
onQuit={() => {}}
|
|
583
|
+
typing={false}
|
|
584
|
+
commands={[]}
|
|
585
|
+
/>,
|
|
586
|
+
)
|
|
587
|
+
await delay(20)
|
|
588
|
+
composer.stdin.write('alpha beta'); await delay(20)
|
|
589
|
+
composer.stdin.write('\x1b[H'); await delay(15)
|
|
590
|
+
composer.stdin.write('^'); await delay(15)
|
|
591
|
+
composer.stdin.write('\x1b[F'); await delay(15)
|
|
592
|
+
composer.stdin.write('$'); await delay(15)
|
|
593
|
+
composer.stdin.write('\x1b[1;5D'); await delay(15)
|
|
594
|
+
composer.stdin.write('|'); await delay(15)
|
|
595
|
+
composer.stdin.write('\x1b[1;5C'); await delay(15)
|
|
596
|
+
composer.stdin.write('!'); await delay(15)
|
|
597
|
+
composer.stdin.write('\r'); await delay(30)
|
|
598
|
+
composer.unmount()
|
|
599
|
+
ok(composerSubmitted[0] === '^alpha |beta$!', `Composer cursor keys edit at expected boundaries (got ${JSON.stringify(composerSubmitted[0])})`)
|
|
600
|
+
}
|
|
601
|
+
|
|
485
602
|
// ════════════════════════════════════════════════════════════════════════════
|
|
486
603
|
// TEST 9 — Codex-style composer keeps multiline pastes intact and grows/shrinks
|
|
487
604
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -500,7 +617,7 @@ async function testComposerMultilinePaste() {
|
|
|
500
617
|
await delay(20)
|
|
501
618
|
const initial = lastFrame() ?? ''
|
|
502
619
|
ok(initial.includes('╭') && initial.includes('╰'), 'composer has a visible bordered input boundary')
|
|
503
|
-
ok(initial.includes('Enter 发送') && initial.includes('Ctrl+J 换行'), 'composer shows
|
|
620
|
+
ok(initial.includes('Enter 发送') && initial.includes('Shift+Enter / Alt+Enter / Ctrl+J 换行'), 'composer shows submit/newline hints')
|
|
504
621
|
|
|
505
622
|
stdin.write('\x1b[200~第一行\r\n第二行\r第三行\x1b[201~')
|
|
506
623
|
await delay(20)
|
|
@@ -747,7 +864,7 @@ async function testChatSseReconnects() {
|
|
|
747
864
|
})
|
|
748
865
|
try {
|
|
749
866
|
const { stdin, lastFrame, unmount } = render(
|
|
750
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
867
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
751
868
|
)
|
|
752
869
|
await delay(40)
|
|
753
870
|
// Ink's test stdin treats one chunk as one keypress. Send text and Enter as
|
|
@@ -819,7 +936,7 @@ async function testIdleCompletedSessionReopensSseOnSend() {
|
|
|
819
936
|
})
|
|
820
937
|
try {
|
|
821
938
|
const { stdin, lastFrame, unmount } = render(
|
|
822
|
-
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
939
|
+
<ChatScreen client={client} ready={ready} webUserId="test-user" resumeSessionId="s1" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
823
940
|
)
|
|
824
941
|
await delay(180)
|
|
825
942
|
ok(sseCall === 1, 'completed idle session did not reconnect by itself')
|
|
@@ -859,7 +976,7 @@ async function testSendRetries502() {
|
|
|
859
976
|
})
|
|
860
977
|
try {
|
|
861
978
|
const { stdin, lastFrame, unmount } = render(
|
|
862
|
-
<ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
979
|
+
<ChatScreen client={client} ready={ready} webUserId="u" onClear={() => {}} onResume={() => {}} onQuit={() => {}} onLogout={() => {}} onReconfigure={() => {}} onConfigCancel={() => {}} />,
|
|
863
980
|
)
|
|
864
981
|
await delay(40)
|
|
865
982
|
stdin.write('hi'); await delay(30); stdin.write('\r')
|
|
@@ -878,11 +995,13 @@ async function main() {
|
|
|
878
995
|
testMarkdownCodeRendering()
|
|
879
996
|
testFirstUserEntryDedupe()
|
|
880
997
|
await testPrepRender()
|
|
998
|
+
await testPrepSearch()
|
|
881
999
|
await testSelectViewport()
|
|
882
1000
|
await testProjectPickerEscQuit()
|
|
883
1001
|
await testTextInputBackspace()
|
|
884
1002
|
await testTextInputDeleteKeys()
|
|
885
1003
|
await testComposerDeleteKeys()
|
|
1004
|
+
await testCursorNavigationKeys()
|
|
886
1005
|
await testComposerMultilinePaste()
|
|
887
1006
|
testWorkingShimmer()
|
|
888
1007
|
testReasoningViews()
|