@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.
- package/CHANGELOG.md +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +3 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /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 {
|
|
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 !==
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
14
|
-
expect(
|
|
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
|
|
19
|
-
const {
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
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', () => {
|
package/src/tools/askUser.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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')
|