@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.
@@ -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
- planMode(): boolean
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: 'Toggle read-only plan mode' },
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
- const current = host.planMode()
294
- const next = !argument || argument === 'toggle' ? !current : argument === 'on' ? true : argument === 'off' ? false : null
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 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
  })