heartcode 0.1.14 → 0.1.24

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 CHANGED
@@ -3,10 +3,11 @@ import { PassThrough } from 'node:stream'
3
3
  import { StringDecoder } from 'node:string_decoder'
4
4
  import { AsyncLocalStorage } from 'node:async_hooks'
5
5
  import fs from 'node:fs'
6
+ import { spawnSync } from 'node:child_process'
6
7
  import path from 'node:path'
7
8
  import { log } from './log.js'
8
9
  import { HOME } from './config.js'
9
- import { record, transcribe, voiceProblem } from './voice.js'
10
+ import { listen, transcribePcm, voiceProblem } from './voice.js'
10
11
 
11
12
  const color = (process.stdout.isTTY || Boolean(process.env.FORCE_COLOR)) && !process.env.NO_COLOR
12
13
  // 24-bit where the terminal says so, else the nearest of 256 (Terminal.app, xterm-256color), else 16.
@@ -103,12 +104,17 @@ export function setTheme(name = 'dark') {
103
104
  setTheme()
104
105
 
105
106
  // 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
+ // Actions: stop, send_first, send_all, queue_mode, cycle_permissions, newline, clear_input, scroll_up, scroll_down (a page),
108
+ // scroll_line_up, scroll_line_down (3 lines), voice, new_tab,
107
109
  // close_tab, next_tab, prev_tab, tab_1…tab_9, none (turns a
108
110
  // 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}`])) }
111
+ const CONTROL_KEYS = { '\x1c': 'ctrl+\\', '\x1d': 'ctrl+]', '\x1e': 'ctrl+^', '\x1f': 'ctrl+_' } // no name from readline
112
+ export const keyId = (key, s = '') => CONTROL_KEYS[key.sequence ?? s] ?? [key.ctrl && 'ctrl', key.meta && 'alt', key.shift && 'shift', key.name ?? s].filter(Boolean).join('+').toLowerCase()
113
+ // While it works, Enter queues a message; Ctrl+] stops the answer and sends the first queued one,
114
+ // Ctrl+\ all of them (as one message); Ctrl+_ switches what goes when an answer is done (all or
115
+ // the next one). /voice records (no key unless keybindings.json gives it one); Ctrl+T, Ctrl+Q, Ctrl+N/P (or Ctrl+PgDn/PgUp), Alt+1…9: tabs; Shift+↑↓ and Alt+↑↓
116
+ // scroll the conversation (as PgUp/PgDn and the mouse wheel do). keybindings.json can move them.
117
+ export const DEFAULT_KEYS = { 'ctrl+]': 'send_first', 'ctrl+\\': 'send_all', 'ctrl+_': 'queue_mode', 'shift+up': 'scroll_line_up', 'shift+down': 'scroll_line_down', 'alt+up': 'scroll_up', 'alt+down': 'scroll_down', '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
118
  export function loadKeys(file = path.join(HOME, 'keybindings.json')) {
113
119
  let raw = {}
114
120
  try {
@@ -120,7 +126,56 @@ export function loadKeys(file = path.join(HOME, 'keybindings.json')) {
120
126
  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
127
  }
122
128
 
129
+ // mouse reports (SGR), with moves while a button is down (for selecting text)
130
+ const MOUSE_ON = '\x1b[?1000h\x1b[?1002h\x1b[?1006h'
131
+ const MOUSE_OFF = '\x1b[?1006l\x1b[?1002l\x1b[?1000l'
132
+
133
+ // The system clipboard (pbcopy, clip, wl-copy, xclip, xsel); else the terminal's (OSC 52, works over SSH too).
134
+ export function copyText(text) {
135
+ const tools = process.platform === 'darwin' ? [['pbcopy']] : process.platform === 'win32' ? [['clip']] : [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '-b', '-i']]
136
+ for (const [cmd, ...args] of tools) if (spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] }).status === 0) return 'system'
137
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text).toString('base64')}\x07`)
138
+ return 'osc'
139
+ }
140
+
141
+ // The cells from…to (columns, to excluded) of a colored line, shown reversed (a selection).
142
+ function highlight(line, from, to) {
143
+ let col = 0
144
+ let on = false
145
+ let out = ''
146
+ for (const [, esc, ch] of line.matchAll(/(\x1b\[[0-9;]*m)|([^\x1b])/gs)) {
147
+ if (esc) {
148
+ out += esc + (on && /^\x1b\[0?m$/.test(esc) ? '\x1b[7m' : '') // a reset inside keeps it reversed
149
+ continue
150
+ }
151
+ if (!on && col >= from && col < to) (out += '\x1b[7m'), (on = true)
152
+ if (on && col >= to) (out += '\x1b[27m'), (on = false)
153
+ out += ch
154
+ col += width(ch)
155
+ }
156
+ return out + (on ? '\x1b[27m' : '')
157
+ }
158
+
159
+ // The characters in columns from…to of plain text.
160
+ function sliceCols(text, from, to) {
161
+ let col = 0
162
+ let out = ''
163
+ for (const ch of text) {
164
+ if (col >= from && col < to) out += ch
165
+ col += width(ch)
166
+ }
167
+ return out
168
+ }
123
169
  const FRAMES = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
170
+
171
+ // 75000 → "1 minute 15 seconds"; the two biggest units: "5 hours 33 minutes", "2 days 3 hours"
172
+ export function duration(ms) {
173
+ const s = Math.max(0, Math.floor(ms / 1000))
174
+ const units = [['day', Math.floor(s / 86400)], ['hour', Math.floor(s / 3600) % 24], ['minute', Math.floor(s / 60) % 60], ['second', s % 60]]
175
+ const first = units.findIndex(([, n]) => n)
176
+ if (first < 0) return '0 seconds'
177
+ return units.slice(first, first + 2).filter(([, n]) => n).map(([u, n]) => `${n} ${u}${n === 1 ? '' : 's'}`).join(' ')
178
+ }
124
179
  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
180
 
126
181
  // "503 from https://…: {"error":{"message":"overloaded"}}" → "503 overloaded"
@@ -149,6 +204,7 @@ export function legacyKeys(text) {
149
204
  if (code === 127 || code === 8) return (alt ? '\x1b' : '') + (ctrl ? '\x17' : '\x7f')
150
205
  if (code < 32 || (code >= 0xe000 && code <= 0xf8ff) || code > 0x10ffff) return '' // keypad, F13+, …
151
206
  let ch = String.fromCodePoint(code)
207
+ if (ctrl && /^[-/]$/.test(ch)) return '\x1f' // Ctrl+- / Ctrl+/ (and with Shift: Ctrl+_), as terminals send them
152
208
  if (ctrl && /^[a-z@[\\\]^_ ]$/i.test(ch)) ch = String.fromCharCode(ch.toUpperCase().charCodeAt(0) & 0x1f)
153
209
  else if (shift) ch = ch.toUpperCase()
154
210
  return (alt ? '\x1b' : '') + ch
@@ -156,7 +212,8 @@ export function legacyKeys(text) {
156
212
  }
157
213
 
158
214
  // The terminal's input, passed through legacyKeys (holding back a sequence split across reads).
159
- function keyStream(input) {
215
+ // Mouse reports (SGR, full screen turns them on) are taken out, to onMouse(button, x, y, released).
216
+ function keyStream(input, onMouse) {
160
217
  const proxy = new PassThrough()
161
218
  proxy.isTTY = true
162
219
  proxy.setRawMode = (on) => input.setRawMode?.(on)
@@ -165,12 +222,13 @@ function keyStream(input) {
165
222
  const dec = new StringDecoder('utf8')
166
223
  let held = ''
167
224
  let timer
168
- const send = (text) => text && proxy.write(legacyKeys(text))
225
+ const mouse = (m, b, x, y, end) => (onMouse?.(Number(b), Number(x), Number(y), end === 'm'), '')
226
+ const send = (text) => (text = text.replace(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g, mouse)) && proxy.write(legacyKeys(text))
169
227
  // bound to where it was made: a tab's keys are logged in that tab's log (src/log.js)
170
228
  input.on('data', AsyncLocalStorage.bind((d) => {
171
229
  clearTimeout(timer)
172
230
  let text = held + dec.write(d)
173
- const part = /\x1b\[[0-9;:]*$/.exec(text)
231
+ const part = /\x1b\[<?[0-9;:]*$/.exec(text)
174
232
  held = part ? part[0] : ''
175
233
  if (part) {
176
234
  text = text.slice(0, part.index)
@@ -296,11 +354,12 @@ export class Block {
296
354
  constructor(write, { marker = ' ', markerStyle = (x) => x, style = (x) => x, width = (process.stdout.columns || 100) - 1 } = {}) {
297
355
  Object.assign(this, { write, marker, markerStyle, style, width: Math.max(20, width) })
298
356
  this.indent = ' '.repeat(marker.length)
357
+ this.hang = '' // more indent for a wrapped line (a list item's text)
299
358
  this.col = 0
300
359
  this.started = false
301
360
  this.newlines = 0
302
361
  }
303
- push(text) {
362
+ push(text, style = this.style) {
304
363
  let out = ''
305
364
  for (const ch of text) {
306
365
  if (ch === '\r') continue
@@ -318,14 +377,14 @@ export class Block {
318
377
  this.col = this.indent.length
319
378
  this.newlines = 0
320
379
  } else if (this.col >= this.width || (ch === ' ' && this.col >= this.width - 12)) {
321
- out += '\n' + this.indent
322
- this.col = this.indent.length
380
+ out += '\n' + this.indent + this.hang
381
+ this.col = this.indent.length + this.hang.length
323
382
  if (ch === ' ') continue
324
383
  }
325
384
  out += ch
326
385
  this.col++
327
386
  }
328
- if (out) this.write(this.style(out))
387
+ if (out) this.write(out.split('\n').map((l) => l && style(l)).join('\n')) // styled per row, so a row's colors stand alone
329
388
  }
330
389
  end() {
331
390
  if (this.started && !this.ended) this.write('\n')
@@ -333,6 +392,210 @@ export class Block {
333
392
  }
334
393
  }
335
394
 
395
+ // The model's Markdown, drawn as it streams in: headings, lists, quotes, code blocks, rules,
396
+ // **bold**, *italic* and `code`. Into a Block (which wraps); held back only while a line's start
397
+ // or a "*" could still be a marker. Tables and links stay as written.
398
+ export class Markdown {
399
+ constructor(block, base = (x) => x) {
400
+ Object.assign(this, { block, base, buf: '', sol: true, fence: false, bold: false, ital: false, code: false, line: null, prev: ' ' })
401
+ }
402
+ push(t) {
403
+ this.buf += t
404
+ this.#run(false)
405
+ }
406
+ end() {
407
+ this.#run(true)
408
+ this.block.end()
409
+ }
410
+ #style() {
411
+ let f = this.line ?? this.base
412
+ if (this.code || this.fence) return c.info
413
+ if (this.bold) f = ((g) => (x) => c.bold(c.bright(g(x))))(f)
414
+ if (this.ital) f = ((g) => (x) => c.italic(g(x)))(f)
415
+ return f
416
+ }
417
+ #out(x, style = this.#style()) {
418
+ if (x) this.block.push(x, style)
419
+ }
420
+ // a line's start: what kind of line it is (false: not known yet)
421
+ #lineStart(s, final) {
422
+ const nl = s.indexOf('\n')
423
+ const head = nl < 0 ? s : s.slice(0, nl)
424
+ const whole = nl >= 0 || final
425
+ if (!whole && /^\s*(#{0,6}|[-*+_]|\d{1,3}[.)]?|>|`{1,2}|([-*_]\s*){1,}|```.*)$/.test(head)) return false // could still be a marker
426
+ const b = this.block
427
+ b.hang = ''
428
+ this.line = null
429
+ if (/^\s*```/.test(head)) {
430
+ this.fence = !this.fence
431
+ return nl < 0 ? s.length : nl + 1 // the fence line itself isn't shown
432
+ }
433
+ if (this.fence) return 0
434
+ if (whole && /^\s*([-*_])(\s*\1){2,}\s*$/.test(head)) {
435
+ this.#out('─'.repeat(Math.min(40, b.width - b.indent.length)), c.ghost)
436
+ return head.length
437
+ }
438
+ let m
439
+ if ((m = /^(#{1,6})\s+/.exec(head))) {
440
+ this.line = (x) => c.bold(c.bright(x))
441
+ return m[0].length
442
+ }
443
+ if ((m = /^(\s*)[-*+]\s+/.exec(head))) {
444
+ b.hang = m[1] + ' '
445
+ this.#out(m[1] + '• ', c.accent)
446
+ return m[0].length
447
+ }
448
+ if ((m = /^(\s*)(\d{1,3})[.)]\s+/.exec(head))) {
449
+ b.hang = m[1] + ' '.repeat(m[2].length + 2)
450
+ this.#out(`${m[1]}${m[2]}. `, c.accent)
451
+ return m[0].length
452
+ }
453
+ if ((m = /^\s*>\s?/.exec(head))) {
454
+ b.hang = ' '
455
+ this.line = (x) => c.faint(c.italic(x))
456
+ this.#out('│ ', c.ghost)
457
+ return m[0].length
458
+ }
459
+ return 0
460
+ }
461
+ #run(final) {
462
+ let s = this.buf
463
+ let text = ''
464
+ const flush = () => (this.#out(text), (text = ''))
465
+ let i = 0
466
+ while (i < s.length) {
467
+ if (this.sol) {
468
+ const n = this.#lineStart(s.slice(i), final)
469
+ if (n === false) break
470
+ this.sol = false
471
+ i += n
472
+ continue
473
+ }
474
+ const ch = s[i]
475
+ if (ch === '\n') {
476
+ text += ch
477
+ flush()
478
+ Object.assign(this, { sol: true, bold: false, ital: false, code: false, line: null, prev: ' ' })
479
+ i++
480
+ continue
481
+ }
482
+ if (this.fence) {
483
+ text += ch
484
+ i++
485
+ continue
486
+ }
487
+ if (ch === '\\' && /[*_`\\#>-]/.test(s[i + 1] ?? '')) {
488
+ text += s[i + 1]
489
+ this.prev = s[i + 1]
490
+ i += 2
491
+ continue
492
+ }
493
+ if (ch === '`') {
494
+ flush()
495
+ this.code = !this.code
496
+ this.prev = ch
497
+ i++
498
+ continue
499
+ }
500
+ if (!this.code && (ch === '*' || ch === '_')) {
501
+ if (i + 1 >= s.length && !final) break // "*" or "**"? wait for the next character
502
+ const next = s[i + 1] ?? ' '
503
+ if (next === ch) {
504
+ // ** always; __ only around words (not snake__case)
505
+ const after = s[i + 2] ?? ' '
506
+ if (ch === '*' || (this.bold ? !/\w/.test(after) : !/\w/.test(this.prev))) {
507
+ flush()
508
+ this.bold = !this.bold
509
+ this.prev = ch
510
+ i += 2
511
+ continue
512
+ }
513
+ } else if (ch === '*' && (this.ital ? !/\s/.test(this.prev) : /[\s(]/.test(this.prev) && !/\s/.test(next))) {
514
+ flush()
515
+ this.ital = !this.ital
516
+ this.prev = ch
517
+ i++
518
+ continue
519
+ }
520
+ }
521
+ text += ch
522
+ this.prev = ch
523
+ i++
524
+ }
525
+ flush()
526
+ this.buf = s.slice(i) // what's held back
527
+ }
528
+ }
529
+
530
+ // Screen lines saved before answers were drawn as Markdown (older sessions and tabs): the answer's
531
+ // and the thinking's own text (not tool output, not code) gets its **bold**, `code`, # headings and
532
+ // - bullets drawn now. Already drawn lines have none of these left in that color.
533
+ export function markdownRows(lines) {
534
+ const fgOf = (f) => /^\x1b\[(38;[0-9;]*)m/.exec(f('x'))?.[1]
535
+ const base = new Set([fgOf(c.text), fgOf(c.think)].filter(Boolean))
536
+ const info = fgOf(c.info)
537
+ if (!base.size) return lines
538
+ let bold = false
539
+ let fence = false
540
+ return lines.map((line) => {
541
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, '')
542
+ if (!plain.trim()) return (bold = false), line
543
+ if (/^[●\s]*```/.test(plain)) return (fence = !fence), line
544
+ if (fence) return line
545
+ const toks = [...line.matchAll(/(\x1b\[[0-9;]*m)|([^\x1b])/gs)]
546
+ const vis = toks.filter((t) => t[2]).map((t) => t[2])
547
+ let fg = ''
548
+ let start = true
549
+ let code = false
550
+ let skip = 0
551
+ let k = -1
552
+ let out = bold ? '\x1b[1m' : ''
553
+ for (const [, esc, ch] of toks) {
554
+ if (esc) {
555
+ const m = /^\x1b\[(38;[0-9;]*|39)m$/.exec(esc)
556
+ if (m) fg = m[1] === '39' ? '' : m[1]
557
+ out += code && m ? `\x1b[${info}m` : esc
558
+ continue
559
+ }
560
+ k++
561
+ if (!base.has(fg) || !info) {
562
+ out += ch
563
+ continue
564
+ }
565
+ if (skip) {
566
+ skip--
567
+ continue
568
+ }
569
+ if (start && ch !== ' ') {
570
+ start = false
571
+ const hashes = /^#{1,6} /.exec(vis.slice(k, k + 7).join(''))
572
+ if (hashes) {
573
+ skip = hashes[0].length - 1
574
+ out += '\x1b[1m'
575
+ continue
576
+ }
577
+ if ('-*+'.includes(ch) && vis[k + 1] === ' ') {
578
+ out += '•'
579
+ continue
580
+ }
581
+ }
582
+ if (ch === '`') {
583
+ code = !code
584
+ out += `\x1b[${code ? info : fg}m`
585
+ continue
586
+ }
587
+ if (!code && ch === '*' && vis[k + 1] === '*') {
588
+ bold = !bold
589
+ out += bold ? '\x1b[1m' : '\x1b[22m'
590
+ skip = 1
591
+ continue
592
+ }
593
+ out += ch
594
+ }
595
+ return out
596
+ })
597
+ }
598
+
336
599
  const MENU_ROWS = 8
337
600
  const INPUT_ROWS = 3 // the input box shows at most this many rows; the rules say what's hidden
338
601
 
@@ -402,23 +665,25 @@ export class UI {
402
665
  // who: the model thinking, named over its thinking (helpers say their own: helperUI in agent.js)
403
666
  response(who) {
404
667
  const t0 = Date.now()
405
- const secs = () => ((Date.now() - t0) / 1000).toFixed(1)
668
+ const secs = () => duration(Date.now() - t0)
406
669
  const write = (x) => this.write(x)
407
670
  let think = null
408
671
  let text = null
409
672
  let thought = false
410
673
  const summary = () => {
411
- if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ ${who ? `${who} ` : ''}thought for ${secs()}s`)))
674
+ if (thought && !this.showThinking) this.print(c.faint(c.italic(`✻ ${who ? `${who} ` : ''}thought for ${secs()}`)))
412
675
  }
413
676
  return {
414
677
  think: (t) => {
415
678
  if (!t || text) return
416
679
  thought = true
417
- if (!this.showThinking) return this.spinner(`thinking · ${secs()}s`)
680
+ if (!this.showThinking) return this.spinner(`thinking · ${secs()}`)
418
681
  if (!think) {
419
682
  this.gap()
420
683
  this.print(c.faint(c.italic(`✻ ${who ? `${who} is thinking` : 'Thinking'}…`)))
421
- think = new Block(write, { style: (x) => c.think(c.italic(x)) })
684
+ const style = (x) => c.think(c.italic(x))
685
+ think = new Block(write, { style })
686
+ if (color) think = new Markdown(think, style)
422
687
  }
423
688
  think.push(t)
424
689
  },
@@ -429,6 +694,7 @@ export class UI {
429
694
  this.gap()
430
695
  summary()
431
696
  text = new Block(write, { marker: '● ', markerStyle: (x) => c.bright(c.bold(x)), style: c.text })
697
+ if (color) text = new Markdown(text, c.text) // plain text (piped, NO_COLOR) stays as the model wrote it
432
698
  }
433
699
  text.push(t)
434
700
  },
@@ -444,7 +710,7 @@ export class UI {
444
710
  #rl() {
445
711
  if (this.rl) return this.rl
446
712
  const terminal = Boolean(this.input.isTTY && this.out.isTTY)
447
- const input = terminal ? keyStream(this.input) : this.input
713
+ const input = terminal ? keyStream(this.input, (...a) => this.#mouse(...a)) : this.input
448
714
  // escapeCodeTimeout: how long a lone Esc waits to be the start of a key's sequence. Readline's
449
715
  // 500ms made Esc Esc and a letter typed right after one Alt+letter: the letter was lost and the
450
716
  // input not cleared. Sequences come in one piece (and keyStream holds a split one for 50ms).
@@ -470,7 +736,8 @@ export class UI {
470
736
  if (this.full || !(this.input.isTTY && this.out.isTTY)) return
471
737
  this.full = true
472
738
  // alternate screen · kitty keyboard protocol (Shift/Cmd+Enter are told apart) · bracketed paste
473
- this.out.write('\x1b[?1049h\x1b[H\x1b[2J\x1b[>1u\x1b[?2004h')
739
+ // · mouse reports (SGR): the wheel scrolls the conversation, not the terminal
740
+ this.out.write('\x1b[?1049h\x1b[H\x1b[2J\x1b[>1u\x1b[?2004h' + MOUSE_ON)
474
741
  this.out.on('resize', () => this.render())
475
742
  this.#rl()
476
743
  process.on('exit', () => this.leaveFullscreen())
@@ -479,7 +746,7 @@ export class UI {
479
746
  // Hands the terminal to another program (an editor) and takes it back afterwards.
480
747
  async outside(fn) {
481
748
  const wasFull = this.full
482
- if (wasFull) this.out.write('\x1b[?2004l\x1b[<u\x1b[?1049l')
749
+ if (wasFull) this.out.write(MOUSE_OFF + '\x1b[?2004l\x1b[<u\x1b[?1049l')
483
750
  this.rl?.pause()
484
751
  this.input.setRawMode?.(false)
485
752
  try {
@@ -488,17 +755,73 @@ export class UI {
488
755
  this.input.setRawMode?.(true)
489
756
  this.rl?.resume()
490
757
  if (wasFull) {
491
- this.out.write('\x1b[?1049h\x1b[>1u\x1b[?2004h')
758
+ this.out.write('\x1b[?1049h\x1b[>1u\x1b[?2004h' + MOUSE_ON)
492
759
  this.render()
493
760
  }
494
761
  }
495
762
  }
496
763
 
764
+ // The mouse: the wheel scrolls the conversation; dragging selects in it, copied when let go
765
+ // (the terminal can't select itself while HeartCode takes the mouse). A click or a key clears it.
766
+ #mouse(b, x, y, up) {
767
+ if (!this.full) return
768
+ if (b & 64) return (b & 0xc3) === 64 || (b & 0xc3) === 65 ? this.scrollBy(b & 1 ? -3 : 3) : undefined // 64 up, 65 down
769
+ if ((b & 3) !== 0 && !up) return // the left button only (a release says 3 in some terminals)
770
+ if (!(b & 32) && !up && y <= 3 && this.onTabClick) return this.onTabClick(x - 1) // the tab bar's 3 rows
771
+ const v = this.viewAt
772
+ if (!v) return
773
+ const r = Math.min(Math.max(y - 1 - v.first, 0), v.avail - 1) // a drag past the edge stops at it
774
+ const at = { row: v.top + Math.min(r, Math.max(0, v.count - 1)), col: x - 1 }
775
+ if (!(b & 32) && !up) {
776
+ // a press: starts a selection, inside the conversation only
777
+ this.sel = y - 1 >= v.first && y - 1 < v.first + v.count ? { a: at, b: at, moved: false } : null
778
+ return this.render()
779
+ }
780
+ if (!this.sel) return
781
+ if (at.row !== this.sel.b.row || at.col !== this.sel.b.col) Object.assign(this.sel, { b: at, moved: true })
782
+ if (up) {
783
+ const text = this.sel.moved && this.#selected()
784
+ if (!text) this.sel = null
785
+ else this.notice(`copied ${text.length} character${text.length === 1 ? '' : 's'}${copyText(text) === 'osc' ? ' (through the terminal)' : ''}`)
786
+ }
787
+ this.render()
788
+ }
789
+ #selRange() {
790
+ const { a, b } = this.sel
791
+ return a.row < b.row || (a.row === b.row && a.col <= b.col) ? [a, b] : [b, a]
792
+ }
793
+ #selected() {
794
+ const [s, e] = this.#selRange()
795
+ const all = this.#rows(this.out.columns || 80)
796
+ const lines = []
797
+ for (let r = s.row; r <= e.row; r++) lines.push(sliceCols((all[r] ?? '').replace(/\x1b\[[0-9;]*m/g, ''), r === s.row ? s.col : 0, r === e.row ? e.col + 1 : Infinity).trimEnd())
798
+ return lines.join('\n').trim()
799
+ }
800
+
801
+ // Sends a line at the main prompt as if typed; what was typed there comes back at the next prompt.
802
+ submit(line) {
803
+ this.kept = this.rl.line
804
+ this.rl.line = line
805
+ this.sent = line
806
+ this.menu.items = []
807
+ this.ttyWrite('\r', { name: 'return' })
808
+ }
809
+
810
+ // The conversation scrolls (rows up, negative: down); the tabs, the input and the status bar stay.
811
+ scrollBy(n) {
812
+ if (!this.full || this.out.hidden) return
813
+ this.scroll = Math.max(0, this.scroll + n)
814
+ this.render()
815
+ }
816
+ page() {
817
+ return Math.max(1, (this.out.rows || 24) - 4)
818
+ }
819
+
497
820
  // Back to the normal screen, leaving the conversation in the terminal's scrollback.
498
821
  leaveFullscreen() {
499
822
  if (!this.full) return
500
823
  this.full = false
501
- this.out.write('\x1b[?2004l\x1b[<u\x1b[?1049l' + this.log.join('\n').replace(/\n+$/, '') + '\n')
824
+ this.out.write(MOUSE_OFF + '\x1b[?2004l\x1b[<u\x1b[?1049l' + this.log.join('\n').replace(/\n+$/, '') + '\n')
502
825
  }
503
826
 
504
827
  // After a theme change: what's on screen already, in the new colors (before/after: themeCodes()).
@@ -516,7 +839,7 @@ export class UI {
516
839
 
517
840
  // Replaces the conversation on screen (resuming or rewinding a session).
518
841
  setLog(lines) {
519
- this.log = lines?.length ? [...lines] : ['']
842
+ this.log = lines?.length ? markdownRows(lines) : ['']
520
843
  if (this.log.at(-1) !== '') this.log.push('')
521
844
  this.wrapped = []
522
845
  this.scroll = 0
@@ -565,16 +888,21 @@ export class UI {
565
888
  const below = [hiddenBelow ? note(hiddenBelow, '↓') : rule, ...(this.status ? [fit(' ' + this.status(), cols)] : [])] // under the input
566
889
  const bottom = []
567
890
  if (this.timer && !this.asking) {
568
- // since the message was sent, and (while waiting on the model) since this request was sent
569
- const since = (t) => {
570
- const secs = Math.floor((Date.now() - t) / 1000)
571
- return secs >= 60 ? `${Math.floor(secs / 60)}m ${secs % 60}s` : `${secs}s`
572
- }
891
+ // how long it's been at this message, and (while waiting on the model) since this request was sent
573
892
  const request = this.requestSince?.()
574
- const extra = this.busySince ? ` · total ${since(this.busySince)}${request ? ` · waiting for model ${since(request)}` : ''} · Esc to stop` : ''
575
- bottom.push(fit(`${c.brand(FRAMES[this.frame % FRAMES.length])} ${c.muted(this.#label())}${c.faint(extra)}`, cols))
893
+ const timer = this.busySince ? `responding ${duration(this.worked())}${request ? ` · waiting for model ${duration(Date.now() - request)}` : ''} · Esc to stop` : ''
894
+ const retrying = this.beforeRetry !== undefined // the label is the retry message: its own line, timer below
895
+ bottom.push(fit(`${c.brand(FRAMES[this.frame % FRAMES.length])} ${c.muted(this.#label())}${retrying || !timer ? '' : c.faint(` · ${timer}`)}`, cols))
896
+ if (retrying && timer) bottom.push(fit(` ${c.faint(timer)}`, cols))
897
+ }
898
+ if (this.scroll) bottom.push(c.dim(` ↓ ${this.scroll} more lines below · scroll down, Shift+↓ or PgDn`))
899
+ if (this.ahead.length) {
900
+ const key = (k, d) => `${c.muted(k)} ${c.faint(d)}`
901
+ const sep = c.ghost(' · ')
902
+ bottom.push(fit(` ${c.bright(`queued ${this.ahead.length}`)}${sep}${c.faint(`when done: ${this.queueMode === 'one' ? 'one by one' : 'all together'}`)}${sep}${[key('Ctrl+]', 'send first now'), key('Ctrl+\\', 'send all now'), key('Ctrl+_', 'change when done')].join(sep)}`, cols))
903
+ for (const [i, l] of this.ahead.slice(0, 5).entries()) bottom.push(fit(` ${c.accent(`${i + 1}.`)} ${c.muted(l.replace(/\s+/g, ' '))}`, cols))
904
+ if (this.ahead.length > 5) bottom.push(c.faint(` … ${this.ahead.length - 5} more`))
576
905
  }
577
- if (this.scroll) bottom.push(c.dim(` ↓ ${this.scroll} more lines below · PgDn`))
578
906
  if (this.noticeText) bottom.push(fit(`${c.warn('!')} ${c.muted(this.noticeText)}`, cols))
579
907
  if (menu.on) bottom.push(...this.#menuLines(cols))
580
908
  bottom.push(first ? note(first, '↑') : rule)
@@ -585,6 +913,11 @@ export class UI {
585
913
  this.scroll = Math.min(this.scroll, Math.max(0, all.length - avail))
586
914
  const top = Math.max(0, all.length - avail - this.scroll)
587
915
  const view = all.slice(top, top + avail)
916
+ this.viewAt = { top, first: head.length, avail, count: view.length } // where the conversation is, for the mouse
917
+ if (this.sel) {
918
+ const [s, e] = this.#selRange()
919
+ for (let i = Math.max(0, s.row - top); i <= Math.min(view.length - 1, e.row - top); i++) view[i] = highlight(view[i], top + i === s.row ? s.col : 0, top + i === e.row ? e.col + 1 : Infinity)
920
+ }
588
921
  // a theme with a background: every row painted with it (a style ending its own background goes back to it)
589
922
  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`
590
923
  let s = '\x1b[?25l'
@@ -607,16 +940,19 @@ export class UI {
607
940
  const out = rl._writeToOutput.bind(rl)
608
941
  rl._writeToOutput = (t) => (this.full ? undefined : out(t)) // in full screen, render() draws the input
609
942
  const write = rl._ttyWrite.bind(rl)
943
+ this.ttyWrite = write
610
944
  rl._ttyWrite = (s, key = {}) => {
611
945
  if (!this.full) return write(s, key)
946
+ if (this.sel) (this.sel = null), this.render() // a key ends a selection
612
947
  const bound = !(key.ctrl && key.name === 'c') && this.keys[keyId(key, s)] // ~/.heart/keybindings.json
613
- if (this.recording) { // Enter or the voice key: done; Esc: never mind; the rest waits
614
- if (key.name === 'escape') this.recording.end(false)
615
- else if (key.name === 'return' || bound === 'voice') this.recording.end(true)
616
- return
617
- }
948
+ if (this.recording) return this.recording.key(key, bound) // voice: Space, Enter, Esc; the rest waits
618
949
  // a fixed dialog (pick(…, { fixed })): only its keys, nothing typed and no shortcuts
619
950
  if (this.menu.pick?.fixed && !['up', 'down', 'return', 'escape'].includes(key.name) && !(key.ctrl && key.name === 'c')) return
951
+ // In a multi-select: Ctrl+A ticks all, Ctrl+N unticks all (before tab shortcuts claim Ctrl+N)
952
+ if (this.menu.pick?.multi && key.ctrl && (key.name === 'a' || key.name === 'n')) {
953
+ for (const x of this.menu.pick.items) x.checked = key.name === 'a'
954
+ return this.refreshMenu()
955
+ }
620
956
  if (bound) return this.#action(bound, write)
621
957
  if (!this.asking && !(key.ctrl && key.name === 'c')) return this.#typeAhead(s, key, write)
622
958
  if (key.name === 'tab' && key.shift) return this.setPermission(PERMISSIONS[(PERMISSIONS.findIndex(([p]) => p === this.permission) + 1) % PERMISSIONS.length][0])
@@ -636,11 +972,7 @@ export class UI {
636
972
  return this.render()
637
973
  }
638
974
  }
639
- if (key.name === 'pageup' || key.name === 'pagedown') {
640
- const page = Math.max(1, (this.out.rows || 24) - 4)
641
- this.scroll = Math.max(0, this.scroll + (key.name === 'pageup' ? page : -page))
642
- return this.render()
643
- }
975
+ if (key.name === 'pageup' || key.name === 'pagedown') return this.scrollBy(key.name === 'pageup' ? this.page() : -this.page())
644
976
  const m = this.menu
645
977
  const it = m.on && m.items[m.sel]
646
978
  if (this.noticeText && !(key.ctrl && key.name === 'c')) this.notice('') // any key dismisses a notice
@@ -676,7 +1008,7 @@ export class UI {
676
1008
  if (key.name === 'up' || key.name === 'down') return this.#move(key.name === 'up' ? -1 : 1)
677
1009
  if (key.name === 'tab') return
678
1010
  if (m.pick.multi && key.name === 'space' && it) {
679
- it.item.checked = !it.item.checked // Space ticks; Enter takes the ticked ones
1011
+ it.item.checked = !it.item.checked // Space ticks; Enter takes the ticked ones; Ctrl+A/N all/none (handled earlier, before tab shortcuts)
680
1012
  return this.refreshMenu()
681
1013
  }
682
1014
  if (key.name === 'escape' || (key.name === 'return' && it)) {
@@ -727,25 +1059,36 @@ export class UI {
727
1059
  }
728
1060
  }
729
1061
 
730
- // Typing while HeartCode works: the text stays in the box; Enter sends it, to the running task
731
- // (it reads it before its next step; takeAhead) or as the next message. Esc stops the task.
1062
+ // Typing while HeartCode works: the text stays in the box; Enter queues it (shown above the
1063
+ // input), sent when the answer is done, or at once with send_first / send_all. Esc stops the task.
732
1064
  #typeAhead(s, key, write) {
733
1065
  const rl = this.rl
734
- if (key.name === 'escape') return this.onEscape?.()
1066
+ const m = this.menu
1067
+ const it = m.on && m.items[m.sel] // a suggestion is highlighted (commands/@files show while working too)
1068
+ if (key.name === 'escape') {
1069
+ if (m.on) return (m.off = rl.line), this.#suggestWhileWorking() // first Esc just closes the suggestions
1070
+ return this.onEscape?.()
1071
+ }
735
1072
  if (key.name === 'tab' && key.shift) return this.setPermission(PERMISSIONS[(PERMISSIONS.findIndex(([p]) => p === this.permission) + 1) % PERMISSIONS.length][0])
736
- if (key.name === 'pageup' || key.name === 'pagedown') {
737
- this.scroll = Math.max(0, this.scroll + (key.name === 'pageup' ? 1 : -1) * Math.max(1, (this.out.rows || 24) - 4))
1073
+ if (key.name === 'pageup' || key.name === 'pagedown') return this.scrollBy(key.name === 'pageup' ? this.page() : -this.page())
1074
+ if (it && (key.name === 'up' || key.name === 'down')) {
1075
+ m.sel = (m.sel + (key.name === 'up' ? m.items.length - 1 : 1)) % m.items.length
738
1076
  return this.render()
739
1077
  }
1078
+ if (m.on && key.name === 'tab') {
1079
+ if (it) (this.#accept(it), (m.off = rl.line))
1080
+ return this.#suggestWhileWorking()
1081
+ }
740
1082
  if (key.name === 'return' && !key.meta && !this.pasting) {
1083
+ if (it && rl.line.slice(it.start, it.end) !== it.value) this.#accept(it) // complete first, then send on the next Enter
1084
+ if (it && rl.line.slice(it.start, it.end) !== it.value + (it.space ? ' ' : '')) return
741
1085
  const line = rl.line.trim()
742
1086
  rl.line = ''
743
1087
  rl.cursor = 0
1088
+ m.items = []
1089
+ m.off = null
744
1090
  if (/^\/btw\b/.test(line) && this.onBtw) this.onBtw(line.replace(/^\/btw\b/, '').trim()) // answered now, beside the task
745
- else if (line) {
746
- this.ahead.push(line)
747
- this.print(`${c.faint(' ↳')} ${c.muted(line.split('\n')[0])} ${c.faint(this.isCommand(line) ? '(runs when this is done)' : '(goes to the model at its next step, or runs next)')}`)
748
- }
1091
+ else if (line) this.ahead.push(line) // a /command runs when the task is done (the queue)
749
1092
  return this.render()
750
1093
  }
751
1094
  if (['up', 'down', 'tab'].includes(key.name) || key.name === 'paste-start' || key.name === 'paste-end') {
@@ -756,7 +1099,17 @@ export class UI {
756
1099
  rl.line = rl.line.slice(0, rl.cursor) + '\n' + rl.line.slice(rl.cursor)
757
1100
  rl.cursor++
758
1101
  } else write(s, key)
759
- this.render()
1102
+ this.#suggestWhileWorking()
1103
+ }
1104
+
1105
+ // Commands and @files suggested while the model works (type-ahead), the same menu as at the prompt.
1106
+ #suggestWhileWorking() {
1107
+ const rl = this.rl
1108
+ const before = rl.line.slice(0, rl.cursor)
1109
+ this.menu.on = this.full && !this.pasting && this.menu.off !== rl.line && (/^\/\S*$/.test(rl.line.trimStart()) || /(^|\s)@\S*$/.test(before))
1110
+ if (this.menu.off !== rl.line) this.menu.off = this.menu.on ? this.menu.off : null
1111
+ if (this.menu.on) this.refreshMenu()
1112
+ else (this.menu.items = []), this.render()
760
1113
  }
761
1114
 
762
1115
  // A key from keybindings.json: an action, or text to send (a /command, a message).
@@ -791,17 +1144,29 @@ export class UI {
791
1144
  case 'clear_input':
792
1145
  this.clearInput()
793
1146
  return this.refreshMenu()
1147
+ case 'send_first':
1148
+ case 'send_all':
1149
+ if (!this.ahead.length) return this.notice('Nothing queued: while it works, type a message and press Enter', 2500)
1150
+ this.sendNow = what === 'send_all' ? 'all' : 'first'
1151
+ if (this.asking && !this.histOn) this.cancelAsk() // a question in the middle of the work: answered no
1152
+ return this.onEscape?.() // stops the answer; the next prompt takes the queue (ask)
1153
+ case 'queue_mode':
1154
+ this.queueMode = this.queueMode === 'one' ? 'all' : 'one'
1155
+ this.onQueueMode?.(this.queueMode)
1156
+ return this.notice(`When an answer is done: ${this.queueMode === 'all' ? 'all queued messages go together' : 'the next queued message goes'}`, 2500)
794
1157
  default:
795
1158
  if (/^tab_\d$/.test(what)) return this.onTab?.(what)
796
1159
  break
797
1160
  case 'scroll_up':
798
1161
  case 'scroll_down':
799
- this.scroll = Math.max(0, this.scroll + (what === 'scroll_up' ? 1 : -1) * Math.max(1, (this.out.rows || 24) - 4))
800
- return this.render()
1162
+ return this.scrollBy(what === 'scroll_up' ? this.page() : -this.page())
1163
+ case 'scroll_line_up':
1164
+ case 'scroll_line_down':
1165
+ return this.scrollBy(what === 'scroll_line_up' ? 3 : -3)
801
1166
  }
802
1167
  if (!this.asking) {
803
1168
  this.ahead.push(what)
804
- return this.print(`${c.faint(' ↳')} ${c.muted(what)} ${c.faint('(runs when this is done)')}`)
1169
+ return this.render()
805
1170
  }
806
1171
  if (!this.histOn || m.pick) return // only at the main prompt, not in a question or picker
807
1172
  rl.line = what
@@ -810,48 +1175,98 @@ export class UI {
810
1175
  write('\r', { name: 'return' })
811
1176
  }
812
1177
 
813
- // Records until Enter (or the voice key) and returns the words, or null (Esc, or it failed: said
814
- // in a notice). voiceConfig() → { voice, providers }: set by the app.
1178
+ // Voice mode (/voice, or a key bound to voice): Space to talk, Space again to pause (and again to go on), Enter
1179
+ // (or the voice key) to finish, Esc to cancel. While you speak, what's been heard so far shows in
1180
+ // the input box (where the cursor was), redone about every second with the quickest model; a
1181
+ // stretch you pause or finish is done again with the best one. Returns the words, or null (Esc,
1182
+ // or it failed: said in a notice); the box is as it was, for the caller to put them in.
1183
+ // voiceConfig() → { voice, providers }: set by the app.
815
1184
  async dictate() {
816
1185
  if (this.recording) return null
817
1186
  const { voice = {}, providers = {} } = this.voiceConfig?.() ?? {}
818
1187
  const problem = voiceProblem(voice, providers)
819
1188
  if (problem) return this.notice(problem, 8000), null
820
- const rec = record()
821
- const t0 = Date.now()
1189
+ const rl = this.rl
1190
+ const base = { line: rl?.line ?? '', cursor: rl?.cursor ?? 0 }
822
1191
  const keep = !this.asking // working: the spinner line stays the task's
823
- const show = () => this.notice(`● recording ${Math.floor((Date.now() - t0) / 1000)}s · Enter or Ctrl+R to stop · Esc to cancel`, 60 * 60_000)
824
- show()
825
- const tick = setInterval(show, 1000)
826
- const go = await new Promise((resolve) => (this.recording = { end: resolve }))
827
- clearInterval(tick)
828
- this.recording = null
829
- if (!go) {
830
- rec.cancel()
831
- this.notice('')
832
- return null
1192
+ const every = voice.provider ? 3000 : 900 // a provider's API is asked less often than whisper here
1193
+ let said = '' // the stretches finished (paused), as the best model heard them
1194
+ let live = '' // the stretch going on, as heard so far
1195
+ let mic = null
1196
+ let busy = Promise.resolve() // a stretch being transcribed (one at a time, in order)
1197
+ let failed = null
1198
+ let finishing = false
1199
+ const words = () => [said, live].filter(Boolean).join(' ')
1200
+ const showBox = () => {
1201
+ if (!rl) return
1202
+ const before = base.line.slice(0, base.cursor)
1203
+ const t = words()
1204
+ const pad = t && before && !/\s$/.test(before) ? ' ' : ''
1205
+ rl.line = before + pad + t + base.line.slice(base.cursor)
1206
+ rl.cursor = before.length + pad.length + t.length
833
1207
  }
834
- try {
835
- const file = await rec.stop()
836
- this.notice('transcribing…', 60 * 60_000)
837
- const text = await transcribe(file, voice, providers)
838
- log('voice', text)
839
- this.notice(text ? '' : 'heard nothing', 3000)
840
- return text || null
841
- } catch (e) {
842
- log('voice', e.message)
843
- this.notice(e.message, 8000)
844
- return null
845
- } finally {
846
- if (keep) this.render()
1208
+ const draw = () => {
1209
+ showBox()
1210
+ const keys = 'Enter to finish · Esc to cancel'
1211
+ if (finishing) return this.notice('transcribing…', 60 * 60_000)
1212
+ this.notice(mic ? `● listening ${Math.floor(mic.seconds())}s · Space to pause · ${keys}` : `voice · Space to ${said ? 'go on' : 'talk'} · ${keys}`, 60 * 60_000)
847
1213
  }
848
- }
849
-
850
- // Messages typed while working that can go to the running task now (not commands); taken.
851
- takeAhead() {
852
- const out = this.ahead.filter((l) => !this.isCommand(l))
853
- this.ahead = this.ahead.filter((l) => this.isCommand(l))
854
- return out
1214
+ const heard = (pcm, fast) =>
1215
+ transcribePcm(pcm, voice, providers, { fast }).catch((e) => {
1216
+ log('voice', e.message)
1217
+ failed = e.message
1218
+ return null
1219
+ })
1220
+ // while a stretch goes on: what's been said of it so far, again and again
1221
+ const follow = async (m) => {
1222
+ while (mic === m) {
1223
+ await new Promise((r) => setTimeout(r, every))
1224
+ if (mic !== m || m.seconds() < 0.6) continue
1225
+ const t = await heard(m.pcm(), true)
1226
+ if (mic === m && t !== null) (live = t), draw()
1227
+ }
1228
+ }
1229
+ const start = () => {
1230
+ failed = null
1231
+ mic = listen()
1232
+ follow(mic)
1233
+ draw()
1234
+ }
1235
+ // a pause: that stretch, done again with the best model, kept
1236
+ const pause = () => {
1237
+ const m = mic
1238
+ mic = null
1239
+ draw()
1240
+ busy = busy.then(async () => {
1241
+ const pcm = await m.stop().catch((e) => ((failed = e.message), null))
1242
+ const t = pcm && (await heard(pcm, false))
1243
+ said = [said, t ?? live].filter(Boolean).join(' ')
1244
+ live = ''
1245
+ draw()
1246
+ })
1247
+ }
1248
+ draw()
1249
+ const tick = setInterval(() => mic && draw(), 1000)
1250
+ const go = await new Promise((resolve) => {
1251
+ this.recording = {
1252
+ key: (key, bound) => {
1253
+ if (key.name === 'escape') resolve(false)
1254
+ else if (key.name === 'return' || bound === 'voice') resolve(true)
1255
+ else if (key.name === 'space') mic ? pause() : start()
1256
+ },
1257
+ }
1258
+ })
1259
+ clearInterval(tick)
1260
+ this.recording = null
1261
+ if (!go) mic?.cancel(), (mic = null)
1262
+ else if (mic) (finishing = true), pause()
1263
+ await busy
1264
+ if (rl) [rl.line, rl.cursor] = [base.line, base.cursor] // the caller puts the words in
1265
+ const text = go ? words().trim() : ''
1266
+ log('voice', go ? text : '(cancelled)')
1267
+ this.notice(go && !text ? failed ?? 'heard nothing' : '', failed ? 8000 : 3000)
1268
+ if (keep) this.render()
1269
+ return text || null
855
1270
  }
856
1271
 
857
1272
  #accept(it) {
@@ -914,7 +1329,7 @@ export class UI {
914
1329
  })
915
1330
  const more = m.items.length - first - shown.length
916
1331
  if (more > 0) lines.push(c.faint(` … ${more} more`))
917
- 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'
1332
+ const how = m.pick?.multi ? '↑↓ move · Space tick · Ctrl+A all · Ctrl+N none · Enter take · Esc cancel · type to filter' : '↑↓ move · Enter choose · Esc cancel · type to filter'
918
1333
  if (m.pick) lines.unshift(c.faint(m.items.length ? how : `nothing matches "${this.rl.line}" · Esc cancel`))
919
1334
  return lines
920
1335
  }
@@ -994,11 +1409,18 @@ export class UI {
994
1409
 
995
1410
  // history: this prompt's messages go into ↑/↓ history (only the main prompt, never answers like keys).
996
1411
  ask(prompt, { initial = '', suggest = false, history = false } = {}) {
1412
+ if (history && this.kept) [initial, this.kept] = [initial || this.kept, ''] // typed before a submit()
997
1413
  this.stopSpin()
998
1414
  const rl = this.#rl()
999
1415
  if (history) this.mainPrompt = prompt
1000
1416
  if (history && this.ahead.length) {
1001
- const l = this.ahead.shift()
1417
+ // all: every queued message as one (commands stay, run after, one each); else the first one
1418
+ const how = this.sendNow ?? (this.queueMode === 'one' ? 'first' : 'all')
1419
+ this.sendNow = null
1420
+ const msgs = this.ahead.filter((x) => !this.isCommand(x))
1421
+ const together = how === 'all' && msgs.length > 1
1422
+ const l = together ? msgs.join('\n\n') : this.ahead.shift()
1423
+ if (together) this.ahead = this.ahead.filter((x) => this.isCommand(x))
1002
1424
  log('input', `${prompt.trim()} ${l} (typed while working)`)
1003
1425
  this.write(prompt.replace(/^ /, '') + c.bright(l) + '\n')
1004
1426
  return Promise.resolve(l)
@@ -1018,11 +1440,14 @@ export class UI {
1018
1440
  this.draft = ''
1019
1441
  if (this.full) {
1020
1442
  this.asking = true
1443
+ this.askedAt = Date.now()
1021
1444
  this.render() // cursor to the input row
1022
1445
  }
1023
1446
  rl.setPrompt(prompt)
1024
1447
  rl.prompt()
1025
- if (initial && rl.terminal) rl.write(initial)
1448
+ // put in the box as it is: typed in (rl.write), each line break would be an Enter
1449
+ if (initial && rl.terminal && this.full) [rl.line, rl.cursor] = [initial, initial.length]
1450
+ else if (initial && rl.terminal) rl.write(initial.replace(/\n/g, ' '))
1026
1451
  if (this.menu.on) this.refreshMenu()
1027
1452
  else this.render()
1028
1453
  return new Promise((resolve) =>
@@ -1036,6 +1461,7 @@ export class UI {
1036
1461
  if (l !== null && !rl.terminal) this.out.write(l + '\n')
1037
1462
  if (this.full) {
1038
1463
  this.asking = false
1464
+ if (this.busySince) this.waited += Date.now() - this.askedAt // waiting on you isn't HeartCode working
1039
1465
  this.scroll = 0
1040
1466
  const pick = this.menu.pick
1041
1467
  const echo = prompt.replace(/^ /, '') // the input's margin isn't part of the conversation
@@ -1183,9 +1609,9 @@ export class UI {
1183
1609
  this.beforeRetry ??= typeof this.label === 'function' ? 'thinking' : this.label || 'thinking'
1184
1610
  const until = Date.now() + ms
1185
1611
  const why = shortError(err)
1186
- const secs = () => Math.max(0, Math.ceil((until - Date.now()) / 1000))
1187
- if (next) return this.spinner(() => `${c.yellow('⚠')} ${why} · trying ${next.provider} · ${next.model}${secs() ? ` in ${secs()}s (all failed once)` : ''} (attempt ${attempt})`)
1188
- this.spinner(() => `${c.yellow('⚠')} retrying in ${secs()}s (attempt ${attempt}) · ${why}`)
1612
+ const left = () => Math.max(0, Math.ceil((until - Date.now()) / 1000)) * 1000
1613
+ if (next) return this.spinner(() => `${c.yellow('⚠')} ${why} · trying ${next.provider} · ${next.model}${left() ? ` in ${duration(left())} (all failed once)` : ''} (attempt ${attempt})`)
1614
+ this.spinner(() => `${c.yellow('⚠')} retrying in ${duration(left())} (attempt ${attempt}) · ${why}`)
1189
1615
  }
1190
1616
 
1191
1617
  stopSpin() {
@@ -1202,8 +1628,13 @@ export class UI {
1202
1628
  busy() {
1203
1629
  if (!this.full) return
1204
1630
  this.busySince = Date.now()
1631
+ this.waited = 0
1205
1632
  this.spinner('working')
1206
1633
  }
1634
+ // time spent on the work so far, without the time it waited for your answers
1635
+ worked() {
1636
+ return Date.now() - this.busySince - this.waited - (this.asking ? Date.now() - this.askedAt : 0)
1637
+ }
1207
1638
  idle() {
1208
1639
  this.busySince = null
1209
1640
  this.stopSpin()