@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.
Files changed (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. 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
+ }