@mobius-os/mobius 0.3.14 → 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.
@@ -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
+ }