@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.
@@ -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 `&#39; &quot; &lt; &gt; &amp;` 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 ?? renderInline(anyT.tokens)
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 ?? renderInline(anyT.tokens)
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'
@@ -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-v2') && before.endsWith('.zip'), 'bundleUrl follows the fixed filename pattern')
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-v2.zip', 'MOBIUS_TUI_PYTHON_BUNDLE_URL overrides the CDN base and trims trailing slash')
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('--silent-shell'), 'Windows reverse connection always requests hidden command shells')
140
- ok(!linux.includes('--silent-shell'), 'non-Windows reverse connection does not receive the Windows-only flag')
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.21'"), 'bundle probe rejects stale AIMUX versions')
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()
@@ -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
- // Pick the first project (created above).
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
- // Pick the first issue.
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()
@@ -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 { fitTranscript } from '../src/components/Chat.js'
24
- import type { AnyEntry } from '../src/types.js'
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 hidden older entry may contribute only its tail rows when the viewport
40
- // has one spare row. That preview must retain the entry's foreground style;
41
- // rendering it as a bare dimColor string makes a clipped cyan Markdown link
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 fitted = fitTranscript(styledEntries, 3, 80)
48
- ok(fitted.peekRows.length === 1, 'small viewport exposes one tail row from the hidden message')
49
- ok(fitted.peekRows[0]?.styled.includes('\x1b[') && fitted.peekRows[0]?.styled.includes('彩色链接'), 'partial older row keeps its ANSI foreground styling')
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(
@@ -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('PageUp'), 'older-records hint offers PageUp (nothing is silently lost)')
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
- // The "↑ 还有 N 条较早记录" hint must be pinned to the FIRST line below the
161
- // header and span the full width it must not float mid-transcript when the
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, `older-records hint is the first line under the header (line ${hintIdx}, expected 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(''), 'older message tail is visible immediately after the history hint')
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('PageDown'), 'after PageUp: a PageDown hint appears (scrolled up)')
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('PageDown'), 'wheel up: a PageDown hint appears (scrolled back)')
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('PageDown'), 'legacy wheel up: a PageDown hint appears (scrolled back)')
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(!after.includes('PageDown'), 'disable-mouse: wheel up does NOT scroll (no PageDown hint)')
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
@@ -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('PageDown'), 'all entries fit — no paging hints at rest')
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)