@xingular/harness 2.1.1 → 3.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 (152) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/NOTICE.md +22 -0
  3. package/README.md +70 -37
  4. package/dist/cli.js +6572 -6700
  5. package/dist/terminal-pi.js +51 -38
  6. package/docs/terminal-tui.md +50 -12
  7. package/package.json +2 -2
  8. package/src/accounts/accounts.ts +4 -3
  9. package/src/capabilities/api.ts +1 -1
  10. package/src/cli.ts +7 -3
  11. package/src/core/bus.test.ts +15 -1
  12. package/src/core/bus.ts +39 -0
  13. package/src/core/caller.ts +34 -0
  14. package/src/core/fs.ts +17 -1
  15. package/src/harness.ts +55 -66
  16. package/src/{memory → history}/recall.test.ts +1 -1
  17. package/src/{memory → history}/recall.ts +1 -1
  18. package/src/{memory → history}/trajectory.test.ts +4 -4
  19. package/src/{memory → history}/trajectory.ts +1 -1
  20. package/src/hub/hub.test.ts +4 -5
  21. package/src/hub/hub.ts +2 -2
  22. package/src/index.ts +28 -14
  23. package/src/mcp/connections.ts +1 -1
  24. package/src/mcp/service.ts +1 -1
  25. package/src/pi/proxy.test.ts +68 -0
  26. package/src/pi/runtime.test.ts +61 -0
  27. package/src/pi/runtime.ts +50 -0
  28. package/src/plugins/api.ts +11 -9
  29. package/src/plugins/builtin.ts +22 -40
  30. package/src/plugins/catalogue.test.ts +13 -41
  31. package/src/plugins/catalogue.ts +3 -32
  32. package/src/plugins/discover.ts +2 -1
  33. package/src/plugins/firstParty.test.ts +1 -1
  34. package/src/plugins/hooks.test.ts +21 -71
  35. package/src/plugins/hooks.ts +6 -7
  36. package/src/plugins/identity.ts +16 -2
  37. package/src/plugins/install.test.ts +1 -1
  38. package/src/plugins/install.ts +2 -1
  39. package/src/plugins/inventory.ts +19 -18
  40. package/src/plugins/plugins.ts +38 -2
  41. package/src/prompt/compile.test.ts +82 -193
  42. package/src/prompt/compile.ts +98 -363
  43. package/src/refine/cli.ts +114 -0
  44. package/src/refine/demo.ts +124 -0
  45. package/src/refine/evidence.ts +349 -0
  46. package/src/refine/host.ts +102 -0
  47. package/src/refine/ledger.ts +104 -0
  48. package/src/refine/library.ts +192 -0
  49. package/src/refine/pi.test.ts +90 -0
  50. package/src/refine/pi.ts +218 -0
  51. package/src/refine/prompts.ts +430 -0
  52. package/src/refine/recursion.ts +353 -0
  53. package/src/refine/refine.test.ts +140 -0
  54. package/src/refine/runner.ts +205 -0
  55. package/src/refine/selection.ts +291 -0
  56. package/src/refine/service.ts +194 -0
  57. package/src/refine/trajectory.ts +71 -0
  58. package/src/refine/types.ts +91 -0
  59. package/src/server/methods.ts +137 -123
  60. package/src/server/server.test.ts +37 -30
  61. package/src/sessions/contracts.ts +7 -2
  62. package/src/sessions/conversation.test.ts +2 -27
  63. package/src/sessions/planMode.test.ts +8 -1
  64. package/src/sessions/planMode.ts +17 -9
  65. package/src/sessions/policy.ts +1 -1
  66. package/src/sessions/pricing.ts +3 -1
  67. package/src/sessions/questionBatches.test.ts +15 -3
  68. package/src/sessions/registry.ts +13 -1
  69. package/src/sessions/runStore.ts +3 -1
  70. package/src/sessions/runner.ts +10 -5
  71. package/src/sessions/sessions.test.ts +60 -13
  72. package/src/sessions/sessions.ts +43 -18
  73. package/src/sessions/subagentService.test.ts +22 -1
  74. package/src/sessions/subagentService.ts +14 -1
  75. package/src/sessions/subagents.ts +2 -0
  76. package/src/terminal/cli.ts +24 -48
  77. package/src/terminal/logs.test.ts +26 -0
  78. package/src/terminal/logs.ts +67 -0
  79. package/src/terminal/piExtension.lifecycle.test.ts +1 -1
  80. package/src/terminal/piExtension.ts +3 -3
  81. package/src/terminal/pluginControl.test.ts +7 -8
  82. package/src/terminal/socket.ts +3 -3
  83. package/src/terminal/terminal.test.ts +41 -39
  84. package/src/terminal/terminals.ts +31 -13
  85. package/src/terminal/tui/application.test.ts +58 -0
  86. package/src/terminal/tui/application.ts +15 -4
  87. package/src/terminal/tui/clipboardFiles.test.ts +94 -0
  88. package/src/terminal/tui/clipboardFiles.ts +86 -0
  89. package/src/terminal/tui/commands.test.ts +13 -1
  90. package/src/terminal/tui/commands.ts +16 -0
  91. package/src/terminal/tui/logPanel.test.ts +61 -0
  92. package/src/terminal/tui/logPanel.ts +175 -0
  93. package/src/terminal/tui/piAdapter.ts +7 -4
  94. package/src/terminal/tui/questionPicker.ts +168 -0
  95. package/src/terminal/tui/questions.test.ts +86 -19
  96. package/src/terminal/tui/questions.ts +27 -22
  97. package/src/terminal/tui/runtime.ts +2 -0
  98. package/src/tools/askUser.test.ts +23 -2
  99. package/src/tools/askUser.ts +15 -5
  100. package/src/tools/definitions.ts +49 -132
  101. package/src/tools/dispatch.ts +78 -0
  102. package/src/tools/harnessTools.ts +23 -0
  103. package/src/tools/spawnSubagent.ts +3 -3
  104. package/src/tools/types.ts +3 -1
  105. package/src/tree/components.ts +221 -0
  106. package/src/tree/materialise.ts +122 -0
  107. package/src/tree/seed.ts +123 -0
  108. package/src/tree/store.ts +193 -0
  109. package/src/tree/surface.ts +135 -0
  110. package/src/tree/tree.test.ts +126 -0
  111. package/src/tree/tree.ts +346 -0
  112. package/src/tree/trust.ts +86 -0
  113. package/src/web/tools.ts +27 -5
  114. package/src/web/web.test.ts +26 -1
  115. package/src/wiki/notes.ts +313 -0
  116. package/src/memory/api.test.ts +0 -82
  117. package/src/memory/api.ts +0 -303
  118. package/src/memory/blocks.test.ts +0 -78
  119. package/src/memory/blocks.ts +0 -408
  120. package/src/memory/caller.ts +0 -69
  121. package/src/memory/curation.test.ts +0 -206
  122. package/src/memory/curation.ts +0 -64
  123. package/src/memory/dispatch.ts +0 -64
  124. package/src/memory/facts.test.ts +0 -41
  125. package/src/memory/facts.ts +0 -248
  126. package/src/memory/graph.ts +0 -103
  127. package/src/memory/health.test.ts +0 -130
  128. package/src/memory/health.ts +0 -74
  129. package/src/memory/maintainer.ts +0 -272
  130. package/src/memory/maintenance.test.ts +0 -192
  131. package/src/memory/maintenance.ts +0 -679
  132. package/src/memory/migrate.ts +0 -79
  133. package/src/memory/operations.ts +0 -150
  134. package/src/memory/proposals.test.ts +0 -168
  135. package/src/memory/proposals.ts +0 -584
  136. package/src/memory/references.ts +0 -77
  137. package/src/memory/runContext.ts +0 -93
  138. package/src/memory/store.test.ts +0 -66
  139. package/src/memory/store.ts +0 -385
  140. package/src/memory/wiki.test.ts +0 -47
  141. package/src/memory/wiki.ts +0 -423
  142. package/src/memory/wikiIndex.test.ts +0 -88
  143. package/src/memory/wikiIndex.ts +0 -117
  144. package/src/plugins/proposal.ts +0 -121
  145. package/src/skills/registry.test.ts +0 -104
  146. package/src/skills/registry.ts +0 -413
  147. package/src/tools/maintenance.ts +0 -29
  148. package/src/tools/memoryTools.ts +0 -22
  149. package/src/tools/pluginPropose.ts +0 -41
  150. package/src/tools/registryWrite.test.ts +0 -101
  151. package/src/tools/registryWrite.ts +0 -182
  152. /package/src/{memory → core}/secrets.ts +0 -0
