@mobius-os/mobius 0.3.27 → 0.3.34
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/install.ps1 +265 -0
- package/package.json +24 -7
- package/scripts/build-python-bundles.sh +129 -0
- package/src/App.tsx +2 -2
- package/src/components/Chat.tsx +158 -341
- package/src/components/ConfigFlow.tsx +2 -0
- package/src/components/primitives.tsx +134 -7
- package/src/lib/entry-view.ts +10 -5
- package/src/lib/screen-text.ts +68 -57
- package/src/lib/transcript-viewport.ts +162 -0
- package/src/lib/windows-input.ts +186 -0
- package/src/main.tsx +5 -1
- package/src/version.ts +21 -0
- package/tests/aimux.test.tsx +210 -0
- package/tests/flow.test.tsx +211 -0
- package/tests/integration.test.ts +114 -0
- package/tests/preview.tsx +104 -0
- package/tests/reconnect.test.tsx +170 -0
- package/tests/resume.test.tsx +73 -0
- package/tests/screen.test.tsx +118 -0
- package/tests/scroll.test.tsx +253 -0
- package/tests/selection.test.tsx +219 -0
- package/tests/ui.test.tsx +901 -0
- package/tests/viewport.test.ts +83 -0
- package/tsconfig.json +19 -0
- package/uninstall.ps1 +144 -0
|
@@ -6,15 +6,118 @@ import React, { useEffect, useRef, useState } from 'react'
|
|
|
6
6
|
import { Box, Text, useInput, useStdout, useStdin, type Key } from 'ink'
|
|
7
7
|
import { useDeleteKeyCapture, applyDeleteIntent } from '../lib/delete-keys.js'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/** Return false when a mounted listener deliberately did not consume the input. */
|
|
10
|
+
type InputHandler = (input: string, key: Key) => void | false
|
|
11
|
+
|
|
12
|
+
type StableInputOptions = {
|
|
13
|
+
isActive?: boolean
|
|
14
|
+
/** Passive listeners (App raw-mode keepalive, Chat paging) must not claim or receive replayed input. */
|
|
15
|
+
interactive?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ReplayInput = { input: string; key: Key; signature: string; claimed: boolean }
|
|
19
|
+
|
|
20
|
+
const pendingRootInputs: ReplayInput[] = []
|
|
21
|
+
const replayQueue: ReplayInput[] = []
|
|
22
|
+
const interactiveHandlers = new Set<InputHandler>()
|
|
23
|
+
const earlyClaimCredits = new Map<string, number>()
|
|
24
|
+
let earlyClaimCleanupScheduled = false
|
|
25
|
+
let replayingInput = false
|
|
26
|
+
|
|
27
|
+
function inputSignature(input: string, key: Key): string {
|
|
28
|
+
const flags = Object.keys(key)
|
|
29
|
+
.filter(name => Boolean((key as unknown as Record<string, unknown>)[name]))
|
|
30
|
+
.sort()
|
|
31
|
+
.join(',')
|
|
32
|
+
return `${input}\u0000${flags}`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function markRootInputClaimed(input: string, key: Key): void {
|
|
36
|
+
const signature = inputSignature(input, key)
|
|
37
|
+
const pending = pendingRootInputs.find(event => !event.claimed && event.signature === signature)
|
|
38
|
+
if (pending) {
|
|
39
|
+
pending.claimed = true
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// React/Ink may register a child's listener before the App listener. Keep a
|
|
44
|
+
// one-microtask credit so the root callback later in the same emitter pass
|
|
45
|
+
// recognizes that this exact input was already handled.
|
|
46
|
+
earlyClaimCredits.set(signature, (earlyClaimCredits.get(signature) ?? 0) + 1)
|
|
47
|
+
if (!earlyClaimCleanupScheduled) {
|
|
48
|
+
earlyClaimCleanupScheduled = true
|
|
49
|
+
queueMicrotask(() => {
|
|
50
|
+
earlyClaimCredits.clear()
|
|
51
|
+
earlyClaimCleanupScheduled = false
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deliverOrQueue(event: ReplayInput): void {
|
|
57
|
+
for (const handler of Array.from(interactiveHandlers).reverse()) {
|
|
58
|
+
replayingInput = true
|
|
59
|
+
try {
|
|
60
|
+
if (handler(event.input, event.key) !== false) return
|
|
61
|
+
} finally {
|
|
62
|
+
replayingInput = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
replayQueue.push(event)
|
|
66
|
+
if (replayQueue.length > 8) replayQueue.shift()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* App-level input safety net. It stays mounted across async route changes and
|
|
71
|
+
* only buffers a key when no interactive Ink listener claimed that emitter
|
|
72
|
+
* pass. The next Select/TextInput/Composer receives the key after it mounts.
|
|
73
|
+
*/
|
|
74
|
+
export function bufferUnclaimedInput(input: string, key: Key): void {
|
|
75
|
+
if (isMouseInput(input)) return
|
|
76
|
+
const signature = inputSignature(input, key)
|
|
77
|
+
const credits = earlyClaimCredits.get(signature) ?? 0
|
|
78
|
+
if (credits > 0) {
|
|
79
|
+
if (credits === 1) earlyClaimCredits.delete(signature)
|
|
80
|
+
else earlyClaimCredits.set(signature, credits - 1)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const event: ReplayInput = { input, key: { ...key }, signature, claimed: false }
|
|
85
|
+
pendingRootInputs.push(event)
|
|
86
|
+
setTimeout(() => {
|
|
87
|
+
const index = pendingRootInputs.indexOf(event)
|
|
88
|
+
if (index >= 0) pendingRootInputs.splice(index, 1)
|
|
89
|
+
if (!event.claimed) deliverOrQueue(event)
|
|
90
|
+
}, 0)
|
|
91
|
+
}
|
|
10
92
|
|
|
11
93
|
/** Keep one Ink listener while a component rerenders; read the latest handler through a ref. */
|
|
12
|
-
export function useStableInput(handler: InputHandler, options?:
|
|
94
|
+
export function useStableInput(handler: InputHandler, options?: StableInputOptions): void {
|
|
13
95
|
const handlerRef = useRef(handler)
|
|
14
96
|
handlerRef.current = handler
|
|
15
97
|
const stableRef = useRef<InputHandler | null>(null)
|
|
16
|
-
|
|
17
|
-
|
|
98
|
+
const interactive = options?.interactive !== false
|
|
99
|
+
if (!stableRef.current) {
|
|
100
|
+
stableRef.current = (input, key) => {
|
|
101
|
+
const handled = handlerRef.current(input, key)
|
|
102
|
+
if (interactive && !replayingInput && handled !== false) markRootInputClaimed(input, key)
|
|
103
|
+
return handled
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
useInput(stableRef.current, { isActive: options?.isActive })
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!interactive || options?.isActive === false || !stableRef.current) return
|
|
110
|
+
const stable = stableRef.current
|
|
111
|
+
interactiveHandlers.add(stable)
|
|
112
|
+
const queued = replayQueue.splice(0)
|
|
113
|
+
replayingInput = true
|
|
114
|
+
try {
|
|
115
|
+
for (const event of queued) stable(event.input, event.key)
|
|
116
|
+
} finally {
|
|
117
|
+
replayingInput = false
|
|
118
|
+
}
|
|
119
|
+
return () => { interactiveHandlers.delete(stable) }
|
|
120
|
+
}, [interactive, options?.isActive])
|
|
18
121
|
}
|
|
19
122
|
|
|
20
123
|
/** Windows Terminal/ConPTY may expose Esc as a named key, a raw byte, or Ctrl+[. */
|
|
@@ -89,6 +192,26 @@ export function parseMouseEvents(input: string): MouseEventInfo[] {
|
|
|
89
192
|
return out
|
|
90
193
|
}
|
|
91
194
|
|
|
195
|
+
/** Collapse each contiguous wheel burst into one delta without reordering clicks. */
|
|
196
|
+
export function coalesceMouseEvents(events: MouseEventInfo[]): MouseEventInfo[] {
|
|
197
|
+
const out: MouseEventInfo[] = []
|
|
198
|
+
let wheelDelta = 0
|
|
199
|
+
const flushWheel = () => {
|
|
200
|
+
if (wheelDelta !== 0) out.push({ kind: 'wheel', delta: wheelDelta })
|
|
201
|
+
wheelDelta = 0
|
|
202
|
+
}
|
|
203
|
+
for (const event of events) {
|
|
204
|
+
if (event.kind === 'wheel') {
|
|
205
|
+
wheelDelta += event.delta
|
|
206
|
+
} else {
|
|
207
|
+
flushWheel()
|
|
208
|
+
out.push(event)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
flushWheel()
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
92
215
|
/**
|
|
93
216
|
* Enables terminal mouse tracking for the lifetime of the calling component and
|
|
94
217
|
* forwards mouse events (wheel + left-button press/motion/release) to the given
|
|
@@ -125,7 +248,7 @@ export function useMouseEvents(handlers: {
|
|
|
125
248
|
// A single read() chunk may carry several events and a sequence may be
|
|
126
249
|
// split across chunks, so accumulate and re-scan.
|
|
127
250
|
buf += String(chunk)
|
|
128
|
-
for (const e of parseMouseEvents(buf)) {
|
|
251
|
+
for (const e of coalesceMouseEvents(parseMouseEvents(buf))) {
|
|
129
252
|
if (e.kind === 'wheel') refs.current.onWheel?.(e.delta)
|
|
130
253
|
else if (e.kind === 'press') refs.current.onPress?.(e.row, e.col)
|
|
131
254
|
else if (e.kind === 'motion') refs.current.onMotion?.(e.row, e.col)
|
|
@@ -309,11 +432,12 @@ export interface SelectProps {
|
|
|
309
432
|
focused?: boolean
|
|
310
433
|
title?: string
|
|
311
434
|
maxVisible?: number // cap rendered rows so long lists never overflow the terminal
|
|
435
|
+
initialActive?: number // initial keyboard focus; useful when a create action occupies row 0
|
|
312
436
|
}
|
|
313
437
|
|
|
314
438
|
export function Select(props: SelectProps) {
|
|
315
439
|
const mode = props.mode ?? 'single'
|
|
316
|
-
const [active, setActive] = useState(0)
|
|
440
|
+
const [active, setActive] = useState(() => Math.max(0, props.initialActive ?? 0))
|
|
317
441
|
const items = props.items
|
|
318
442
|
const selectedSet = new Set<string>(mode === 'multi' ? (props.selected as string[]) ?? [] : [])
|
|
319
443
|
const { stdout } = useStdout()
|
|
@@ -326,7 +450,10 @@ export function Select(props: SelectProps) {
|
|
|
326
450
|
if (key.upArrow) { setActive(a => (a - 1 + items.length) % items.length); return }
|
|
327
451
|
if (key.downArrow) { setActive(a => (a + 1) % items.length); return }
|
|
328
452
|
if (mode === 'single') {
|
|
329
|
-
if (key.return) {
|
|
453
|
+
if (key.return) {
|
|
454
|
+
props.onSelect?.(items[active].value)
|
|
455
|
+
return
|
|
456
|
+
}
|
|
330
457
|
} else {
|
|
331
458
|
if (key.return) { props.onConfirm?.(Array.from(selectedSet)); return }
|
|
332
459
|
if (input === ' ') { props.onToggle?.(items[active].value); return }
|
package/src/lib/entry-view.ts
CHANGED
|
@@ -638,18 +638,18 @@ export function toolLabel(name: string): string {
|
|
|
638
638
|
// agent 输出, 则视为同一次输入的重复入口 → 丢弃, 避免 TUI 把同一条提问显示多次.
|
|
639
639
|
export function userTextOf(e: AnyEntry): string {
|
|
640
640
|
if (e?.type === 'event_msg' && e?.payload?.type === 'user_message') {
|
|
641
|
-
return String(e?.payload?.message || '')
|
|
641
|
+
return canonicalUserText(String(e?.payload?.message || ''))
|
|
642
642
|
}
|
|
643
643
|
if (e?.type === 'response_item' && e?.payload?.type === 'message' && e?.payload?.role === 'user') {
|
|
644
644
|
const c = e?.payload?.content
|
|
645
|
-
if (typeof c === 'string') return c
|
|
646
|
-
if (Array.isArray(c)) return c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n')
|
|
645
|
+
if (typeof c === 'string') return canonicalUserText(c)
|
|
646
|
+
if (Array.isArray(c)) return canonicalUserText(c.map((b: any) => b?.text || b?.input_text || '').filter(Boolean).join('\n'))
|
|
647
647
|
return ''
|
|
648
648
|
}
|
|
649
649
|
if (e?.type === 'user') {
|
|
650
650
|
const c = e?.message?.content
|
|
651
|
-
if (typeof c === 'string') return c
|
|
652
|
-
if (Array.isArray(c)) return c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n')
|
|
651
|
+
if (typeof c === 'string') return canonicalUserText(c)
|
|
652
|
+
if (Array.isArray(c)) return canonicalUserText(c.filter((b: any) => b?.type === 'text').map((b: any) => b?.text || '').join('\n'))
|
|
653
653
|
return ''
|
|
654
654
|
}
|
|
655
655
|
return ''
|
|
@@ -669,6 +669,11 @@ export function stripUserFraming(text: string): string {
|
|
|
669
669
|
return after || text
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
/** Canonical user text for duplicate event identities (framed vs plain). */
|
|
673
|
+
function canonicalUserText(text: string): string {
|
|
674
|
+
return stripUserFraming(text).replace(/\s+/g, ' ').trim()
|
|
675
|
+
}
|
|
676
|
+
|
|
672
677
|
export function isAssistantOutput(e: AnyEntry): boolean {
|
|
673
678
|
if (e?.type === 'assistant') return true
|
|
674
679
|
if (e?.type === 'event_msg' && e?.payload?.type === 'agent_message') return true
|
package/src/lib/screen-text.ts
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import wrapAnsi from 'wrap-ansi'
|
|
14
14
|
import { renderMarkdownLines } from '../markdown.js'
|
|
15
15
|
import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
|
|
16
|
-
import type { AnyEntry } from '../types.js'
|
|
17
16
|
|
|
18
17
|
// ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
|
|
19
18
|
export function displayWidth(str: string): number {
|
|
@@ -103,8 +102,29 @@ export function headTailLines(text: string, width: number, maxLines: number): st
|
|
|
103
102
|
export interface ScreenRows {
|
|
104
103
|
/** Whether this view renders with a leading blank row (its Box has marginTop). */
|
|
105
104
|
marginTop: boolean
|
|
106
|
-
/** Full screen
|
|
107
|
-
rows:
|
|
105
|
+
/** Full screen rows: a plain layout/copy projection plus its styled ANSI text. */
|
|
106
|
+
rows: ScreenRow[]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ScreenRowTone =
|
|
110
|
+
| 'normal'
|
|
111
|
+
| 'user'
|
|
112
|
+
| 'tool'
|
|
113
|
+
| 'tool_result'
|
|
114
|
+
| 'tool_error'
|
|
115
|
+
| 'edit_header'
|
|
116
|
+
| 'edit_old'
|
|
117
|
+
| 'edit_new'
|
|
118
|
+
| 'reasoning'
|
|
119
|
+
| 'system'
|
|
120
|
+
| 'error'
|
|
121
|
+
|
|
122
|
+
export interface ScreenRow {
|
|
123
|
+
/** Visible text used for geometry, hit-testing, and clipboard extraction. */
|
|
124
|
+
plain: string
|
|
125
|
+
/** Same row with Markdown/syntax ANSI styling preserved for terminal output. */
|
|
126
|
+
styled: string
|
|
127
|
+
tone: ScreenRowTone
|
|
108
128
|
}
|
|
109
129
|
|
|
110
130
|
const textWidth = (columns: number) => Math.max(8, columns - 4)
|
|
@@ -114,67 +134,87 @@ const fullWidth = (columns: number) => Math.max(1, columns - 2) // root paddingX
|
|
|
114
134
|
export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
|
|
115
135
|
const width = textWidth(columns)
|
|
116
136
|
const full = fullWidth(columns)
|
|
117
|
-
const
|
|
137
|
+
const row = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow => ({
|
|
138
|
+
plain: stripAnsi(styled),
|
|
139
|
+
styled,
|
|
140
|
+
tone,
|
|
141
|
+
})
|
|
142
|
+
// Keep the original styled string and let Ink perform its ANSI-aware
|
|
143
|
+
// truncate-end rendering. The plain projection mirrors the visible row.
|
|
144
|
+
const fit = (rows: string[], tone: ScreenRowTone = 'normal'): ScreenRow[] => rows.map((styled) => ({
|
|
145
|
+
plain: truncateText(stripAnsi(styled), full),
|
|
146
|
+
styled,
|
|
147
|
+
tone,
|
|
148
|
+
}))
|
|
149
|
+
const wrap = (styled: string, tone: ScreenRowTone = 'normal'): ScreenRow[] => (
|
|
150
|
+
wrapAnsi(styled, full, { trim: false, hard: true }).split('\n').map(text => row(text, tone))
|
|
151
|
+
)
|
|
118
152
|
switch (view.kind) {
|
|
119
153
|
case 'skip':
|
|
120
154
|
return { marginTop: false, rows: [] }
|
|
121
155
|
case 'user': {
|
|
122
156
|
const rows = view.text.split('\n').map((l, i) => (i === 0 ? `› ${l}` : ` ${l}`))
|
|
123
|
-
return { marginTop: true, rows: fit(rows) }
|
|
157
|
+
return { marginTop: true, rows: fit(rows, 'user') }
|
|
124
158
|
}
|
|
125
159
|
case 'assistant': {
|
|
126
160
|
const md = renderMarkdownLines(view.text)
|
|
127
|
-
const rows:
|
|
161
|
+
const rows: ScreenRow[] = []
|
|
128
162
|
md.forEach((line, i) => {
|
|
129
163
|
const prefix = i === 0 ? '• ' : ' '
|
|
130
|
-
const fullLine = prefix +
|
|
131
|
-
if (line.code) rows.push(
|
|
132
|
-
else rows.push(...
|
|
164
|
+
const fullLine = prefix + (line.text || ' ')
|
|
165
|
+
if (line.code) rows.push(...fit([fullLine]))
|
|
166
|
+
else rows.push(...wrap(fullLine))
|
|
133
167
|
})
|
|
134
168
|
return { marginTop: true, rows }
|
|
135
169
|
}
|
|
136
170
|
case 'tool_call': {
|
|
137
171
|
const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
|
|
138
|
-
const rows = [`• ${head}`]
|
|
139
|
-
if (view.result) rows.push(
|
|
140
|
-
|
|
172
|
+
const rows = fit([`• ${head}`], 'tool')
|
|
173
|
+
if (view.result) rows.push(...fit(
|
|
174
|
+
[` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`],
|
|
175
|
+
view.result.isError ? 'tool_error' : 'tool_result',
|
|
176
|
+
))
|
|
177
|
+
return { marginTop: true, rows }
|
|
141
178
|
}
|
|
142
179
|
case 'tool_result': {
|
|
143
180
|
const lines = headTailLines(view.text, width - 4, 5)
|
|
144
|
-
return { marginTop: false, rows: fit(
|
|
181
|
+
return { marginTop: false, rows: fit(
|
|
182
|
+
lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`),
|
|
183
|
+
view.isError ? 'tool_error' : 'tool_result',
|
|
184
|
+
) }
|
|
145
185
|
}
|
|
146
186
|
case 'code_edit': {
|
|
147
|
-
const rows = [`✎ 编辑 ${view.filePath || '(未指定文件)'}`]
|
|
148
|
-
if (view.oldString) rows.push(...view.oldString.split('\n').map((l) => ` − ${l}`))
|
|
149
|
-
if (view.newString) rows.push(...view.newString.split('\n').map((l) => ` + ${l}`))
|
|
150
|
-
return { marginTop: true, rows
|
|
187
|
+
const rows = fit([`✎ 编辑 ${view.filePath || '(未指定文件)'}`], 'edit_header')
|
|
188
|
+
if (view.oldString) rows.push(...fit(view.oldString.split('\n').map((l) => ` − ${l}`), 'edit_old'))
|
|
189
|
+
if (view.newString) rows.push(...fit(view.newString.split('\n').map((l) => ` + ${l}`), 'edit_new'))
|
|
190
|
+
return { marginTop: true, rows }
|
|
151
191
|
}
|
|
152
192
|
case 'write_file': {
|
|
153
|
-
const rows = [`✎ 写入 ${view.filePath || '(未指定文件)'}`]
|
|
154
|
-
rows.push(...view.content.split('\n').map((l) => ` + ${l}`))
|
|
155
|
-
return { marginTop: true, rows
|
|
193
|
+
const rows = fit([`✎ 写入 ${view.filePath || '(未指定文件)'}`], 'edit_header')
|
|
194
|
+
rows.push(...fit(view.content.split('\n').map((l) => ` + ${l}`), 'edit_new'))
|
|
195
|
+
return { marginTop: true, rows }
|
|
156
196
|
}
|
|
157
197
|
case 'reasoning': {
|
|
158
198
|
const lines = clampLines(view.text, width - 4, 2)
|
|
159
|
-
return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`)) }
|
|
199
|
+
return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`), 'reasoning') }
|
|
160
200
|
}
|
|
161
201
|
case 'system':
|
|
162
|
-
return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`]) }
|
|
202
|
+
return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`], 'system') }
|
|
163
203
|
case 'error': {
|
|
164
204
|
const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
|
|
165
|
-
return { marginTop: true, rows: fit(rows) }
|
|
205
|
+
return { marginTop: true, rows: fit(rows, 'error') }
|
|
166
206
|
}
|
|
167
207
|
default:
|
|
168
208
|
return { marginTop: false, rows: [] }
|
|
169
209
|
}
|
|
170
210
|
}
|
|
171
211
|
|
|
172
|
-
/** Flatten a whole entry
|
|
173
|
-
export function
|
|
174
|
-
const lines:
|
|
212
|
+
/** Flatten a whole entry into stable rows, with margins represented explicitly. */
|
|
213
|
+
export function entryScreenRows(views: EntryView[], columns: number): ScreenRow[] {
|
|
214
|
+
const lines: ScreenRow[] = []
|
|
175
215
|
for (const v of views) {
|
|
176
216
|
const { marginTop, rows } = viewScreenRows(v, columns)
|
|
177
|
-
if (marginTop) lines.push('')
|
|
217
|
+
if (marginTop) lines.push({ plain: '', styled: '', tone: 'normal' })
|
|
178
218
|
lines.push(...rows)
|
|
179
219
|
}
|
|
180
220
|
return lines
|
|
@@ -194,30 +234,6 @@ export interface TranscriptGeometry {
|
|
|
194
234
|
* (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
|
|
195
235
|
* + tip + help. Margins that render as extra rows are counted explicitly.
|
|
196
236
|
*/
|
|
197
|
-
export function computeTranscriptGeometry(opts: {
|
|
198
|
-
viewportRows: number
|
|
199
|
-
composerRows: number
|
|
200
|
-
statusRows: number
|
|
201
|
-
activityRows: number
|
|
202
|
-
helpRows: number
|
|
203
|
-
showWelcome: boolean
|
|
204
|
-
welcomeRows: number
|
|
205
|
-
olderHintShown: boolean
|
|
206
|
-
tipShown: boolean
|
|
207
|
-
}): TranscriptGeometry {
|
|
208
|
-
// The composer's reported height already includes its marginTop; the status
|
|
209
|
-
// area and working indicator rows are already folded into statusRows and
|
|
210
|
-
// activityRows. No extra +1 here — calibrated against the rendered frame.
|
|
211
|
-
const bottomH = opts.activityRows + opts.composerRows + opts.statusRows
|
|
212
|
-
const midH = opts.viewportRows - bottomH
|
|
213
|
-
const headerH = opts.showWelcome ? opts.welcomeRows : 1
|
|
214
|
-
const hintH = opts.olderHintShown ? 1 : 0
|
|
215
|
-
const tipH = opts.tipShown ? 2 : 0 // marginTop 1 + content 1
|
|
216
|
-
const helpH = opts.helpRows > 0 ? opts.helpRows + 1 : 0 // +1 marginTop
|
|
217
|
-
const boxTop = headerH + hintH + tipH + helpH
|
|
218
|
-
return { boxTop, boxH: Math.max(0, midH - boxTop) }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
237
|
// ── selection mapping ────────────────────────────────────────────────────────
|
|
222
238
|
export interface SelPoint {
|
|
223
239
|
entry: number // index into the fitted entries
|
|
@@ -230,11 +246,6 @@ export interface TranscriptModel {
|
|
|
230
246
|
totalRows: number
|
|
231
247
|
}
|
|
232
248
|
|
|
233
|
-
export function buildTranscriptModel(fittedEntries: AnyEntry[], columns: number): TranscriptModel {
|
|
234
|
-
const entries = fittedEntries.map((e) => entryScreenLines(viewsForEntry(e), columns))
|
|
235
|
-
return { entries, totalRows: entries.reduce((sum, l) => sum + l.length, 0) }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
249
|
/** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
|
|
239
250
|
export function screenToSelPoint(
|
|
240
251
|
screenRow: number,
|
|
@@ -269,7 +280,7 @@ function charAtDisplayWidth(line: string, col: number): number {
|
|
|
269
280
|
const w = displayWidth(ch)
|
|
270
281
|
if (col < acc + w) return i
|
|
271
282
|
acc += w
|
|
272
|
-
i
|
|
283
|
+
i += ch.length
|
|
273
284
|
}
|
|
274
285
|
return i
|
|
275
286
|
}
|
|
@@ -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
|
+
}
|