heartcode 0.1.15 → 0.1.25

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
@@ -562,19 +885,23 @@ export class UI {
562
885
  const hiddenBelow = input.rows.length - first - box.length
563
886
  const rule = c.ghost('─'.repeat(cols))
564
887
  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))}`)
565
- const below = [hiddenBelow ? note(hiddenBelow, '↓') : rule, ...(this.status ? [fit(' ' + this.status(), cols)] : [])] // under the input
888
+ const below = [hiddenBelow ? note(hiddenBelow, '↓') : rule, ...(this.status ? this.status().split('\n').map((l) => fit(' ' + l, cols)) : [])] // the status bar, one line or more
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
+ // the spinner on its own line; the timer (and Esc) on the next, so neither line is crammed
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
+ bottom.push(fit(`${c.brand(FRAMES[this.frame % FRAMES.length])} ${c.muted(this.#label())}`, cols))
895
+ if (timer) bottom.push(fit(` ${c.faint(timer)}`, cols))
896
+ }
897
+ if (this.scroll) bottom.push(c.dim(` ↓ ${this.scroll} more lines below · scroll down, Shift+↓ or PgDn`))
898
+ if (this.ahead.length) {
899
+ const key = (k, d) => `${c.muted(k)} ${c.faint(d)}`
900
+ const sep = c.ghost(' · ')
901
+ 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))
902
+ 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))
903
+ if (this.ahead.length > 5) bottom.push(c.faint(` … ${this.ahead.length - 5} more`))
576
904
  }
577
- if (this.scroll) bottom.push(c.dim(` ↓ ${this.scroll} more lines below · PgDn`))
578
905
  if (this.noticeText) bottom.push(fit(`${c.warn('!')} ${c.muted(this.noticeText)}`, cols))
579
906
  if (menu.on) bottom.push(...this.#menuLines(cols))
580
907
  bottom.push(first ? note(first, '↑') : rule)
@@ -585,6 +912,11 @@ export class UI {
585
912
  this.scroll = Math.min(this.scroll, Math.max(0, all.length - avail))
586
913
  const top = Math.max(0, all.length - avail - this.scroll)
587
914
  const view = all.slice(top, top + avail)
915
+ this.viewAt = { top, first: head.length, avail, count: view.length } // where the conversation is, for the mouse
916
+ if (this.sel) {
917
+ const [s, e] = this.#selRange()
918
+ 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)
919
+ }
588
920
  // a theme with a background: every row painted with it (a style ending its own background goes back to it)
589
921
  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
922
  let s = '\x1b[?25l'
@@ -607,16 +939,19 @@ export class UI {
607
939
  const out = rl._writeToOutput.bind(rl)
608
940
  rl._writeToOutput = (t) => (this.full ? undefined : out(t)) // in full screen, render() draws the input
609
941
  const write = rl._ttyWrite.bind(rl)
942
+ this.ttyWrite = write
610
943
  rl._ttyWrite = (s, key = {}) => {
611
944
  if (!this.full) return write(s, key)
945
+ if (this.sel) (this.sel = null), this.render() // a key ends a selection
612
946
  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
- }
947
+ if (this.recording) return this.recording.key(key, bound) // voice: Space, Enter, Esc; the rest waits
618
948
  // a fixed dialog (pick(…, { fixed })): only its keys, nothing typed and no shortcuts
619
949
  if (this.menu.pick?.fixed && !['up', 'down', 'return', 'escape'].includes(key.name) && !(key.ctrl && key.name === 'c')) return
950
+ // In a multi-select: Ctrl+A ticks all, Ctrl+N unticks all (before tab shortcuts claim Ctrl+N)
951
+ if (this.menu.pick?.multi && key.ctrl && (key.name === 'a' || key.name === 'n')) {
952
+ for (const x of this.menu.pick.items) x.checked = key.name === 'a'
953
+ return this.refreshMenu()
954
+ }
620
955
  if (bound) return this.#action(bound, write)
621
956
  if (!this.asking && !(key.ctrl && key.name === 'c')) return this.#typeAhead(s, key, write)
622
957
  if (key.name === 'tab' && key.shift) return this.setPermission(PERMISSIONS[(PERMISSIONS.findIndex(([p]) => p === this.permission) + 1) % PERMISSIONS.length][0])
@@ -636,11 +971,7 @@ export class UI {
636
971
  return this.render()
637
972
  }
638
973
  }
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
- }
974
+ if (key.name === 'pageup' || key.name === 'pagedown') return this.scrollBy(key.name === 'pageup' ? this.page() : -this.page())
644
975
  const m = this.menu