@@ -1,10 +1,12 @@
1
1
  // Pi 1.2.0 omits CLI builtins, rich builtin renderers, clipboard reads and the global theme setter
2
2
  // from its public exports. Keep those version-sensitive presentation hooks here; session/runtime
3
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'
4
+ import type { InlineExtension, Theme, ToolRenderers } from '@earendil-works/pi-coding-agent'
5
5
  import { join } from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
7
 
8
+ import { loadPi, PINNED_PI_VERSION } from '../../pi/runtime'
9
+
8
10
  export interface PiAdapter {
9
11
  builtins: InlineExtension[]
10
12
  setTheme(theme: Theme): void
@@ -19,13 +21,14 @@ let adapter: PiAdapter | undefined
19
21
  let loading: Promise<PiAdapter> | undefined
20
22
 
21
23
  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.`)
24
+ if (version !== PINNED_PI_VERSION) throw new Error(`The Harness terminal adapter requires Pi ${PINNED_PI_VERSION}; found ${version}. Install the Harness package's pinned dependencies.`)
23
25
  }
24
26
 
25
27
  export function loadPiAdapter(): Promise<PiAdapter> {
26
28
  loading ??= (async () => {
27
- assertPiAdapterVersion(VERSION)
28
- const root = getPackageDir()
29
+ const pi = await loadPi()
30
+ assertPiAdapterVersion(pi.VERSION)
31
+ const root = pi.getPackageDir()
29
32
  const [extensions, theme, renderers, clipboard, images] = await Promise.all([
30
33
  import(pathToFileURL(join(root, 'dist/extensions/index.js')).href),
31
34
  import(pathToFileURL(join(root, 'dist/modes/interactive/theme/theme.js')).href),
@@ -0,0 +1,168 @@
1
+ import type { Theme } from '@earendil-works/pi-coding-agent'
2
+ import { SelectList, stripTerminalSequences, truncateToWidth, visibleWidth, type Component, type Focusable, type SelectListTheme, type TUI } from '@earendil-works/pi-tui'
3
+
4
+ import type { QuestionOption } from '../../sessions/contracts'
5
+ import { DialogPanel } from './dialogs'
6
+
7
+ const RECOMMENDED = '(Recommended)'
8
+
9
+ type DisplayOption = { option: QuestionOption; index: number }
10
+
11
+ export type QuestionPickerResult =
12
+ | { type: 'answer'; selected: string[] }
13
+ | { type: 'text'; selected: string[] }
14
+ | { type: 'cancel' }
15
+
16
+ export function questionOptionsInDisplayOrder(options: readonly QuestionOption[]): DisplayOption[] {
17
+ const indexed = options.map((option, index) => ({ option, index }))
18
+ return [...indexed.filter(({ option }) => option.recommended), ...indexed.filter(({ option }) => !option.recommended)]
19
+ }
20
+
21
+ function recommendedSelectTheme(theme: Theme): SelectListTheme {
22
+ return {
23
+ selectedPrefix: text => theme.fg('accent', text),
24
+ selectedText: text => {
25
+ const marker = text.lastIndexOf(RECOMMENDED)
26
+ if (marker < 0) return theme.fg('accent', text.replace(/^→/, '❯'))
27
+ const afterMarker = marker + RECOMMENDED.length
28
+ return theme.fg('accent', text.slice(0, marker).replace(/^→/, '❯')) + theme.fg('warning', RECOMMENDED) + theme.fg('accent', text.slice(afterMarker))
29
+ },
30
+ description: text => theme.fg('muted', text),
31
+ scrollInfo: text => theme.fg('dim', text),
32
+ noMatch: text => theme.fg('warning', text.replace('commands', 'options'))
33
+ }
34
+ }
35
+
36
+ class QuestionPickerContent implements Component, Focusable {
37
+ private list!: SelectList
38
+ private selectedIndexes: number[]
39
+ private focusedValue = false
40
+ private readonly displayOptions: DisplayOption[]
41
+
42
+ constructor(
43
+ private readonly tui: TUI,
44
+ private readonly theme: Theme,
45
+ private readonly options: readonly QuestionOption[],
46
+ private readonly multiSelect: boolean,
47
+ private readonly allowText: boolean,
48
+ selected: readonly string[],
49
+ private readonly done: (result: QuestionPickerResult) => void
50
+ ) {
51
+ this.displayOptions = questionOptionsInDisplayOrder(options)
52
+ this.selectedIndexes = []
53
+ const remaining = [...selected]
54
+ options.forEach((option, index) => {
55
+ const selectedIndex = remaining.indexOf(option.label)
56
+ if (selectedIndex >= 0) {
57
+ this.selectedIndexes.push(index)
58
+ remaining.splice(selectedIndex, 1)
59
+ }
60
+ })
61
+ this.rebuild()
62
+ }
63
+
64
+ get focused(): boolean { return this.focusedValue }
65
+ set focused(value: boolean) { this.focusedValue = value }
66
+
67
+ private labels(): string[] {
68
+ return this.selectedIndexes.map(index => this.options[index]!.label)
69
+ }
70
+
71
+ private rebuild(): void {
72
+ const selectedValue = this.list?.getSelectedItem()?.value
73
+ const items = this.displayOptions.map(({ option }, displayIndex) => ({
74
+ value: `option:${displayIndex}`,
75
+ label: `${displayIndex + 1}. ${this.selectedIndexes.includes(this.displayOptions[displayIndex]!.index) ? '✓ ' : ''}${option.label}${option.recommended ? ` ${RECOMMENDED}` : ''}`,
76
+ description: option.description
77
+ }))
78
+ if (this.allowText) items.push({ value: 'write', label: 'Write an answer', description: undefined })
79
+ if (this.multiSelect) items.push({ value: 'submit', label: 'Submit choices', description: undefined })
80
+
81
+ const maxVisible = Math.min(12, Math.max(1, Math.floor(this.tui.terminal.rows * 0.6) - 5))
82
+ const theme = recommendedSelectTheme(this.theme)
83
+ this.list = new SelectList(items, maxVisible, theme, {
84
+ minPrimaryColumnWidth: 1,
85
+ maxPrimaryColumnWidth: 60,
86
+ truncatePrimary: ({ text, maxWidth, isSelected }) => {
87
+ const marker = text.lastIndexOf(RECOMMENDED)
88
+ if (marker < 0) return truncateToWidth(text, maxWidth, '')
89
+ const label = text.slice(0, marker).trimEnd()
90
+ const suffix = text.slice(marker)
91
+ const suffixWidth = visibleWidth(suffix)
92
+ const shortened = suffixWidth >= maxWidth ? truncateToWidth(suffix, maxWidth, '') : `${truncateToWidth(label, Math.max(1, maxWidth - suffixWidth), '…')} ${suffix}`
93
+ if (isSelected) return shortened
94
+ const shortenedMarker = shortened.lastIndexOf(RECOMMENDED)
95
+ return shortenedMarker < 0
96
+ ? shortened
97
+ : shortened.slice(0, shortenedMarker) + this.theme.fg('warning', shortened.slice(shortenedMarker))
98
+ }
99
+ })
100
+ if (selectedValue) {
101
+ const index = items.findIndex(item => item.value === selectedValue)
102
+ if (index >= 0) this.list.setSelectedIndex(index)
103
+ }
104
+ this.list.onSelect = item => {
105
+ if (item.value === 'write') {
106
+ this.done({ type: 'text', selected: this.labels() })
107
+ return
108
+ }
109
+ if (item.value === 'submit') {
110
+ this.done({ type: 'answer', selected: this.labels() })
111
+ return
112
+ }
113
+ const displayIndex = Number(item.value.slice('option:'.length))
114
+ const originalIndex = this.displayOptions[displayIndex]?.index
115
+ if (originalIndex === undefined) return
116
+ if (!this.multiSelect) {
117
+ this.done({ type: 'answer', selected: [this.options[originalIndex]!.label] })
118
+ return
119
+ }
120
+ const selectedIndex = this.selectedIndexes.indexOf(originalIndex)
121
+ if (selectedIndex >= 0) this.selectedIndexes.splice(selectedIndex, 1)
122
+ else this.selectedIndexes.push(originalIndex)
123
+ this.rebuild()
124
+ this.tui.requestRender()
125
+ }
126
+ this.list.onCancel = () => this.done({ type: 'cancel' })
127
+ }
128
+
129
+ invalidate(): void { this.list.invalidate() }
130
+ handleInput(data: string): void {
131
+ if (/^[1-9]$/.test(data)) {
132
+ const index = Number(data) - 1
133
+ if (index >= 0 && index < this.displayOptions.length) {
134
+ this.list.setSelectedIndex(index)
135
+ this.list.handleInput('\r')
136
+ return
137
+ }
138
+ }
139
+ this.list.handleInput(data === 'j' ? '\x1b[B' : data === 'k' ? '\x1b[A' : data)
140
+ }
141
+
142
+ render(width: number): string[] {
143
+ return this.list.render(width).map(line => {
144
+ const bounded = truncateToWidth(line, width, '')
145
+ return stripTerminalSequences(line).startsWith('❯')
146
+ ? this.theme.bg('selectedBg', bounded + ' '.repeat(Math.max(0, width - visibleWidth(bounded))))
147
+ : bounded
148
+ })
149
+ }
150
+ }
151
+
152
+ export function createQuestionPicker(
153
+ tui: TUI,
154
+ theme: Theme,
155
+ title: string,
156
+ question: string | undefined,
157
+ options: readonly QuestionOption[],
158
+ multiSelect: boolean,
159
+ allowText: boolean,
160
+ selected: readonly string[],
161
+ done: (result: QuestionPickerResult) => void
162
+ ): DialogPanel {
163
+ const content = new QuestionPickerContent(tui, theme, options, multiSelect, allowText, selected, done)
164
+ const hint = multiSelect ? '↑/↓ select · Enter toggle · Submit choices · Esc cancel' : '↑/↓ select · Enter choose · Esc cancel'
165
+ const panel = new DialogPanel(title, content, () => theme, hint, question)
166
+ panel.maxRows = () => Math.max(3, Math.floor(tui.terminal.rows * 0.8))
167
+ return panel
168
+ }
@@ -1,41 +1,108 @@
1
- import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
1
+ import type { ExtensionUIContext, KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
2
+ import type { Component, TUI } from '@earendil-works/pi-tui'
3
+ import { stripTerminalSequences } from '@earendil-works/pi-tui'
2
4
  import { describe, expect, it, vi } from 'vitest'
3
5
  import { answerTerminalQuestion } from './questions'
4
6
 
5
7
  function fixture() {
6
- const ui = { select: vi.fn(), input: vi.fn() }
7
- return { ui, context: ui as unknown as ExtensionUIContext, signal: new AbortController().signal }
8
+ const tui = { terminal: { rows: 24 }, requestRender: vi.fn() } as unknown as TUI
9
+ const theme = {
10
+ fg: (color: string, text: string) => color === 'warning' ? `\x1b[33m${text}\x1b[39m` : text,
11
+ bg: (_color: string, text: string) => text,
12
+ bold: (text: string) => text
13
+ } as unknown as Theme
14
+ let active: Component | undefined
15
+ const custom = vi.fn((factory: Parameters<ExtensionUIContext['custom']>[0]) => new Promise<unknown>((resolve) => {
16
+ active = factory(tui, theme, {} as KeybindingsManager, resolve) as Component
17
+ }))
18
+ const ui = { custom, input: vi.fn() }
19
+ return { ui, context: ui as unknown as ExtensionUIContext, signal: new AbortController().signal, getActive: () => active }
8
20
  }
9
21
 
10
22
  describe('terminal question answers', () => {
11
23
  it('keeps action-like option labels distinct from dialog actions', async () => {
12
- const { ui, context, signal } = fixture()
13
- ui.select.mockResolvedValue('1. Write an answer — Actual choice')
14
- expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'Write an answer', description: 'Actual choice' }] }, signal)).toEqual({ selected: ['Write an answer'] })
24
+ const { ui, context, signal, getActive } = fixture()
25
+ const answer = answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'Write an answer', description: 'Actual choice' }] }, signal)
26
+ expect(stripTerminalSequences(getActive()!.render(80).join('\n'))).toContain('1. Write an answer')
27
+ getActive()!.handleInput?.('\r')
28
+ await expect(answer).resolves.toEqual({ selected: ['Write an answer'] })
15
29
  expect(ui.input).not.toHaveBeenCalled()
16
30
  })
