@mobius-os/mobius 0.3.31 → 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/README.md +7 -0
- package/package.json +2 -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 +118 -197
- 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 +44 -5
- package/src/lib/cursor-keys.ts +70 -0
- package/src/lib/delete-keys.ts +4 -4
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +0 -35
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/markdown.ts +34 -10
- package/src/version.ts +21 -0
- package/tests/aimux.test.tsx +17 -6
- package/tests/flow.test.tsx +25 -4
- package/tests/screen.test.tsx +11 -10
- package/tests/scroll.test.tsx +13 -12
- package/tests/selection.test.tsx +1 -1
- package/tests/ui.test.tsx +141 -10
- package/tests/viewport.test.ts +83 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure row-level transcript viewport.
|
|
3
|
+
*
|
|
4
|
+
* Entries are materialized lazily through RowAccess.rowsAt(). Navigation keeps
|
|
5
|
+
* an entry/row anchor, so moving by N rows is exact even when one entry is much
|
|
6
|
+
* taller than the terminal. No React or Ink dependency belongs in this file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface RowAnchor {
|
|
10
|
+
entryId: string
|
|
11
|
+
entryIndex: number
|
|
12
|
+
rowIndex: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ViewportRow<T> {
|
|
16
|
+
entryId: string
|
|
17
|
+
entryIndex: number
|
|
18
|
+
rowIndex: number
|
|
19
|
+
row: T
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RowAccess<T> {
|
|
23
|
+
length: number
|
|
24
|
+
idAt: (index: number) => string
|
|
25
|
+
indexOf: (entryId: string) => number
|
|
26
|
+
rowsAt: (index: number) => readonly T[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ViewportSlice<T> {
|
|
30
|
+
anchor: RowAnchor | null
|
|
31
|
+
rows: ViewportRow<T>[]
|
|
32
|
+
hasOlder: boolean
|
|
33
|
+
hasNewer: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createRowAccess<E, T>(
|
|
37
|
+
entries: readonly E[],
|
|
38
|
+
getId: (entry: E, index: number) => string,
|
|
39
|
+
getRows: (entry: E, index: number) => readonly T[],
|
|
40
|
+
): RowAccess<T> {
|
|
41
|
+
const ids = entries.map(getId)
|
|
42
|
+
const indexById = new Map(ids.map((id, index) => [id, index]))
|
|
43
|
+
return {
|
|
44
|
+
length: entries.length,
|
|
45
|
+
idAt: (index) => ids[index] ?? '',
|
|
46
|
+
indexOf: (entryId) => indexById.get(entryId) ?? -1,
|
|
47
|
+
rowsAt: (index) => index >= 0 && index < entries.length ? getRows(entries[index], index) : [],
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function previousNonEmpty<T>(access: RowAccess<T>, from: number): number {
|
|
52
|
+
for (let index = from; index >= 0; index--) {
|
|
53
|
+
if (access.rowsAt(index).length > 0) return index
|
|
54
|
+
}
|
|
55
|
+
return -1
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nextNonEmpty<T>(access: RowAccess<T>, from: number): number {
|
|
59
|
+
for (let index = from; index < access.length; index++) {
|
|
60
|
+
if (access.rowsAt(index).length > 0) return index
|
|
61
|
+
}
|
|
62
|
+
return -1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolveAnchor<T>(access: RowAccess<T>, anchor: RowAnchor | null): RowAnchor | null {
|
|
66
|
+
if (!anchor || access.length === 0) return null
|
|
67
|
+
let entryIndex = access.indexOf(anchor.entryId)
|
|
68
|
+
if (entryIndex < 0) entryIndex = Math.max(0, Math.min(access.length - 1, anchor.entryIndex))
|
|
69
|
+
|
|
70
|
+
let rows = access.rowsAt(entryIndex)
|
|
71
|
+
if (rows.length === 0) {
|
|
72
|
+
const next = nextNonEmpty(access, entryIndex + 1)
|
|
73
|
+
const previous = previousNonEmpty(access, entryIndex - 1)
|
|
74
|
+
entryIndex = next >= 0 ? next : previous
|
|
75
|
+
if (entryIndex < 0) return null
|
|
76
|
+
rows = access.rowsAt(entryIndex)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
entryId: access.idAt(entryIndex),
|
|
81
|
+
entryIndex,
|
|
82
|
+
rowIndex: Math.max(0, Math.min(rows.length - 1, anchor.rowIndex)),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function tailAnchor<T>(access: RowAccess<T>, viewportRows: number): RowAnchor | null {
|
|
87
|
+
let remaining = Math.max(1, Math.floor(viewportRows))
|
|
88
|
+
let first: RowAnchor | null = null
|
|
89
|
+
for (let entryIndex = access.length - 1; entryIndex >= 0; entryIndex--) {
|
|
90
|
+
const rows = access.rowsAt(entryIndex)
|
|
91
|
+
if (rows.length === 0) continue
|
|
92
|
+
first = { entryId: access.idAt(entryIndex), entryIndex, rowIndex: 0 }
|
|
93
|
+
if (rows.length >= remaining) {
|
|
94
|
+
return { entryId: access.idAt(entryIndex), entryIndex, rowIndex: rows.length - remaining }
|
|
95
|
+
}
|
|
96
|
+
remaining -= rows.length
|
|
97
|
+
}
|
|
98
|
+
return first
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Move the top-row anchor by an exact signed row delta. Positive means newer. */
|
|
102
|
+
export function moveAnchorByRows<T>(access: RowAccess<T>, anchor: RowAnchor | null, deltaRows: number): RowAnchor | null {
|
|
103
|
+
const resolved = resolveAnchor(access, anchor)
|
|
104
|
+
if (!resolved || deltaRows === 0) return resolved
|
|
105
|
+
|
|
106
|
+
let entryIndex = resolved.entryIndex
|
|
107
|
+
let rowIndex = resolved.rowIndex
|
|
108
|
+
let remaining = Math.abs(Math.trunc(deltaRows))
|
|
109
|
+
|
|
110
|
+
if (deltaRows > 0) {
|
|
111
|
+
while (remaining > 0) {
|
|
112
|
+
const rows = access.rowsAt(entryIndex)
|
|
113
|
+
const within = rows.length - 1 - rowIndex
|
|
114
|
+
if (remaining <= within) { rowIndex += remaining; remaining = 0; break }
|
|
115
|
+
remaining -= within
|
|
116
|
+
const next = nextNonEmpty(access, entryIndex + 1)
|
|
117
|
+
if (next < 0) { rowIndex = rows.length - 1; break }
|
|
118
|
+
entryIndex = next
|
|
119
|
+
rowIndex = 0
|
|
120
|
+
remaining -= 1
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
while (remaining > 0) {
|
|
124
|
+
if (remaining <= rowIndex) { rowIndex -= remaining; remaining = 0; break }
|
|
125
|
+
remaining -= rowIndex
|
|
126
|
+
const previous = previousNonEmpty(access, entryIndex - 1)
|
|
127
|
+
if (previous < 0) { rowIndex = 0; break }
|
|
128
|
+
entryIndex = previous
|
|
129
|
+
rowIndex = access.rowsAt(entryIndex).length - 1
|
|
130
|
+
remaining -= 1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { entryId: access.idAt(entryIndex), entryIndex, rowIndex }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function sliceViewport<T>(access: RowAccess<T>, anchor: RowAnchor | null, viewportRows: number): ViewportSlice<T> {
|
|
138
|
+
const resolved = resolveAnchor(access, anchor)
|
|
139
|
+
const limit = Math.max(0, Math.floor(viewportRows))
|
|
140
|
+
if (!resolved || limit === 0) return { anchor: resolved, rows: [], hasOlder: false, hasNewer: false }
|
|
141
|
+
|
|
142
|
+
const visible: ViewportRow<T>[] = []
|
|
143
|
+
let entryIndex = resolved.entryIndex
|
|
144
|
+
let rowIndex = resolved.rowIndex
|
|
145
|
+
while (entryIndex < access.length && visible.length < limit) {
|
|
146
|
+
const rows = access.rowsAt(entryIndex)
|
|
147
|
+
for (; rowIndex < rows.length && visible.length < limit; rowIndex++) {
|
|
148
|
+
visible.push({ entryId: access.idAt(entryIndex), entryIndex, rowIndex, row: rows[rowIndex] })
|
|
149
|
+
}
|
|
150
|
+
entryIndex = nextNonEmpty(access, entryIndex + 1)
|
|
151
|
+
rowIndex = 0
|
|
152
|
+
if (entryIndex < 0) break
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasOlder = resolved.rowIndex > 0 || previousNonEmpty(access, resolved.entryIndex - 1) >= 0
|
|
156
|
+
const last = visible.at(-1)
|
|
157
|
+
const hasNewer = !!last && (
|
|
158
|
+
last.rowIndex < access.rowsAt(last.entryIndex).length - 1 ||
|
|
159
|
+
nextNonEmpty(access, last.entryIndex + 1) >= 0
|
|
160
|
+
)
|
|
161
|
+
return { anchor: resolved, rows: visible, hasOlder, hasNewer }
|
|
162
|
+
}
|
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/src/version.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The TUI package version has one source of truth: package.json.
|
|
3
|
+
*
|
|
4
|
+
* Keep all runtime/UI consumers behind this module so a release cannot show
|
|
5
|
+
* a stale hard-coded version while the npm package has already been bumped.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from 'node:module'
|
|
8
|
+
|
|
9
|
+
interface TuiPackageMetadata {
|
|
10
|
+
name?: string
|
|
11
|
+
version?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const packageJson = createRequire(import.meta.url)('../package.json') as TuiPackageMetadata
|
|
15
|
+
|
|
16
|
+
if (!packageJson.version) {
|
|
17
|
+
throw new Error('TUI package.json is missing a version')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const TUI_VERSION = packageJson.version
|
|
21
|
+
export const TUI_PACKAGE_NAME = packageJson.name ?? '@mobius-os/mobius'
|
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/screen.test.tsx
CHANGED
|
@@ -20,8 +20,9 @@ import { Box, Text } from 'ink'
|
|
|
20
20
|
import { render } from 'ink-testing-library'
|
|
21
21
|
import { Screen } from '../src/components/Screen.js'
|
|
22
22
|
import { Select } from '../src/components/primitives.js'
|
|
23
|
-
import {
|
|
24
|
-
import
|
|
23
|
+
import { entryScreenRows } from '../src/lib/screen-text.js'
|
|
24
|
+
import { viewsForEntry } from '../src/lib/entry-view.js'
|
|
25
|
+
import { createRowAccess, sliceViewport, tailAnchor } from '../src/lib/transcript-viewport.js'
|
|
25
26
|
|
|
26
27
|
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
27
28
|
const strip = (s: string) => s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
@@ -36,17 +37,17 @@ const ROWS = 24
|
|
|
36
37
|
async function main() {
|
|
37
38
|
console.log('\n[SCREEN] no-residue picker transitions\n')
|
|
38
39
|
|
|
39
|
-
// A
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
// look gray even though the rest of the message is colored.
|
|
43
|
-
const styledEntries: AnyEntry[] = [
|
|
40
|
+
// A partial message is now represented by the same styled ScreenRow as a
|
|
41
|
+
// complete message. Virtual slicing must preserve its ANSI foreground bytes.
|
|
42
|
+
const styledEntries = [
|
|
44
43
|
{ type: 'assistant', uuid: 'styled-old', message: { role: 'assistant', content: [{ type: 'text', text: '[彩色链接](https://example.com)' }] } },
|
|
45
44
|
{ type: 'assistant', uuid: 'styled-new', message: { role: 'assistant', content: [{ type: 'text', text: '最新消息' }] } },
|
|
46
45
|
]
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
const rowAccess = createRowAccess(styledEntries, entry => entry.uuid, entry => entryScreenRows(viewsForEntry(entry), 80))
|
|
47
|
+
const viewport = sliceViewport(rowAccess, tailAnchor(rowAccess, 3), 3)
|
|
48
|
+
const partial = viewport.rows.find(item => item.entryId === 'styled-old')?.row
|
|
49
|
+
ok(partial !== undefined, 'small viewport exposes a row from the partial older message')
|
|
50
|
+
ok(!!partial?.styled.includes('\x1b[') && partial.styled.includes('彩色链接'), 'partial older row keeps its ANSI foreground styling')
|
|
50
51
|
|
|
51
52
|
// ── 1. Without Screen, a tall frame overflows the terminal (the bug). ───────
|
|
52
53
|
const tall = render(
|
package/tests/scroll.test.tsx
CHANGED
|
@@ -138,7 +138,7 @@ async function main() {
|
|
|
138
138
|
const tailFrame = strip(lastFrame() ?? '')
|
|
139
139
|
|
|
140
140
|
ok(tailFrame.includes('回答 24'), 'latest entry visible at tail (not hidden)')
|
|
141
|
-
ok(tailFrame.includes('
|
|
141
|
+
ok(tailFrame.includes('↑ 还有较早内容') && tailFrame.includes('滚轮 3 行'), 'navigation reports older content and the exact wheel step')
|
|
142
142
|
|
|
143
143
|
// ── live resize: refit one dynamic frame, never retain old-width output ──
|
|
144
144
|
resize(stdout as unknown as NodeJS.WriteStream, 52, 18)
|
|
@@ -157,21 +157,20 @@ async function main() {
|
|
|
157
157
|
ok(tallAnswers > narrowAnswers, 'larger resize reveals more history in the same viewport')
|
|
158
158
|
ok((tallFrame.match(/>_ Mobius/g) ?? []).length === 1, 'larger resize still has one dynamic header')
|
|
159
159
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
// viewport has spare rows (regression for real terminals, which bound the
|
|
163
|
-
// transcript box height via stdout.isTTY).
|
|
160
|
+
// Navigation is a fixed one-row part of the conversation chrome. It must be
|
|
161
|
+
// the FIRST line below the header and state the exact PageUp/PageDown step.
|
|
164
162
|
const tallLines = tallFrame.split('\n')
|
|
165
|
-
const hintIdx = tallLines.findIndex(l => l.includes('
|
|
166
|
-
ok(hintIdx === 1, `
|
|
163
|
+
const hintIdx = tallLines.findIndex(l => l.includes('滚轮 3 行'))
|
|
164
|
+
ok(hintIdx === 1, `navigation is the first line under the header (line ${hintIdx}, expected 1)`)
|
|
165
|
+
ok(/PageUp\/PageDown \d+ 行/.test(tallLines[hintIdx] ?? ''), 'navigation exposes the deterministic page size')
|
|
167
166
|
const messageRows = tallLines.slice(hintIdx + 1).filter(line => line.trim())
|
|
168
|
-
ok(messageRows.length > 0 && messageRows[0].includes('
|
|
167
|
+
ok(messageRows.length > 0 && messageRows[0].includes('回答'), 'a real virtualized message row follows navigation without a synthetic peek row')
|
|
169
168
|
|
|
170
169
|
// ── PageUp: viewport scrolls back over history ────────────────────────────
|
|
171
170
|
stdin.write('\x1b[5~') // PageUp
|
|
172
171
|
await delay(300)
|
|
173
172
|
const upFrame = strip(lastFrame() ?? '')
|
|
174
|
-
ok(upFrame.includes('
|
|
173
|
+
ok(upFrame.includes('↓ 较新内容'), 'after PageUp: navigation reports newer content below')
|
|
175
174
|
ok(!upFrame.includes('回答 24'), 'after PageUp: latest entry paged out of view')
|
|
176
175
|
ok(/回答 \d+/.test(upFrame), 'after PageUp: an older entry is visible')
|
|
177
176
|
|
|
@@ -185,7 +184,7 @@ async function main() {
|
|
|
185
184
|
stdin.write('\x1b[<64;5;5M') // wheel up = scroll back
|
|
186
185
|
await delay(300)
|
|
187
186
|
const wheelUp = strip(lastFrame() ?? '')
|
|
188
|
-
ok(wheelUp.includes('
|
|
187
|
+
ok(wheelUp.includes('↓ 较新内容'), 'wheel up: navigation reports newer content below')
|
|
189
188
|
ok(!wheelUp.includes('回答 24'), 'wheel up: latest entry paged out of view')
|
|
190
189
|
ok(/回答 \d+/.test(wheelUp), 'wheel up: an older entry is visible')
|
|
191
190
|
|
|
@@ -199,7 +198,7 @@ async function main() {
|
|
|
199
198
|
stdin.write('\x1b[M' + String.fromCharCode(96, 50, 50))
|
|
200
199
|
await delay(300)
|
|
201
200
|
const legacyUp = strip(lastFrame() ?? '')
|
|
202
|
-
ok(legacyUp.includes('
|
|
201
|
+
ok(legacyUp.includes('↓ 较新内容'), 'legacy wheel up: navigation reports newer content below')
|
|
203
202
|
ok(!legacyUp.includes('回答 24'), 'legacy wheel up: latest entry paged out of view')
|
|
204
203
|
|
|
205
204
|
stdin.write('\x1b[M' + String.fromCharCode(97, 50, 50)) // wheel down Cb = 0x61
|
|
@@ -229,13 +228,15 @@ async function main() {
|
|
|
229
228
|
await bootToChat(second.stdin, second.lastFrame)
|
|
230
229
|
await populateTranscript(second.stdin, emitEntry)
|
|
231
230
|
const before = strip(second.lastFrame() ?? '')
|
|
231
|
+
const beforeAnswers = before.match(/回答 \d+/g) ?? []
|
|
232
232
|
ok(before.includes('回答 24'), 'disable-mouse: latest entry visible before wheel')
|
|
233
233
|
|
|
234
234
|
second.stdin.write('\x1b[<64;5;5M') // wheel up — must be ignored
|
|
235
235
|
await delay(300)
|
|
236
236
|
const after = strip(second.lastFrame() ?? '')
|
|
237
|
+
const afterAnswers = after.match(/回答 \d+/g) ?? []
|
|
237
238
|
ok(after.includes('回答 24'), 'disable-mouse: wheel up leaves latest entry in view')
|
|
238
|
-
ok(
|
|
239
|
+
ok(JSON.stringify(afterAnswers) === JSON.stringify(beforeAnswers), 'disable-mouse: wheel up leaves the visible row window unchanged')
|
|
239
240
|
} finally {
|
|
240
241
|
second.unmount()
|
|
241
242
|
delete process.env.MOBIUS_TUI_DISABLE_MOUSE
|
package/tests/selection.test.tsx
CHANGED
|
@@ -139,7 +139,7 @@ async function main() {
|
|
|
139
139
|
const row1 = lines.findIndex(l => l.includes('回答 1'))
|
|
140
140
|
const row3 = lines.findIndex(l => l.includes('回答 3'))
|
|
141
141
|
ok(row1 >= 0 && row3 >= 0, `found 回答 1 (row ${row1}) and 回答 3 (row ${row3}) in the transcript`)
|
|
142
|
-
ok(!frame.includes('
|
|
142
|
+
ok(frame.includes('全部内容') && !frame.includes('↑ 较早内容') && !frame.includes('↓ 还有较新内容'), 'all entries fit — navigation reports the complete transcript')
|
|
143
143
|
|
|
144
144
|
// press on 回答 1 (col 4 → first content char), drag to 回答 3 (col beyond EOL)
|
|
145
145
|
stdin.write(`\x1b[<0;5;${row1 + 1}M`) // left-button press (SGR 1-based)
|