@xingular/harness 2.1.1 → 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.
@@ -15,6 +15,7 @@ import { rootWithProject, writeFile } from '../../tests/helpers'
15
15
  import { parseCli } from './cli'
16
16
  import { SOCKET_ENV, TOKEN_ENV, socketCall, type SocketConnection } from './client'
17
17
  import { SessionSocket } from './socket'
18
+ import { LOG_READ_CHUNK_BYTES } from './logs'
18
19
 
19
20
  const open: Harness[] = []
20
21
  afterEach(async () => {
@@ -467,6 +468,25 @@ describe('decisions in the Harness-owned frontend', () => {
467
468
  await expect(socketCall(connection, 'terminal.trajectory.list', {}, 1000, key)).resolves.toEqual({ runs: [] })
468
469
  })
469
470
 
471
+ it('serves a bounded diagnostic snapshot only through the private terminal control channel', async () => {
472
+ const { connection, harness } = await setup()
473
+ await expect(socketCall(connection, 'terminal.logs.info')).rejects.toMatchObject({ code: 'unauthorized' })
474
+ const { key } = await socketCall<{ key: string }>(connection, 'control.claim')
475
+ await expect(socketCall(connection, 'terminal.logs.info', {}, 1000, key)).resolves.toEqual({ size: null })
476
+ await expect(socketCall(connection, 'terminal.logs.read', { snapshotSize: 1, offset: 0 }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
477
+
478
+ const text = `${'a'.repeat(LOG_READ_CHUNK_BYTES - 1)}🧠tail`
479
+ writeFileSync(join(harness.stateDir, 'harness.log'), text)
480
+ const { size } = await socketCall<{ size: number }>(connection, 'terminal.logs.info', {}, 1000, key)
481
+ expect(size).toBe(Buffer.byteLength(text))
482
+ await expect(socketCall(connection, 'terminal.logs.read', { snapshotSize: size, offset: size }, 1000, key)).rejects.toMatchObject({ code: 'invalid_request' })
483
+ const first = await socketCall<{ data: string; nextOffset: number }>(connection, 'terminal.logs.read', { snapshotSize: size, offset: 0 }, 1000, key)
484
+ const second = await socketCall<{ data: string; nextOffset: number }>(connection, 'terminal.logs.read', { snapshotSize: size, offset: first.nextOffset }, 1000, key)
485
+ expect(first.nextOffset).toBe(LOG_READ_CHUNK_BYTES)
486
+ expect(second.nextOffset).toBe(size)
487
+ expect(Buffer.concat([Buffer.from(first.data, 'base64'), Buffer.from(second.data, 'base64')]).toString('utf8')).toBe(text)
488
+ })
489
+
470
490
  it('requires the private input-owner key for pending decisions and exact draft state', async () => {
471
491
  const { connection, harness, tell } = await setup()
472
492
  await tell('ready', { nativeSessionId: '/native.jsonl' })
@@ -43,6 +43,7 @@ import { SOCKET_ENV, TOKEN_ENV } from './client'
43
43
  import { TerminalController, type PolicyReply } from './controller'
44
44
  import { resolvePiCommand, terminalCommandFiles } from './launch'
45
45
  import { SessionSocket, socketPath } from './socket'
46
+ import { harnessLogInfo, readHarnessLogChunk } from './logs'
46
47
  import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
47
48
 
48
49
  /** Identity of the exact resource decision that this terminal was prepared with. */
@@ -543,6 +544,16 @@ export class Terminals {
543
544
  broker,
544
545
  terminal: { accept: (event, data) => live.controller.accept(event, data), disconnected: () => live.controller.disconnected(), control: async (call, params) => {
545
546
  if (call.startsWith('terminal.subagents.')) return this.subagentsCall(live, call, params)
547
+ if (call === 'terminal.logs.info') return harnessLogInfo(this.options.stateDir)
548
+ if (call === 'terminal.logs.read') {
549
+ const { snapshotSize, offset } = params
550
+ if (typeof snapshotSize !== 'number' || !Number.isSafeInteger(snapshotSize) || snapshotSize < 0 || typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0 || offset >= snapshotSize) {
551
+ throw invalid('A valid diagnostics snapshot size and byte offset are required.')
552
+ }
553
+ const current = harnessLogInfo(this.options.stateDir).size
554
+ if (current === null || snapshotSize > current) throw invalid('The diagnostics snapshot is no longer available.')
555
+ return readHarnessLogChunk(this.options.stateDir, snapshotSize, offset)
556
+ }
546
557
  if (call.startsWith('terminal.trajectory.')) return this.trajectoryCall(live, call, params)
547
558
  if (call === 'terminal.permissions.get') {
548
559
  const policy = this.options.approvals.read()
@@ -25,6 +25,7 @@ import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPI
25
25
  import { HarnessWelcome } from './welcome'
26
26
  import { answerTerminalQuestion } from './questions'
27
27
  import type { QuestionAnswer } from '../../sessions/registry'
28
+ import { readHarnessLogSnapshot, type HarnessLogChunk, type HarnessLogInfo } from '../logs'
28
29
  import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
29
30
 
30
31
  const exec = promisify(execFile)
@@ -190,6 +191,10 @@ export class TerminalApplication {
190
191
  plan: brief => this.enqueuePlan(brief),
191
192
  copyText: copyToClipboard, openExternal,
192
193
  changed: this.change,
194
+ logs: { read: () => readHarnessLogSnapshot(
195
+ () => controlCall<HarnessLogInfo>('terminal.logs.info'),
196
+ (snapshotSize, offset) => controlCall<HarnessLogChunk>('terminal.logs.read', { snapshotSize, offset })
197
+ ) },
193
198
  trajectory: {
194
199
  list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
195
200
  read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
@@ -33,6 +33,7 @@ function setup() {
33
33
  refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
34
34
  plan: vi.fn(),
35
35
  hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
36
+ logs: { read: vi.fn(async () => '[harness] diagnostic') },
36
37
  trajectory: {
37
38
  list: vi.fn(async () => [trajectoryRun]),
38
39
  read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
@@ -59,7 +60,7 @@ describe('owned terminal commands', () => {
59
60
  it('populates completion from builtins and loaded resources', () => {
60
61
  const { commands } = setup()
61
62
  const names = commands.completions().map(item => item.name)
62
- expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
63
+ expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'log', 'permissions']))
63
64
  expect(names).not.toContain('thinking')
64
65
  expect(names).not.toContain('skill:diagnose')
65
66
  expect(new Set(names).size).toBe(names.length)
@@ -172,6 +173,17 @@ describe('owned terminal commands', () => {
172
173
  expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
173
174
  })
174
175
 
176
+ it('opens a full-screen snapshot combining diagnostics and current-conversation run events', async () => {
177
+ const { commands, host, ui } = setup()
178
+ await commands.execute('/log')
179
+ expect(host.logs.read).toHaveBeenCalledOnce()
180
+ expect(host.trajectory.list).toHaveBeenCalledOnce()
181
+ expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
182
+ expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), {
183
+ overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', anchor: 'top-left' }
184
+ })
185
+ })
186
+
175
187
  it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
176
188
  const { commands, host, runtime, ui } = setup()
177
189
  vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
@@ -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'
@@ -30,6 +31,7 @@ export interface TerminalCommandHost {
30
31
  suspend?(): Promise<void>
31
32
  hotkeys?(): string
32
33
  changelog?(): Promise<string>
34
+ logs: { read(): Promise<string> }
33
35
  trajectory: {
34
36
  list(): Promise<TerminalTrajectoryRun[]>
35
37
  read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
@@ -54,6 +56,7 @@ const BUILTINS: TerminalCommandCompletion[] = [
54
56
  { name: 'name', description: 'Name the current session' },
55
57
  { name: 'session', description: 'Current session statistics' },
56
58
  { name: 'trajectory', description: 'Inspect recorded runs for this native session' },
59
+ { name: 'log', description: 'View Harness diagnostics and this conversation run events' },
57
60
  { name: 'compact', description: 'Compact the current context' },
58
61
  { name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
59
62
  { name: 'trust', description: 'Review plugin code and project trust' },
@@ -230,6 +233,18 @@ export function createTerminalCommands(host: TerminalCommandHost) {
230
233
  }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
231
234
  }
232
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
+
233
248
  async function permissions() {
234
249
  for (;;) {
235
250
  const runtime = host.runtime()
@@ -387,6 +402,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
387
402
  }
388
403
  case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
389
404
  case 'trajectory': await trajectory(argument); break
405
+ case 'log': await logs(); break
390
406
  case 'compact': await session.compact(argument || undefined); break
391
407
  case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
392
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
+ }