17
31
 
18
- it('returns to choices when text entry is cancelled and retains multiple selections', async () => {
19
- const { ui, context, signal } = fixture()
20
- ui.select.mockResolvedValueOnce('1. A').mockResolvedValueOnce('Write an answer').mockResolvedValueOnce('Submit choices')
32
+ it('returns the original label when a recommended choice is selected', async () => {
33
+ const { context, signal, getActive } = fixture()
34
+ const answer = answerTerminalQuestion(context, {
35
+ requestId: 'q', question: 'Choose?', options: [{ label: 'Plan A' }, { label: 'Plan B', recommended: true }], allowText: false
36
+ }, signal)
37
+ getActive()!.handleInput?.('\r')
38
+ await expect(answer).resolves.toEqual({ selected: ['Plan B'] })
39
+ })
40
+
41
+ it('keeps numeric choice shortcuts aligned with the displayed recommendation-first order', async () => {
42
+ const { context, signal, getActive } = fixture()
43
+ const answer = answerTerminalQuestion(context, {
44
+ requestId: 'q', question: 'Choose?', options: [{ label: 'Plan A' }, { label: 'Plan B', recommended: true }], allowText: false
45
+ }, signal)
46
+ getActive()!.handleInput?.('2')
47
+ await expect(answer).resolves.toEqual({ selected: ['Plan A'] })
48
+ })
49
+
50
+ it('returns to choices after text entry is cancelled and retains multiple selections', async () => {
51
+ const { ui, context, signal, getActive } = fixture()
21
52
  ui.input.mockResolvedValue(undefined)
22
- expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Choose?', options: [{ label: 'A' }], multiSelect: true }, signal)).toEqual({ selected: ['A'] })
23
- expect(ui.select).toHaveBeenCalledTimes(3)
24
- expect(ui.select.mock.calls[2][1]).toContain('1. ✓ A')
53
+ const answer = answerTerminalQuestion(context, {
54
+ requestId: 'q', question: 'Choose?', options: [{ label: 'A' }], multiSelect: true
55
+ }, signal)
56
+ getActive()!.handleInput?.('\r')
57
+ getActive()!.handleInput?.('\x1b[B')
58
+ getActive()!.handleInput?.('\r')
59
+ await new Promise(resolve => setTimeout(resolve, 0))
60
+ expect(ui.custom).toHaveBeenCalledTimes(2)
61
+ getActive()!.handleInput?.('\x1b[B')
62
+ getActive()!.handleInput?.('\x1b[B')
63
+ getActive()!.handleInput?.('\r')
64
+ await expect(answer).resolves.toEqual({ selected: ['A'] })
65
+ })
66
+
67
+ it('shows recommended options first with a yellow marker, without modifying the labels', async () => {
68
+ const controller = new AbortController()
69
+ const { ui, context, getActive } = fixture()
70
+ const answer = answerTerminalQuestion(context, {
71
+ requestId: 'q', question: 'Choose?', options: [
72
+ { label: 'Plan A' },
73
+ { label: 'Plan B', recommended: true },
74
+ { label: 'Plan C', recommended: true }
75
+ ], multiSelect: true
76
+ }, controller.signal)
77
+ const rendered = getActive()!.render(100).join('\n')
78
+ const plain = stripTerminalSequences(rendered)
79
+ expect(plain.indexOf('Plan B')).toBeLessThan(plain.indexOf('Plan A'))
80
+ expect(plain.indexOf('Plan C')).toBeLessThan(plain.indexOf('Plan A'))
81
+ expect(rendered).toContain('\x1b[33m(Recommended)\x1b[39m')
82
+ controller.abort()
83
+ await expect(answer).resolves.toBeUndefined()
84
+ expect(ui.custom).toHaveBeenCalledOnce()
25
85
  })
26
86
 
27
- it('shows real batch progress and returns cancellation without inventing an answer', async () => {
87
+ it('shows batch progress and the full question in text entry', async () => {
28
88
  const { ui, context, signal } = fixture()
29
89
  ui.input.mockResolvedValue(undefined)
30
- expect(await answerTerminalQuestion(context, { requestId: 'q', question: 'Why?', summary: 'Reason', batchId: 'batch', batchIndex: 2, batchCount: 3 }, signal)).toBeUndefined()
90
+ const answer = answerTerminalQuestion(context, {
91
+ requestId: 'q', question: 'Why?', summary: 'Reason', batchId: 'batch', batchIndex: 2, batchCount: 3
92
+ }, signal)
93
+ await expect(answer).resolves.toBeUndefined()
31
94
  expect(ui.input).toHaveBeenCalledWith('2 of 3 · Reason: Why?', undefined, { signal })
32
95
  })
33
96
 
34
- it('honors text restrictions and ignores answers after expiry', async () => {
35
- const { ui, context } = fixture()
97
+ it('honors text restrictions and cancels the picker when the question expires', async () => {
36
98
  const controller = new AbortController()
37
- ui.select.mockImplementation(async () => { controller.abort(); return '1. A' })
38
- expect(await answerTerminalQuestion(context, { requestId: 'q', options: [{ label: 'A' }], allowText: false }, controller.signal)).toBeUndefined()
39
- expect(ui.select.mock.calls[0][1]).toEqual(['1. A'])
99
+ const { ui, context, getActive } = fixture()
100
+ const answer = answerTerminalQuestion(context, {
101
+ requestId: 'q', options: [{ label: 'A' }], allowText: false
102
+ }, controller.signal)
103
+ expect(stripTerminalSequences(getActive()!.render(80).join('\n'))).not.toContain('Write an answer')
104
+ controller.abort()
105
+ await expect(answer).resolves.toBeUndefined()
106
+ expect(ui.custom).toHaveBeenCalledOnce()
40
107
  })
41
108
  })
@@ -1,11 +1,12 @@
1
1
  import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
2
2
  import type { QuestionAnswer } from '../../sessions/registry'
3
+ import { createQuestionPicker, type QuestionPickerResult } from './questionPicker'
3
4
 
4
5
  export interface TerminalQuestion {
5
6
  requestId: string
6
7
  question?: string
7
8
  summary?: string
8
- options?: { label: string; description?: string }[]
9
+ options?: { label: string; description?: string; recommended?: boolean }[]
9
10
  multiSelect?: boolean
10
11
  allowText?: boolean
11
12
  batchId?: string
@@ -16,32 +17,36 @@ export interface TerminalQuestion {
16
17
  /** Answers stay local until the complete group can be submitted to the registry. */
17
18
  export async function answerTerminalQuestion(ui: ExtensionUIContext, request: TerminalQuestion, signal: AbortSignal): Promise<QuestionAnswer | undefined> {
18
19
  const choices = request.options ?? []
19
- const selected: string[] = []
20
20
  const progress = request.batchId ? `${request.batchIndex} of ${request.batchCount} · ` : ''
21
- const title = `${progress}${request.summary ? request.summary + ': ' : ''}${request.question ?? 'Question'}`
21
+ const title = `${progress}${request.summary ?? 'Question'}`
22
+ const inputTitle = `${progress}${request.summary ? `${request.summary}: ` : ''}${request.question ?? 'Question'}`
22
23
  if (!choices.length) {
23
- if (request.allowText === false) return { selected }
24
- const text = await ui.input(title, undefined, { signal })
25
- return text === undefined || signal.aborted ? undefined : { selected, text }
24
+ if (request.allowText === false) return { selected: [] }
25
+ const text = await ui.input(inputTitle, undefined, { signal })
26
+ return text === undefined || signal.aborted ? undefined : { selected: [], text }
26
27
  }
28
+ let selected: string[] = []
27
29
  while (!signal.aborted) {
28
- const labels = choices.map((option, index) => `${index + 1}. ${selected.includes(option.label) ? '✓ ' : ''}${option.label}${option.description ? ' — ' + option.description : ''}`)
29
- const menu = [...labels, ...(request.allowText !== false ? ['Write an answer'] : []), ...(request.multiSelect ? ['Submit choices'] : [])]
30
- const choice = await ui.select(title, menu, { signal })
31
- if (choice === undefined || signal.aborted) return undefined
32
- if (choice === 'Submit choices') return { selected }
33
- if (choice === 'Write an answer') {
34
- const text = await ui.input(title, undefined, { signal })
35
- if (signal.aborted) return undefined
36
- if (text !== undefined) return { selected, text }
37
- continue
30
+ let cancelPicker = () => {}
31
+ const onAbort = () => cancelPicker()
32
+ signal.addEventListener('abort', onAbort, { once: true })
33
+ const picker = ui.custom<QuestionPickerResult>((tui, theme, _keybindings, done) => {
34
+ cancelPicker = () => done({ type: 'cancel' })
35
+ if (signal.aborted) cancelPicker()
36
+ return createQuestionPicker(tui, theme, title, request.question, choices, request.multiSelect === true, request.allowText !== false, selected, done)
37
+ })
38
+ let result: QuestionPickerResult | undefined
39
+ try {
40
+ result = await picker
41
+ } finally {
42
+ signal.removeEventListener('abort', onAbort)
38
43
  }
39
- const index = labels.indexOf(choice)
40
- if (index < 0) throw new Error('Choose one of the displayed answers.')
41
- const label = choices[index]!.label
42
- if (selected.includes(label)) selected.splice(selected.indexOf(label), 1)
43
- else selected.push(label)
44
- if (!request.multiSelect) return { selected }
44
+ if (!result || result.type === 'cancel' || signal.aborted) return undefined
45
+ selected = result.selected
46
+ if (result.type === 'answer') return { selected }
47
+ const text = await ui.input(inputTitle, undefined, { signal })
48
+ if (signal.aborted) return undefined
49
+ if (text !== undefined) return { selected, text }
45
50
  }
46
51
  return undefined
47
52
  }
@@ -11,6 +11,7 @@ import terminalExtension, { type TerminalPlanRunState } from '../piExtension'
11
11
  import { planModeTools } from './planMode'
12
12
  import { claimTerminalControl, controlCall } from '../control'
13
13
  import { loadPiAdapter } from './piAdapter'
14
+ import { loadPi } from '../../pi/runtime'
14
15
  import type { SessionAccount } from '../accountsMenu'
15
16
  import type { QuestionAnswer } from '../../sessions/registry'
16
17
  import type { TerminalQuestion } from './questions'
@@ -174,6 +175,7 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
174
175
  assertTerminalSessionScope(resume, cwd)
175
176
  }
176
177
 
178
+ await loadPi()
177
179
  // Claim before loading any plugin code and retain it in the trusted process across reloads.
178
180
  await claimTerminalControl()
179
181
  const identity = await controlCall<SessionAccount>('accounts.session').catch(() => null)
@@ -1,3 +1,4 @@
1
+ import { Type } from 'typebox'
1
2
  import { expect, test } from 'vitest'
2
3
 
3
4
  import type { ActiveRun, QuestionInput } from '../sessions/registry'
@@ -16,24 +17,43 @@ test('ask_user requests one to five summarized questions and returns answers in
16
17
  } as unknown as ActiveRun)
17
18
 
18
19
  const response = await tool.execute({ questions: [
19
- { summary: 'Navigation style', question: 'Which navigation?', options: [{ label: 'Keyboard', description: 'Move through controls with keys.' }, { label: 'Pointer', description: 'Select controls with a mouse or trackpad.' }], multiSelect: true },
20
+ { summary: 'Navigation style', question: 'Which navigation?', options: [{ label: 'Keyboard', description: 'Move through controls with keys.', recommended: true }, { label: 'Pointer', description: 'Select controls with a mouse or trackpad.', recommended: true }], multiSelect: true },
20
21
  { summary: 'Delivery timing', question: 'When?', options: [{ label: 'Today', description: 'Start immediately.' }, { label: 'Next week', description: 'Start after the current week.' }], allowText: true }
21
22
  ] })
22
23
 
23
24
  expect(requested).toMatchObject([
24
- { summary: 'Navigation style', question: 'Which navigation?', multiSelect: true, allowText: true },
25
+ { summary: 'Navigation style', question: 'Which navigation?', options: [{ label: 'Keyboard', recommended: true }, { label: 'Pointer', recommended: true }], multiSelect: true, allowText: true },
25
26
  { summary: 'Delivery timing', question: 'When?', multiSelect: false, allowText: true }
26
27
  ])
27
28
  expect(response).toContain('Navigation style: The user chose: Keyboard. Their note for Keyboard: Keep it accessible.')
28
29
  expect(response).toContain('Delivery timing: The user answered: Next week')
29
30
  })
30
31
 
32
+ test('ask_user exposes optional recommendation metadata in its option schema', () => {
33
+ const tool = askUserTool({ requestAnswers: async () => [] } as unknown as ActiveRun)
34
+ const schema = tool.parameters(Type) as any
35
+ const option = schema.properties.questions.items.properties.options.items
36
+ expect(option.properties.recommended).toMatchObject({ type: 'boolean' })
37
+ expect(option.required).not.toContain('recommended')
38
+ })
39
+
31
40
  test('ask_user rejects empty questions and summaries', async () => {
32
41
  const tool = askUserTool({ requestAnswers: async () => [] } as unknown as ActiveRun)
33
42
  await expect(tool.execute({ questions: [] })).resolves.toBe('Ask between one and five questions.')
34
43
  await expect(tool.execute({ questions: [{ summary: '', question: 'Pick one' }] })).resolves.toBe('Each question needs a short summary of no more than 100 characters.')
35
44
  })
36
45
 
46
+ test('ask_user rejects multiple recommendations on a single-choice question', async () => {
47
+ let requested = false
48
+ const tool = askUserTool({ requestAnswers: async () => { requested = true; return [] } } as unknown as ActiveRun)
49
+ await expect(tool.execute({ questions: [{
50
+ summary: 'Plan choice',
51
+ question: 'Which plan?',
52
+ options: [{ label: 'Plan A', description: 'First plan.', recommended: true }, { label: 'Plan B', description: 'Second plan.', recommended: true }]
53
+ }] })).resolves.toBe('A single-choice question may recommend at most one option.')
54
+ expect(requested).toBe(false)
55
+ })
56
+
37
57
  test('ask_user requires a description for every choice', async () => {
38
58
  let requested = false
39
59
  const tool = askUserTool({ requestAnswers: async () => { requested = true; return [] } } as unknown as ActiveRun)
@@ -62,6 +82,7 @@ test('ask_user treats 25 words as guidance for questions and choice descriptions
62
82
  expect(response).toContain('Reply detail: The user chose: Detailed.')
63
83
  expect(tool.description).toContain('aim to keep each question to 25 words or fewer')
64
84
  expect(tool.description).toContain('every option must include a helpful description of 25 words or fewer')
85
+ expect(tool.description).toContain('recommended: true')
65
86
  })
66
87
 
67
88
  test('describeAnswer includes choice-specific notes', () => {
@@ -22,7 +22,7 @@ export function askUserTool(run: Pick<ActiveRun, 'requestAnswers'>): ToolSpec {
22
22
  label: 'Ask the user',
23
23
  description:
24
24
  'Ask the user one to five related questions in one request and wait until they answer the batch. Give each question a concise 2–3 word summary and aim to keep each question to 25 words or fewer. ' +
25
- 'Offer up to 6 short options when the choices are clear; every option must include a helpful description of 25 words or fewer. Every question includes a free-text “Something else” answer. Set multiSelect when several can apply. Use it when you need a decision, not for small talk.',
25
+ 'Offer up to 6 short options when the choices are clear; every option must include a helpful description of 25 words or fewer. Recommendations are visual only: they never preselect an answer, and submitted choices keep their original labels. Mark a recommended option with recommended: true instead of changing its label; mark at most one for a single-choice question, or several when multiSelect is true. Every question includes a free-text “Something else” answer. Set multiSelect when several can apply. Use it when you need a decision, not for small talk.',
26
26
  parameters: (Type) =>
27
27
  Type.Object({
28
28
  questions: Type.Array(Type.Object({
@@ -30,8 +30,9 @@ export function askUserTool(run: Pick<ActiveRun, 'requestAnswers'>): ToolSpec {
30
30
  question: Type.String({ description: 'A clear, standalone question; aim for 25 words or fewer' }),
31
31
  options: Type.Optional(
32
32
  Type.Array(Type.Object({
33
- label: Type.String({ description: 'A short choice' }),
34
- description: Type.String({ description: 'Required. Briefly explain this choice in 25 words or fewer.' })
33
+ label: Type.String({ description: 'A short choice; do not add a recommendation suffix to the label' }),
34
+ description: Type.String({ description: 'Required. Briefly explain this choice in 25 words or fewer.' }),
35
+ recommended: Type.Optional(Type.Boolean({ description: 'Whether to show this choice as a visual recommendation; it is not preselected, and answers use the original label' }))
35
36
  }), { maxItems: MAX_OPTIONS })
36
37
  ),
37
38
  multiSelect: Type.Optional(Type.Boolean())
@@ -44,13 +45,22 @@ export function askUserTool(run: Pick<ActiveRun, 'requestAnswers'>): ToolSpec {
44
45
  const summary = text(item.summary)
45
46
  const question = text(item.question)
46
47
  const options = (Array.isArray(item.options) ? item.options : [])
47
- .map((option) => ({ label: text((option as { label?: unknown }).label), description: text((option as { description?: unknown }).description) }))
48
+ .map((option) => {
49
+ const choice = option as { label?: unknown; description?: unknown; recommended?: unknown }
50
+ return {
51
+ label: text(choice.label),
52
+ description: text(choice.description),
53
+ ...(choice.recommended === true ? { recommended: true } : {})
54
+ }
55
+ })
48
56
  .filter((option) => option.label)
49
57
  .slice(0, MAX_OPTIONS)
50
- return { summary, question, options, multiSelect: options.length > 1 && item.multiSelect === true, allowText: true }
58
+ const multiSelect = options.length > 1 && item.multiSelect === true
59
+ return { summary, question, options, multiSelect, allowText: true }
51
60
  })
52
61
  if (questions.some((question) => !question.summary || question.summary.length > 100)) return 'Each question needs a short summary of no more than 100 characters.'
53
62
  if (questions.some((question) => !question.question)) return 'Every question needs a question sentence.'
63
+ if (questions.some((question) => !question.multiSelect && question.options.filter((option) => option.recommended).length > 1)) return 'A single-choice question may recommend at most one option.'
54
64
  if (questions.some((question) => question.options.some((option) => !option.description))) return 'Every choice needs a short description. Aim for 25 words or fewer.'
55
65
  const answers = await run.requestAnswers(questions)
56
66
  return questions.map((question, index) => `${question.summary}: ${describeAnswer(answers[index])}`).join('\n')