@xingular/harness 0.1.6 → 0.1.7

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 (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -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,84 @@
1
+ import { getSelectListTheme, initTheme, type KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
2
+ import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, stripTerminalSequences, type TUI, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { HarnessEditor } from './editor'
6
+ import { createHarnessTheme } from './theme'
7
+
8
+ initTheme('dark', false)
9
+ const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
10
+ function setup() {
11
+ const editor = new HarnessEditor({ terminal: { rows: 30 }, requestRender() {} } as unknown as TUI,
12
+ { borderColor: (text) => text, selectList: getSelectListTheme() }, new TuiKeybindingsManager(TUI_KEYBINDINGS) as KeybindingsManager, {
13
+ band: (width, above, below) => `${above}:${below}`.padEnd(width, '─'),
14
+ theme: () => theme, showHint: () => true, thinkingKey: '⇧⇥'
15
+ })
16
+ editor.focused = true
17
+ return editor
18
+ }
19
+
20
+ describe('band composer over the native Pi editor', () => {
21
+ it('uses exactly a band and a curved prompt row, preserving the real cursor beside the hint', () => {
22
+ const editor = setup()
23
+ const rows = editor.render(80)
24
+ expect(rows).toHaveLength(2)
25
+ expect(stripTerminalSequences(rows[1])).toMatch(/^╰─ .*⇧⇥ to change thinking effort$/)
26
+ expect(rows[1]).toContain(CURSOR_MARKER)
27
+ expect(rows.every((row) => visibleWidth(row) <= 80)).toBe(true)
28
+ expect(editor.getText()).toBe('')
29
+ })
30
+
31
+ it('closes the fake cursor before the hint, so the rest of the row is not reverse video', () => {
32
+ const editor = setup()
33
+ // Mirrors pi-tui's resolveFakeCursors: the cursor opens reverse video and its end marker closes it.
34
+ const resolved = editor.render(80)[1].replace(/\x1b_pi:fc\x07/g, '\x1b[7m').replace(/\x1b_pi:\/fc\x07/g, '\x1b[27m')
35
+ const reverseOn = resolved.indexOf('\x1b[7m')
36
+ const reverseOff = resolved.indexOf('\x1b[27m')
37
+ expect(reverseOn).toBeGreaterThan(-1)
38
+ expect(reverseOff).toBeGreaterThan(reverseOn)
39
+ expect(reverseOff).toBeLessThan(resolved.indexOf('⇧⇥'))
40
+ })
41
+
42
+ it('closes the cursor cell so the hint is not painted in reverse video', () => {
43
+ const editor = setup()
44
+ // Why: pi-tui paints its fake cursor as reverse video when it resolves a frame.
45
+ const prompt = editor.render(80)[1].replace(/\x1b_pi:fc\x07/g, '\x1b[7m').replace(/\x1b_pi:\/fc\x07/g, '\x1b[27m')
46
+ expect(prompt.split('\x1b[7m')).toHaveLength(prompt.split('\x1b[27m').length)
47
+ expect(prompt.indexOf('to change')).toBeGreaterThan(prompt.lastIndexOf('\x1b[27m'))
48
+ })
49
+
50
+ it('preserves editing, multiline wrapping, history, and cursor markers across resizes', () => {
51
+ const editor = setup()
52
+ const draft = 'Read 日本語 and compare the result'.repeat(3) + '\nSecond line'
53
+ editor.setText(draft)
54
+ for (const width of [120, 40, 10, 3, 1, 80]) {
55
+ const rows = editor.render(width)
56
+ expect(rows.every((row) => visibleWidth(row) <= width), `width ${width}`).toBe(true)
57
+ expect(rows.join('')).not.toContain('\u0000')
58
+ expect(rows.join('')).toContain(CURSOR_MARKER)
59
+ expect(editor.getText()).toBe(draft)
60
+ }
61
+ editor.setText('')
62
+ editor.handleInput('hello')
63
+ expect(editor.getText()).toBe('hello')
64
+ editor.addToHistory('previous prompt')
65
+ editor.setText('')
66
+ editor.handleInput('\x1b[A')
67
+ expect(editor.getText()).toBe('previous prompt')
68
+ })
69
+
70
+ it('retains autocomplete rows and selection without mistaking them for the removed bottom border', async () => {
71
+ const editor = setup()
72
+ editor.setAutocompleteProvider({
73
+ getSuggestions: async () => ({ items: [{ value: '/accounts', label: '/accounts' }, { value: '/plugins', label: '/plugins' }], prefix: '/' }),
74
+ applyCompletion: (lines, line, _col, item) => ({ lines: lines.map((value, index) => index === line ? item.value : value), cursorLine: line, cursorCol: item.value.length })
75
+ })
76
+ editor.handleInput('/')
77
+ await new Promise((resolve) => setTimeout(resolve, 30))
78
+ const rows = editor.render(50)
79
+ expect(rows.some((row) => stripTerminalSequences(row).includes('/accounts'))).toBe(true)
80
+ expect(rows.every((row) => visibleWidth(row) <= 50)).toBe(true)
81
+ editor.handleInput('\t')
82
+ expect(editor.getText()).toContain('/accounts')
83
+ })
84
+ })
@@ -0,0 +1,79 @@
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, sliceByColumn, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
4
+
5
+ const BOTTOM = '\u0000harness-composer-bottom'
6
+
7
+ export interface ComposerView {
8
+ band(width: number, hiddenAbove: number, hiddenBelow: number): string
9
+ theme(): Theme
10
+ showHint(): boolean
11
+ thinkingKey: string
12
+ }
13
+
14
+ export class HarnessEditor extends CustomEditor {
15
+ private hiddenAbove = 0
16
+ private hiddenBelow = 0
17
+ private contentRows = 1
18
+ private gutterWidth = 3
19
+
20
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
21
+ super(tui, theme, keybindings, { paddingX: 0 })
22
+ }
23
+
24
+ override setPaddingX(_padding: number): void {
25
+ super.setPaddingX(0)
26
+ }
27
+
28
+ protected override renderTopBorder(_width: number, hiddenLineCount: number): string {
29
+ this.hiddenAbove = hiddenLineCount
30
+ return ''
31
+ }
32
+
33
+ protected override renderBottomBorder(_width: number, hiddenLineCount: number): string {
34
+ this.hiddenBelow = hiddenLineCount
35
+ return BOTTOM
36
+ }
37
+
38
+ override render(width: number): string[] {
39
+ if (width < 1) return []
40
+ this.gutterWidth = Math.min(3, Math.max(0, width - 1))
41
+ const innerWidth = Math.max(1, width - this.gutterWidth)
42
+ // Pi 1.2 cannot lay out a two-cell grapheme in a one-cell text area.
43
+ // Lay out safely, then clip the presentation without changing the draft.
44
+ const rows = super.render(Math.max(3, innerWidth))
45
+ const bottom = rows.indexOf(BOTTOM)
46
+ const content = rows.slice(1, bottom)
47
+ this.contentRows = content.length
48
+ const theme = this.view.theme()
49
+ const prompt = content.map((line, index) => {
50
+ const cue = index === 0 ? '╰─ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth)
51
+ let text = line
52
+ if (index === 0 && this.getText() === '' && this.view.showHint()) {
53
+ const hint = theme.fg('accent', this.view.thinkingKey) + ' ' + theme.italic(theme.fg('dim', 'to change thinking effort'))
54
+ // Why: a one-cell slice cuts off the fake cursor's closing marker, so pi-tui
55
+ // paints the rest of the row in reverse video. Two cells keep the marker.
56
+ const prefix = sliceByColumn(line, 0, 2)
57
+ const gap = innerWidth - visibleWidth(prefix) - visibleWidth(hint)
58
+ if (gap >= 1) text = prefix + ' '.repeat(gap) + hint
59
+ }
60
+ return theme.fg('borderMuted', cue) + text
61
+ })
62
+ const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
63
+ const result = [this.view.band(width, this.hiddenAbove, this.hiddenBelow), ...prompt, ...completions].map((line) => truncateToWidth(line, width, ''))
64
+ if (this.focused && !result.some((line) => line.includes(CURSOR_MARKER))) {
65
+ const index = Math.max(1, Math.min(result.length - 1, prompt.length))
66
+ result[index] = truncateToWidth(result[index], Math.max(0, width - 1), '') + CURSOR_MARKER + ' '
67
+ }
68
+ return result
69
+ }
70
+
71
+ override handleMouse(event: TuiMouseEvent) {
72
+ return super.handleMouse({
73
+ ...event,
74
+ x: event.x - this.gutterWidth,
75
+ y: event.y > this.contentRows ? event.y + 1 : event.y,
76
+ width: Math.max(1, event.width - this.gutterWidth)
77
+ })
78
+ }
79
+ }
@@ -0,0 +1,22 @@
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('reloads edited isolated keybindings on the existing public manager', () => {
8
+ const root = mkdtempSync(join(tmpdir(), 'harness-keybindings-'))
9
+ try {
10
+ const file = join(root, 'keybindings.json')
11
+ writeFileSync(file, JSON.stringify({ 'app.model.select': 'ctrl+k' }))
12
+ const manager = terminalKeybindings(root)
13
+ expect(manager.matches('\x0b', 'app.model.select')).toBe(true)
14
+ writeFileSync(file, JSON.stringify({ 'app.model.select': 'ctrl+y' }))
15
+ manager.reload()
16
+ expect(manager.matches('\x0b', 'app.model.select')).toBe(false)
17
+ expect(manager.matches('\x19', 'app.model.select')).toBe(true)
18
+ writeFileSync(file, '{}')
19
+ manager.reload()
20
+ expect(manager.matches('\x0c', 'app.model.select')).toBe(true)
21
+ } finally { rmSync(root, { recursive: true, force: true }) }
22
+ })
@@ -0,0 +1,50 @@
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
+ // Harness owns application actions. Editing and selection retain Pi's reusable bindings.
7
+ const actions: Record<string, string | string[]> = {
8
+ 'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
9
+ 'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
10
+ 'app.thinking.cycle': 'shift+tab', 'app.thinking.save': 'ctrl+s',
11
+ 'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
12
+ 'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
13
+ 'app.editor.external': 'ctrl+g', 'app.message.copy': 'ctrl+x',
14
+ 'app.message.followUp': 'alt+enter', 'app.message.dequeue': 'alt+up',
15
+ 'app.clipboard.pasteImage': process.platform === 'darwin' ? 'alt+v' : 'ctrl+v',
16
+ 'app.session.toggleNamedFilter': 'ctrl+n', 'app.session.togglePath': 'ctrl+p',
17
+ 'app.session.toggleSort': 'ctrl+s', 'app.session.rename': 'ctrl+r',
18
+ 'app.session.delete': 'ctrl+d', 'app.session.deleteNoninvasive': 'ctrl+backspace',
19
+ 'app.tree.foldOrUp': ['alt+left', 'ctrl+left'], 'app.tree.unfoldOrDown': ['alt+right', 'ctrl+right'],
20
+ 'app.tree.editLabel': 'shift+l', 'app.tree.toggleLabelTimestamp': 'shift+t',
21
+ 'app.tree.filter.default': 'ctrl+d', 'app.tree.filter.noTools': 'ctrl+t',
22
+ 'app.tree.filter.userOnly': 'ctrl+u', 'app.tree.filter.labeledOnly': 'ctrl+l',
23
+ 'app.tree.filter.all': 'ctrl+a', 'app.tree.filter.cycleForward': 'ctrl+o',
24
+ 'app.tree.filter.cycleBackward': 'ctrl+shift+o', 'app.models.save': 'ctrl+s',
25
+ 'app.models.enableAll': 'ctrl+a', 'app.models.clearAll': 'ctrl+x',
26
+ 'app.models.toggleProvider': 'ctrl+p', 'app.models.reorderUp': 'alt+up', 'app.models.reorderDown': 'alt+down',
27
+ 'app.session.new': [], 'app.session.tree': [], 'app.session.fork': [], 'app.session.resume': []
28
+ }
29
+ export const HARNESS_KEYBINDINGS: KeybindingDefinitions = {
30
+ ...TUI_KEYBINDINGS,
31
+ ...Object.fromEntries(Object.entries(actions).map(([name, keys]) => [name, { defaultKeys: keys, description: name.replace(/^app\./, '').replaceAll('.', ' ') }])) as KeybindingDefinitions
32
+ }
33
+
34
+ function readBindings(agentDir: string): KeybindingsConfig {
35
+ let bindings: KeybindingsConfig = {}
36
+ try {
37
+ const source = JSON.parse(readFileSync(join(agentDir, 'keybindings.json'), 'utf8'))
38
+ if (source && typeof source === 'object' && !Array.isArray(source)) {
39
+ bindings = Object.fromEntries(Object.entries(source).filter(([, value]) => typeof value === 'string' || Array.isArray(value) && value.every(key => typeof key === 'string'))) as KeybindingsConfig
40
+ }
41
+ } catch { /* A missing keybindings file uses the application defaults. */ }
42
+ return bindings
43
+ }
44
+
45
+ export function terminalKeybindings(agentDir: string): PiKeybindingsManager {
46
+ const manager = new KeybindingsManager(HARNESS_KEYBINDINGS, readBindings(agentDir))
47
+ // CustomEditor and selector factories type their manager as Pi's private subclass.
48
+ // They consume the public base plus getEffectiveConfig; Harness owns the definitions.
49
+ return Object.assign(manager, { getEffectiveConfig: () => manager.getResolvedBindings(), reload() { manager.setUserBindings(readBindings(agentDir)) } }) as unknown as PiKeybindingsManager
50
+ }
@@ -0,0 +1,62 @@
1
+ // Pi 1.2.0 omits CLI builtins, rich builtin renderers, clipboard reads and the global theme setter
2
+ // from its public exports. Keep those version-sensitive presentation hooks here; session/runtime
3
+ // work uses public APIs. Clipboard helpers are only called for an explicit user paste.
4
+ import { getPackageDir, VERSION, type InlineExtension, type Theme, type ToolRenderers } from '@earendil-works/pi-coding-agent'
5
+ import { join } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ export interface PiAdapter {
9
+ builtins: InlineExtension[]
10
+ setTheme(theme: Theme): void
11
+ withBuiltInRenderers(toolName: string, definition: ToolRenderers | undefined): ToolRenderers | undefined
12
+ readClipboardText(): Promise<string | null>
13
+ readClipboardFilePaths(): Promise<string[] | null>
14
+ readClipboardImage(): Promise<{ bytes: Uint8Array; mimeType: string } | null>
15
+ extensionForImageMimeType(mimeType: string): string | null
16
+ }
17
+
18
+ let adapter: PiAdapter | undefined
19
+ let loading: Promise<PiAdapter> | undefined
20
+
21
+ export function assertPiAdapterVersion(version: string): void {
22
+ if (version !== '1.2.0') throw new Error(`The Harness terminal adapter requires Pi 1.2.0; found ${version}. Install the Harness package's pinned dependencies.`)
23
+ }
24
+
25
+ export function loadPiAdapter(): Promise<PiAdapter> {
26
+ loading ??= (async () => {
27
+ assertPiAdapterVersion(VERSION)
28
+ const root = getPackageDir()
29
+ const [extensions, theme, renderers, clipboard, images] = await Promise.all([
30
+ import(pathToFileURL(join(root, 'dist/extensions/index.js')).href),
31
+ import(pathToFileURL(join(root, 'dist/modes/interactive/theme/theme.js')).href),
32
+ import(pathToFileURL(join(root, 'dist/core/tools/renderers/index.js')).href),
33
+ import(pathToFileURL(join(root, 'dist/utils/clipboard.js')).href),
34
+ import(pathToFileURL(join(root, 'dist/utils/clipboard-image.js')).href)
35
+ ])
36
+ if (!Array.isArray(extensions.builtInExtensions) || typeof theme.setThemeInstance !== 'function' || typeof renderers.withBuiltInRenderers !== 'function' || typeof clipboard.readClipboardText !== 'function' || typeof clipboard.readClipboardFilePaths !== 'function' || typeof images.readClipboardImage !== 'function' || typeof images.extensionForImageMimeType !== 'function') {
37
+ throw new Error('The pinned Pi terminal adapter no longer exposes its required builtin or theme hooks.')
38
+ }
39
+ adapter = {
40
+ builtins: extensions.builtInExtensions as InlineExtension[],
41
+ setTheme: theme.setThemeInstance,
42
+ withBuiltInRenderers: renderers.withBuiltInRenderers,
43
+ readClipboardText: clipboard.readClipboardText,
44
+ readClipboardFilePaths: clipboard.readClipboardFilePaths,
45
+ readClipboardImage: images.readClipboardImage,
46
+ extensionForImageMimeType: images.extensionForImageMimeType
47
+ }
48
+ return adapter
49
+ })()
50
+ return loading
51
+ }
52
+
53
+ /** Initialize once with loadPiAdapter() before rendering native rich tool components. */
54
+ export function setPiTheme(theme: Theme): void {
55
+ if (!adapter) throw new Error('Initialize the Pi terminal adapter before setting its theme.')
56
+ adapter.setTheme(theme)
57
+ }
58
+
59
+ export function withBuiltInRenderers(toolName: string, definition: ToolRenderers | undefined): ToolRenderers | undefined {
60
+ if (!adapter) throw new Error('Initialize the Pi terminal adapter before resolving builtin renderers.')
61
+ return adapter.withBuiltInRenderers(toolName, definition)
62
+ }