645
976
  const it = m.on && m.items[m.sel]
646
977
  if (this.noticeText && !(key.ctrl && key.name === 'c')) this.notice('') // any key dismisses a notice
@@ -676,7 +1007,7 @@ export class UI {
676
1007
  if (key.name === 'up' || key.name === 'down') return this.#move(key.name === 'up' ? -1 : 1)
677
1008
  if (key.name === 'tab') return
678
1009
  if (m.pick.multi && key.name === 'space' && it) {
679
- it.item.checked = !it.item.checked // Space ticks; Enter takes the ticked ones
1010
+ it.item.checked = !it.item.checked // Space ticks; Enter takes the ticked ones; Ctrl+A/N all/none (handled earlier, before tab shortcuts)
680
1011
  return this.refreshMenu()
681
1012
  }
682
1013
  if (key.name === 'escape' || (key.name === 'return' && it)) {
@@ -727,25 +1058,36 @@ export class UI {
727
1058
  }
728
1059
  }
729
1060
 
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.
1061
+ // Typing while HeartCode works: the text stays in the box; Enter queues it (shown above the
1062
+ // input), sent when the answer is done, or at once with send_first / send_all. Esc stops the task.
732
1063
  #typeAhead(s, key, write) {
733
1064
  const rl = this.rl
734
- if (key.name === 'escape') return this.onEscape?.()
1065
+ const m = this.menu
1066
+ const it = m.on && m.items[m.sel] // a suggestion is highlighted (commands/@files show while working too)
1067
+ if (key.name === 'escape') {
1068
+ if (m.on) return (m.off = rl.line), this.#suggestWhileWorking() // first Esc just closes the suggestions
1069
+ return this.onEscape?.()
1070
+ }
735
1071
  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))
1072
+ if (key.name === 'pageup' || key.name === 'pagedown') return this.scrollBy(key.name === 'pageup' ? this.page() : -this.page())
1073
+ if (it && (key.name === 'up' || key.name === 'down')) {
1074
+ m.sel = (m.sel + (key.name === 'up' ? m.items.length - 1 : 1)) % m.items.length
738
1075
  return this.render()
739
1076
  }
1077
+ if (m.on && key.name === 'tab') {
1078
+ if (it) (this.#accept(it), (m.off = rl.line))
1079
+ return this.#suggestWhileWorking()
1080
+ }
740
1081
  if (key.name === 'return' && !key.meta && !this.pasting) {
1082
+ if (it && rl.line.slice(it.start, it.end) !== it.value) this.#accept(it) // complete first, then send on the next Enter
1083
+ if (it && rl.line.slice(it.start, it.end) !== it.value + (it.space ? ' ' : '')) return
741
1084
  const line = rl.line.trim()
742
1085
  rl.line = ''
743
1086
  rl.cursor = 0
1087
+ m.items = []
1088
+ m.off = null
744
1089
  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
- }
1090
+ else if (line) this.ahead.push(line) // a /command runs when the task is done (the queue)
749
1091
  return this.render()
750
1092
  }
