@mobius-os/mobius 0.3.15 → 0.3.20
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 +3 -2
- package/src/App.tsx +28 -0
- package/src/components/Chat.tsx +202 -119
- package/src/components/ConfigFlow.tsx +120 -0
- package/src/components/primitives.tsx +99 -44
- package/src/lib/delete-keys.ts +152 -0
- package/src/lib/screen-text.ts +339 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen-text model for the transcript: reproduces the exact rows Ink renders
|
|
3
|
+
* so mouse coordinates can be mapped to (entry, line, char) for tmux-style
|
|
4
|
+
* drag selection, and so selected rows can be re-rendered with a highlight.
|
|
5
|
+
*
|
|
6
|
+
* Alignment strategy: Ink wraps `<Text wrap="wrap">` with `wrap-ansi` using
|
|
7
|
+
* `{ trim: false, hard: true }` at the Text's available width. The transcript
|
|
8
|
+
* lives under the root Box whose `paddingX={1}` leaves `columns - 2` columns,
|
|
9
|
+
* so every visible line is wrapped/truncated at `columns - 2`. The same width
|
|
10
|
+
* and the same wrap call are used here, so the model's rows match the rendered
|
|
11
|
+
* frame (verified by tests/scroll.test.tsx).
|
|
12
|
+
*/
|
|
13
|
+
import wrapAnsi from 'wrap-ansi'
|
|
14
|
+
import { renderMarkdownLines } from '../markdown.js'
|
|
15
|
+
import { toolLabel, viewsForEntry, type EntryView } from './entry-view.js'
|
|
16
|
+
import type { AnyEntry } from '../types.js'
|
|
17
|
+
|
|
18
|
+
// ── shared text helpers (mirrored from Chat.tsx, kept here to avoid a cycle) ─
|
|
19
|
+
export function displayWidth(str: string): number {
|
|
20
|
+
let width = 0
|
|
21
|
+
for (const ch of Array.from(str)) {
|
|
22
|
+
const code = ch.codePointAt(0)!
|
|
23
|
+
// Zero-width / combining marks.
|
|
24
|
+
if (code === 0x200d) continue
|
|
25
|
+
if ((code >= 0x0300 && code <= 0x036f) || (code >= 0xfe00 && code <= 0xfe0f) || (code >= 0x1ab0 && code <= 0x1aff)) continue
|
|
26
|
+
// Narrow: halfwidth katakana, Hangul jamo, Latin-1-ish control-ish.
|
|
27
|
+
if (code < 0x100 && !(code >= 0x1100 && code <= 0x115f)) width += 1
|
|
28
|
+
else if (code >= 0xff61 && code <= 0xffdc) width += 1
|
|
29
|
+
else if (code >= 0x1100 && code <= 0x115f) width += 2
|
|
30
|
+
else if (code >= 0x2e80 && code <= 0x303e) width += 2
|
|
31
|
+
else if (code >= 0x3040 && code <= 0xa4cf) width += 2
|
|
32
|
+
else if (code >= 0xac00 && code <= 0xd7a3) width += 2
|
|
33
|
+
else if (code >= 0xf900 && code <= 0xfaff) width += 2
|
|
34
|
+
else if (code >= 0xfe30 && code <= 0xfe4f) width += 2
|
|
35
|
+
else if (code >= 0xff00 && code <= 0xff60) width += 2
|
|
36
|
+
else if (code >= 0xffe0 && code <= 0xffe6) width += 2
|
|
37
|
+
else if (code >= 0x1f300 && code <= 0x1faff) width += 2
|
|
38
|
+
else width += 1
|
|
39
|
+
}
|
|
40
|
+
return width
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function stripAnsi(s: string): string {
|
|
44
|
+
return s
|
|
45
|
+
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
46
|
+
.replace(/\x1b\][^\x1b]*?(?:\x07|\x1b\\)/g, '')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Hard-wrap at `width` display columns, exactly as Ink does for wrap="wrap". */
|
|
50
|
+
export function wrapText(text: string, width: number): string[] {
|
|
51
|
+
return wrapAnsi(text, Math.max(1, width), { trim: false, hard: true }).split('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Truncate to `width` display columns (cli-truncate style, trailing …). */
|
|
55
|
+
export function truncateText(text: string, width: number): string {
|
|
56
|
+
if (displayWidth(text) <= width) return text
|
|
57
|
+
let acc = 0
|
|
58
|
+
let out = ''
|
|
59
|
+
for (const ch of Array.from(text)) {
|
|
60
|
+
const w = displayWidth(ch)
|
|
61
|
+
if (acc + w > width - 1) break
|
|
62
|
+
out += ch
|
|
63
|
+
acc += w
|
|
64
|
+
}
|
|
65
|
+
return out + '…'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Hard-slice a string into `width`-display-column chunks (compacts ≤ maxLines). */
|
|
69
|
+
export function clampLines(text: string, width: number, maxLines: number): string[] {
|
|
70
|
+
if (!text) return ['']
|
|
71
|
+
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
72
|
+
const wrapped: string[] = []
|
|
73
|
+
for (const para of paras) {
|
|
74
|
+
if (para === '') { wrapped.push(''); continue }
|
|
75
|
+
for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
|
|
76
|
+
}
|
|
77
|
+
if (wrapped.length <= maxLines) return wrapped
|
|
78
|
+
const trimmed = wrapped.slice(0, maxLines)
|
|
79
|
+
const last = trimmed[maxLines - 1]
|
|
80
|
+
trimmed[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + '…' : last + '…'
|
|
81
|
+
return trimmed
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** head + ellipsis + tail truncation (mirrors Chat.tsx headTailLines). */
|
|
85
|
+
export function headTailLines(text: string, width: number, maxLines: number): string[] {
|
|
86
|
+
if (!text) return ['']
|
|
87
|
+
const paras = text.replace(/\r\n/g, '\n').split('\n')
|
|
88
|
+
const wrapped: string[] = []
|
|
89
|
+
for (const para of paras) {
|
|
90
|
+
if (para === '') { wrapped.push(''); continue }
|
|
91
|
+
for (let i = 0; i < para.length; i += width) wrapped.push(para.slice(i, i + width))
|
|
92
|
+
}
|
|
93
|
+
if (wrapped.length <= maxLines) return wrapped.slice(0, maxLines)
|
|
94
|
+
const budget = maxLines - 1
|
|
95
|
+
const head = Math.max(1, Math.ceil(budget / 2))
|
|
96
|
+
const tail = Math.max(1, budget - head)
|
|
97
|
+
const omitted = wrapped.length - head - tail
|
|
98
|
+
if (omitted <= 0) return wrapped.slice(0, maxLines)
|
|
99
|
+
return [...wrapped.slice(0, head), `… +${omitted} 行`, ...wrapped.slice(wrapped.length - tail)]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── transcript row model ─────────────────────────────────────────────────────
|
|
103
|
+
export interface ScreenRows {
|
|
104
|
+
/** Whether this view renders with a leading blank row (its Box has marginTop). */
|
|
105
|
+
marginTop: boolean
|
|
106
|
+
/** Full screen lines: prefix included, wrapped/truncated, plain text (no ANSI). */
|
|
107
|
+
rows: string[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const textWidth = (columns: number) => Math.max(8, columns - 4)
|
|
111
|
+
const fullWidth = (columns: number) => Math.max(1, columns - 2) // root paddingX=1
|
|
112
|
+
|
|
113
|
+
/** Reproduce the visible screen rows for one EntryView (mirrors ViewLine). */
|
|
114
|
+
export function viewScreenRows(view: EntryView, columns: number): ScreenRows {
|
|
115
|
+
const width = textWidth(columns)
|
|
116
|
+
const full = fullWidth(columns)
|
|
117
|
+
const fit = (rows: string[]): string[] => rows.map((r) => truncateText(r, full))
|
|
118
|
+
switch (view.kind) {
|
|
119
|
+
case 'skip':
|
|
120
|
+
return { marginTop: false, rows: [] }
|
|
121
|
+
case 'user': {
|
|
122
|
+
const rows = view.text.split('\n').map((l, i) => (i === 0 ? `› ${l}` : ` ${l}`))
|
|
123
|
+
return { marginTop: true, rows: fit(rows) }
|
|
124
|
+
}
|
|
125
|
+
case 'assistant': {
|
|
126
|
+
const md = renderMarkdownLines(view.text)
|
|
127
|
+
const rows: string[] = []
|
|
128
|
+
md.forEach((line, i) => {
|
|
129
|
+
const prefix = i === 0 ? '• ' : ' '
|
|
130
|
+
const fullLine = prefix + stripAnsi(line.text || ' ')
|
|
131
|
+
if (line.code) rows.push(truncateText(fullLine, full))
|
|
132
|
+
else rows.push(...wrapText(fullLine, full))
|
|
133
|
+
})
|
|
134
|
+
return { marginTop: true, rows }
|
|
135
|
+
}
|
|
136
|
+
case 'tool_call': {
|
|
137
|
+
const head = clampLines(`${toolLabel(view.toolName)} ${view.summary}`.trim(), width - 2, 1)[0]
|
|
138
|
+
const rows = [`• ${head}`]
|
|
139
|
+
if (view.result) rows.push(` └ ${clampLines(view.result.text, width - 4, 1)[0] || '(无输出)'}`)
|
|
140
|
+
return { marginTop: true, rows: fit(rows) }
|
|
141
|
+
}
|
|
142
|
+
case 'tool_result': {
|
|
143
|
+
const lines = headTailLines(view.text, width - 4, 5)
|
|
144
|
+
return { marginTop: false, rows: fit(lines.map((l, i) => `${i === 0 ? ' └ ' : ' '}${l}`)) }
|
|
145
|
+
}
|
|
146
|
+
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: fit(rows) }
|
|
151
|
+
}
|
|
152
|
+
case 'write_file': {
|
|
153
|
+
const rows = [`✎ 写入 ${view.filePath || '(未指定文件)'}`]
|
|
154
|
+
rows.push(...view.content.split('\n').map((l) => ` + ${l}`))
|
|
155
|
+
return { marginTop: true, rows: fit(rows) }
|
|
156
|
+
}
|
|
157
|
+
case 'reasoning': {
|
|
158
|
+
const lines = clampLines(view.text, width - 4, 2)
|
|
159
|
+
return { marginTop: true, rows: fit(lines.map((l, i) => `${i === 0 ? ' ◇ ' : ' '}${l}`)) }
|
|
160
|
+
}
|
|
161
|
+
case 'system':
|
|
162
|
+
return { marginTop: false, rows: fit([` ${clampLines(view.text, width - 2, 2)[0]}`]) }
|
|
163
|
+
case 'error': {
|
|
164
|
+
const rows = view.text.split('\n').map((l, i) => `${i === 0 ? '⚠ ' : ' '}${l}`)
|
|
165
|
+
return { marginTop: true, rows: fit(rows) }
|
|
166
|
+
}
|
|
167
|
+
default:
|
|
168
|
+
return { marginTop: false, rows: [] }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Flatten a whole entry (all its views) into screen lines, margins as ''. */
|
|
173
|
+
export function entryScreenLines(views: EntryView[], columns: number): string[] {
|
|
174
|
+
const lines: string[] = []
|
|
175
|
+
for (const v of views) {
|
|
176
|
+
const { marginTop, rows } = viewScreenRows(v, columns)
|
|
177
|
+
if (marginTop) lines.push('')
|
|
178
|
+
lines.push(...rows)
|
|
179
|
+
}
|
|
180
|
+
return lines
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── vertical geometry ────────────────────────────────────────────────────────
|
|
184
|
+
export interface TranscriptGeometry {
|
|
185
|
+
/** Screen row where the transcript box's top edge sits. */
|
|
186
|
+
boxTop: number
|
|
187
|
+
/** Transcript box height in rows. */
|
|
188
|
+
boxH: number
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Mirror the ChatScreen layout so a screen (row, col) can be mapped into the
|
|
193
|
+
* transcript. The bottom section (activity + composer + status) is fixed height
|
|
194
|
+
* (`flexShrink=0`); the middle column holds header + hint + transcript (flexGrow)
|
|
195
|
+
* + tip + help. Margins that render as extra rows are counted explicitly.
|
|
196
|
+
*/
|
|
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
|
+
// ── selection mapping ────────────────────────────────────────────────────────
|
|
222
|
+
export interface SelPoint {
|
|
223
|
+
entry: number // index into the fitted entries
|
|
224
|
+
row: number // index into that entry's screen lines
|
|
225
|
+
col: number // char offset into the screen line
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface TranscriptModel {
|
|
229
|
+
entries: string[][] // per fitted entry, its screen lines (margins as '')
|
|
230
|
+
totalRows: number
|
|
231
|
+
}
|
|
232
|
+
|
|
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
|
+
/** Convert a screen (row, col) into a SelPoint, or null if outside the transcript. */
|
|
239
|
+
export function screenToSelPoint(
|
|
240
|
+
screenRow: number,
|
|
241
|
+
screenCol: number,
|
|
242
|
+
model: TranscriptModel,
|
|
243
|
+
geo: TranscriptGeometry,
|
|
244
|
+
): SelPoint | null {
|
|
245
|
+
if (screenRow < geo.boxTop || screenRow >= geo.boxTop + geo.boxH) return null
|
|
246
|
+
let local = screenRow - geo.boxTop
|
|
247
|
+
const startOffset = geo.boxH - model.totalRows
|
|
248
|
+
if (local < startOffset) return null
|
|
249
|
+
local -= startOffset
|
|
250
|
+
let acc = 0
|
|
251
|
+
for (let e = 0; e < model.entries.length; e++) {
|
|
252
|
+
const n = model.entries[e].length
|
|
253
|
+
if (local < acc + n) {
|
|
254
|
+
const line = model.entries[e][local - acc]
|
|
255
|
+
const colOff = screenCol - 1 // root paddingX=1
|
|
256
|
+
return { entry: e, row: local - acc, col: charAtDisplayWidth(line, colOff) }
|
|
257
|
+
}
|
|
258
|
+
acc += n
|
|
259
|
+
}
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Char index under display column `col` (clamped), so selection lands on chars. */
|
|
264
|
+
function charAtDisplayWidth(line: string, col: number): number {
|
|
265
|
+
if (col <= 0) return 0
|
|
266
|
+
let acc = 0
|
|
267
|
+
let i = 0
|
|
268
|
+
for (const ch of Array.from(line)) {
|
|
269
|
+
const w = displayWidth(ch)
|
|
270
|
+
if (col < acc + w) return i
|
|
271
|
+
acc += w
|
|
272
|
+
i++
|
|
273
|
+
}
|
|
274
|
+
return i
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function compareSel(a: SelPoint, b: SelPoint): number {
|
|
278
|
+
return a.entry - b.entry || a.row - b.row || a.col - b.col
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Char range [start,end) per (entry → row) that the selection covers. */
|
|
282
|
+
export function buildSelectionMap(
|
|
283
|
+
model: TranscriptModel,
|
|
284
|
+
anchor: SelPoint,
|
|
285
|
+
end: SelPoint,
|
|
286
|
+
): Map<number, Map<number, { start: number; end: number }>> {
|
|
287
|
+
const a = compareSel(anchor, end) <= 0 ? anchor : end
|
|
288
|
+
const b = compareSel(anchor, end) <= 0 ? end : anchor
|
|
289
|
+
const map = new Map<number, Map<number, { start: number; end: number }>>()
|
|
290
|
+
for (let e = a.entry; e <= b.entry; e++) {
|
|
291
|
+
const lines = model.entries[e]
|
|
292
|
+
const rowStart = e === a.entry ? a.row : 0
|
|
293
|
+
const rowEnd = e === b.entry ? b.row : lines.length - 1
|
|
294
|
+
const rows = new Map<number, { start: number; end: number }>()
|
|
295
|
+
for (let r = rowStart; r <= rowEnd; r++) {
|
|
296
|
+
const s = e === a.entry && r === a.row ? a.col : 0
|
|
297
|
+
const en = e === b.entry && r === b.row ? b.col : lines[r].length
|
|
298
|
+
if (s < en) rows.set(r, { start: s, end: en })
|
|
299
|
+
}
|
|
300
|
+
if (rows.size) map.set(e, rows)
|
|
301
|
+
}
|
|
302
|
+
return map
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Leading decorators (bullets / indent / diff signs) that are part of the TUI
|
|
306
|
+
// chrome, not the content — stripped so copied text is clean (the anchor row is
|
|
307
|
+
// usually already after the bullet, but fully-covered middle rows are not).
|
|
308
|
+
const DECORATOR_RE = /^(?:• |› |◇ |⚠ |✎ 编辑 |✎ 写入 | └ | − | \+ | | )/
|
|
309
|
+
|
|
310
|
+
function stripDecorator(line: string): string {
|
|
311
|
+
return line.replace(DECORATOR_RE, '')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Extract the selected plain text (joined by '\n'); blanks and chrome trimmed. */
|
|
315
|
+
export function buildSelectionText(model: TranscriptModel, anchor: SelPoint, end: SelPoint): string {
|
|
316
|
+
const a = compareSel(anchor, end) <= 0 ? anchor : end
|
|
317
|
+
const b = compareSel(anchor, end) <= 0 ? end : anchor
|
|
318
|
+
const parts: string[] = []
|
|
319
|
+
for (let e = a.entry; e <= b.entry; e++) {
|
|
320
|
+
const lines = model.entries[e]
|
|
321
|
+
const rowStart = e === a.entry ? a.row : 0
|
|
322
|
+
const rowEnd = e === b.entry ? b.row : lines.length - 1
|
|
323
|
+
for (let r = rowStart; r <= rowEnd; r++) {
|
|
324
|
+
const line = lines[r]
|
|
325
|
+
const s = e === a.entry && r === a.row ? a.col : 0
|
|
326
|
+
const en = e === b.entry && r === b.row ? b.col : line.length
|
|
327
|
+
parts.push(line.slice(s, en))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return parts
|
|
331
|
+
.map((l) => stripDecorator(l.trimEnd()))
|
|
332
|
+
.filter((l) => l.trim() !== '')
|
|
333
|
+
.join('\n')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Encode text as an OSC 52 clipboard write (base64); returns the escape. */
|
|
337
|
+
export function osc52(text: string): string {
|
|
338
|
+
return `\x1b]52;c;${Buffer.from(text, 'utf8').toString('base64')}\x07`
|
|
339
|
+
}
|