@xingular/harness 2.1.0 → 2.1.2
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 +10 -0
- package/README.md +3 -1
- package/dist/cli.js +731 -189
- package/dist/terminal-pi.js +105 -26
- package/docs/terminal-tui.md +31 -13
- package/package.json +1 -1
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/sessions/contracts.ts +1 -1
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +8 -0
- package/src/sessions/sessions.test.ts +15 -7
- package/src/sessions/sessions.ts +3 -1
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +28 -1
- package/src/terminal/piExtension.ts +17 -3
- package/src/terminal/terminal.test.ts +20 -0
- package/src/terminal/terminals.ts +11 -0
- package/src/terminal/tui/application.test.ts +30 -34
- package/src/terminal/tui/application.ts +72 -21
- package/src/terminal/tui/commands.test.ts +26 -2
- package/src/terminal/tui/commands.ts +20 -7
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- 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 +24 -2
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/types.ts +2 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
|
@@ -6,6 +6,7 @@ import { promisify } from 'node:util'
|
|
|
6
6
|
import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
7
7
|
import { SettingsPanel, type TerminalSettingItem } from './settings'
|
|
8
8
|
import { DialogPanel, SearchableSelect } from './dialogs'
|
|
9
|
+
import { LogPanel, formatRunEventLog } from './logPanel'
|
|
9
10
|
import { TrajectoryPanel } from './trajectoryPanel'
|
|
10
11
|
import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
|
|
11
12
|
import manifest from '../../../package.json'
|
|
@@ -21,8 +22,7 @@ export interface TerminalCommandHost {
|
|
|
21
22
|
sessionDirectory(): string
|
|
22
23
|
refresh(): void
|
|
23
24
|
changed(): void
|
|
24
|
-
|
|
25
|
-
setPlanMode(enabled: boolean): void
|
|
25
|
+
plan(brief: string): void
|
|
26
26
|
shutdown(): void
|
|
27
27
|
setEditorText(text: string): void
|
|
28
28
|
copyText(text: string): Promise<void>
|
|
@@ -31,6 +31,7 @@ export interface TerminalCommandHost {
|
|
|
31
31
|
suspend?(): Promise<void>
|
|
32
32
|
hotkeys?(): string
|
|
33
33
|
changelog?(): Promise<string>
|
|
34
|
+
logs: { read(): Promise<string> }
|
|
34
35
|
trajectory: {
|
|
35
36
|
list(): Promise<TerminalTrajectoryRun[]>
|
|
36
37
|
read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
|
|
@@ -41,7 +42,7 @@ export interface TerminalCommandCompletion { name: string; description?: string
|
|
|
41
42
|
|
|
42
43
|
const BUILTINS: TerminalCommandCompletion[] = [
|
|
43
44
|
{ name: 'help', description: 'Available terminal commands' },
|
|
44
|
-
{ name: 'plan', description: '
|
|
45
|
+
{ name: 'plan', description: 'Run one read-only plan request: /plan <brief>' },
|
|
45
46
|
{ name: 'new', description: 'Start a new session' },
|
|
46
47
|
{ name: 'resume', description: 'Resume a session in this account and folder' },
|
|
47
48
|
{ name: 'tree', description: 'Navigate the session tree' },
|
|
@@ -55,6 +56,7 @@ const BUILTINS: TerminalCommandCompletion[] = [
|
|
|
55
56
|
{ name: 'name', description: 'Name the current session' },
|
|
56
57
|
{ name: 'session', description: 'Current session statistics' },
|
|
57
58
|
{ name: 'trajectory', description: 'Inspect recorded runs for this native session' },
|
|
59
|
+
{ name: 'log', description: 'View Harness diagnostics and this conversation run events' },
|
|
58
60
|
{ name: 'compact', description: 'Compact the current context' },
|
|
59
61
|
{ name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
|
|
60
62
|
{ name: 'trust', description: 'Review plugin code and project trust' },
|
|
@@ -231,6 +233,18 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
231
233
|
}, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
|
|
232
234
|
}
|
|
233
235
|
|
|
236
|
+
async function logs() {
|
|
237
|
+
const [diagnostics, runs] = await Promise.all([host.logs.read(), host.trajectory.list()])
|
|
238
|
+
const snapshots = await Promise.all(runs.map(run => host.trajectory.read(run.id)))
|
|
239
|
+
const sections = [
|
|
240
|
+
{ title: 'Harness diagnostics', text: diagnostics || 'No Harness diagnostics have been captured by this host.' },
|
|
241
|
+
{ title: 'Conversation run events', text: formatRunEventLog(snapshots) }
|
|
242
|
+
]
|
|
243
|
+
await host.ui.custom<void>((tui, theme, _keys, done) => new LogPanel({
|
|
244
|
+
tui, theme, sections, close: () => done(undefined), changed: host.changed
|
|
245
|
+
}), { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' } })
|
|
246
|
+
}
|
|
247
|
+
|
|
234
248
|
async function permissions() {
|
|
235
249
|
for (;;) {
|
|
236
250
|
const runtime = host.runtime()
|
|
@@ -290,10 +304,8 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
290
304
|
switch (name) {
|
|
291
305
|
case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
|
|
292
306
|
case 'plan': {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
if (next === null) throw new Error('Use /plan, /plan on, /plan off, or /plan toggle.')
|
|
296
|
-
host.setPlanMode(next)
|
|
307
|
+
if (!argument || ['on', 'off', 'toggle'].includes(argument.toLowerCase())) throw new Error('Use /plan <plan brief>.')
|
|
308
|
+
host.plan(argument)
|
|
297
309
|
break
|
|
298
310
|
}
|
|
299
311
|
case 'quit': host.shutdown(); break
|
|
@@ -390,6 +402,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
390
402
|
}
|
|
391
403
|
case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
|
|
392
404
|
case 'trajectory': await trajectory(argument); break
|
|
405
|
+
case 'log': await logs(); break
|
|
393
406
|
case 'compact': await session.compact(argument || undefined); break
|
|
394
407
|
case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
|
|
395
408
|
case 'trust': {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import type { RunEvent } from '../../sessions/contracts'
|
|
6
|
+
import type { TerminalTrajectorySnapshot } from '../trajectory'
|
|
7
|
+
import { formatRunEventLog, LogPanel } from './logPanel'
|
|
8
|
+
import { createHarnessTheme } from './theme'
|
|
9
|
+
|
|
10
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
11
|
+
const event = (sequence: number, type: string, payload: unknown): RunEvent => ({
|
|
12
|
+
id: `event-${sequence}`, runId: 'run-1', sessionId: 'terminal-1', sequence,
|
|
13
|
+
timestamp: `2026-10-18T00:00:0${sequence}.000Z`, type: type as RunEvent['type'], payload
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function plain(panel: LogPanel): string {
|
|
17
|
+
return panel.render(80).map(stripTerminalSequences).join('\n')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('terminal log viewer', () => {
|
|
21
|
+
it('shows diagnostic run events without prompt or successful response content', () => {
|
|
22
|
+
const snapshot: TerminalTrajectorySnapshot = {
|
|
23
|
+
run: { id: 'run-1', mode: 'code', goal: 'Private prompt', startedAt: '2026-10-18T00:00:00.000Z', endedAt: '2026-10-18T00:00:02.000Z', status: 'failed' },
|
|
24
|
+
events: [
|
|
25
|
+
event(0, 'context.snapshot', { systemPrompt: 'Private prompt' }),
|
|
26
|
+
event(1, 'tool.completed', { name: 'web_fetch', ok: true, output: JSON.stringify({ url: 'https://example.com/?token=private', status: 404, error: 'Page missing', text: 'Private response body' }) }),
|
|
27
|
+
event(2, 'failed', { code: 'network', message: 'Failed at https://example.com/path?token=private' })
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
const text = formatRunEventLog([snapshot])
|
|
31
|
+
expect(text).toContain('HTTP 404: Page missing')
|
|
32
|
+
expect(text).toContain('network')
|
|
33
|
+
expect(text).not.toContain('Private prompt')
|
|
34
|
+
expect(text).not.toContain('Private response body')
|
|
35
|
+
expect(text).not.toContain('private')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('opens at the newest lines, pages through the snapshot, switches sources, and closes on Escape', () => {
|
|
39
|
+
const close = vi.fn()
|
|
40
|
+
const changed = vi.fn()
|
|
41
|
+
const panel = new LogPanel({
|
|
42
|
+
tui: { terminal: { rows: 8 } } as unknown as TUI,
|
|
43
|
+
theme,
|
|
44
|
+
sections: [
|
|
45
|
+
{ title: 'Harness diagnostics', text: Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join('\n') },
|
|
46
|
+
{ title: 'Conversation run events', text: 'run event' }
|
|
47
|
+
],
|
|
48
|
+
close, changed
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(plain(panel)).toContain('line 20')
|
|
52
|
+
panel.handleInput('\x1b[5~')
|
|
53
|
+
expect(plain(panel)).toContain('line 11')
|
|
54
|
+
expect(plain(panel)).not.toContain('line 20')
|
|
55
|
+
panel.handleInput('\t')
|
|
56
|
+
expect(plain(panel)).toContain('Conversation run events')
|
|
57
|
+
panel.handleInput('\x1b')
|
|
58
|
+
expect(close).toHaveBeenCalledOnce()
|
|
59
|
+
expect(changed).toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { matchesKey, stripTerminalSequences, truncateToWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
|
|
4
|
+
import { redactLogText } from '../../core/bus'
|
|
5
|
+
import type { RunEvent } from '../../sessions/contracts'
|
|
6
|
+
import type { TerminalTrajectorySnapshot } from '../trajectory'
|
|
7
|
+
|
|
8
|
+
export interface LogSection {
|
|
9
|
+
title: string
|
|
10
|
+
text: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const payloadOf = (event: RunEvent): Record<string, unknown> =>
|
|
14
|
+
event.payload && typeof event.payload === 'object' ? event.payload as Record<string, unknown> : {}
|
|
15
|
+
const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined
|
|
16
|
+
const numberOf = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
17
|
+
|
|
18
|
+
function safe(text: string): string {
|
|
19
|
+
return redactLogText(stripTerminalSequences(text)).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ').replace(/\t/g, ' ')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function timeOf(value: string): string {
|
|
23
|
+
const parsed = Date.parse(value)
|
|
24
|
+
return Number.isFinite(parsed) ? new Date(parsed).toLocaleTimeString() : value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function webError(output: string): string | undefined {
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(output) as Record<string, unknown>
|
|
30
|
+
if (typeof value.error !== 'string') return undefined
|
|
31
|
+
const status = numberOf(value.status)
|
|
32
|
+
return `${status === undefined ? '' : `HTTP ${status}: `}${value.error}`
|
|
33
|
+
} catch {
|
|
34
|
+
return /^(?:The page could not be read:|The search could not be completed:)/.test(output) ? output : undefined
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function eventDetail(event: RunEvent): string[] {
|
|
39
|
+
const payload = payloadOf(event)
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case 'context.snapshot':
|
|
42
|
+
return ['prompt and tool definitions omitted']
|
|
43
|
+
case 'message.delta':
|
|
44
|
+
case 'message.completed':
|
|
45
|
+
case 'message.steered':
|
|
46
|
+
return ['message content omitted']
|
|
47
|
+
case 'tool.started':
|
|
48
|
+
return [`tool ${stringOf(payload.name) ?? 'unknown'} started; inputs omitted`]
|
|
49
|
+
case 'tool.completed': {
|
|
50
|
+
const name = stringOf(payload.name) ?? 'unknown'
|
|
51
|
+
const duration = numberOf(payload.durationMs)
|
|
52
|
+
const result = [`tool ${name} ${payload.ok === false ? 'failed' : 'completed'}${duration === undefined ? '' : ` · ${duration} ms`}`]
|
|
53
|
+
const output = stringOf(payload.output)
|
|
54
|
+
const webFailure = (name === 'web_fetch' || name === 'web_search') && output ? webError(output) : undefined
|
|
55
|
+
if (webFailure) result.push(` ${webFailure}`)
|
|
56
|
+
else if (payload.ok === false && output) result.push(` ${output}`)
|
|
57
|
+
if (payload.outputTruncated === true) result.push(' recorded output was truncated')
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
case 'retry.started':
|
|
61
|
+
case 'retry.ended':
|
|
62
|
+
return [`attempt ${numberOf(payload.attempt) ?? '?'}${numberOf(payload.maxAttempts) === undefined ? '' : ` of ${numberOf(payload.maxAttempts)}`}${numberOf(payload.delayMs) === undefined ? '' : ` · retry delay ${numberOf(payload.delayMs)} ms`}`, ...(stringOf(payload.error) ? [` ${payload.error}`] : [])]
|
|
63
|
+
case 'compaction.ended':
|
|
64
|
+
return [payload.aborted === true ? 'compaction aborted' : 'compaction completed', ...(stringOf(payload.error) ? [` ${payload.error}`] : [])]
|
|
65
|
+
case 'extension.notice':
|
|
66
|
+
return payload.kind === 'error' || payload.level === 'error' ? [stringOf(payload.text) ?? 'extension error'] : ['non-error extension notice omitted']
|
|
67
|
+
case 'account.status': {
|
|
68
|
+
const message = stringOf(payload.message)
|
|
69
|
+
return [stringOf(payload.category) ?? 'account status', ...(message ? [message] : [])]
|
|
70
|
+
}
|
|
71
|
+
case 'status.changed': {
|
|
72
|
+
const reason = stringOf(payload.reason)
|
|
73
|
+
return [stringOf(payload.status) ?? 'status changed', ...(reason ? [reason] : [])]
|
|
74
|
+
}
|
|
75
|
+
case 'failed': {
|
|
76
|
+
const message = stringOf(payload.message)
|
|
77
|
+
return [stringOf(payload.code) ?? 'run failed', ...(message ? [message] : [])]
|
|
78
|
+
}
|
|
79
|
+
case 'usage.updated': {
|
|
80
|
+
const request = payload.request && typeof payload.request === 'object' ? payload.request as Record<string, unknown> : {}
|
|
81
|
+
return [`${stringOf(request.provider) ?? stringOf(payload.provider) ?? 'provider'}/${stringOf(request.model) ?? stringOf(payload.model) ?? 'model'} · request ${numberOf(request.index) ?? numberOf(payload.requests) ?? '?'}`]
|
|
82
|
+
}
|
|
83
|
+
case 'approval.requested':
|
|
84
|
+
case 'approval.resolved':
|
|
85
|
+
return [`tool ${stringOf(payload.target) ?? ''} · decision ${stringOf(payload.decision) ?? 'pending'}; action details omitted`]
|
|
86
|
+
case 'question.requested':
|
|
87
|
+
case 'question.answered':
|
|
88
|
+
return ['question content omitted']
|
|
89
|
+
default:
|
|
90
|
+
return []
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatRunEventLog(snapshots: TerminalTrajectorySnapshot[]): string {
|
|
95
|
+
if (!snapshots.length) return 'No run events recorded for this conversation.'
|
|
96
|
+
const sections: string[] = []
|
|
97
|
+
for (const snapshot of snapshots) {
|
|
98
|
+
sections.push(`Run ${snapshot.run.id} · ${snapshot.run.status} · ${snapshot.run.mode}`)
|
|
99
|
+
const events = snapshot.events.slice().sort((left, right) => left.sequence - right.sequence)
|
|
100
|
+
if (!events.length) sections.push(' No recorded events')
|
|
101
|
+
for (const event of events) {
|
|
102
|
+
sections.push(`${timeOf(event.timestamp)} #${event.sequence} ${event.type}`)
|
|
103
|
+
sections.push(...eventDetail(event).map(detail => ` ${detail}`))
|
|
104
|
+
}
|
|
105
|
+
sections.push('')
|
|
106
|
+
}
|
|
107
|
+
return safe(sections.join('\n').trimEnd())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LogPanelOptions {
|
|
111
|
+
tui: TUI
|
|
112
|
+
theme: Theme
|
|
113
|
+
sections: LogSection[]
|
|
114
|
+
close(): void
|
|
115
|
+
changed(): void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A static, full-screen view of the diagnostic snapshot taken when /log was opened. */
|
|
119
|
+
export class LogPanel implements Component, Focusable {
|
|
120
|
+
private source = 0
|
|
121
|
+
private offset = 0
|
|
122
|
+
private atEnd = true
|
|
123
|
+
private maxOffset = 0
|
|
124
|
+
private focusedValue = false
|
|
125
|
+
|
|
126
|
+
constructor(private readonly options: LogPanelOptions) {}
|
|
127
|
+
|
|
128
|
+
get focused(): boolean { return this.focusedValue }
|
|
129
|
+
set focused(value: boolean) { this.focusedValue = value }
|
|
130
|
+
invalidate(): void {}
|
|
131
|
+
|
|
132
|
+
private changeSource(direction: number): void {
|
|
133
|
+
const count = this.options.sections.length
|
|
134
|
+
if (!count) return
|
|
135
|
+
this.source = (this.source + direction + count) % count
|
|
136
|
+
this.offset = 0
|
|
137
|
+
this.atEnd = true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
handleInput(data: string): void {
|
|
141
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.options.close(); return }
|
|
142
|
+
if (matchesKey(data, 'left') || matchesKey(data, 'shift+tab')) this.changeSource(-1)
|
|
143
|
+
else if (matchesKey(data, 'right') || matchesKey(data, 'tab')) this.changeSource(1)
|
|
144
|
+
else if (matchesKey(data, 'home')) { this.offset = 0; this.atEnd = false }
|
|
145
|
+
else if (matchesKey(data, 'end')) this.atEnd = true
|
|
146
|
+
else if (matchesKey(data, 'up')) { this.offset = Math.max(0, this.currentOffset() - 1); this.atEnd = false }
|
|
147
|
+
else if (matchesKey(data, 'down')) { this.offset = this.currentOffset() + 1; this.atEnd = false }
|
|
148
|
+
else if (matchesKey(data, 'pageUp')) { this.offset = Math.max(0, this.currentOffset() - this.pageSize()); this.atEnd = false }
|
|
149
|
+
else if (matchesKey(data, 'pageDown')) { this.offset = this.currentOffset() + this.pageSize(); this.atEnd = false }
|
|
150
|
+
this.options.changed()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private pageSize(): number { return Math.max(1, this.options.tui.terminal.rows - 4) }
|
|
154
|
+
private currentOffset(): number { return this.atEnd ? this.maxOffset : this.offset }
|
|
155
|
+
|
|
156
|
+
render(width: number): string[] {
|
|
157
|
+
width = Math.max(1, Math.trunc(width))
|
|
158
|
+
const rows = Math.max(1, Math.trunc(this.options.tui.terminal.rows))
|
|
159
|
+
const section = this.options.sections[this.source] ?? { title: 'Logs', text: 'No log sources are available.' }
|
|
160
|
+
const bodyRows = Math.max(0, rows - 2)
|
|
161
|
+
const contentWidth = Math.max(1, width)
|
|
162
|
+
const raw = safe(section.text).split('\n')
|
|
163
|
+
const lines = raw.flatMap(line => line ? wrapTextWithAnsi(line, contentWidth) : [''])
|
|
164
|
+
const maxOffset = Math.max(0, lines.length - bodyRows)
|
|
165
|
+
this.maxOffset = maxOffset
|
|
166
|
+
if (this.atEnd) this.offset = maxOffset
|
|
167
|
+
else this.offset = Math.max(0, Math.min(maxOffset, this.offset))
|
|
168
|
+
const first = this.offset
|
|
169
|
+
const last = Math.min(lines.length, first + bodyRows)
|
|
170
|
+
const position = `${this.source + 1}/${Math.max(1, this.options.sections.length)} · lines ${lines.length ? first + 1 : 0}-${last}/${lines.length}`
|
|
171
|
+
const title = this.options.theme.bold(this.options.theme.fg('accent', `${section.title} · ${position}`))
|
|
172
|
+
const footer = this.options.theme.fg('dim', '↑/↓ scroll · PgUp/PgDn page · ←/→ source · Esc close')
|
|
173
|
+
return [title, ...lines.slice(first, last), footer].slice(0, rows).map(line => truncateToWidth(line, width, ''))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -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
|
})
|