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