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/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