heartcode 0.0.0-stage → 0.1.1
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 +117 -2
- package/SKILL.md +179 -0
- package/bin/heart.js +78 -0
- package/bin/heartcode.js +1572 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +348 -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 +697 -0
- package/src/client.js +629 -0
- package/src/config.js +109 -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 +218 -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 +376 -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 +104 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1618 -0
- package/src/settings.js +554 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +865 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +149 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/src/tabs.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream'
|
|
2
|
+
import { EventEmitter } from 'node:events'
|
|
3
|
+
import { c, width } from './ui.js'
|
|
4
|
+
import { logContext } from './log.js'
|
|
5
|
+
|
|
6
|
+
// Several HeartCodes on one screen. Each tab has its own UI (on its own input and output) and its
|
|
7
|
+
// own conversation; the terminal's keys go to the tab on screen, and only that tab draws. Tabs in
|
|
8
|
+
// the background keep working; the bar on top says which work (●), wait for you (?) or are done (✓).
|
|
9
|
+
export class Tabs {
|
|
10
|
+
// open(tab, opts): makes tab.ui (on tab.input/tab.output, with tab.hooks: the bar, the tab keys)
|
|
11
|
+
// and tab.app right away, then runs the tab until it's closed (a promise).
|
|
12
|
+
constructor(open, { input = process.stdin, output = process.stdout } = {}) {
|
|
13
|
+
Object.assign(this, { open, input, output })
|
|
14
|
+
this.list = []
|
|
15
|
+
this.at = null // the tab on screen
|
|
16
|
+
this.onShow = null // (tab) → after a tab comes on screen
|
|
17
|
+
this.onChange = null // () → after a tab is opened, closed or shown
|
|
18
|
+
this.quitting = false // all of HeartCode is quitting: closing a tab doesn't take it off the list
|
|
19
|
+
this.done = new Promise((resolve) => (this.empty = resolve)) // the last tab closed
|
|
20
|
+
input.on('data', (d) => this.at?.input.write(d))
|
|
21
|
+
output.on('resize', () => this.at?.output.emit('resize'))
|
|
22
|
+
// the bar shows what tabs in the background do: redrawn when that changes
|
|
23
|
+
this.timer = setInterval(() => {
|
|
24
|
+
if (this.list.length < 2) return // no bar
|
|
25
|
+
const now = this.list.map((t) => this.state(t)).join()
|
|
26
|
+
if (now === this.seen) return
|
|
27
|
+
this.seen = now
|
|
28
|
+
this.at?.ui.render()
|
|
29
|
+
}, 300)
|
|
30
|
+
this.timer.unref()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
add(opts = {}) {
|
|
34
|
+
const real = { input: this.input, output: this.output }
|
|
35
|
+
const tab = { name: opts.name ?? '' }
|
|
36
|
+
const on = () => this.at === tab
|
|
37
|
+
tab.input = new PassThrough()
|
|
38
|
+
tab.input.isTTY = true
|
|
39
|
+
tab.input.setRawMode = (raw) => on() && real.input.setRawMode?.(raw)
|
|
40
|
+
tab.input.on('pause', () => on() && real.input.pause())
|
|
41
|
+
tab.input.on('resume', () => on() && real.input.resume())
|
|
42
|
+
tab.output = Object.assign(new EventEmitter(), { isTTY: true, write: (s) => (on() ? real.output.write(s) : true) })
|
|
43
|
+
for (const k of ['columns', 'rows']) Object.defineProperty(tab.output, k, { get: () => real.output[k] })
|
|
44
|
+
Object.defineProperty(tab.output, 'hidden', { get: () => !on() })
|
|
45
|
+
tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what) }
|
|
46
|
+
this.list.push(tab)
|
|
47
|
+
this.at = tab
|
|
48
|
+
tab.id = this.made = (this.made ?? 0) + 1 // for its log file: tab-1.log… (its place in the bar can change)
|
|
49
|
+
const run = logContext.run({ tab: tab.id }, () => this.open(tab, opts))
|
|
50
|
+
// Closing a tab while others are open: off the screen first, so it doesn't take the terminal
|
|
51
|
+
// (raw mode, the full screen) with it.
|
|
52
|
+
const close = tab.ui.close.bind(tab.ui)
|
|
53
|
+
tab.ui.close = () => {
|
|
54
|
+
if (this.list.length > 1 && this.list.includes(tab) && !this.quitting) this.remove(tab)
|
|
55
|
+
close()
|
|
56
|
+
}
|
|
57
|
+
this.onShow?.(tab) // on screen already: it drew its first prompt, with the bar
|
|
58
|
+
this.onChange?.()
|
|
59
|
+
Promise.resolve(run).finally(() => {
|
|
60
|
+
if (this.list.includes(tab)) tab.ui.close()
|
|
61
|
+
if (!this.list.includes(tab)) return
|
|
62
|
+
this.list.splice(this.list.indexOf(tab), 1)
|
|
63
|
+
this.empty()
|
|
64
|
+
})
|
|
65
|
+
return tab
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
show(tab) {
|
|
69
|
+
if (!tab) return
|
|
70
|
+
this.at = tab
|
|
71
|
+
tab.unseen = false
|
|
72
|
+
this.input.setRawMode?.(true)
|
|
73
|
+
this.input.resume()
|
|
74
|
+
this.onShow?.(tab)
|
|
75
|
+
this.onChange?.()
|
|
76
|
+
tab.ui.wrapped = []
|
|
77
|
+
tab.ui.render()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
remove(tab) {
|
|
81
|
+
const i = this.list.indexOf(tab)
|
|
82
|
+
this.list.splice(i, 1)
|
|
83
|
+
if (this.at === tab) this.show(this.list[Math.min(i, this.list.length - 1)])
|
|
84
|
+
else this.onChange?.()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// new_tab, close_tab, next_tab, prev_tab, tab_1…tab_9 (keys, from the UI); none while locked
|
|
88
|
+
// (the settings page is on: its dialog holds the screen)
|
|
89
|
+
key(what) {
|
|
90
|
+
if (this.locked) return
|
|
91
|
+
const i = this.list.indexOf(this.at)
|
|
92
|
+
if (what === 'close_tab') return this.at.ui.onCloseTab?.()
|
|
93
|
+
if (what === 'new_tab') return this.add({ root: this.at.app.root })
|
|
94
|
+
if (what === 'next_tab' || what === 'prev_tab') return this.show(this.list[(i + (what === 'next_tab' ? 1 : this.list.length - 1)) % this.list.length])
|
|
95
|
+
const n = /^tab_(\d)$/.exec(what)?.[1]
|
|
96
|
+
if (n) this.show(this.list[Number(n) - 1])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// working, waiting for an answer, or done since you last looked at it
|
|
100
|
+
state(t) {
|
|
101
|
+
const ui = t.ui
|
|
102
|
+
const working = Boolean(ui.busySince || ui.timer) && !ui.asking
|
|
103
|
+
if (t.working && !working && t !== this.at) t.unseen = true
|
|
104
|
+
t.working = working
|
|
105
|
+
return ui.asking && !ui.histOn ? '?' : working ? '●' : t.unseen ? '✓' : ''
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The tab bar, three rows: tabs drawn as tabs, the one on screen open onto it (its bottom
|
|
109
|
+
// edge missing), the others closed off. Number, name, and what it's doing.
|
|
110
|
+
// ╭─────────────╮ ╭──────────────╮
|
|
111
|
+
// │ 1 heart_cli │ │ 2 fix tests ●│ + Ctrl+T new tab
|
|
112
|
+
// ─╯ ╰─┴──────────────┴──────────────────
|
|
113
|
+
bar(cols = 100) {
|
|
114
|
+
const mark = { '?': c.warn('?'), '●': c.brand('●'), '✓': c.ok('✓') }
|
|
115
|
+
const room = Math.max(6, Math.floor((cols - 24) / this.list.length) - 7) // a name's cells, so they all fit
|
|
116
|
+
let [top, mid, bottom] = [' ', ' ', c.ghost('─')]
|
|
117
|
+
this.list.forEach((t, i) => {
|
|
118
|
+
const on = t === this.at
|
|
119
|
+
const s = this.state(t)
|
|
120
|
+
const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), room)
|
|
121
|
+
const text = ` ${i + 1} ${name}${s ? ' ' + s : ''} `
|
|
122
|
+
const w = width(text)
|
|
123
|
+
const line = on ? c.brand : c.ghost
|
|
124
|
+
const label = ` ${on ? c.bright(c.bold(`${i + 1} ${name}`)) : c.muted(`${i + 1} ${name}`)}${s ? ' ' + mark[s] : ''} `
|
|
125
|
+
top += line(`╭${'─'.repeat(w)}╮`) + ' '
|
|
126
|
+
mid += line('│') + label + line('│') + ' '
|
|
127
|
+
bottom += (on ? c.brand(`╯${' '.repeat(w)}╰`) : c.ghost(`┴${'─'.repeat(w)}┴`)) + c.ghost('─')
|
|
128
|
+
})
|
|
129
|
+
const hint = this.list.length > 1 ? 'Ctrl+T new · Ctrl+Q close · Ctrl+N/P switch' : 'Ctrl+T new tab'
|
|
130
|
+
mid += ` ${c.faint(hint)}`
|
|
131
|
+
bottom += c.ghost('─'.repeat(Math.max(0, cols - width(bottom.replace(/\x1b\[[0-9;]*m/g, '')))))
|
|
132
|
+
return [top, mid, bottom]
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// At most n cells, with … when cut.
|
|
137
|
+
function cut(s, n) {
|
|
138
|
+
if (width(s) <= n) return s
|
|
139
|
+
let out = ''
|
|
140
|
+
for (const ch of s) {
|
|
141
|
+
if (width(out + ch) > n - 1) break
|
|
142
|
+
out += ch
|
|
143
|
+
}
|
|
144
|
+
return out + '…'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pathTail = (p = '') => p.split(/[\\/]/).filter(Boolean).pop() ?? p
|