@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.
- package/CHANGELOG.md +6 -0
- package/README.md +3 -1
- package/dist/cli.js +624 -154
- package/dist/terminal-pi.js +32 -14
- package/docs/terminal-tui.md +24 -6
- 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 +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/terminal.test.ts +20 -0
- package/src/terminal/terminals.ts +11 -0
- package/src/terminal/tui/application.ts +5 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- 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
|
@@ -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
|
+
}
|