heartcode 0.1.15 → 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/CHANGELOG.md +186 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +262 -131
- package/src/settings.js +11 -2
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
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 {
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 = () => (
|
|
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()}
|
|
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()}
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
//
|
|
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
|
|
575
|
-
|
|
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)
|
|
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
|
|
731
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
800
|
-
|
|
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.
|
|
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
|
-
//
|
|
814
|
-
//
|
|
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
|
|
821
|
-
const
|
|
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
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
return
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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 ·
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1187
|
-
if (next) return this.spinner(() => `${c.yellow('⚠')} ${why} · trying ${next.provider} · ${next.model}${
|
|
1188
|
-
this.spinner(() => `${c.yellow('⚠')} retrying in ${
|
|
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()
|