751
1093
  if (['up', 'down', 'tab'].includes(key.name) || key.name === 'paste-start' || key.name === 'paste-end') {
@@ -756,7 +1098,17 @@ export class UI {
756
1098
  rl.line = rl.line.slice(0, rl.cursor) + '\n' + rl.line.slice(rl.cursor)
757
1099
  rl.cursor++
758
1100
  } else write(s, key)
759
- this.render()
1101
+ this.#suggestWhileWorking()
1102
+ }
1103
+
1104
+ // Commands and @files suggested while the model works (type-ahead), the same menu as at the prompt.
1105
+ #suggestWhileWorking() {
1106
+ const rl = this.rl
1107
+ const before = rl.line.slice(0, rl.cursor)
1108
+ this.menu.on = this.full && !this.pasting && this.menu.off !== rl.line && (/^\/\S*$/.test(rl.line.trimStart()) || /(^|\s)@\S*$/.test(before))
1109
+ if (this.menu.off !== rl.line) this.menu.off = this.menu.on ? this.menu.off : null
1110
+ if (this.menu.on) this.refreshMenu()
1111
+ else (this.menu.items = []), this.render()
760
1112
  }
761
1113
 
762
1114
  // A key from keybindings.json: an action, or text to send (a /command, a message).
@@ -791,17 +1143,29 @@ export class UI {
791
1143
  case 'clear_input':
792
1144
  this.clearInput()
793
1145
  return this.refreshMenu()
1146
+ case 'send_first':
1147
+ case 'send_all':
1148
+ if (!this.ahead.length) return this.notice('Nothing queued: while it works, type a message and press Enter', 2500)
1149
+ this.sendNow = what === 'send_all' ? 'all' : 'first'
1150
+ if (this.asking && !this.histOn) this.cancelAsk() // a question in the middle of the work: answered no
1151
+ return this.onEscape?.() // stops the answer; the next prompt takes the queue (ask)
1152
+ case 'queue_mode':
1153
+ this.queueMode = this.queueMode === 'one' ? 'all' : 'one'
1154
+ this.onQueueMode?.(this.queueMode)
1155
+ return this.notice(`When an answer is done: ${this.queueMode === 'all' ? 'all queued messages go together' : 'the next queued message goes'}`, 2500)
794
1156
  default:
795
1157
  if (/^tab_\d$/.test(what)) return this.onTab?.(what)
796
1158
  break
797
1159
  case 'scroll_up':
798
1160
  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()
1161
+ return this.scrollBy(what === 'scroll_up' ? this.page() : -this.page())
1162
+ case 'scroll_line_up':
1163
+ case 'scroll_line_down':
1164
+ return this.scrollBy(what === 'scroll_line_up' ? 3 : -3)
801
1165
  }
802
1166
  if (!this.asking) {
803
1167
  this.ahead.push(what)
804
- return this.print(`${c.faint(' ↳')} ${c.muted(what)} ${c.faint('(runs when this is done)')}`)
1168
+ return this.render()
805
1169
  }
806
1170
  if (!this.histOn || m.pick) return // only at the main prompt, not in a question or picker
807
1171
  rl.line = what
@@ -810,48 +1174,98 @@ export class UI {
810
1174
  write('\r', { name: 'return' })
811
1175
  }
