@xingular/harness 0.1.6 → 2.0.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/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Rounded selector chrome follows Oh My Pi 18.11.3 (MIT; see NOTICE-oh-my-pi).
|
|
2
|
+
import type { ExtensionUIContext, ExtensionUIDialogOptions, KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { Editor, Input, SelectList, fuzzyFilter, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type OverlayHandle, type OverlayOptions, type SelectListTheme, type TUI } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
type DisposableComponent = Component & { dispose?(): void }
|
|
6
|
+
export function selectTheme(theme: Theme): SelectListTheme {
|
|
7
|
+
return {
|
|
8
|
+
selectedPrefix: text => theme.fg('accent', text), selectedText: text => theme.fg('accent', text.replace(/^→/, '❯')),
|
|
9
|
+
description: text => theme.fg('muted', text), scrollInfo: text => theme.fg('dim', text),
|
|
10
|
+
noMatch: text => theme.fg('warning', text.replace('commands', 'options'))
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** Input keeps the original value; only its rendered cells are masked. */
|
|
14
|
+
export class SecretInput extends Input {
|
|
15
|
+
override render(width: number): string[] {
|
|
16
|
+
const lines = super.render(width)
|
|
17
|
+
if (!this.getValue()) return lines
|
|
18
|
+
const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
19
|
+
return lines.map(line => line.split(/(\x1b(?:\[[0-9;?]*[ -/]*[@-~]|_[^\x07]*\x07))/g).map((part, index) => index % 2
|
|
20
|
+
? part
|
|
21
|
+
: [...segments.segment(part)].map(item => '•'.repeat(visibleWidth(item.segment))).join('')).join(''))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function safe(text: string): string {
|
|
26
|
+
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** OMP hook-selector.ts uses type-to-search once its options exceed the list window. */
|
|
30
|
+
export class SearchableSelect implements Component, Focusable {
|
|
31
|
+
private search = new Input({ prompt: '' })
|
|
32
|
+
private list: SelectList
|
|
33
|
+
private query = ''
|
|
34
|
+
private limit = 0
|
|
35
|
+
private items: { value: string; label: string }[]
|
|
36
|
+
private filtered: { value: string; label: string }[] = []
|
|
37
|
+
private focusedValue = false
|
|
38
|
+
private currentTheme: Theme | undefined
|
|
39
|
+
constructor(options: string[], private readonly tui: TUI, private readonly theme: () => Theme, private readonly select: (index: number) => void, private readonly cancel: () => void) {
|
|
40
|
+
this.items = options.map((option, index) => ({ value: String(index), label: safe(option) }))
|
|
41
|
+
this.list = new SelectList([], 1, selectTheme(theme()))
|
|
42
|
+
this.rebuild()
|
|
43
|
+
}
|
|
44
|
+
get focused(): boolean { return this.focusedValue }
|
|
45
|
+
set focused(value: boolean) { this.focusedValue = value; this.search.focused = value && this.searchEnabled() }
|
|
46
|
+
private searchEnabled(): boolean { return this.items.length > this.limit || this.query.length > 0 }
|
|
47
|
+
private rebuild(): void {
|
|
48
|
+
const selected = this.list.getSelectedItem()?.value
|
|
49
|
+
this.limit = Math.min(12, Math.max(1, Math.floor(this.tui.terminal.rows * 0.8) - 7))
|
|
50
|
+
this.currentTheme = this.theme()
|
|
51
|
+
this.filtered = fuzzyFilter(this.items, this.query, item => item.label)
|
|
52
|
+
this.list = new SelectList(this.filtered, this.limit, selectTheme(this.currentTheme))
|
|
53
|
+
if (selected) {
|
|
54
|
+
const index = this.filtered.findIndex(item => item.value === selected)
|
|
55
|
+
if (index >= 0) this.list.setSelectedIndex(index)
|
|
56
|
+
}
|
|
57
|
+
this.list.onSelect = item => this.select(Number(item.value))
|
|
58
|
+
this.list.onCancel = this.cancel
|
|
59
|
+
this.search.focused = this.focusedValue && this.searchEnabled()
|
|
60
|
+
}
|
|
61
|
+
invalidate(): void { this.rebuild(); this.search.invalidate() }
|
|
62
|
+
handleInput(data: string): void {
|
|
63
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.cancel(); return }
|
|
64
|
+
if (!this.query && /^[1-9]$/.test(data)) {
|
|
65
|
+
const index = this.items.findIndex(item => item.label.startsWith(`${data}. `))
|
|
66
|
+
if (index >= 0) { this.select(index); return }
|
|
67
|
+
}
|
|
68
|
+
if (matchesKey(data, 'up') || matchesKey(data, 'down') || matchesKey(data, 'enter') || matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown')) {
|
|
69
|
+
this.list.handleInput(data)
|
|
70
|
+
} else if (this.searchEnabled()) {
|
|
71
|
+
this.search.handleInput(data)
|
|
72
|
+
if (this.query !== this.search.getValue()) { this.query = this.search.getValue(); this.rebuild() }
|
|
73
|
+
} else if (data === 'j' || data === 'k') this.list.handleInput(data === 'j' ? '\x1b[B' : '\x1b[A')
|
|
74
|
+
}
|
|
75
|
+
render(width: number): string[] {
|
|
76
|
+
if (this.limit !== Math.min(12, Math.max(1, Math.floor(this.tui.terminal.rows * 0.8) - 7)) || this.currentTheme !== this.theme()) this.rebuild()
|
|
77
|
+
const theme = this.theme()
|
|
78
|
+
const lines = this.list.render(Math.max(4, width)).map(line => {
|
|
79
|
+
const bounded = truncateToWidth(line, width, '')
|
|
80
|
+
return stripTerminalSequences(line).startsWith('❯')
|
|
81
|
+
? theme.bg('selectedBg', bounded + ' '.repeat(Math.max(0, width - visibleWidth(bounded))))
|
|
82
|
+
: bounded
|
|
83
|
+
})
|
|
84
|
+
if (this.searchEnabled()) {
|
|
85
|
+
const selected = this.filtered.findIndex(item => item.value === this.list.getSelectedItem()?.value) + 1
|
|
86
|
+
const count = `(${selected}/${this.filtered.length}${this.query ? ` of ${this.items.length}` : ''})`
|
|
87
|
+
lines.push(this.query ? theme.fg('dim', ` ${count} Search: `) + (this.search.render(Math.max(2, width - count.length - 11))[0] ?? '') : theme.fg('dim', ` ${count} Type to search`))
|
|
88
|
+
}
|
|
89
|
+
return lines.map(line => truncateToWidth(line, width, ''))
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** An overlay owns focus; its input emits the cursor marker through this delegate. */
|
|
94
|
+
export class DialogPanel implements Component, Focusable {
|
|
95
|
+
deadline?: number
|
|
96
|
+
maxRows?: () => number
|
|
97
|
+
private focusedValue = false
|
|
98
|
+
private detailOffset = 0
|
|
99
|
+
private detailRows = 0
|
|
100
|
+
private detailTotal = 0
|
|
101
|
+
constructor(readonly title: string, readonly content: Component, private readonly theme: () => Theme, readonly hint: string, private readonly message?: string) {}
|
|
102
|
+
get focused(): boolean { return this.focusedValue }
|
|
103
|
+
set focused(value: boolean) {
|
|
104
|
+
this.focusedValue = value
|
|
105
|
+
if ('focused' in this.content) (this.content as Component & Focusable).focused = value
|
|
106
|
+
}
|
|
107
|
+
invalidate(): void { this.content.invalidate() }
|
|
108
|
+
handleInput(data: string): void {
|
|
109
|
+
if (this.detailTotal > this.detailRows && (matchesKey(data, 'pageUp') || matchesKey(data, 'pageDown'))) {
|
|
110
|
+
const delta = matchesKey(data, 'pageUp') ? -1 : 1
|
|
111
|
+
this.detailOffset = Math.max(0, Math.min(this.detailTotal - this.detailRows, this.detailOffset + delta * Math.max(1, this.detailRows)))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
this.content.handleInput?.(data)
|
|
115
|
+
}
|
|
116
|
+
render(width: number): string[] {
|
|
117
|
+
if (width < 1) return []
|
|
118
|
+
const theme = this.theme()
|
|
119
|
+
const inner = Math.max(2, width - 4)
|
|
120
|
+
const cap = (value: string) => truncateToWidth(value, width, '')
|
|
121
|
+
const border = (value: string) => theme.fg('border', value)
|
|
122
|
+
const title = truncateToWidth(` ${safe(this.title)} `, Math.max(0, width - 4), '')
|
|
123
|
+
const top = cap(border('╭─') + theme.bold(theme.fg('accent', title)) + border('─'.repeat(Math.max(0, width - visibleWidth(title) - 3)) + '╮'))
|
|
124
|
+
const bottom = cap(border('╰' + '─'.repeat(Math.max(0, width - 2)) + '╯'))
|
|
125
|
+
const row = (line: string) => {
|
|
126
|
+
const shown = truncateToWidth(line, inner, '')
|
|
127
|
+
return cap(border('│') + ' ' + shown + ' '.repeat(Math.max(0, width - visibleWidth(shown) - 3)) + border('│'))
|
|
128
|
+
}
|
|
129
|
+
const details = this.message
|
|
130
|
+
? wrapTextWithAnsi(stripTerminalSequences(this.message).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '').replace(/\t/g, ' '), inner)
|
|
131
|
+
: []
|
|
132
|
+
const content = this.content.render(inner)
|
|
133
|
+
const height = this.maxRows ? Math.max(3, Math.floor(this.maxRows())) : details.length + content.length + 4
|
|
134
|
+
const budget = Math.max(0, height - 3)
|
|
135
|
+
// Keep choices and the footer mounted even when an approval contains a large command or path.
|
|
136
|
+
const minContent = Math.min(content.length, Math.min(2, budget))
|
|
137
|
+
const detailBudget = Math.min(details.length, Math.max(0, budget - Math.max(minContent, Math.min(content.length, budget))))
|
|
138
|
+
this.detailRows = detailBudget
|
|
139
|
+
this.detailTotal = details.length
|
|
140
|
+
this.detailOffset = Math.max(0, Math.min(this.detailOffset, details.length - detailBudget))
|
|
141
|
+
const body = details.slice(this.detailOffset, this.detailOffset + detailBudget).map(line => theme.fg('muted', line))
|
|
142
|
+
const available = Math.max(0, budget - body.length)
|
|
143
|
+
const cursor = content.findIndex(line => line.includes('\x1b_pi:c\x07') || stripTerminalSequences(line).startsWith('❯'))
|
|
144
|
+
const first = content.length <= available ? 0 : Math.max(0, Math.min(content.length - available, cursor - Math.floor(available / 2)))
|
|
145
|
+
body.push(...content.slice(first, first + available))
|
|
146
|
+
const seconds = this.deadline === undefined ? '' : ` · ${Math.max(0, Math.ceil((this.deadline - Date.now()) / 1000))}s`
|
|
147
|
+
let footerText = this.hint + seconds
|
|
148
|
+
if (details.length > detailBudget) {
|
|
149
|
+
footerText = `PgUp/PgDn details ${this.detailOffset + 1}-${this.detailOffset + detailBudget}/${details.length} · ${footerText}`
|
|
150
|
+
if (visibleWidth(footerText) > inner) footerText = 'PgUp/PgDn details · Enter · Esc' + seconds
|
|
151
|
+
}
|
|
152
|
+
const footer = row(theme.fg('dim', footerText))
|
|
153
|
+
return [top, ...body.map(row), footer, bottom].slice(0, height)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export class TerminalDialogs {
|
|
158
|
+
private pending = new Set<() => void>()
|
|
159
|
+
private disposed = false
|
|
160
|
+
constructor(private readonly tui: TUI, private readonly theme: () => Theme, private readonly keybindings: KeybindingsManager, private readonly changed: () => void) {}
|
|
161
|
+
|
|
162
|
+
private show<T>(build: (done: (value: T | undefined) => void) => DialogPanel, options?: ExtensionUIDialogOptions): Promise<T | undefined> {
|
|
163
|
+
if (this.disposed || options?.signal?.aborted) return Promise.resolve(undefined)
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
let handle: OverlayHandle | undefined
|
|
166
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
167
|
+
let countdown: ReturnType<typeof setInterval> | undefined
|
|
168
|
+
let settled = false
|
|
169
|
+
let panel: DialogPanel | undefined
|
|
170
|
+
const finish = (value: T | undefined, error?: unknown) => {
|
|
171
|
+
if (settled) return
|
|
172
|
+
settled = true
|
|
173
|
+
clearTimeout(timer)
|
|
174
|
+
clearInterval(countdown)
|
|
175
|
+
options?.signal?.removeEventListener('abort', cancel)
|
|
176
|
+
this.pending.delete(cancel)
|
|
177
|
+
handle?.hide()
|
|
178
|
+
const content = panel?.content as DisposableComponent | undefined
|
|
179
|
+
try { content?.dispose?.() } catch { /* The dialog must still release focus and settle. */ }
|
|
180
|
+
this.changed()
|
|
181
|
+
if (error !== undefined) reject(error)
|
|
182
|
+
else resolve(value)
|
|
183
|
+
}
|
|
184
|
+
const done = (value: T | undefined) => finish(value)
|
|
185
|
+
const cancel = () => done(undefined)
|
|
186
|
+
this.pending.add(cancel)
|
|
187
|
+
options?.signal?.addEventListener('abort', cancel, { once: true })
|
|
188
|
+
try {
|
|
189
|
+
panel = build(done)
|
|
190
|
+
panel.maxRows = () => Math.max(3, Math.floor(this.tui.terminal.rows * 0.8))
|
|
191
|
+
if (options?.timeout !== undefined) {
|
|
192
|
+
panel.deadline = Date.now() + Math.max(0, options.timeout)
|
|
193
|
+
timer = setTimeout(cancel, Math.max(0, options.timeout))
|
|
194
|
+
countdown = setInterval(this.changed, 250)
|
|
195
|
+
countdown.unref?.()
|
|
196
|
+
}
|
|
197
|
+
handle = this.tui.showOverlay(panel, { anchor: 'bottom-center', width: '100%', maxHeight: '80%' })
|
|
198
|
+
if (settled) handle.hide()
|
|
199
|
+
this.changed()
|
|
200
|
+
} catch (error) {
|
|
201
|
+
finish(undefined, error)
|
|
202
|
+
}
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined> {
|
|
207
|
+
return this.show<string>(done => {
|
|
208
|
+
const list = new SearchableSelect(options, this.tui, this.theme, index => done(options[index]), () => done(undefined))
|
|
209
|
+
return new DialogPanel(title, list, this.theme, '↑/↓ select · Enter confirm · Esc cancel')
|
|
210
|
+
}, opts)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean> {
|
|
214
|
+
return (await this.show<boolean>(done => {
|
|
215
|
+
const list = new SelectList([{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }], 2, selectTheme(this.theme()))
|
|
216
|
+
list.onSelect = item => done(item.value === 'yes')
|
|
217
|
+
list.onCancel = () => done(undefined)
|
|
218
|
+
return new DialogPanel(title, list, this.theme, '↑/↓ select · Enter confirm · Esc cancel', message)
|
|
219
|
+
}, opts)) ?? false
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions, secret = false): Promise<string | undefined> {
|
|
223
|
+
return this.show<string>(done => {
|
|
224
|
+
const InputType = secret ? SecretInput : Input
|
|
225
|
+
const input = new InputType({ prompt: secret ? '' : '> ', placeholder, placeholderStyle: text => this.theme().fg('dim', text) })
|
|
226
|
+
input.onSubmit = done
|
|
227
|
+
input.onEscape = () => done(undefined)
|
|
228
|
+
return new DialogPanel(title, input, this.theme, 'Enter submit · Esc cancel')
|
|
229
|
+
}, opts)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
editor(title: string, prefill?: string): Promise<string | undefined> {
|
|
233
|
+
return this.show<string>(done => {
|
|
234
|
+
const editor = new Editor(this.tui, { borderColor: text => this.theme().fg('border', text), selectList: selectTheme(this.theme()) })
|
|
235
|
+
editor.setText(prefill ?? '')
|
|
236
|
+
editor.onSubmit = done
|
|
237
|
+
const content: Component & Focusable = {
|
|
238
|
+
get focused() { return editor.focused }, set focused(value) { editor.focused = value },
|
|
239
|
+
invalidate: () => editor.invalidate(), render: width => editor.render(Math.max(4, width)).map(line => truncateToWidth(line, width, '')),
|
|
240
|
+
handleInput: data => {
|
|
241
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) done(undefined)
|
|
242
|
+
else editor.handleInput(data)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return new DialogPanel(title, content, this.theme, 'Enter submit · Shift+Enter newline · Esc cancel')
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
custom<T>(factory: Parameters<ExtensionUIContext['custom']>[0], options?: Parameters<ExtensionUIContext['custom']>[1]): Promise<T> {
|
|
250
|
+
if (this.disposed) return Promise.resolve(undefined as T)
|
|
251
|
+
return new Promise<T>((resolve, reject) => {
|
|
252
|
+
let handle: OverlayHandle | undefined
|
|
253
|
+
let component: DisposableComponent | undefined
|
|
254
|
+
let settled = false
|
|
255
|
+
const done = (value: unknown) => {
|
|
256
|
+
if (settled) return
|
|
257
|
+
settled = true
|
|
258
|
+
this.pending.delete(cancel)
|
|
259
|
+
handle?.hide()
|
|
260
|
+
try { component?.dispose?.() } catch { /* Cleanup cannot prevent completion. */ }
|
|
261
|
+
this.changed()
|
|
262
|
+
resolve(value as T)
|
|
263
|
+
}
|
|
264
|
+
const cancel = () => done(undefined)
|
|
265
|
+
this.pending.add(cancel)
|
|
266
|
+
Promise.resolve().then(() => factory(this.tui, this.theme(), this.keybindings, done)).then(value => {
|
|
267
|
+
component = value
|
|
268
|
+
if (settled) {
|
|
269
|
+
try { component.dispose?.() } catch { /* It never mounted. */ }
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
const defaults: OverlayOptions = { anchor: 'bottom-center', width: '100%', maxHeight: '90%' }
|
|
273
|
+
const source = options?.overlayOptions
|
|
274
|
+
const overlayOptions = typeof source === 'function'
|
|
275
|
+
? new Proxy(defaults, { get: (target, key: keyof OverlayOptions) => source()[key] ?? target[key] })
|
|
276
|
+
: { ...defaults, ...source }
|
|
277
|
+
handle = this.tui.showOverlay(component, overlayOptions)
|
|
278
|
+
options?.onHandle?.(handle)
|
|
279
|
+
this.changed()
|
|
280
|
+
}).catch(error => {
|
|
281
|
+
if (!settled) {
|
|
282
|
+
settled = true
|
|
283
|
+
this.pending.delete(cancel)
|
|
284
|
+
handle?.hide()
|
|
285
|
+
try { component?.dispose?.() } catch { /* Cleanup cannot prevent completion. */ }
|
|
286
|
+
this.changed()
|
|
287
|
+
reject(error)
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
reset(): void {
|
|
294
|
+
for (const cancel of [...this.pending]) cancel()
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
dispose(): void {
|
|
298
|
+
this.disposed = true
|
|
299
|
+
this.reset()
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { getSelectListTheme, initTheme, type KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, renderFakeCursor, stripTerminalSequences, type TUI, type TuiMouseEvent, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { COMPOSER_SUGGESTIONS, type ComposerView, HarnessEditor } from './editor'
|
|
6
|
+
import { createHarnessTheme } from './theme'
|
|
7
|
+
|
|
8
|
+
initTheme('dark', false)
|
|
9
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
|
+
const bandText = (width: number) => '[band]'.padEnd(width)
|
|
11
|
+
function setup(view: Partial<ComposerView> = {}) {
|
|
12
|
+
const editor = new HarnessEditor({ terminal: { rows: 30 }, requestRender() {} } as unknown as TUI,
|
|
13
|
+
{ borderColor: (text) => text, selectList: getSelectListTheme() }, new TuiKeybindingsManager(TUI_KEYBINDINGS) as KeybindingsManager, {
|
|
14
|
+
band: (width) => bandText(width),
|
|
15
|
+
theme: () => theme, placeholder: () => undefined, ...view
|
|
16
|
+
})
|
|
17
|
+
editor.focused = true
|
|
18
|
+
return editor
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('Claude-style composer frame over the native Pi editor', () => {
|
|
22
|
+
it('frames the prompt between two full-width rules, with the band below', () => {
|
|
23
|
+
const editor = setup()
|
|
24
|
+
const rows = editor.render(40)
|
|
25
|
+
expect(rows).toHaveLength(4)
|
|
26
|
+
expect(stripTerminalSequences(rows[0])).toBe('─'.repeat(40))
|
|
27
|
+
expect(rows[0]).toContain('38;2;136;136;136')
|
|
28
|
+
expect(stripTerminalSequences(rows[1])).toMatch(/^❯ /)
|
|
29
|
+
expect(rows[1]).toContain(CURSOR_MARKER)
|
|
30
|
+
expect(stripTerminalSequences(rows[2])).toBe('─'.repeat(40))
|
|
31
|
+
expect(stripTerminalSequences(rows[3]).trimEnd()).toBe('[band]')
|
|
32
|
+
expect(rows.every((row) => visibleWidth(row) <= 40)).toBe(true)
|
|
33
|
+
expect(editor.getText()).toBe('')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('shows the suggestion as a fake cursor on its first character, only while focused', () => {
|
|
37
|
+
const editor = setup({ placeholder: () => COMPOSER_SUGGESTIONS[0] })
|
|
38
|
+
const rows = editor.render(60)
|
|
39
|
+
expect(stripTerminalSequences(rows[1])).toContain('Try "fix the failing test"')
|
|
40
|
+
expect(rows[1]).toContain(CURSOR_MARKER + renderFakeCursor('T'))
|
|
41
|
+
editor.focused = false
|
|
42
|
+
const idle = editor.render(60)[1]
|
|
43
|
+
expect(idle).not.toContain(CURSOR_MARKER)
|
|
44
|
+
expect(stripTerminalSequences(idle)).toContain('Try "fix the failing test"')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('drops the suggestion as soon as there is a draft', () => {
|
|
48
|
+
const editor = setup({ placeholder: () => 'fix the failing test' })
|
|
49
|
+
editor.handleInput('h')
|
|
50
|
+
const rows = editor.render(60)
|
|
51
|
+
expect(editor.getText()).toBe('h')
|
|
52
|
+
expect(stripTerminalSequences(rows[1])).toMatch(/^❯ h/)
|
|
53
|
+
expect(stripTerminalSequences(rows[1])).not.toContain('Try')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not render a shortcuts or thinking hint row', () => {
|
|
57
|
+
const editor = setup()
|
|
58
|
+
const rows = editor.render(80).map(stripTerminalSequences)
|
|
59
|
+
expect(rows.join('\n')).not.toContain('? for shortcuts')
|
|
60
|
+
expect(rows.join('\n')).not.toContain('to change thinking effort')
|
|
61
|
+
editor.handleInput('x')
|
|
62
|
+
expect(editor.render(80).map(stripTerminalSequences).join('\n')).not.toContain('to clear')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('preserves editing, multiline wrapping, history, and cursor markers across resizes', () => {
|
|
66
|
+
const editor = setup()
|
|
67
|
+
const draft = 'Read 日本語 and compare the result'.repeat(3) + '\nSecond line'
|
|
68
|
+
editor.setText(draft)
|
|
69
|
+
for (const width of [120, 40, 10, 3, 1, 80]) {
|
|
70
|
+
const rows = editor.render(width)
|
|
71
|
+
expect(rows.every((row) => visibleWidth(row) <= width), `width ${width}`).toBe(true)
|
|
72
|
+
expect(rows.join('')).not.toContain('\u0000')
|
|
73
|
+
expect(rows.join('')).toContain(CURSOR_MARKER)
|
|
74
|
+
expect(editor.getText()).toBe(draft)
|
|
75
|
+
}
|
|
76
|
+
editor.setText('')
|
|
77
|
+
editor.handleInput('hello')
|
|
78
|
+
expect(editor.getText()).toBe('hello')
|
|
79
|
+
editor.addToHistory('previous prompt')
|
|
80
|
+
editor.setText('')
|
|
81
|
+
editor.handleInput('\x1b[A')
|
|
82
|
+
expect(editor.getText()).toBe('previous prompt')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('lists autocomplete above the composer with matching orange dividers and accepts a selection', async () => {
|
|
86
|
+
const editor = setup()
|
|
87
|
+
editor.setAutocompleteProvider({
|
|
88
|
+
getSuggestions: async () => ({ items: [{ value: '/accounts', label: '/accounts' }, { value: '/plugins', label: '/plugins' }], prefix: '/' }),
|
|
89
|
+
applyCompletion: (lines, line, _col, item) => ({ lines: lines.map((value, index) => index === line ? item.value : value), cursorLine: line, cursorCol: item.value.length })
|
|
90
|
+
})
|
|
91
|
+
editor.handleInput('/')
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, 30))
|
|
93
|
+
const rows = editor.render(50)
|
|
94
|
+
const band = rows.findIndex((row) => stripTerminalSequences(row).startsWith('[band]'))
|
|
95
|
+
const completion = rows.findIndex((row) => stripTerminalSequences(row).includes('/accounts'))
|
|
96
|
+
const composerDivider = rows.findIndex((row, index) => index > completion && stripTerminalSequences(row) === '─'.repeat(50))
|
|
97
|
+
expect(rows[0]).toContain('38;2;255;179;71')
|
|
98
|
+
expect(rows[composerDivider]).toContain('38;2;136;136;136')
|
|
99
|
+
expect(completion).toBeGreaterThan(0)
|
|
100
|
+
expect(composerDivider).toBeGreaterThan(completion)
|
|
101
|
+
expect(band).toBeGreaterThan(composerDivider)
|
|
102
|
+
expect(rows.every((row) => visibleWidth(row) <= 50)).toBe(true)
|
|
103
|
+
editor.handleInput('\t')
|
|
104
|
+
expect(editor.getText()).toContain('/accounts')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not hand the band row to the editor as its own', () => {
|
|
108
|
+
const editor = setup()
|
|
109
|
+
editor.render(40)
|
|
110
|
+
const at = (y: number) => ({ x: 5, y, width: 40, height: 5 }) as TuiMouseEvent
|
|
111
|
+
expect(editor.handleMouse(at(3))).toBeUndefined()
|
|
112
|
+
})
|
|
113
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Harness owns composer geometry and actions; Pi provides the editing primitive.
|
|
2
|
+
import { CustomEditor, type KeybindingsManager, type Theme } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent, parseColor, renderFakeCursor, truncateToWidth } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
import { autocompleteRuleColor, composerRuleColor } from './theme'
|
|
6
|
+
|
|
7
|
+
const BOTTOM = '\u0000harness-composer-bottom'
|
|
8
|
+
|
|
9
|
+
// Generic on purpose: the placeholder names no file, project or product.
|
|
10
|
+
export const COMPOSER_SUGGESTIONS = [
|
|
11
|
+
'fix the failing test',
|
|
12
|
+
'explain how this project is laid out',
|
|
13
|
+
'find where the configuration is loaded',
|
|
14
|
+
'summarize what changed since the last commit',
|
|
15
|
+
'write a test for the next change'
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
export interface ComposerView {
|
|
19
|
+
band(width: number, hiddenAbove: number, hiddenBelow: number): string
|
|
20
|
+
theme(): Theme
|
|
21
|
+
placeholder(): string | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HarnessEditor extends CustomEditor {
|
|
25
|
+
private hiddenAbove = 0
|
|
26
|
+
private hiddenBelow = 0
|
|
27
|
+
private contentRows = 1
|
|
28
|
+
private completionRows = 0
|
|
29
|
+
private gutterWidth = 2
|
|
30
|
+
|
|
31
|
+
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
|
|
32
|
+
super(tui, theme, keybindings, { paddingX: 0 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
override setPaddingX(_padding: number): void {
|
|
36
|
+
super.setPaddingX(0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
protected override renderTopBorder(_width: number, hiddenLineCount: number): string {
|
|
40
|
+
this.hiddenAbove = hiddenLineCount
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected override renderBottomBorder(_width: number, hiddenLineCount: number): string {
|
|
45
|
+
this.hiddenBelow = hiddenLineCount
|
|
46
|
+
return BOTTOM
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override render(width: number): string[] {
|
|
50
|
+
if (width < 1) return []
|
|
51
|
+
this.gutterWidth = Math.min(2, Math.max(0, width - 1))
|
|
52
|
+
const innerWidth = Math.max(1, width - this.gutterWidth)
|
|
53
|
+
// Pi 1.2 cannot lay out a two-cell grapheme in a one-cell text area.
|
|
54
|
+
// Lay out safely, then clip the presentation without changing the draft.
|
|
55
|
+
const rows = super.render(Math.max(3, innerWidth))
|
|
56
|
+
const bottom = rows.indexOf(BOTTOM)
|
|
57
|
+
const content = rows.slice(1, bottom)
|
|
58
|
+
this.contentRows = content.length
|
|
59
|
+
const theme = this.view.theme()
|
|
60
|
+
const placeholder = this.getText() === '' ? this.view.placeholder() : undefined
|
|
61
|
+
const prompt = content.map((line, index) => {
|
|
62
|
+
const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
|
|
63
|
+
if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
|
|
64
|
+
return cue + line
|
|
65
|
+
})
|
|
66
|
+
const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
|
|
67
|
+
this.completionRows = completions.length
|
|
68
|
+
const completionDivider = completions.length > 0 ? [this.rule(width, theme, autocompleteRuleColor(theme))] : []
|
|
69
|
+
const result = [
|
|
70
|
+
...completionDivider, ...completions,
|
|
71
|
+
this.rule(width, theme), ...prompt, this.rule(width, theme), this.view.band(width, this.hiddenAbove, this.hiddenBelow)
|
|
72
|
+
].map((line) => truncateToWidth(line, width, ''))
|
|
73
|
+
if (this.focused && !result.some((line) => line.includes(CURSOR_MARKER))) {
|
|
74
|
+
const composerOffset = completions.length > 0 ? completions.length + 1 : 0
|
|
75
|
+
const index = composerOffset + Math.max(1, Math.min(result.length - composerOffset - 1, prompt.length))
|
|
76
|
+
result[index] = truncateToWidth(result[index], Math.max(0, width - 1), '') + CURSOR_MARKER + ' '
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private rule(width: number, theme: Theme, color = composerRuleColor(theme)): string {
|
|
82
|
+
return theme.style('─'.repeat(width), { fg: parseColor(color) })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private placeholderRow(suggestion: string, innerWidth: number, theme: Theme): string {
|
|
86
|
+
const text = truncateToWidth(`Try "${suggestion}"`, innerWidth, '')
|
|
87
|
+
if (!this.focused) return theme.fg('dim', text)
|
|
88
|
+
// The first character is the fake cursor, so an empty prompt reads as the caret it will type into.
|
|
89
|
+
const [first = '', ...rest] = Array.from(text)
|
|
90
|
+
return CURSOR_MARKER + renderFakeCursor(first) + theme.fg('dim', rest.join(''))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override handleMouse(event: TuiMouseEvent) {
|
|
94
|
+
// Pi sees its own rows; the suggestion divider and status band are presentation rows.
|
|
95
|
+
const composerOffset = this.completionRows > 0 ? this.completionRows + 1 : 0
|
|
96
|
+
const band = composerOffset + this.contentRows + 2
|
|
97
|
+
if (event.y === band) return undefined
|
|
98
|
+
if (this.completionRows > 0 && event.y === 0) return undefined
|
|
99
|
+
const y = this.completionRows > 0 && event.y <= this.completionRows
|
|
100
|
+
? this.contentRows + 1 + event.y
|
|
101
|
+
: event.y - composerOffset
|
|
102
|
+
return super.handleMouse({
|
|
103
|
+
...event,
|
|
104
|
+
x: event.x - this.gutterWidth,
|
|
105
|
+
y,
|
|
106
|
+
width: Math.max(1, event.width - this.gutterWidth)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { expect, it } from 'vitest'
|
|
5
|
+
import { terminalKeybindings } from './keybindings'
|
|
6
|
+
|
|
7
|
+
it('steps thinking effort on shift arrows and gives shift+tab to plan mode', () => {
|
|
8
|
+
const manager = terminalKeybindings('/unused/isolated-account')
|
|
9
|
+
expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
|
|
10
|
+
expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
|
|
11
|
+
expect(manager.matches('\x1b[Z', 'app.plan.toggle')).toBe(true)
|
|
12
|
+
expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('reloads edited isolated keybindings on the existing public manager', () => {
|
|
16
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-keybindings-'))
|
|
17
|
+
try {
|
|
18
|
+
const file = join(root, 'keybindings.json')
|
|
19
|
+
writeFileSync(file, JSON.stringify({ 'app.model.select': 'ctrl+k' }))
|
|
20
|
+
const manager = terminalKeybindings(root)
|
|
21
|
+
expect(manager.matches('\x0b', 'app.model.select')).toBe(true)
|
|
22
|
+
writeFileSync(file, JSON.stringify({ 'app.model.select': 'ctrl+y' }))
|
|
23
|
+
manager.reload()
|
|
24
|
+
expect(manager.matches('\x0b', 'app.model.select')).toBe(false)
|
|
25
|
+
expect(manager.matches('\x19', 'app.model.select')).toBe(true)
|
|
26
|
+
writeFileSync(file, '{}')
|
|
27
|
+
manager.reload()
|
|
28
|
+
expect(manager.matches('\x0c', 'app.model.select')).toBe(true)
|
|
29
|
+
} finally { rmSync(root, { recursive: true, force: true }) }
|
|
30
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { KeybindingsManager as PiKeybindingsManager } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { KeybindingsManager, TUI_KEYBINDINGS, type KeybindingDefinitions, type KeybindingsConfig } from '@earendil-works/pi-tui'
|
|
5
|
+
|
|
6
|
+
declare module '@earendil-works/pi-tui' {
|
|
7
|
+
interface Keybindings {
|
|
8
|
+
'app.thinking.increase': true
|
|
9
|
+
'app.thinking.decrease': true
|
|
10
|
+
'app.plan.toggle': true
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Harness owns application actions. Editing and selection retain Pi's reusable bindings.
|
|
15
|
+
const actions: Record<string, string | string[]> = {
|
|
16
|
+
'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
|
|
17
|
+
'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
|
|
18
|
+
'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s', 'app.plan.toggle': 'shift+tab',
|
|
19
|
+
'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
|
|
20
|
+
'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
|
|
21
|
+
'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
|
|
22
|
+
'app.editor.external': 'ctrl+g', 'app.message.copy': 'ctrl+x',
|
|
23
|
+
'app.message.followUp': 'alt+enter', 'app.message.dequeue': 'alt+up',
|
|
24
|
+
'app.clipboard.pasteImage': process.platform === 'darwin' ? 'alt+v' : 'ctrl+v',
|
|
25
|
+
'app.session.toggleNamedFilter': 'ctrl+n', 'app.session.togglePath': 'ctrl+p',
|
|
26
|
+
'app.session.toggleSort': 'ctrl+s', 'app.session.rename': 'ctrl+r',
|
|
27
|
+
'app.session.delete': 'ctrl+d', 'app.session.deleteNoninvasive': 'ctrl+backspace',
|
|
28
|
+
'app.tree.foldOrUp': ['alt+left', 'ctrl+left'], 'app.tree.unfoldOrDown': ['alt+right', 'ctrl+right'],
|
|
29
|
+
'app.tree.editLabel': 'shift+l', 'app.tree.toggleLabelTimestamp': 'shift+t',
|
|
30
|
+
'app.tree.filter.default': 'ctrl+d', 'app.tree.filter.noTools': 'ctrl+t',
|
|
31
|
+
'app.tree.filter.userOnly': 'ctrl+u', 'app.tree.filter.labeledOnly': 'ctrl+l',
|
|
32
|
+
'app.tree.filter.all': 'ctrl+a', 'app.tree.filter.cycleForward': 'ctrl+o',
|
|
33
|
+
'app.tree.filter.cycleBackward': 'ctrl+shift+o', 'app.models.save': 'ctrl+s',
|
|
34
|
+
'app.models.enableAll': 'ctrl+a', 'app.models.clearAll': 'ctrl+x',
|
|
35
|
+
'app.models.toggleProvider': 'ctrl+p', 'app.models.reorderUp': 'alt+up', 'app.models.reorderDown': 'alt+down',
|
|
36
|
+
'app.session.new': [], 'app.session.tree': [], 'app.session.fork': [], 'app.session.resume': []
|
|
37
|
+
}
|
|
38
|
+
export const HARNESS_KEYBINDINGS: KeybindingDefinitions = {
|
|
39
|
+
...TUI_KEYBINDINGS,
|
|
40
|
+
...Object.fromEntries(Object.entries(actions).map(([name, keys]) => [name, { defaultKeys: keys, description: name.replace(/^app\./, '').replaceAll('.', ' ') }])) as KeybindingDefinitions
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readBindings(agentDir: string): KeybindingsConfig {
|
|
44
|
+
let bindings: KeybindingsConfig = {}
|
|
45
|
+
try {
|
|
46
|
+
const source = JSON.parse(readFileSync(join(agentDir, 'keybindings.json'), 'utf8'))
|
|
47
|
+
if (source && typeof source === 'object' && !Array.isArray(source)) {
|
|
48
|
+
bindings = Object.fromEntries(Object.entries(source).filter(([, value]) => typeof value === 'string' || Array.isArray(value) && value.every(key => typeof key === 'string'))) as KeybindingsConfig
|
|
49
|
+
}
|
|
50
|
+
} catch { /* A missing keybindings file uses the application defaults. */ }
|
|
51
|
+
return bindings
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function terminalKeybindings(agentDir: string): PiKeybindingsManager {
|
|
55
|
+
const manager = new KeybindingsManager(HARNESS_KEYBINDINGS, readBindings(agentDir))
|
|
56
|
+
// CustomEditor and selector factories type their manager as Pi's private subclass.
|
|
57
|
+
// They consume the public base plus getEffectiveConfig; Harness owns the definitions.
|
|
58
|
+
return Object.assign(manager, { getEffectiveConfig: () => manager.getResolvedBindings(), reload() { manager.setUserBindings(readBindings(agentDir)) } }) as unknown as PiKeybindingsManager
|
|
59
|
+
}
|