heartcode 0.0.0-stage → 0.1.0

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/src/ui.js ADDED
@@ -0,0 +1,1278 @@
1
+ import readline from 'node:readline'
2
+ import { PassThrough } from 'node:stream'
3
+ import { StringDecoder } from 'node:string_decoder'
4
+ import { AsyncLocalStorage } from 'node:async_hooks'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { log } from './log.js'
8
+ import { HOME } from './config.js'
9
+ import { record, transcribe, voiceProblem } from './voice.js'
10
+
11
+ const color = (process.stdout.isTTY || Boolean(process.env.FORCE_COLOR)) && !process.env.NO_COLOR
12
+ // 24-bit where the terminal says so, else the nearest of 256 (Terminal.app, xterm-256color), else 16.
13
+ const depth = !color ? 0 : /truecolor|24bit/i.test(process.env.COLORTERM ?? '') ? 3 : /256/.test(process.env.TERM ?? '') || process.env.TERM_PROGRAM === 'Apple_Terminal' ? 2 : 1
14
+ const to256 = ([r, g, b]) => {
15
+ if (Math.max(r, g, b) - Math.min(r, g, b) < 12) return r < 8 ? 16 : r > 238 ? 231 : 232 + Math.round(((r - 8) / 247) * 23)
16
+ return 16 + 36 * Math.round((r / 255) * 5) + 6 * Math.round((g / 255) * 5) + Math.round((b / 255) * 5)
17
+ }
18
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
19
+ // Each style switches itself off with its own code (not a full reset), so styles nest.
20
+ const sgr = (on, off) => (s) => (color ? `\x1b[${on}m${s}\x1b[${off}m` : String(s))
21
+ const fg = (hex, basic) => sgr(depth === 3 ? `38;2;${rgb(hex).join(';')}` : depth === 2 ? `38;5;${to256(rgb(hex))}` : basic, 39)
22
+ const bg = (hex, basic) => sgr(depth === 3 ? `48;2;${rgb(hex).join(';')}` : depth === 2 ? `48;5;${to256(rgb(hex))}` : basic, 49)
23
+
24
+ // Themes: a color per kind of thing, and four shades of white/grey for importance (the number is
25
+ // the 16-color fallback), and the background (none: the terminal's own). The settings page picks one;
26
+ // ~/.heart/theme.json changes any color on top of it: { "brand": "#ff6b9d", "background": "#101014" }
27
+ export const THEMES = {
28
+ dark: {
29
+ background: [null],
30
+ bright: ['#f5f5f7', 97], // what the user typed, things to read first
31
+ text: ['#d4d4d8', 39], // the model's answer
32
+ muted: ['#9aa0aa', 37], // secondary: labels, descriptions, the spinner
33
+ faint: ['#6b7280', 90], // hints, tool results, line numbers
34
+ ghost: ['#3f4451', 90], // rules and borders
35
+ brand: ['#ff6b9d', 35], // HeartCode's ♥, the selection marker
36
+ accent: ['#7aa2f7', 34], // tool steps, headings
37
+ info: ['#7dcfff', 36], // file paths, model names
38
+ think: ['#a8a29e', 37], // the model's thinking
39
+ ok: ['#9ece6a', 32],
40
+ warn: ['#e0af68', 33],
41
+ err: ['#f7768e', 31],
42
+ select: ['#2a2f3a', 47], // the highlighted row in menus and pickers (a background)
43
+ },
44
+ light: {
45
+ background: ['#fafbfc', 107],
46
+ bright: ['#1f2328', 30],
47
+ text: ['#24292f', 39],
48
+ muted: ['#57606a', 90],
49
+ faint: ['#6e7781', 90],
50
+ ghost: ['#c8ced6', 37],
51
+ brand: ['#d6336c', 35],
52
+ accent: ['#0969da', 34],
53
+ info: ['#0b6e8a', 36],
54
+ think: ['#6e7781', 90],
55
+ ok: ['#1a7f37', 32],
56
+ warn: ['#9a6700', 33],
57
+ err: ['#cf222e', 31],
58
+ select: ['#dbe4f0', 47],
59
+ },
60
+ midnight: {
61
+ background: ['#1a1b26', 40],
62
+ bright: ['#e6e9f5', 97],
63
+ text: ['#c0caf5', 39],
64
+ muted: ['#9aa5ce', 37],
65
+ faint: ['#636d96', 90],
66
+ ghost: ['#343a55', 90],
67
+ brand: ['#ff7ab2', 35],
68
+ accent: ['#7aa2f7', 34],
69
+ info: ['#7dcfff', 36],
70
+ think: ['#a9b1d6', 37],
71
+ ok: ['#9ece6a', 32],
72
+ warn: ['#e0af68', 33],
73
+ err: ['#f7768e', 31],
74
+ select: ['#2f3549', 47],
75
+ },
76
+ mono: Object.fromEntries(['bright', 'text', 'muted', 'faint', 'ghost', 'brand', 'accent', 'info', 'think', 'ok', 'warn', 'err', 'select'].map((k) => [k, [null, { bright: 1, faint: 2, ghost: 2, select: 7 }[k] ?? 39]])), // no colors, just bold and dim
77
+ }
78
+ export const c = { bold: sgr(1, 22), italic: sgr(3, 23) }
79
+ export let background = '' // the theme's background, as an escape sequence ('' = the terminal's)
80
+ // Each palette color's opening sequence, to recolor what's already on screen (UI.recolor).
81
+ export const themeCodes = () => Object.fromEntries(Object.keys(THEMES.dark).filter((k) => k !== 'background').map((k) => [k, c[k]('\0').split('\0')[0]]))
82
+ export function setTheme(name = 'dark') {
83
+ const own = (() => {
84
+ try {
85
+ return JSON.parse(fs.readFileSync(path.join(HOME, 'theme.json'), 'utf8'))
86
+ } catch {
87
+ return {}
88
+ }
89
+ })()
90
+ const t = { ...(THEMES[name] ?? THEMES.dark) }
91
+ for (const [k, v] of Object.entries(own)) if (t[k] && /^#[0-9a-f]{6}$/i.test(v)) t[k] = [v, t[k][1]]
92
+ const [bgHex, bgBasic] = t.background ?? [null]
93
+ delete t.background
94
+ background = color && bgHex ? bg(bgHex, bgBasic)('\0').split('\0')[0] : ''
95
+ for (const [k, [hex, basic]] of Object.entries(t)) {
96
+ if (!hex) c[k] = k === 'select' ? sgr(7, 27) : basic === 1 ? sgr(1, 22) : basic === 2 ? sgr(2, 22) : (s) => String(s)
97
+ else c[k] = (k === 'select' ? bg : fg)(hex, basic)
98
+ }
99
+ // Older names, used across the code base, mapped onto the palette.
100
+ Object.assign(c, { dim: c.muted, cyan: c.info, magenta: c.brand, green: c.ok, red: c.err, yellow: c.warn, blue: c.accent })
101
+ return THEMES[name] ? name : 'dark'
102
+ }
103
+ setTheme()
104
+
105
+ // keybindings.json: { "ctrl+o": "cycle_permissions", "ctrl+g": "/toggle_thinking", "shift+tab": "none" }.
106
+ // Actions: stop, cycle_permissions, newline, clear_input, scroll_up, scroll_down, voice, new_tab,
107
+ // close_tab, next_tab, prev_tab, tab_1…tab_9, none (turns a
108
+ // built-in key off); anything else is sent as typed (a /command or a message). Ctrl+C stays Ctrl+C.
109
+ export const keyId = (key, s = '') => [key.ctrl && 'ctrl', key.meta && 'alt', key.shift && 'shift', key.name ?? s].filter(Boolean).join('+').toLowerCase()
110
+ // Ctrl+R records; Ctrl+T, Ctrl+Q, Ctrl+N/P (or Ctrl+PgDn/PgUp), Alt+1…9: tabs. keybindings.json can move them.
111
+ export const DEFAULT_KEYS = { 'ctrl+r': 'voice', 'ctrl+t': 'new_tab', 'ctrl+q': 'close_tab', 'ctrl+n': 'next_tab', 'ctrl+p': 'prev_tab', 'ctrl+pagedown': 'next_tab', 'ctrl+pageup': 'prev_tab', ...Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => [`alt+${n}`, `tab_${n}`])) }
112
+ export function loadKeys(file = path.join(HOME, 'keybindings.json')) {
113
+ let raw = {}
114
+ try {
115
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'))
116
+ } catch (e) {
117
+ if (e.code !== 'ENOENT') log('keys', `${file}: ${e.message}`)
118
+ }
119
+ // "Ctrl+P", "ctrl-p", "esc" → "ctrl+p", "escape"
120
+ return Object.fromEntries(Object.entries(raw.bindings ?? raw).filter(([, v]) => typeof v === 'string' && v.trim()).map(([k, v]) => [k.toLowerCase().replace(/\s+/g, '').replace(/-(?=.)/g, '+').replace(/\besc\b/, 'escape').replace(/\b(option|meta|cmd)\b/, 'alt').replace(/\benter\b/, 'return'), v.trim()]))
121
+ }
122
+
123
+ const FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
124
+ const WIDE = /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]|\p{Emoji_Presentation}/u // ♥ © etc. are 1 cell; 😀 is 2
125
+
126
+ // "503 from https://…: {"error":{"message":"overloaded"}}" → "503 overloaded"
127
+ export function shortError(err) {
128
+ if (!err.status) return err.message.replace(/\s+/g, ' ').slice(0, 120)
129
+ const body = err.message.slice(err.message.indexOf(': ') + 2)
130
+ let said = body
131
+ try {
132
+ const j = JSON.parse(body)
133
+ said = j.error?.message ?? j.message ?? (typeof j.error === 'string' ? j.error : body)
134
+ } catch {}
135
+ return `${err.status} ${String(said).replace(/\s+/g, ' ')}`.trim().slice(0, 120)
136
+ }
137
+
138
+ // Terminals speaking the kitty keyboard protocol (HeartCode turns it on) or xterm's modifyOtherKeys
139
+ // report keys as CSI sequences that readline doesn't know. Back to the legacy bytes, except that
140
+ // Enter with any modifier (Shift, Ctrl, Alt, Cmd) becomes "\n": a new line in the input.
141
+ export function legacyKeys(text) {
142
+ return text.replace(/\x1b\[(\d+)(?::\d+)*(?:;(\d+)(?::\d+)*)?u|\x1b\[27;(\d+);(\d+)~/g, (m, k1, m1, m2, k2) => {
143
+ const code = Number(k1 ?? k2)
144
+ const mods = Number(m1 ?? m2 ?? 1) - 1 // shift 1, alt 2, ctrl 4, super 8
145
+ const [shift, alt, ctrl] = [mods & 1, mods & 2, mods & 4]
146
+ if (code === 13) return mods ? '\n' : '\r'
147
+ if (code === 9) return shift ? '\x1b[Z' : '\t'
148
+ if (code === 27) return '\x1b'
149
+ if (code === 127 || code === 8) return (alt ? '\x1b' : '') + (ctrl ? '\x17' : '\x7f')
150
+ if (code < 32 || (code >= 0xe000 && code <= 0xf8ff) || code > 0x10ffff) return '' // keypad, F13+, …
151
+ let ch = String.fromCodePoint(code)
152
+ if (ctrl && /^[a-z@[\\\]^_ ]$/i.test(ch)) ch = String.fromCharCode(ch.toUpperCase().charCodeAt(0) & 0x1f)
153
+ else if (shift) ch = ch.toUpperCase()
154
+ return (alt ? '\x1b' : '') + ch
155
+ })
156
+ }
157
+
158
+ // The terminal's input, passed through legacyKeys (holding back a sequence split across reads).
159
+ function keyStream(input) {
160
+ const proxy = new PassThrough()
161
+ proxy.isTTY = true
162
+ proxy.setRawMode = (on) => input.setRawMode?.(on)
163
+ proxy.on('pause', () => input.pause())
164
+ proxy.on('resume', () => input.resume())
165
+ const dec = new StringDecoder('utf8')
166
+ let held = ''
167
+ let timer
168
+ const send = (text) => text && proxy.write(legacyKeys(text))
169
+ // bound to where it was made: a tab's keys are logged in that tab's log (src/log.js)
170
+ input.on('data', AsyncLocalStorage.bind((d) => {
171
+ clearTimeout(timer)
172
+ let text = held + dec.write(d)
173
+ const part = /\x1b\[[0-9;:]*$/.exec(text)
174
+ held = part ? part[0] : ''
175
+ if (part) {
176
+ text = text.slice(0, part.index)
177
+ timer = setTimeout(() => send(held, (held = '')), 50)
178
+ }
179
+ send(text)
180
+ }))
181
+ input.on('end', () => proxy.end())
182
+ return proxy
183
+ }
184
+
185
+ // Where each character of the input goes on screen: rows of at most `cols` cells, the prompt
186
+ // first, later lines indented under it. Returns the rows (with the prompt's colors) and the
187
+ // cursor's row and column.
188
+ function layoutInput(prompt, line, cursor, cols) {
189
+ const pw = width(prompt.replace(/\x1b\[[0-9;]*m/g, ''))
190
+ const rows = [{ text: prompt, w: pw }]
191
+ let cur = { row: 0, col: pw }
192
+ const pad = ' '.repeat(Math.min(pw, cols - 1))
193
+ for (let i = 0; i <= line.length; i++) {
194
+ if (i === cursor) cur = { row: rows.length - 1, col: rows.at(-1).w }
195
+ if (i === line.length) break
196
+ const ch = String.fromCodePoint(line.codePointAt(i))
197
+ if (ch.length === 2) i++
198
+ if (ch === '\n') {
199
+ rows.push({ text: pad, w: pad.length })
200
+ continue
201
+ }
202
+ const shown = ch === '\t' ? ' ' : ch
203
+ const w = width(shown)
204
+ if (rows.at(-1).w + w > cols) rows.push({ text: '', w: 0 })
205
+ rows.at(-1).text += shown
206
+ rows.at(-1).w += w
207
+ }
208
+ if (cur.col >= cols) cur = { row: cur.row + 1, col: 0 }
209
+ return { rows: rows.map((r) => r.text), cur }
210
+ }
211
+
212
+ export const width = (str) => {
213
+ let w = 0
214
+ for (const ch of str) w += WIDE.test(ch) ? 2 : 1
215
+ return w
216
+ }
217
+
218
+ // The styles a piece of text leaves switched on: { fg, bg, bold, italic } (each a code or '').
219
+ function sgrState(state = {}, text) {
220
+ const st = { ...state }
221
+ for (const [, params] of text.matchAll(/\x1b\[([0-9;]*)m/g)) {
222
+ const n = params.split(';')
223
+ for (let i = 0; i < n.length; i++) {
224
+ const v = Number(n[i] || 0)
225
+ if (v === 0) Object.assign(st, { fg: '', bg: '', bold: '', italic: '' })
226
+ else if (v === 38 || v === 48) {
227
+ const len = n[i + 1] === '2' ? 5 : 3
228
+ st[v === 38 ? 'fg' : 'bg'] = n.slice(i, i + len).join(';')
229
+ i += len - 1
230
+ } else if (v === 39) st.fg = ''
231
+ else if (v === 49) st.bg = ''
232
+ else if (v === 1) st.bold = '1'
233
+ else if (v === 22) st.bold = ''
234
+ else if (v === 3) st.italic = '3'
235
+ else if (v === 23) st.italic = ''
236
+ else if ((v >= 30 && v <= 37) || (v >= 90 && v <= 97)) st.fg = String(v)
237
+ }
238
+ }
239
+ return st
240
+ }
241
+ const sgrPrefix = (st = {}) => [st.fg, st.bg, st.bold, st.italic].filter(Boolean).map((x) => `\x1b[${x}m`).join('')
242
+
243
+ // Cuts a colored line to `cols` cells.
244
+ function fit(line, cols) {
245
+ return wrapAnsi(line, cols)[0]
246
+ }
247
+
248
+ // The picker's rows for a filter: matching items under their group headers.
249
+ function pickView(items, filter) {
250
+ const words = filter.toLowerCase().split(/\s+/).filter(Boolean)
251
+ const rows = []
252
+ let group
253
+ for (const it of items) {
254
+ const text = `${it.group ?? ''} ${it.label} ${it.desc ?? ''}`.toLowerCase()
255
+ if (!words.every((w) => text.includes(w))) continue
256
+ if (it.group && it.group !== group) rows.push({ header: true, value: (group = it.group) })
257
+ rows.push({ value: it.checked === undefined ? it.label : `${it.checked ? '◉' : '○'} ${it.label}`, desc: it.desc, item: it })
258
+ }
259
+ return rows
260
+ }
261
+
262
+ // Splits one line into screen rows of at most `cols` cells, keeping colors across the breaks.
263
+ export function wrapAnsi(line, cols) {
264
+ const rows = []
265
+ let row = ''
266
+ let width = 0
267
+ let style = ''
268
+ const sgr = /\x1b\[[0-9;]*m/y // any length: 24-bit colors are ~19 characters
269
+ for (let i = 0; i < line.length; ) {
270
+ sgr.lastIndex = i
271
+ const esc = sgr.exec(line)
272
+ if (esc) {
273
+ row += esc[0]
274
+ style = esc[0] === '\x1b[0m' ? '' : style + esc[0]
275
+ i += esc[0].length
276
+ continue
277
+ }
278
+ const ch = String.fromCodePoint(line.codePointAt(i))
279
+ const w = WIDE.test(ch) ? 2 : ch === '\t' ? 4 : 1
280
+ if (width + w > cols) {
281
+ rows.push(row + (style ? '\x1b[0m' : ''))
282
+ row = style
283
+ width = 0
284
+ }
285
+ row += ch === '\t' ? ' ' : ch
286
+ width += w
287
+ i += ch.length
288
+ }
289
+ rows.push(row)
290
+ return rows
291
+ }
292
+
293
+ // Streams text as an indented, word-wrapped block: "<marker>first line\n next line".
294
+ // Leading/trailing blank lines are dropped; runs of blank lines collapse to one.
295
+ export class Block {
296
+ constructor(write, { marker = ' ', markerStyle = (x) => x, style = (x) => x, width = (process.stdout.columns || 100) - 1 } = {}) {
297
+ Object.assign(this, { write, marker, markerStyle, style, width: Math.max(20, width) })
298
+ this.indent = ' '.repeat(marker.length)
299
+ this.col = 0
300
+ this.started = false
301
+ this.newlines = 0
302
+ }
303
+ push(text) {
304
+ let out = ''
305
+ for (const ch of text) {
306
+ if (ch === '\r') continue
307
+ if (ch === '\n') {
308
+ if (this.started) this.newlines++
309
+ continue
310
+ }
311
+ if (!this.started) {
312
+ if (ch === ' ' || ch === '\t') continue
313
+ this.write(this.markerStyle(this.marker))
314
+ this.col = this.marker.length
315
+ this.started = true
316
+ } else if (this.newlines) {
317
+ out += '\n'.repeat(Math.min(this.newlines, 2)) + this.indent
318
+ this.col = this.indent.length
319
+ this.newlines = 0
320
+ } else if (this.col >= this.width || (ch === ' ' && this.col >= this.width - 12)) {
321
+ out += '\n' + this.indent
322
+ this.col = this.indent.length
323
+ if (ch === ' ') continue
324
+ }
325
+ out += ch
326
+ this.col++
327
+ }
328
+ if (out) this.write(this.style(out))
329
+ }
330
+ end() {
331
+ if (this.started && !this.ended) this.write('\n')
332
+ this.ended = true
333
+ }
334
+ }
335
+
336
+ const MENU_ROWS = 8
337
+ const INPUT_ROWS = 3 // the input box shows at most this many rows; the rules say what's hidden
338
+
339
+ // Best matches first: the exact name, then names starting with it, then a word of the name
340
+ // starting with it (/mcp → /login_mcp), then anywhere in it, then starts of its words in order
341
+ // (/sm or /semo → /select_model). A leading "/" doesn't count.
342
+ export function rank(items, typed, key = (x) => x.value) {
343
+ const t = typed.toLowerCase().replace(/^\//, '')
344
+ // t split into prefixes of the words, in order (words may be skipped)
345
+ const initials = (words, t) => !t || words.some((w, i) => [...Array(Math.min(w.length, t.length))].some((_, n) => w.startsWith(t.slice(0, n + 1)) && initials(words.slice(i + 1), t.slice(n + 1))))
346
+ const score = (x) => {
347
+ const k = key(x).toLowerCase().replace(/^\//, '')
348
+ const words = k.split(/[_\-./\s:]+/).filter(Boolean)
349
+ if (k === t) return 0
350
+ if (k.startsWith(t)) return 1
351
+ if (words.some((w) => w.startsWith(t))) return 2
352
+ if (k.includes(t)) return 3
353
+ return t.length > 1 && words.length > 1 && initials(words, t) ? 4 : -1
354
+ }
355
+ return items
356
+ .map((x, i) => ({ x, i, s: score(x) }))
357
+ .filter((r) => r.s >= 0)
358
+ .sort((a, b) => a.s - b.s || a.i - b.i) // same score: keep the list's order
359
+ .map((r) => r.x)
360
+ }
361
+
362
+ // What runs without asking. Shift+Tab cycles through them.
363
+ export const PERMISSIONS = [
364
+ ['all', 'do everything without asking'],
365
+ ['commands', 'apply edits; ask before running commands'],
366
+ ['ask', 'ask before every edit and command'],
367
+ ['plan', 'plan first: look around without changing anything, then approve the plan'],
368
+ ]
369
+
370
+ export class UI {
371
+ constructor({ yes = false, input = process.stdin, output = process.stdout } = {}) {
372
+ this.permission = yes ? 'all' : 'ask' // see PERMISSIONS
373
+ this.onPermission = null
374
+ this.onEscape = null // Esc while a task runs // called when the user switches mode (Shift+Tab or "don't ask again")
375
+ this.input = input
376
+ this.out = output
377
+ this.queue = []
378
+ this.ahead = [] // messages typed (and sent with Enter) while HeartCode was working
379
+ this.keys = { ...DEFAULT_KEYS, ...loadKeys() }
380
+ this.waiters = []
381
+ this.closed = false
382
+ this.rl = null
383
+ this.onInterrupt = null
384
+ this.timer = null
385
+ this.label = ''
386
+ this.showThinking = true
387
+ this.tail = ''
388
+ // suggest(line, cursor) → [{ value, desc, start, end, space, insertOnly }] for the menu under the prompt
389
+ this.suggest = null
390
+ this.status = null // () → the status bar line (full screen only)
391
+ this.header = null // (cols) → lines pinned to the top (full screen only)
392
+ this.menu = { on: false, items: [], sel: 0, off: null }
393
+ this.full = false
394
+ this.log = [''] // full-screen transcript, one entry per line
395
+ this.scroll = 0
396
+ this.asking = false
397
+ this.frame = 0
398
+ }
399
+
400
+ // One model reply: thinking (dim, its own block) kept apart from the answer.
401
+ response() {
402
+ const t0 = Date.now()
403
+ const secs = () => ((Date.now() - t0) / 1000).toFixed(1)
404
+ const write = (x) => this.write(x)
405
+ let think = null
406
+ let text = null
407
+ let thought = false
408
+ const summary = () => {
409
+ if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ thought for ${secs()}s`)))
410
+ }
411
+ return {
412
+ think: (t) => {
413
+ if (!t || text) return
414
+ thought = true
415
+ if (!this.showThinking) return this.spinner(`thinking · ${secs()}s`)
416
+ if (!think) {
417
+ this.gap()
418
+ this.print(c.faint(c.italic('✻ Thinking…')))
419
+ think = new Block(write, { style: (x) => c.think(c.italic(x)) })
420
+ }
421
+ think.push(t)
422
+ },
423
+ text: (t) => {
424
+ if (!t) return
425
+ if (!text) {
426
+ think?.end()
427
+ this.gap()
428
+ summary()
429
+ text = new Block(write, { marker: '● ', markerStyle: (x) => c.bright(c.bold(x)), style: c.text })
430
+ }
431
+ text.push(t)
432
+ },
433
+ end: () => {
434
+ if (text) text.end()
435
+ else if (think) think.end()
436
+ else summary()
437
+ this.afterBlock = Boolean(text || think)
438
+ },
439
+ }
440
+ }
441
+
442
+ #rl() {
443
+ if (this.rl) return this.rl
444
+ const terminal = Boolean(this.input.isTTY && this.out.isTTY)
445
+ const input = terminal ? keyStream(this.input) : this.input
446
+ // escapeCodeTimeout: how long a lone Esc waits to be the start of a key's sequence. Readline's
447
+ // 500ms made Esc Esc and a letter typed right after one Alt+letter: the letter was lost and the
448
+ // input not cleared. Sequences come in one piece (and keyStream holds a split one for 50ms).
449
+ this.rl = readline.createInterface({ input, output: this.out, terminal, historySize: 0, escapeCodeTimeout: 50 }) // HeartCode keeps its own history (below)
450
+ // Our own line queue, so piped input isn't lost between questions.
451
+ this.rl.on('line', (l) => {
452
+ if (this.sent !== undefined) [l, this.sent] = [this.sent, undefined] // readline mangles multi-line text
453
+ const w = this.waiters.shift()
454
+ w ? w(l) : this.queue.push(l)
455
+ })
456
+ this.rl.on('close', () => {
457
+ this.closed = true
458
+ this.cancelAsk()
459
+ })
460
+ this.rl.on('SIGINT', () => (this.onInterrupt ? this.onInterrupt() : process.exit(130)))
461
+ if (terminal) this.#keys(this.rl)
462
+ return this.rl
463
+ }
464
+
465
+ // Full-screen mode: the alternate screen, the conversation on top, the input pinned to the
466
+ // bottom row, the spinner and the suggestion menu just above the input. PgUp/PgDn scroll.
467
+ fullscreen() {
468
+ if (this.full || !(this.input.isTTY && this.out.isTTY)) return
469
+ this.full = true
470
+ // alternate screen · kitty keyboard protocol (Shift/Cmd+Enter are told apart) · bracketed paste
471
+ this.out.write('\x1b[?1049h\x1b[H\x1b[2J\x1b[>1u\x1b[?2004h')
472
+ this.out.on('resize', () => this.render())
473
+ this.#rl()
474
+ process.on('exit', () => this.leaveFullscreen())
475
+ }
476
+
477
+ // Hands the terminal to another program (an editor) and takes it back afterwards.
478
+ async outside(fn) {
479
+ const wasFull = this.full
480
+ if (wasFull) this.out.write('\x1b[?2004l\x1b[<u\x1b[?1049l')
481
+ this.rl?.pause()
482
+ this.input.setRawMode?.(false)
483
+ try {
484
+ return await fn()
485
+ } finally {
486
+ this.input.setRawMode?.(true)
487
+ this.rl?.resume()
488
+ if (wasFull) {
489
+ this.out.write('\x1b[?1049h\x1b[>1u\x1b[?2004h')
490
+ this.render()
491
+ }
492
+ }
493
+ }
494
+
495
+ // Back to the normal screen, leaving the conversation in the terminal's scrollback.
496
+ leaveFullscreen() {
497
+ if (!this.full) return
498
+ this.full = false
499
+ this.out.write('\x1b[?2004l\x1b[<u\x1b[?1049l' + this.log.join('\n').replace(/\n+$/, '') + '\n')
500
+ }
501
+
502
+ // After a theme change: what's on screen already, in the new colors (before/after: themeCodes()).
503
+ recolor(before, after) {
504
+ const swap = new Map(Object.keys(before).filter((k) => before[k] && before[k] !== after[k]).map((k) => [before[k], after[k]]))
505
+ if (swap.size) {
506
+ const re = new RegExp([...swap.keys()].map((x) => x.replace(/[[\]\\^$.|?*+()]/g, '\\$&')).join('|'), 'g')
507
+ this.log = this.log.map((l) => l.replace(re, (m) => swap.get(m)))
508
+ if (this.rl) this.rl.setPrompt(this.rl._prompt.replace(re, (m) => swap.get(m))) // the input's ♥, while it waits
509
+ if (this.mainPrompt) this.mainPrompt = this.mainPrompt.replace(re, (m) => swap.get(m)) // and while it works
510
+ this.wrapped = []
511
+ }
512
+ this.render()
513
+ }
514
+
515
+ // Replaces the conversation on screen (resuming or rewinding a session).
516
+ setLog(lines) {
517
+ this.log = lines?.length ? [...lines] : ['']
518
+ if (this.log.at(-1) !== '') this.log.push('')
519
+ this.wrapped = []
520
+ this.scroll = 0
521
+ this.tail = '\n\n'
522
+ this.render()
523
+ }
524
+
525
+ // Wipes the conversation off the screen (full screen) or the terminal.
526
+ clear() {
527
+ this.tail = ''
528
+ if (!this.full) return this.out.isTTY && this.out.write('\x1b[2J\x1b[3J\x1b[H')
529
+ this.log = ['']
530
+ this.sgr = {}
531
+ this.wrapped = []
532
+ this.scroll = 0
533
+ this.render()
534
+ }
535
+
536
+ // The conversation as screen rows (wrapped to the width; cached per line).
537
+ #rows(cols) {
538
+ if (this.wrapCols !== cols) [this.wrapped, this.wrapCols] = [[], cols]
539
+ const out = []
540
+ this.log.forEach((line, i) => {
541
+ const w = this.wrapped[i]
542
+ if (!w || w.src !== line) this.wrapped[i] = { src: line, rows: wrapAnsi(line, cols) }
543
+ out.push(...this.wrapped[i].rows)
544
+ })
545
+ return out
546
+ }
547
+
548
+ // Layout, top to bottom: header · conversation · spinner/menu · rule · input · rule · status bar.
549
+ render() {
550
+ if (!this.full || this.out.hidden) return // a tab in the background draws when it's shown
551
+ const { out, rl, menu } = this
552
+ const rows = out.rows || 24
553
+ const cols = out.columns || 80
554
+ // The input box: the rows around the cursor, at most INPUT_ROWS; the rules count the rest.
555
+ const typing = !this.asking && rl && this.mainPrompt // working: what's typed meanwhile shows in the box
556
+ const input = this.asking || typing ? layoutInput(this.asking ? rl._prompt : this.mainPrompt, rl.line, rl.cursor, cols) : { rows: [''], cur: { row: 0, col: 0 } }
557
+ const first = Math.max(0, Math.min(input.cur.row - INPUT_ROWS + 1, input.rows.length - INPUT_ROWS))
558
+ const box = input.rows.slice(first, first + INPUT_ROWS)
559
+ const inputRows = box.length
560
+ const hiddenBelow = input.rows.length - first - box.length
561
+ const rule = c.ghost('─'.repeat(cols))
562
+ const note = (n, arrow) => c.ghost('── ') + c.faint(`${arrow} ${n} more line${n > 1 ? 's' : ''}`) + c.ghost(` ${'─'.repeat(Math.max(0, cols - 18 - String(n).length))}`)
563
+ const below = [hiddenBelow ? note(hiddenBelow, '↓') : rule, ...(this.status ? [fit(' ' + this.status(), cols)] : [])] // under the input
564
+ const bottom = []
565
+ if (this.timer && !this.asking) {
566
+ // since the message was sent, and (while waiting on the model) since this request was sent
567
+ const since = (t) => {
568
+ const secs = Math.floor((Date.now() - t) / 1000)
569
+ return secs >= 60 ? `${Math.floor(secs / 60)}m ${secs % 60}s` : `${secs}s`
570
+ }
571
+ const request = this.requestSince?.()
572
+ const extra = this.busySince ? ` · total ${since(this.busySince)}${request ? ` · waiting for model ${since(request)}` : ''} · Esc to stop` : ''
573
+ bottom.push(fit(`${c.brand(FRAMES[this.frame % FRAMES.length])} ${c.muted(this.#label())}${c.faint(extra)}`, cols))
574
+ }
575
+ if (this.scroll) bottom.push(c.dim(` ↓ ${this.scroll} more lines below · PgDn`))
576
+ if (this.noticeText) bottom.push(fit(`${c.warn('!')} ${c.muted(this.noticeText)}`, cols))
577
+ if (menu.on) bottom.push(...this.#menuLines(cols))
578
+ bottom.push(first ? note(first, '↑') : rule)
579
+ const head = [...(this.tabBar?.(cols) ?? []), ...(this.header?.(cols) ?? [])].map((l) => fit(l, cols))
580
+ const inputTop = rows - below.length - inputRows + 1 // first row of the input
581
+ const avail = Math.max(0, inputTop - 1 - bottom.length - head.length)
582
+ const all = this.#rows(cols)
583
+ this.scroll = Math.min(this.scroll, Math.max(0, all.length - avail))
584
+ const top = Math.max(0, all.length - avail - this.scroll)
585
+ const view = all.slice(top, top + avail)
586
+ // a theme with a background: every row painted with it (a style ending its own background goes back to it)
587
+ const row = background ? (line) => `${background}${line.replace(/\x1b\[(0|49)?m/g, (m) => m + background)}\x1b[K\x1b[0m` : (line) => `${line}\x1b[0m\x1b[K`
588
+ let s = '\x1b[?25l'
589
+ for (let r = 0; r < inputTop - 1; r++) {
590
+ const i = r - head.length
591
+ const line = i < 0 ? head[r] : i < avail ? (view[i] ?? '') : bottom[i - avail]
592
+ s += `\x1b[${r + 1};1H${row(line)}`
593
+ }
594
+ box.forEach((line, i) => (s += `\x1b[${inputTop + i};1H${row(line)}`))
595
+ below.forEach((line, i) => (s += `\x1b[${inputTop + inputRows + i};1H${row(line)}`))
596
+ if (this.asking || typing) s += `\x1b[${inputTop + input.cur.row - first};${input.cur.col + 1}H\x1b[?25h`
597
+ else s += `\x1b[${inputTop};1H`
598
+ out.write(s)
599
+ }
600
+
601
+
602
+ // Keys while the prompt is up: PgUp/PgDn scroll; with the menu open, ↑↓ choose, Tab completes,
603
+ // Enter picks (and runs a command), Esc hides it.
604
+ #keys(rl) {
605
+ const out = rl._writeToOutput.bind(rl)
606
+ rl._writeToOutput = (t) => (this.full ? undefined : out(t)) // in full screen, render() draws the input
607
+ const write = rl._ttyWrite.bind(rl)
608
+ rl._ttyWrite = (s, key = {}) => {
609
+ if (!this.full) return write(s, key)
610
+ const bound = !(key.ctrl && key.name === 'c') && this.keys[keyId(key, s)] // ~/.heart/keybindings.json
611
+ if (this.recording) { // Enter or the voice key: done; Esc: never mind; the rest waits
612
+ if (key.name === 'escape') this.recording.end(false)
613
+ else if (key.name === 'return' || bound === 'voice') this.recording.end(true)
614
+ return
615
+ }
616
+ // a fixed dialog (pick(…, { fixed })): only its keys, nothing typed and no shortcuts
617
+ if (this.menu.pick?.fixed && !['up', 'down', 'return', 'escape'].includes(key.name) && !(key.ctrl && key.name === 'c')) return
618
+ if (bound) return this.#action(bound, write)
619
+ if (!this.asking && !(key.ctrl && key.name === 'c')) return this.#typeAhead(s, key, write)
620
+ if (key.name === 'tab' && key.shift) return this.setPermission(PERMISSIONS[(PERMISSIONS.findIndex(([p]) => p === this.permission) + 1) % PERMISSIONS.length][0])
621
+ // A new line instead of sending: Shift/Ctrl/Alt/Cmd+Enter (arrive as "\n" or ESC+Enter),
622
+ // Ctrl+J, "\" right before Enter, and line breaks inside a paste.
623
+ if (key.name === 'paste-start' || key.name === 'paste-end') {
624
+ this.pasting = key.name === 'paste-start'
625
+ return
626
+ }
627
+ if ((key.name === 'return' || key.name === 'enter') && !this.menu.pick) {
628
+ const backslash = key.name === 'return' && !key.meta && rl.line[rl.cursor - 1] === '\\'
629
+ if (this.pasting || key.name === 'enter' || key.meta || backslash) {
630
+ const at = rl.cursor - (backslash ? 1 : 0)
631
+ rl.line = rl.line.slice(0, at) + '\n' + rl.line.slice(rl.cursor)
632
+ rl.cursor = at + 1
633
+ this.menu.items = []
634
+ return this.render()
635
+ }
636
+ }
637
+ if (key.name === 'pageup' || key.name === 'pagedown') {
638
+ const page = Math.max(1, (this.out.rows || 24) - 4)
639
+ this.scroll = Math.max(0, this.scroll + (key.name === 'pageup' ? page : -page))
640
+ return this.render()
641
+ }
642
+ const m = this.menu
643
+ const it = m.on && m.items[m.sel]
644
+ if (this.noticeText && !(key.ctrl && key.name === 'c')) this.notice('') // any key dismisses a notice
645
+ // Esc in a typed question (a name, a key, "your answer"): cancels it, as an empty answer would
646
+ if (key.name === 'escape' && !this.histOn && !m.pick) {
647
+ rl.line = ''
648
+ rl.cursor = 0
649
+ return this.waiters.shift()?.(null)
650
+ }
651
+
652
+ // Esc twice with text in the input: clear it.
653
+ if (key.name === 'escape' && !m.pick && rl.line) {
654
+ const twice = key.sequence === '\x1b\x1b' || Date.now() - (this.lastEscape ?? 0) < 800 // a lone Esc also comes with meta set
655
+ this.lastEscape = twice ? 0 : Date.now()
656
+ if (twice) {
657
+ this.clearInput()
658
+ return this.refreshMenu()
659
+ }
660
+ // the first Esc still closes the suggestion menu (below)
661
+ }
662
+ // Esc twice on an empty input: whatever onDoubleEscape returns is sent (e.g. "/rewind").
663
+ else if (key.name === 'escape' && !m.pick && !rl.line && this.onDoubleEscape) {
664
+ const twice = key.sequence === '\x1b\x1b' || Date.now() - (this.lastEscape ?? 0) < 800 // a lone Esc also comes with meta set
665
+ this.lastEscape = twice ? 0 : Date.now()
666
+ if (twice) {
667
+ rl.line = this.onDoubleEscape()
668
+ this.sent = rl.line
669
+ return write('\r', { name: 'return' })
670
+ }
671
+ return
672
+ }
673
+ if (m.pick) {
674
+ if (key.name === 'up' || key.name === 'down') return this.#move(key.name === 'up' ? -1 : 1)
675
+ if (key.name === 'tab') return
676
+ if (m.pick.multi && key.name === 'space' && it) {
677
+ it.item.checked = !it.item.checked // Space ticks; Enter takes the ticked ones
678
+ return this.refreshMenu()
679
+ }
680
+ if (key.name === 'escape' || (key.name === 'return' && it)) {
681
+ const ticked = m.pick.items.filter((x) => x.checked)
682
+ m.pick.chosen = key.name !== 'return' ? null : m.pick.multi ? (ticked.length ? ticked : [it.item]) : it.item
683
+ rl.line = ''
684
+ rl.cursor = 0
685
+ return write('\r', { name: 'return' })
686
+ }
687
+ if (key.name === 'return') return // nothing matches the filter
688
+ write(s, key)
689
+ return this.refreshMenu()
690
+ }
691
+ // ↑/↓: between the lines of a multi-line input, then through earlier messages (unless the
692
+ // suggestion menu is open and the user hasn't started going through history).
693
+ if ((key.name === 'up' || key.name === 'down') && this.histOn && !key.ctrl && !key.meta && (!it || this.histAt >= 0)) {
694
+ if (this.#moveLine(key.name === 'up' ? -1 : 1)) return this.render()
695
+ this.#browse(key.name === 'up' ? 1 : -1)
696
+ return this.refreshMenu()
697
+ }
698
+ if (key.name !== 'up' && key.name !== 'down') this.histAt = -1 // typing ends the browsing
699
+ if (it && (key.name === 'up' || key.name === 'down')) {
700
+ m.sel = (m.sel + (key.name === 'up' ? m.items.length - 1 : 1)) % m.items.length
701
+ return this.render()
702
+ }
703
+ if (it && key.name === 'escape') {
704
+ m.off = rl.line
705
+ return this.refreshMenu()
706
+ }
707
+ if (m.on && key.name === 'tab') return it && this.#accept(it)
708
+ if (it && key.name === 'return' && rl.line.slice(it.start, it.end) !== it.value) {
709
+ this.#accept(it)
710
+ if (it.insertOnly) return
711
+ }
712
+ if (key.name === 'return') {
713
+ m.items = []
714
+ this.sent = rl.line
715
+ }
716
+ write(s, key)
717
+ if (m.on) this.refreshMenu()
718
+ else this.render()
719
+ }
720
+ }
721
+
722
+ // Typing while HeartCode works: the text stays in the box; Enter sends it, to the running task
723
+ // (it reads it before its next step; takeAhead) or as the next message. Esc stops the task.
724
+ #typeAhead(s, key, write) {
725
+ const rl = this.rl
726
+ if (key.name === 'escape') return this.onEscape?.()
727
+ if (key.name === 'tab' && key.shift) return this.setPermission(PERMISSIONS[(PERMISSIONS.findIndex(([p]) => p === this.permission) + 1) % PERMISSIONS.length][0])
728
+ if (key.name === 'pageup' || key.name === 'pagedown') {
729
+ this.scroll = Math.max(0, this.scroll + (key.name === 'pageup' ? 1 : -1) * Math.max(1, (this.out.rows || 24) - 4))
730
+ return this.render()
731
+ }
732
+ if (key.name === 'return' && !key.meta && !this.pasting) {
733
+ const line = rl.line.trim()
734
+ rl.line = ''
735
+ rl.cursor = 0
736
+ if (/^\/btw\b/.test(line) && this.onBtw) this.onBtw(line.replace(/^\/btw\b/, '').trim()) // answered now, beside the task
737
+ else if (line) {
738
+ this.ahead.push(line)
739
+ this.print(`${c.faint(' ↳')} ${c.muted(line.split('\n')[0])} ${c.faint(/^\//.test(line) ? '(runs when this is done)' : '(goes to the model at its next step, or runs next)')}`)
740
+ }
741
+ return this.render()
742
+ }
743
+ if (['up', 'down', 'tab'].includes(key.name) || key.name === 'paste-start' || key.name === 'paste-end') {
744
+ if (key.name.startsWith('paste')) this.pasting = key.name === 'paste-start'
745
+ return
746
+ }
747
+ if (this.pasting && key.name === 'return') {
748
+ rl.line = rl.line.slice(0, rl.cursor) + '\n' + rl.line.slice(rl.cursor)
749
+ rl.cursor++
750
+ } else write(s, key)
751
+ this.render()
752
+ }
753
+
754
+ // A key from keybindings.json: an action, or text to send (a /command, a message).
755
+ #action(what, write) {
756
+ const rl = this.rl
757
+ const m = this.menu
758
+ switch (what) {
759
+ case 'none':
760
+ return
761
+ case 'new_tab':
762
+ case 'close_tab':
763
+ case 'next_tab':
764
+ case 'prev_tab':
765
+ return this.onTab?.(what)
766
+ case 'voice': // the words go where the cursor is, in the box (also while it works)
767
+ if (m.pick || (this.asking && !this.histOn)) return
768
+ return this.dictate().then((text) => {
769
+ if (!text) return
770
+ const pad = rl.line && !/\s$/.test(rl.line.slice(0, rl.cursor)) ? ' ' : ''
771
+ rl.line = rl.line.slice(0, rl.cursor) + pad + text + rl.line.slice(rl.cursor)
772
+ rl.cursor += pad.length + text.length
773
+ this.render()
774
+ })
775
+ case 'stop':
776
+ return this.asking ? undefined : this.onEscape?.()
777
+ case 'cycle_permissions':
778
+ return this.setPermission(PERMISSIONS[(PERMISSIONS.findIndex(([p]) => p === this.permission) + 1) % PERMISSIONS.length][0])
779
+ case 'newline':
780
+ rl.line = rl.line.slice(0, rl.cursor) + '\n' + rl.line.slice(rl.cursor)
781
+ rl.cursor++
782
+ return this.render()
783
+ case 'clear_input':
784
+ this.clearInput()
785
+ return this.refreshMenu()
786
+ default:
787
+ if (/^tab_\d$/.test(what)) return this.onTab?.(what)
788
+ break
789
+ case 'scroll_up':
790
+ case 'scroll_down':
791
+ this.scroll = Math.max(0, this.scroll + (what === 'scroll_up' ? 1 : -1) * Math.max(1, (this.out.rows || 24) - 4))
792
+ return this.render()
793
+ }
794
+ if (!this.asking) {
795
+ this.ahead.push(what)
796
+ return this.print(`${c.faint(' ↳')} ${c.muted(what)} ${c.faint('(runs when this is done)')}`)
797
+ }
798
+ if (!this.histOn || m.pick) return // only at the main prompt, not in a question or picker
799
+ rl.line = what
800
+ this.sent = what
801
+ m.items = []
802
+ write('\r', { name: 'return' })
803
+ }
804
+
805
+ // Records until Enter (or the voice key) and returns the words, or null (Esc, or it failed: said
806
+ // in a notice). voiceConfig() → { voice, providers }: set by the app.
807
+ async dictate() {
808
+ if (this.recording) return null
809
+ const { voice = {}, providers = {} } = this.voiceConfig?.() ?? {}
810
+ const problem = voiceProblem(voice, providers)
811
+ if (problem) return this.notice(problem, 8000), null
812
+ const rec = record()
813
+ const t0 = Date.now()
814
+ const keep = !this.asking // working: the spinner line stays the task's
815
+ const show = () => this.notice(`● recording ${Math.floor((Date.now() - t0) / 1000)}s · Enter or Ctrl+R to stop · Esc to cancel`, 60 * 60_000)
816
+ show()
817
+ const tick = setInterval(show, 1000)
818
+ const go = await new Promise((resolve) => (this.recording = { end: resolve }))
819
+ clearInterval(tick)
820
+ this.recording = null
821
+ if (!go) {
822
+ rec.cancel()
823
+ this.notice('')
824
+ return null
825
+ }
826
+ try {
827
+ const file = await rec.stop()
828
+ this.notice('transcribing…', 60 * 60_000)
829
+ const text = await transcribe(file, voice, providers)
830
+ log('voice', text)
831
+ this.notice(text ? '' : 'heard nothing', 3000)
832
+ return text || null
833
+ } catch (e) {
834
+ log('voice', e.message)
835
+ this.notice(e.message, 8000)
836
+ return null
837
+ } finally {
838
+ if (keep) this.render()
839
+ }
840
+ }
841
+
842
+ // Messages typed while working that can go to the running task now (not commands); taken.
843
+ takeAhead() {
844
+ const out = this.ahead.filter((l) => !l.startsWith('/'))
845
+ this.ahead = this.ahead.filter((l) => l.startsWith('/'))
846
+ return out
847
+ }
848
+
849
+ #accept(it) {
850
+ const rl = this.rl
851
+ const v = it.value + (it.space ? ' ' : '')
852
+ rl.line = rl.line.slice(0, it.start) + v + rl.line.slice(it.end)
853
+ rl.cursor = it.start + v.length
854
+ rl._refreshLine()
855
+ this.refreshMenu()
856
+ }
857
+
858
+ // Picker: move the highlight, skipping group headers.
859
+ #move(d) {
860
+ const m = this.menu
861
+ for (let i = 0, n = m.items.length; i < n; i++) {
862
+ m.sel = (m.sel + d + n) % n
863
+ if (!m.items[m.sel].header) break
864
+ }
865
+ this.render()
866
+ }
867
+
868
+ refreshMenu() {
869
+ const m = this.menu
870
+ if (!m.on || !this.rl) return
871
+ const line = this.rl.line
872
+ if (m.pick) {
873
+ const was = m.items[m.sel]?.item
874
+ m.items = pickView(m.pick.items, line)
875
+ const keep = m.items.findIndex((x) => x.item && x.item === (was ?? m.pick.items.find((i) => i.current)))
876
+ // typing: the first whose name matches (a filter also matches descriptions: typing "no"
877
+ // must not leave "Yes" chosen because its description says "knows")
878
+ const words = line.toLowerCase().split(/\s+/).filter(Boolean)
879
+ const named = words.length && line !== m.pick.typed ? m.items.findIndex((x) => x.item && words.every((w) => x.item.label.toLowerCase().includes(w))) : -1
880
+ m.pick.typed = line
881
+ m.sel = named !== -1 ? named : keep !== -1 ? keep : Math.max(0, m.items.findIndex((x) => !x.header))
882
+ return this.render()
883
+ }
884
+ const items = m.off === line ? [] : (this.suggest?.(line, this.rl.cursor) ?? [])
885
+ if (m.off !== line) m.off = null
886
+ if (items.length !== m.items.length || items.some((x, i) => x.value !== m.items[i].value)) m.sel = 0
887
+ m.items = items
888
+ this.render()
889
+ }
890
+
891
+ #menuLines(cols) {
892
+ const m = this.menu
893
+ const width = cols - 1
894
+ const max = m.pick ? Math.max(5, (this.out.rows || 24) - 8) : MENU_ROWS
895
+ const first = Math.max(0, Math.min(m.sel - max + 1, m.items.length - max))
896
+ const shown = m.items.slice(first, first + max)
897
+ const pad = m.items.some((x) => x.header) ? ' ' : ''
898
+ const w = Math.min(Math.max(0, ...shown.filter((x) => !x.header).map((x) => x.value.length)), 48)
899
+ const lines = shown.map((x, i) => {
900
+ if (x.header) return c.accent(c.bold(x.value.slice(0, width)))
901
+ const plain = `${x.value.padEnd(w)} ${x.desc ?? ''}`.slice(0, width - 2 - pad.length)
902
+ const name = plain.slice(0, w)
903
+ if (first + i !== m.sel) return `${pad} ${c.text(name)}${c.faint(plain.slice(w))}`
904
+ const row = `${c.brand('❯')} ${c.bright(c.bold(name))}${c.muted(plain.slice(w))}`
905
+ return pad + c.select(row + ' '.repeat(Math.max(0, width - pad.length - 2 - plain.length))) // the whole row lit
906
+ })
907
+ const more = m.items.length - first - shown.length
908
+ if (more > 0) lines.push(c.faint(` … ${more} more`))
909
+ const how = m.pick?.multi ? '↑↓ move · Space tick · Enter take the ticked ones · Esc cancel · type to filter' : '↑↓ move · Enter choose · Esc cancel · type to filter'
910
+ if (m.pick) lines.unshift(c.faint(m.items.length ? how : `nothing matches "${this.rl.line}" · Esc cancel`))
911
+ return lines
912
+ }
913
+
914
+
915
+ // initial: text already typed in, ready to edit. suggest: show the suggestion menu while typing.
916
+ // A one-line message just above the input until `ms` pass or a key is pressed ('' hides it).
917
+ notice(text, ms = 2000) {
918
+ this.noticeText = text
919
+ clearTimeout(this.noticeTimer)
920
+ if (text) this.noticeTimer = setTimeout(() => this.notice(''), ms)
921
+ this.render()
922
+ }
923
+
924
+ // Empties the input (Ctrl+C or Esc Esc with text in it).
925
+ clearInput() {
926
+ if (!this.rl) return
927
+ this.rl.line = ''
928
+ this.rl.cursor = 0
929
+ this.histAt = -1
930
+ this.render()
931
+ }
932
+
933
+ // ↑/↓ inside a multi-line input: to the line above/below, same column. false at the first/last line.
934
+ #moveLine(d) {
935
+ const rl = this.rl
936
+ const lines = rl.line.split('\n')
937
+ if (lines.length < 2) return false
938
+ let i = 0
939
+ let start = 0
940
+ while (i < lines.length - 1 && start + lines[i].length < rl.cursor) start += lines[i++].length + 1
941
+ const j = i + d
942
+ if (j < 0 || j >= lines.length) return false
943
+ const col = rl.cursor - start
944
+ const to = lines.slice(0, j).reduce((n, l) => n + l.length + 1, 0)
945
+ rl.cursor = to + Math.min(col, lines[j].length)
946
+ return true
947
+ }
948
+
949
+ // Earlier messages: d = 1 older, -1 newer; past the newest comes back what was being typed.
950
+ #browse(d) {
951
+ const rl = this.rl
952
+ const h = this.history()
953
+ const at = Math.max(-1, Math.min(h.length - 1, this.histAt + d))
954
+ if (at === this.histAt) return
955
+ if (this.histAt === -1) this.draft = rl.line
956
+ this.histAt = at
957
+ rl.line = at === -1 ? (this.draft ?? '') : h[h.length - 1 - at]
958
+ // going back: cursor on the last line; going forward: on the first, so ↑/↓ walk its lines next
959
+ const nl = rl.line.indexOf('\n')
960
+ rl.cursor = d > 0 || nl === -1 ? rl.line.length : nl
961
+ if (this.menu.on) this.menu.off = rl.line // a recalled "/command" doesn't open the menu
962
+ }
963
+
964
+ // Sent messages, oldest first, kept in ~/.heart/history.json across runs.
965
+ history() {
966
+ if (!this.sentHistory) {
967
+ try {
968
+ this.sentHistory = JSON.parse(fs.readFileSync(path.join(HOME, 'history.json'), 'utf8')).filter((x) => typeof x === 'string')
969
+ } catch {
970
+ this.sentHistory = []
971
+ }
972
+ }
973
+ return this.sentHistory
974
+ }
975
+
976
+ remember(line) {
977
+ const h = this.history()
978
+ if (!line.trim() || h.at(-1) === line) return
979
+ h.push(line)
980
+ if (h.length > 500) h.splice(0, h.length - 500)
981
+ try {
982
+ fs.mkdirSync(HOME, { recursive: true })
983
+ fs.writeFileSync(path.join(HOME, 'history.json'), JSON.stringify(h), { mode: 0o600 })
984
+ } catch {}
985
+ }
986
+
987
+ // history: this prompt's messages go into ↑/↓ history (only the main prompt, never answers like keys).
988
+ ask(prompt, { initial = '', suggest = false, history = false } = {}) {
989
+ this.stopSpin()
990
+ const rl = this.#rl()
991
+ if (history) this.mainPrompt = prompt
992
+ if (history && this.ahead.length) {
993
+ const l = this.ahead.shift()
994
+ log('input', `${prompt.trim()} ${l} (typed while working)`)
995
+ this.write(prompt.replace(/^ /, '') + c.bright(l) + '\n')
996
+ return Promise.resolve(l)
997
+ }
998
+ // a question in the middle of work: what's being typed for later waits until it's answered
999
+ const draft = !history && rl.line ? [rl.line, (rl.line = ''), (rl.cursor = 0)][0] : ''
1000
+ if (this.queue.length) {
1001
+ const l = this.queue.shift()
1002
+ log('input', `${prompt.trim()} ${/api key/i.test(prompt) && l ? `${l.slice(0, 4)}…(${l.length} chars)` : l}`) // never log keys
1003
+ this.write(prompt + l + '\n')
1004
+ return Promise.resolve(l)
1005
+ }
1006
+ if (this.closed) return Promise.resolve(null)
1007
+ this.menu.on = Boolean(suggest && (this.suggest || this.menu.pick) && this.full)
1008
+ this.histOn = history
1009
+ this.histAt = -1
1010
+ this.draft = ''
1011
+ if (this.full) {
1012
+ this.asking = true
1013
+ this.render() // cursor to the input row
1014
+ }
1015
+ rl.setPrompt(prompt)
1016
+ rl.prompt()
1017
+ if (initial && rl.terminal) rl.write(initial)
1018
+ if (this.menu.on) this.refreshMenu()
1019
+ else this.render()
1020
+ return new Promise((resolve) =>
1021
+ this.waiters.push((l) => {
1022
+ this.menu.items = []
1023
+ this.menu.on = false
1024
+ if (history && l) this.remember(l)
1025
+ this.histOn = false
1026
+ const said = this.menu.pick ? (this.menu.pick.chosen?.label ?? '(cancelled)') : (l ?? '(cancelled)')
1027
+ log('input', `${prompt.trim()} ${/api key/i.test(prompt) && l ? `${l.slice(0, 4)}…(${l.length} chars)` : said}`) // never log keys
1028
+ if (l !== null && !rl.terminal) this.out.write(l + '\n')
1029
+ if (this.full) {
1030
+ this.asking = false
1031
+ this.scroll = 0
1032
+ const pick = this.menu.pick
1033
+ const echo = prompt.replace(/^ /, '') // the input's margin isn't part of the conversation
1034
+ this.inputMark = this.log.length - 1 // where this message starts, for /rewind
1035
+ const picked = Array.isArray(pick?.chosen) ? pick.chosen.map((x) => x.label).join(', ') : pick?.chosen?.label
1036
+ if (pick?.quiet) this.render()
1037
+ else if (pick) this.write(echo + (picked ?? c.dim('cancelled')) + '\n')
1038
+ else if (l !== null) this.write(echo + c.bright(l.replace(/\n/g, '\n' + ' '.repeat(width(echo.replace(/\x1b\[[0-9;]*m/g, ''))))) + '\n')
1039
+ else this.render()
1040
+ }
1041
+ if (draft) [rl.line, rl.cursor] = [draft, draft.length]
1042
+ resolve(l)
1043
+ }),
1044
+ )
1045
+ }
1046
+
1047
+ cancelAsk() {
1048
+ for (const w of this.waiters.splice(0)) w(null)
1049
+ }
1050
+
1051
+ // Interactive list: type to filter, ↑↓ move, Enter chooses, Esc cancels (→ null).
1052
+ // items: [{ label, desc?, group?, current? }]; without full screen, a numbered list.
1053
+ // multi: tick several with Space; returns the ticked items (an array), or null.
1054
+ // quiet: the question and answer aren't left in the conversation (a question before it starts).
1055
+ // fixed: a dialog: no typing to filter, no other keys meanwhile.
1056
+ async pick(title, items, { filter = '', multi = false, quiet = false, fixed = false } = {}) {
1057
+ if (!this.full) {
1058
+ items.forEach((it, i) => this.print(` ${i + 1}. ${it.group ? `${it.group} · ` : ''}${it.label}${it.desc ? ' ' + c.dim(it.desc) : ''}`))
1059
+ const a = await this.ask(`${title} ${c.dim(multi ? '(numbers, like 1,3)' : '(number)')}: `)
1060
+ if (!multi) return items[Number(a) - 1] ?? null
1061
+ const chosen = String(a ?? '').split(/[\s,]+/).map((n) => items[Number(n) - 1]).filter(Boolean)
1062
+ return chosen.length ? chosen : null
1063
+ }
1064
+ if (multi) for (const it of items) it.checked ??= false
1065
+ this.menu.pick = { items, chosen: null, multi, quiet, fixed }
1066
+ try {
1067
+ await this.ask(`${c.bold(title)} ${c.dim('›')} `, { suggest: true, initial: filter })
1068
+ return this.menu.pick.chosen
1069
+ } finally {
1070
+ this.menu.pick = null
1071
+ }
1072
+ }
1073
+
1074
+ setPermission(mode) {
1075
+ log('permissions', mode)
1076
+ if (mode === 'plan' && this.permission !== 'plan') this.beforePlan = this.permission // what plan mode asks like, and goes back to
1077
+ this.permission = mode
1078
+ this.onPermission?.(mode)
1079
+ this.render()
1080
+ }
1081
+
1082
+ // kind: 'edit', 'command' or 'read'. true = yes, false = no, string = "no, and here is why"
1083
+ // kind 'read' (web search and fetch): asked like a command, but never in plan mode, which is for
1084
+ // looking around. Otherwise plan mode asks as the mode before it did (only reading tools get here).
1085
+ async confirm(question, kind = 'edit') {
1086
+ if (kind === 'read' && this.permission === 'plan') return true
1087
+ const mode = this.permission === 'plan' ? (this.beforePlan ?? 'ask') : this.permission
1088
+ if (mode === 'all' || (mode === 'commands' && kind === 'edit')) return true
1089
+ if (this.full) {
1090
+ const opts = [
1091
+ { label: 'Yes', value: true },
1092
+ { label: "Yes, and don't ask again", value: 'all' },
1093
+ { label: 'No', value: false },
1094
+ { label: 'No, and tell the model what to do instead', value: 'why' },
1095
+ ]
1096
+ const a = (await this.pick(`${c.yellow('?')} ${question}`, opts))?.value ?? false
1097
+ if (a === 'all') this.setPermission('all')
1098
+ if (a !== 'why') return Boolean(a)
1099
+ return (await this.ask(`${c.bold('What should it do instead?')} `))?.trim() || false
1100
+ }
1101
+ const a = await this.ask(`${c.yellow('?')} ${question} ${c.dim('[Y/n or type feedback]')} `)
1102
+ if (a === null) return false
1103
+ const t = a.trim()
1104
+ if (/^(y|yes)?$/i.test(t)) return true
1105
+ if (/^(n|no)$/i.test(t)) return false
1106
+ return t
1107
+ }
1108
+
1109
+ write(s) {
1110
+ if (!this.full) this.stopSpin() // inline, the spinner shares the line; full screen it has its own row
1111
+ if (!s) return
1112
+ this.tail = (this.tail + s.replace(/\x1b\[[0-9;]*m/g, '')).slice(-2)
1113
+ if (!this.full) return this.out.write(s)
1114
+ // The screen draws each line on its own, so a style still open at a line break is reopened
1115
+ // on the next line (a multi-line thinking block stays grey and italic).
1116
+ const parts = s.replace(/\r/g, '').split('\n')
1117
+ parts.forEach((part, k) => {
1118
+ if (k) this.log.push(sgrPrefix(this.sgr))
1119
+ this.log[this.log.length - 1] += part
1120
+ this.sgr = sgrState(this.sgr, part)
1121
+ })
1122
+ if (!this.pending) {
1123
+ this.pending = true
1124
+ setImmediate(() => {
1125
+ this.pending = false
1126
+ this.render()
1127
+ })
1128
+ }
1129
+ }
1130
+ // A blank line before a new block, unless there already is one.
1131
+ gap() {
1132
+ if (this.tail && this.tail !== '\n\n') this.write(this.tail.endsWith('\n') ? '\n' : '\n\n')
1133
+ }
1134
+ print(s = '') {
1135
+ this.write(s + '\n')
1136
+ }
1137
+ step(name, detail = '') {
1138
+ if (this.afterBlock) this.gap()
1139
+ this.afterBlock = false
1140
+ this.print(`${c.accent('●')} ${c.accent(c.bold(name))} ${c.info(detail)}`)
1141
+ }
1142
+ result(text) {
1143
+ this.print(` ${c.ghost('⎿')} ${c.faint(text)}`)
1144
+ }
1145
+ warn(text) {
1146
+ log('warn', text)
1147
+ this.print(`${c.warn('!')} ${c.warn(text)}`)
1148
+ }
1149
+
1150
+ spinner(label) {
1151
+ this.label = label
1152
+ if (!this.out.isTTY || this.timer) return
1153
+ let i = 0
1154
+ const tick = () => {
1155
+ this.frame++
1156
+ if (this.full) this.render()
1157
+ else this.out.write(`\r\x1b[K${c.magenta(FRAMES[i++ % FRAMES.length])} ${c.dim(this.#label())}`)
1158
+ }
1159
+ this.timer = setInterval(tick, 80)
1160
+ tick()
1161
+ }
1162
+ // The spinner's text; a function label is re-read every frame (for countdowns).
1163
+ #label() {
1164
+ return typeof this.label === 'function' ? this.label() : this.label
1165
+ }
1166
+
1167
+ // A request failed and will be retried in `ms`: show why and count down. null = it worked again.
1168
+ // next: the model it goes on to (the model list on the settings page)
1169
+ retrying(err, ms, attempt, next) {
1170
+ if (!err) {
1171
+ if (this.beforeRetry !== undefined) this.spinner(this.beforeRetry)
1172
+ this.beforeRetry = undefined
1173
+ return
1174
+ }
1175
+ this.beforeRetry ??= typeof this.label === 'function' ? 'thinking' : this.label || 'thinking'
1176
+ const until = Date.now() + ms
1177
+ const why = shortError(err)
1178
+ const secs = () => Math.max(0, Math.ceil((until - Date.now()) / 1000))
1179
+ if (next) return this.spinner(() => `${c.yellow('⚠')} ${why} · trying ${next.provider} · ${next.model}${secs() ? ` in ${secs()}s (all failed once)` : ''} (attempt ${attempt})`)
1180
+ this.spinner(() => `${c.yellow('⚠')} retrying in ${secs()}s (attempt ${attempt}) · ${why}`)
1181
+ }
1182
+
1183
+ stopSpin() {
1184
+ if (!this.timer) return
1185
+ if (this.busySince) return void (this.label = 'working') // still busy: keep spinning, generically
1186
+ clearInterval(this.timer)
1187
+ this.timer = null
1188
+ if (this.full) this.render()
1189
+ else this.out.write('\r\x1b[K')
1190
+ }
1191
+
1192
+ // Full screen: from the moment work starts until it's done (a task, a probe, a command), the
1193
+ // spinner row stays on, whatever gets printed meanwhile. It steps aside while asking the user.
1194
+ busy() {
1195
+ if (!this.full) return
1196
+ this.busySince = Date.now()
1197
+ this.spinner('working')
1198
+ }
1199
+ idle() {
1200
+ this.busySince = null
1201
+ this.stopSpin()
1202
+ }
1203
+
1204
+ close() {
1205
+ this.busySince = null // closing ends any work in progress: the spinner stops too
1206
+ this.stopSpin()
1207
+ this.rl?.close()
1208
+ this.leaveFullscreen()
1209
+ }
1210
+ }
1211
+
1212
+ // Line diff: trims the common prefix/suffix, then LCS on what is left.
1213
+ export function diffLines(a, b) {
1214
+ const A = a.split('\n')
1215
+ const B = b.split('\n')
1216
+ let p = 0
1217
+ while (p < A.length && p < B.length && A[p] === B[p]) p++
1218
+ let s = 0
1219
+ while (s < A.length - p && s < B.length - p && A[A.length - 1 - s] === B[B.length - 1 - s]) s++
1220
+ const x = A.slice(p, A.length - s)
1221
+ const y = B.slice(p, B.length - s)
1222
+ const mid = []
1223
+ if (x.length * y.length > 4e6) {
1224
+ // ponytail: huge rewrites shown as remove-all/add-all; real Myers diff if that matters
1225
+ for (const l of x) mid.push(['-', l])
1226
+ for (const l of y) mid.push(['+', l])
1227
+ } else {
1228
+ const m = y.length + 1
1229
+ const dp = new Uint32Array((x.length + 1) * m)
1230
+ for (let i = x.length - 1; i >= 0; i--) {
1231
+ for (let j = y.length - 1; j >= 0; j--) {
1232
+ dp[i * m + j] = x[i] === y[j] ? dp[(i + 1) * m + j + 1] + 1 : Math.max(dp[(i + 1) * m + j], dp[i * m + j + 1])
1233
+ }
1234
+ }
1235
+ let i = 0
1236
+ let j = 0
1237
+ while (i < x.length && j < y.length) {
1238
+ if (x[i] === y[j]) {
1239
+ mid.push([' ', x[i++]])
1240
+ j++
1241
+ }
1242
+ else if (dp[(i + 1) * m + j] >= dp[i * m + j + 1]) mid.push(['-', x[i++]])
1243
+ else mid.push(['+', y[j++]])
1244
+ }
1245
+ while (i < x.length) mid.push(['-', x[i++]])
1246
+ while (j < y.length) mid.push(['+', y[j++]])
1247
+ }
1248
+ return [...A.slice(0, p).map((l) => [' ', l]), ...mid, ...A.slice(A.length - s).map((l) => [' ', l])]
1249
+ }
1250
+
1251
+ export function renderDiff(ops, title, useColor = color, context = 3, maxLines = 200) {
1252
+ const show = new Array(ops.length).fill(false)
1253
+ ops.forEach(([t], i) => {
1254
+ if (t === ' ') return
1255
+ for (let k = Math.max(0, i - context); k <= Math.min(ops.length - 1, i + context); k++) show[k] = true
1256
+ })
1257
+ const add = ops.filter(([t]) => t === '+').length
1258
+ const del = ops.filter(([t]) => t === '-').length
1259
+ const paintIf = (f, s) => (useColor ? f(s) : s)
1260
+ const out = [paintIf(c.ghost, '── ') + paintIf((x) => c.info(c.bold(x)), title) + ' ' + paintIf(c.ok, `+${add}`) + ' ' + paintIf(c.err, `-${del}`)]
1261
+ let gap = false
1262
+ let n = 0
1263
+ for (let i = 0; i < ops.length; i++) {
1264
+ if (!show[i]) {
1265
+ gap = true
1266
+ continue
1267
+ }
1268
+ if (gap && out.length > 1) out.push(paintIf(c.ghost, ' ⋯'))
1269
+ gap = false
1270
+ if (++n > maxLines) {
1271
+ out.push(paintIf(c.faint, ` … ${ops.slice(i).filter(([t]) => t !== ' ').length} more changed lines`))
1272
+ break
1273
+ }
1274
+ const [t, l] = ops[i]
1275
+ out.push(t === '+' ? paintIf(c.ok, `+ ${l}`) : t === '-' ? paintIf(c.err, `- ${l}`) : paintIf(c.faint, ` ${l}`))
1276
+ }
1277
+ return out.join('\n')
1278
+ }