812
1176
 
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.
1177
+ // Voice mode (/voice, or a key bound to voice): Space to talk, Space again to pause (and again to go on), Enter
1178
+ // (or the voice key) to finish, Esc to cancel. While you speak, what's been heard so far shows in
1179
+ // the input box (where the cursor was), redone about every second with the quickest model; a
1180
+ // stretch you pause or finish is done again with the best one. Returns the words, or null (Esc,
1181
+ // or it failed: said in a notice); the box is as it was, for the caller to put them in.
1182
+ // voiceConfig() → { voice, providers }: set by the app.
815
1183
  async dictate() {
816
1184
  if (this.recording) return null
817
1185
  const { voice = {}, providers = {} } = this.voiceConfig?.() ?? {}
818
1186
  const problem = voiceProblem(voice, providers)
819
1187
  if (problem) return this.notice(problem, 8000), null
820
- const rec = record()
821
- const t0 = Date.now()
1188
+ const rl = this.rl
1189
+ const base = { line: rl?.line ?? '', cursor: rl?.cursor ?? 0 }
822
1190
  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
1191
+ const every = voice.provider ? 3000 : 900 // a provider's API is asked less often than whisper here
1192
+ let said = '' // the stretches finished (paused), as the best model heard them
1193
+ let live = '' // the stretch going on, as heard so far
1194
+ let mic = null
1195
+ let busy = Promise.resolve() // a stretch being transcribed (one at a time, in order)
1196
+ let failed = null
1197
+ let finishing = false
1198
+ const words = () => [said, live].filter(Boolean).join(' ')
1199
+ const showBox = () => {
1200
+ if (!rl) return
1201
+ const before = base.line.slice(0, base.cursor)
1202
+ const t = words()
1203
+ const pad = t && before && !/\s$/.test(before) ? ' ' : ''
1204
+ rl.line = before + pad + t + base.line.slice(base.cursor)
1205
+ rl.cursor = before.length + pad.length + t.length
833
1206
  }
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()
1207
+ const draw = () => {
1208
+ showBox()
1209
+ const keys = 'Enter to finish · Esc to cancel'
1210
+ if (finishing) return this.notice('transcribing…', 60 * 60_000)
1211
+ 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
1212
  }
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
1213
+ const heard = (pcm, fast) =>
1214
+ transcribePcm(pcm, voice, providers, { fast }).catch((e) => {
1215
+ log('voice', e.message)
1216
+ failed = e.message
1217
+ return null
1218
+ })
1219
+ // while a stretch goes on: what's been said of it so far, again and again
1220
+ const follow = async (m) => {
1221
+ while (mic === m) {
1222
+ await new Promise((r) => setTimeout(r, every))
1223
+ if (mic !== m || m.seconds() < 0.6) continue
1224
+ const t = await heard(m.pcm(), true)
1225
+ if (mic === m && t !== null) (live = t), draw()
1226
+ }
1227
+ }
1228
+ const start = () => {
1229
+ failed = null
1230
+ mic = listen()
1231
+ follow(mic)
1232
+ draw()
1233
+ }
1234
+ // a pause: that stretch, done again with the best model, kept
1235
+ const pause = () => {
1236
+ const m = mic
1237
+ mic = null
1238
+ draw()
1239
+ busy = busy.then(async () => {
1240
+ const pcm = await m.stop().catch((e) => ((failed = e.message), null))
1241
+ const t = pcm && (await heard(pcm, false))
1242
+ said = [said, t ?? live].filter(Boolean).join(' ')
1243
+ live = ''
1244
+ draw()
1245
+ })
1246
+ }
1247
+ draw()
1248
+ const tick = setInterval(() => mic && draw(), 1000)
1249
+ const go = await new Promise((resolve) => {
1250
+ this.recording = {
1251
+ key: (key, bound) => {
1252
+ if (key.name === 'escape') resolve(false)
1253
+ else if (key.name === 'return' || bound === 'voice') resolve(true)
1254
+ else if (key.name === 'space') mic ? pause() : start()
1255
+ },
1256
+ }
1257
+ })
1258
+ clearInterval(tick)
1259
+ this.recording = null
1260
+ if (!go) mic?.cancel(), (mic = null)
1261
+ else if (mic) (finishing = true), pause()
1262
+ await busy
1263
+ if (rl) [rl.line, rl.cursor] = [base.line, base.cursor] // the caller puts the words in
1264
+ const text = go ? words().trim() : ''
1265
+ log('voice', go ? text : '(cancelled)')
1266
+ this.notice(go && !text ? failed ?? 'heard nothing' : '', failed ? 8000 : 3000)
1267
+ if (keep) this.render()
1268
+ return text || null
855
1269
  }
856
1270
 
857
1271
  #accept(it) {
@@ -914,7 +1328,7 @@ export class UI {
914
1328
  })
915
1329
  const more = m.items.length - first - shown.length
916
1330
  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'
1331
+ 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
1332
  if (m.pick) lines.unshift(c.faint(m.items.length ? how : `nothing matches "${this.rl.line}" · Esc cancel`))
919
1333
  return lines
920
1334
  }
@@ -994,11 +1408,18 @@ export class UI {
994
1408
 
995
1409
  // history: this prompt's messages go into ↑/↓ history (only the main prompt, never answers like keys).
996
1410
  ask(prompt, { initial = '', suggest = false, history = false } = {}) {
1411
+ if (history && this.kept) [initial, this.kept] = [initial || this.kept, ''] // typed before a submit()
997
1412
  this.stopSpin()
998
1413
  const rl = this.#rl()
999
1414
  if (history) this.mainPrompt = prompt
1000
1415
  if (history && this.ahead.length) {
1001
- const l = this.ahead.shift()
1416
+ // all: every queued message as one (commands stay, run after, one each); else the first one
1417
+ const how = this.sendNow ?? (this.queueMode === 'one' ? 'first' : 'all')
1418
+ this.sendNow = null
1419
+ const msgs = this.ahead.filter((x) => !this.isCommand(x))
1420
+ const together = how === 'all' && msgs.length > 1
1421
+ const l = together ? msgs.join('\n\n') : this.ahead.shift()
1422
+ if (together) this.ahead = this.ahead.filter((x) => this.isCommand(x))
1002
1423
  log('input', `${prompt.trim()} ${l} (typed while working)`)
1003
1424
  this.write(prompt.replace(/^ /, '') + c.bright(l) + '\n')
1004
1425
  return Promise.resolve(l)
@@ -1018,11 +1439,14 @@ export class UI {
1018
1439
  this.draft = ''
1019
1440
  if (this.full) {
1020
1441
  this.asking = true
1442
+ this.askedAt = Date.now()
1021
1443
  this.render() // cursor to the input row
1022
1444
  }
1023
1445
  rl.setPrompt(prompt)
1024
1446
  rl.prompt()
1025
- if (initial && rl.terminal) rl.write(initial)
1447
+ // put in the box as it is: typed in (rl.write), each line break would be an Enter
1448
+ if (initial && rl.terminal && this.full) [rl.line, rl.cursor] = [initial, initial.length]
1449
+ else if (initial && rl.terminal) rl.write(initial.replace(/\n/g, ' '))
1026
1450
  if (this.menu.on) this.refreshMenu()
1027
1451
  else this.render()
1028
1452
  return new Promise((resolve) =>
@@ -1036,6 +1460,7 @@ export class UI {
1036
1460
  if (l !== null && !rl.terminal) this.out.write(l + '\n')
1037
1461
  if (this.full) {
1038
1462
  this.asking = false
1463
+ if (this.busySince) this.waited += Date.now() - this.askedAt // waiting on you isn't HeartCode working
1039
1464
  this.scroll = 0
1040
1465
  const pick = this.menu.pick
1041
1466
  const echo = prompt.replace(/^ /, '') // the input's margin isn't part of the conversation
@@ -1183,9 +1608,9 @@ export class UI {
1183
1608
  this.beforeRetry ??= typeof this.label === 'function' ? 'thinking' : this.label || 'thinking'
1184
1609
  const until = Date.now() + ms
1185
1610
  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}`)
1611
+ const left = () => Math.max(0, Math.ceil((until - Date.now()) / 1000)) * 1000
1612
+ if (next) return this.spinner(() => `${c.yellow('⚠')} ${why} · trying ${next.provider} · ${next.model}${left() ? ` in ${duration(left())} (all failed once)` : ''} (attempt ${attempt})`)
1613
+ this.spinner(() => `${c.yellow('⚠')} retrying in ${duration(left())} (attempt ${attempt}) · ${why}`)
1189
1614
  }
1190
1615
 
1191
1616
  stopSpin() {
@@ -1202,8 +1627,13 @@ export class UI {
1202
1627
  busy() {
1203
1628
  if (!this.full) return
1204
1629
  this.busySince = Date.now()
1630
+ this.waited = 0
1205
1631
  this.spinner('working')
1206
1632
  }
1633
+ // time spent on the work so far, without the time it waited for your answers
1634
+ worked() {
1635
+ return Date.now() - this.busySince - this.waited - (this.asking ? Date.now() - this.askedAt : 0)
1636
+ }
1207
1637
  idle() {
1208
1638
  this.busySince = null
1209
1639
  this.stopSpin()