@xingular/harness 0.1.5 → 0.1.7
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 +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createEditToolDefinition, createReadToolDefinition, type Theme, type ToolRenderers } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { createHarnessTheme } from './theme'
|
|
6
|
+
import { harnessToolRenderers } from './tools'
|
|
7
|
+
|
|
8
|
+
type Context = Parameters<NonNullable<ToolRenderers['renderCall']>>[2]
|
|
9
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
|
+
function context(overrides: Partial<Context> = {}): Context {
|
|
11
|
+
return {
|
|
12
|
+
args: {}, toolCallId: 'test-call', invalidate() {}, lastComponent: undefined,
|
|
13
|
+
state: {}, cwd: '/private/tmp', executionStarted: false, argsComplete: true,
|
|
14
|
+
isPartial: true, expanded: false, showImages: true, isError: false,
|
|
15
|
+
durationMs: undefined, outputPad: 1, ...overrides
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function generic(name = 'wiki_recall') {
|
|
19
|
+
return harnessToolRenderers(name, () => undefined)!
|
|
20
|
+
}
|
|
21
|
+
function result(lines: number) {
|
|
22
|
+
return { content: [{ type: 'text' as const, text: Array.from({ length: lines }, (_, index) => `source line ${index}`).join('\n') }], details: { evidence: 'original' } }
|
|
23
|
+
}
|
|
24
|
+
const plain = (rows: string[]) => rows.map(stripTerminalSequences)
|
|
25
|
+
|
|
26
|
+
describe('Harness tool presentation', () => {
|
|
27
|
+
it('resolves display hooks without replacing execution, schemas or the original tool', () => {
|
|
28
|
+
const execute = vi.fn()
|
|
29
|
+
const parameters = { type: 'object', properties: { query: { type: 'string' } } }
|
|
30
|
+
const definition = Object.freeze({ name: 'wiki_recall', parameters, execute, renderShell: 'default' as const })
|
|
31
|
+
const next = vi.fn(() => definition)
|
|
32
|
+
const display = harnessToolRenderers('wiki_recall', next)!
|
|
33
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
34
|
+
expect(Object.keys(display).sort()).toEqual(['renderCall', 'renderResult', 'renderShell'])
|
|
35
|
+
expect(display.renderShell).toBe('default')
|
|
36
|
+
expect(definition.parameters).toBe(parameters)
|
|
37
|
+
expect(definition.execute).toBe(execute)
|
|
38
|
+
expect(execute).not.toHaveBeenCalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('respects plugin renderer hooks even when the plugin uses a built-in tool name', () => {
|
|
42
|
+
for (const name of ['read', 'edit', 'bash', 'wiki_recall']) {
|
|
43
|
+
for (const hooks of [{ renderCall: vi.fn() }, { renderResult: vi.fn() }, { renderShell: 'self' as const }]) {
|
|
44
|
+
const next = vi.fn(() => hooks)
|
|
45
|
+
expect(harnessToolRenderers(name, next)).toBe(hooks)
|
|
46
|
+
expect(next).toHaveBeenCalledTimes(1)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('preserves Pi rich file, diff and image renderer identities', () => {
|
|
52
|
+
for (const definition of [createReadToolDefinition('/private/tmp'), createEditToolDefinition('/private/tmp')]) {
|
|
53
|
+
const resolved = harnessToolRenderers(definition.name, () => definition as unknown as ToolRenderers)
|
|
54
|
+
expect(resolved).toBe(definition)
|
|
55
|
+
expect(resolved?.renderCall).toBe(definition.renderCall)
|
|
56
|
+
expect(resolved?.renderResult).toBe(definition.renderResult)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shows pending, executing, completed and error state from actual rendering context', () => {
|
|
61
|
+
const hooks = generic()
|
|
62
|
+
const states = [
|
|
63
|
+
[{}, '⏳ wiki_recall'],
|
|
64
|
+
[{ executionStarted: true }, '⟳ wiki_recall'],
|
|
65
|
+
[{ isPartial: false }, '• wiki_recall'],
|
|
66
|
+
[{ isPartial: false, isError: true }, '✘ wiki_recall']
|
|
67
|
+
] as const
|
|
68
|
+
for (const [state, expected] of states) {
|
|
69
|
+
const rows = hooks.renderCall!({}, theme, context(state)).render(80)
|
|
70
|
+
expect(plain(rows)[0]).toBe(expected)
|
|
71
|
+
if ('isError' in state && state.isError) expect(rows[0]).toContain(theme.getFgAnsi('error'))
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('reveals real nested arguments when expanded and keeps partial JSON metadata out of the display', () => {
|
|
76
|
+
const hooks = generic()
|
|
77
|
+
const args = Object.freeze({ query: 'source evidence', filter: { page: ['Introduction', 'Results'] }, __partialJson: 'transport metadata' })
|
|
78
|
+
const collapsed = plain(hooks.renderCall!(args, theme, context()).render(90)).join('\n')
|
|
79
|
+
expect(collapsed).toContain('└─ query="source evidence", filter={1 keys}')
|
|
80
|
+
expect(collapsed).not.toContain('transport metadata')
|
|
81
|
+
const expanded = plain(hooks.renderCall!(args, theme, context({ expanded: true })).render(90)).join('\n')
|
|
82
|
+
expect(expanded).toContain('Args')
|
|
83
|
+
expect(expanded).toContain('page: [2 items]')
|
|
84
|
+
expect(expanded).toContain('0: "Introduction"')
|
|
85
|
+
expect(expanded).toContain('1: "Results"')
|
|
86
|
+
expect(args.filter.page).toEqual(['Introduction', 'Results'])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('uses the returned text verbatim while changing only the output window', () => {
|
|
90
|
+
const hooks = generic()
|
|
91
|
+
const source = result(20)
|
|
92
|
+
const snapshot = structuredClone(source)
|
|
93
|
+
for (const isPartial of [false, true]) {
|
|
94
|
+
const collapsed = plain(hooks.renderResult!(source, { expanded: false, isPartial }, theme, context()).render(80))
|
|
95
|
+
expect(collapsed).toEqual(['source line 0', 'source line 1', 'source line 2', 'source line 3', '… (16 more lines)', '[Ctrl+O: Expand]'])
|
|
96
|
+
const expanded = plain(hooks.renderResult!(source, { expanded: true, isPartial }, theme, context()).render(80))
|
|
97
|
+
expect(expanded).toHaveLength(13)
|
|
98
|
+
expect(expanded[11]).toBe('source line 11')
|
|
99
|
+
expect(expanded[12]).toBe('… (8 more lines)')
|
|
100
|
+
}
|
|
101
|
+
expect(source).toEqual(snapshot)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders JSON trees and preserves mixed image payloads and result metadata', () => {
|
|
105
|
+
const hooks = generic()
|
|
106
|
+
const image = { type: 'image' as const, data: 'original-image-bytes', mimeType: 'image/png' }
|
|
107
|
+
const source = { content: [{ type: 'text' as const, text: '{"pages":[{"title":"Evidence","score":0.8}]}' }, image], details: { citation: 'ev_original' } }
|
|
108
|
+
const rows = plain(hooks.renderResult!(source, { expanded: true, isPartial: false }, theme, context()).render(80))
|
|
109
|
+
expect(rows.join('\n')).toContain('title: "Evidence"')
|
|
110
|
+
expect(rows.join('\n')).toContain('score: 0.8')
|
|
111
|
+
expect(source.content[1]).toBe(image)
|
|
112
|
+
expect(source.content[1]).toEqual(image)
|
|
113
|
+
expect(source.details.citation).toBe('ev_original')
|
|
114
|
+
expect(plain(hooks.renderResult!({ content: [image], details: undefined }, { expanded: false, isPartial: false }, theme, context()).render(80))).toEqual(['(image output)'])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('bounds every row by terminal cells while rejecting terminal commands and retaining safe output colors', () => {
|
|
118
|
+
const hooks = generic()
|
|
119
|
+
const source = { content: [{ type: 'text' as const, text: '\x1b]2;do not rename\x07\x1b[2J\x1b[31m日本語 🧠 知識é\x1b[0m\n\tsecond\rline' }], details: undefined }
|
|
120
|
+
for (let width = 0; width < 100; width++) {
|
|
121
|
+
const call = hooks.renderCall!({ 'query\n\x07': '日本語 🧠 知識'.repeat(15) }, theme, context())
|
|
122
|
+
const output = hooks.renderResult!(source, { expanded: false, isPartial: true }, theme, context())
|
|
123
|
+
for (const component of [call, output]) {
|
|
124
|
+
for (const row of component.render(width)) {
|
|
125
|
+
expect(visibleWidth(row), `width ${width}`).toBeLessThanOrEqual(width)
|
|
126
|
+
expect(row).not.toContain('\x1b]')
|
|
127
|
+
expect(row).not.toContain('\x1b[2J')
|
|
128
|
+
expect(stripTerminalSequences(row)).not.toMatch(/[\x00-\x1f\x7f]/)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const colored = hooks.renderResult!(source, { expanded: true, isPartial: false }, theme, context()).render(90)
|
|
133
|
+
expect(colored[0]).toContain('\x1b[31m')
|
|
134
|
+
expect(plain(colored)[0]).toBe('日本語 🧠 知識é')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('bounds deep and cyclic argument trees without hiding their existence', () => {
|
|
138
|
+
const cyclic: Record<string, unknown> = { count: 1 }
|
|
139
|
+
cyclic.self = cyclic
|
|
140
|
+
const rows = plain(generic().renderCall!(cyclic, theme, context({ expanded: true })).render(80))
|
|
141
|
+
expect(rows.join('\n')).toContain('[circular]')
|
|
142
|
+
const many = Object.fromEntries(Array.from({ length: 1000 }, (_, index) => [`key${index}`, index]))
|
|
143
|
+
expect(generic().renderCall!(many, theme, context({ expanded: true })).render(80).length).toBeLessThanOrEqual(205)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presentation adapted from @oh-my-pi/pi-tui 18.11.3 tools/default-renderer.ts
|
|
3
|
+
* and tools/json-tree.ts (MIT, Mario Zechner, Can Bölük and Stencil Labs, Inc.).
|
|
4
|
+
* See NOTICE.md for the upstream license. Execution remains owned by Pi.
|
|
5
|
+
*/
|
|
6
|
+
import type { Theme, ToolRenderers } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import { stripTerminalSequences, truncateToWidth, type Component } from '@earendil-works/pi-tui'
|
|
8
|
+
|
|
9
|
+
type CallContext = Parameters<NonNullable<ToolRenderers['renderCall']>>[2]
|
|
10
|
+
|
|
11
|
+
const hiddenArgs = new Set(['__partialJson'])
|
|
12
|
+
|
|
13
|
+
/** Permit color and emphasis, but never replay terminal commands from a tool result. */
|
|
14
|
+
function outputText(value: string): string {
|
|
15
|
+
return value.split(/(\x1b\[[0-9;:]*m)/g).map((part, index) => index % 2
|
|
16
|
+
? part
|
|
17
|
+
: stripTerminalSequences(part).replace(/\t/g, ' ').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')).join('')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function label(value: string): string {
|
|
21
|
+
return stripTerminalSequences(value).replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function scalar(value: unknown, width: number): string {
|
|
25
|
+
const text = typeof value === 'string'
|
|
26
|
+
? JSON.stringify(stripTerminalSequences(value))
|
|
27
|
+
: value === null ? 'null'
|
|
28
|
+
: Array.isArray(value) ? `[${value.length} items]`
|
|
29
|
+
: typeof value === 'object' ? `{${Object.keys(value).length} keys}`
|
|
30
|
+
: String(value)
|
|
31
|
+
return truncateToWidth(text, Math.max(0, width))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function entries(value: unknown): [string, unknown][] {
|
|
35
|
+
return value && typeof value === 'object'
|
|
36
|
+
? Object.entries(value).filter(([key]) => !hiddenArgs.has(key))
|
|
37
|
+
: []
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function argsInline(args: unknown, width: number): string {
|
|
41
|
+
return truncateToWidth(entries(args).map(([key, value]) => `${label(key)}=${scalar(value, 24)}`).join(', '), width)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Bounded depth and row budgets keep large structured results responsive. */
|
|
45
|
+
function tree(value: unknown, expanded: boolean, theme: Theme, width: number): string[] {
|
|
46
|
+
const maxDepth = expanded ? 6 : 2
|
|
47
|
+
const maxLines = expanded ? 200 : 6
|
|
48
|
+
const maxScalar = expanded ? 2000 : 60
|
|
49
|
+
const lines: string[] = []
|
|
50
|
+
const ancestors = new Set<object>()
|
|
51
|
+
let clipped = false
|
|
52
|
+
function visit(item: unknown, indent: string, key: string | undefined, last: boolean, depth: number): void {
|
|
53
|
+
if (lines.length >= maxLines) { clipped = true; return }
|
|
54
|
+
const branch = last ? '└─' : '├─'
|
|
55
|
+
const keyText = key === undefined ? '' : `${label(key)}: `
|
|
56
|
+
const compound = item !== null && typeof item === 'object'
|
|
57
|
+
const repeated = compound && ancestors.has(item)
|
|
58
|
+
const row = `${indent}${theme.fg('dim', branch)} ${keyText}${scalar(item, maxScalar)}${repeated ? ' [circular]' : ''}`
|
|
59
|
+
lines.push(truncateToWidth(theme.fg('toolOutput', row), width))
|
|
60
|
+
if (!compound || repeated || depth >= maxDepth) return
|
|
61
|
+
const children = entries(item)
|
|
62
|
+
ancestors.add(item)
|
|
63
|
+
for (let index = 0; index < children.length; index++) {
|
|
64
|
+
if (lines.length >= maxLines) { clipped = true; break }
|
|
65
|
+
const [childKey, child] = children[index]
|
|
66
|
+
visit(child, indent + (last ? ' ' : '│ '), childKey, index === children.length - 1, depth + 1)
|
|
67
|
+
}
|
|
68
|
+
ancestors.delete(item)
|
|
69
|
+
}
|
|
70
|
+
const roots = entries(value)
|
|
71
|
+
if (roots.length) {
|
|
72
|
+
for (let index = 0; index < roots.length; index++) {
|
|
73
|
+
if (lines.length >= maxLines) { clipped = true; break }
|
|
74
|
+
const [key, item] = roots[index]
|
|
75
|
+
visit(item, '', key, index === roots.length - 1, 0)
|
|
76
|
+
}
|
|
77
|
+
} else visit(value, '', undefined, true, 0)
|
|
78
|
+
if (clipped) lines.push(theme.fg('dim', truncateToWidth('…', width)))
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function status(context: CallContext, theme: Theme): string {
|
|
83
|
+
if (context.isPartial) return context.executionStarted ? theme.fg('accent', '⟳') : theme.fg('muted', '⏳')
|
|
84
|
+
return context.isError ? theme.fg('error', '✘') : theme.fg('success', '•')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function widthComponent(render: (width: number) => string[]): Component {
|
|
88
|
+
return {
|
|
89
|
+
invalidate() {},
|
|
90
|
+
render(width) {
|
|
91
|
+
const columns = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0
|
|
92
|
+
if (columns === 0) return []
|
|
93
|
+
return render(columns).map(line => truncateToWidth(line, columns))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A resolver supplies display hooks only. Existing renderers include richer file,
|
|
100
|
+
* diff, image and plugin-specific evidence; next() is authoritative for them.
|
|
101
|
+
* Pi owns its default Box(1, 1), lifecycle background and image rendering.
|
|
102
|
+
*/
|
|
103
|
+
export function harnessToolRenderers(toolName: string, next: () => ToolRenderers | undefined): ToolRenderers | undefined {
|
|
104
|
+
const downstream = next()
|
|
105
|
+
if (downstream?.renderCall || downstream?.renderResult || downstream?.renderShell === 'self') return downstream
|
|
106
|
+
return {
|
|
107
|
+
renderShell: 'default',
|
|
108
|
+
renderCall(args, theme, context) {
|
|
109
|
+
return widthComponent(width => {
|
|
110
|
+
const lines = [`${status(context, theme)} ${theme.fg('accent', label(toolName))}`]
|
|
111
|
+
if (context.expanded && args !== undefined) {
|
|
112
|
+
lines.push('', theme.fg('dim', 'Args'), ...tree(args, true, theme, width), '')
|
|
113
|
+
} else {
|
|
114
|
+
const summary = argsInline(args, Math.max(0, width - 5))
|
|
115
|
+
if (summary) lines.push(` ${theme.fg('dim', `└─ ${summary}`)}`)
|
|
116
|
+
}
|
|
117
|
+
return lines
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
renderResult(result, options, theme) {
|
|
121
|
+
return widthComponent(width => {
|
|
122
|
+
const content = result.content.filter(part => part.type === 'text').map(part => part.text).join('\n').trimEnd()
|
|
123
|
+
if (!content) return [theme.fg('dim', result.content.some(part => part.type === 'image') ? '(image output)' : '(no output)')]
|
|
124
|
+
if (/^[\[{]/.test(content)) {
|
|
125
|
+
try {
|
|
126
|
+
const lines = tree(JSON.parse(content), options.expanded, theme, width)
|
|
127
|
+
if (!options.expanded) lines.push(theme.fg('dim', '[Ctrl+O: Expand]'))
|
|
128
|
+
return lines
|
|
129
|
+
} catch {
|
|
130
|
+
// A text result starting with '[' is still a text result when it is not JSON.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const source = outputText(content).split('\n')
|
|
134
|
+
const cap = options.expanded ? 12 : 4
|
|
135
|
+
const shown = source.slice(0, cap).map(line => theme.fg('toolOutput', line))
|
|
136
|
+
if (source.length > cap) shown.push(theme.fg('dim', `… (${source.length - cap} more lines)`))
|
|
137
|
+
if (!options.expanded) shown.push(theme.fg('dim', '[Ctrl+O: Expand]'))
|
|
138
|
+
return shown
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AgentSession, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import type { TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { expect, it, vi } from 'vitest'
|
|
4
|
+
import { TerminalTranscript } from './transcript'
|
|
5
|
+
import { createHarnessTheme } from './theme'
|
|
6
|
+
|
|
7
|
+
it('detaches all custom transcript components before their session is invalidated', () => {
|
|
8
|
+
const first = { render: () => ['first'], invalidate() {}, dispose: vi.fn(() => { throw new Error('plugin cleanup failed') }) }
|
|
9
|
+
const second = { render: () => ['second'], invalidate() {}, dispose: vi.fn() }
|
|
10
|
+
const session = {
|
|
11
|
+
settingsManager: { getHideThinkingBlock: () => false },
|
|
12
|
+
sessionManager: { buildSessionProjection: () => ({ entries: [first, second].map((_, index) => ({ sourceEntry: { type: 'custom', customType: String(index) }, messages: [] })) }) },
|
|
13
|
+
extensionRunner: { getEntryRenderer: (name: string) => () => [first, second][Number(name)] }
|
|
14
|
+
} as unknown as AgentSession
|
|
15
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
16
|
+
const transcript = new TerminalTranscript({} as TUI, () => theme, () => undefined)
|
|
17
|
+
transcript.bind(session)
|
|
18
|
+
expect(transcript.render(80)).toEqual(['first', 'second'])
|
|
19
|
+
transcript.clearSession()
|
|
20
|
+
expect(first.dispose).toHaveBeenCalledOnce()
|
|
21
|
+
expect(second.dispose).toHaveBeenCalledOnce()
|
|
22
|
+
expect(transcript.render(80)).toEqual([])
|
|
23
|
+
transcript.clearSession()
|
|
24
|
+
expect(second.dispose).toHaveBeenCalledOnce()
|
|
25
|
+
})
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { AssistantMessageComponent, BranchSummaryMessageComponent, CompactionSummaryMessageComponent, CustomMessageComponent, ToolExecutionComponent, UserMessageComponent, getMarkdownTheme, type AgentSession, type AgentSessionEvent, type Theme, type ToolRenderers } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Container, Text, type Component, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { harnessToolRenderers } from './tools'
|
|
4
|
+
import type { AgentMessage } from '@earendil-works/pi-agent-core'
|
|
5
|
+
|
|
6
|
+
type BuiltinRenderers = (name: string, definition: ToolRenderers | undefined) => ToolRenderers | undefined
|
|
7
|
+
|
|
8
|
+
/** A projection of SDK history/events. This module never executes tools or writes run records. */
|
|
9
|
+
export class TerminalTranscript extends Container {
|
|
10
|
+
private tools = new Map<string, ToolExecutionComponent>()
|
|
11
|
+
private assistant?: AssistantMessageComponent
|
|
12
|
+
private session?: AgentSession
|
|
13
|
+
private hideThinking = false
|
|
14
|
+
private expanded = false
|
|
15
|
+
private hiddenLabel = 'Thinking…'
|
|
16
|
+
|
|
17
|
+
constructor(private readonly tui: TUI, private readonly theme: () => Theme, private readonly builtinRenderers: BuiltinRenderers) { super() }
|
|
18
|
+
|
|
19
|
+
clearSession(): void {
|
|
20
|
+
for (const component of this.children) {
|
|
21
|
+
try { (component as Component & { dispose?(): void }).dispose?.() } catch { /* Detach every component even when a plugin's cleanup fails. */ }
|
|
22
|
+
}
|
|
23
|
+
this.clear()
|
|
24
|
+
this.tools.clear()
|
|
25
|
+
this.assistant = undefined
|
|
26
|
+
this.session = undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
bind(session: AgentSession): void {
|
|
30
|
+
this.clearSession()
|
|
31
|
+
this.session = session
|
|
32
|
+
this.hideThinking = session.settingsManager.getHideThinkingBlock()
|
|
33
|
+
for (const projected of session.sessionManager.buildSessionProjection().entries) {
|
|
34
|
+
if (projected.sourceEntry.type === 'custom') {
|
|
35
|
+
const entry = projected.sourceEntry
|
|
36
|
+
const component = session.extensionRunner.getEntryRenderer(entry.customType)?.(entry, { expanded: this.expanded }, this.theme())
|
|
37
|
+
if (component) this.addChild(component)
|
|
38
|
+
}
|
|
39
|
+
for (const message of projected.messages) this.message(message, false)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setExpanded(expanded: boolean): void {
|
|
44
|
+
this.expanded = expanded
|
|
45
|
+
for (const component of this.children) {
|
|
46
|
+
if ('setExpanded' in component) (component as Component & { setExpanded(value: boolean): void }).setExpanded(expanded)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setThinking(hidden: boolean, label = this.hiddenLabel): void {
|
|
51
|
+
if (this.hideThinking === hidden && this.hiddenLabel === label) return
|
|
52
|
+
this.hideThinking = hidden
|
|
53
|
+
this.hiddenLabel = label
|
|
54
|
+
for (const component of this.children) if (component instanceof AssistantMessageComponent) {
|
|
55
|
+
component.setHideThinkingBlock(hidden)
|
|
56
|
+
component.setHiddenThinkingLabel(label)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private tool(id: string, name: string, args: unknown): ToolExecutionComponent {
|
|
61
|
+
let component = this.tools.get(id)
|
|
62
|
+
if (!component) {
|
|
63
|
+
const session = this.session!
|
|
64
|
+
const renderer = harnessToolRenderers(name, () => session.extensionRunner.resolveToolRenderers(name, () => this.builtinRenderers(name, session.getToolDefinition(name))))
|
|
65
|
+
component = new ToolExecutionComponent(name, id, args, {
|
|
66
|
+
showImages: session.settingsManager.getShowImages(), imageWidthCells: session.settingsManager.getImageWidthCells(), outputPad: 1
|
|
67
|
+
}, renderer, this.tui, session.sessionManager.getCwd())
|
|
68
|
+
component.setExpanded(this.expanded)
|
|
69
|
+
this.tools.set(id, component)
|
|
70
|
+
this.addChild(component)
|
|
71
|
+
} else component.updateArgs(args)
|
|
72
|
+
return component
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private message(message: AgentMessage, streaming: boolean): void {
|
|
76
|
+
const session = this.session!
|
|
77
|
+
const transformers = session.extensionRunner.getMarkdownTransformers()
|
|
78
|
+
if (message.role === 'user') {
|
|
79
|
+
const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
|
80
|
+
const images = typeof message.content === 'string' ? 0 : message.content.filter(part => part.type === 'image').length
|
|
81
|
+
const content = [text, images ? `[${images} attached image${images === 1 ? '' : 's'}]` : ''].filter(Boolean).join('\n')
|
|
82
|
+
if (content) this.addChild(new UserMessageComponent(content, getMarkdownTheme(), 1, transformers))
|
|
83
|
+
} else if (message.role === 'assistant') {
|
|
84
|
+
const assistant = new AssistantMessageComponent(message, this.hideThinking, getMarkdownTheme(), this.hiddenLabel, 1, transformers)
|
|
85
|
+
assistant.updateContent(message, streaming)
|
|
86
|
+
this.addChild(assistant)
|
|
87
|
+
this.assistant = streaming ? assistant : undefined
|
|
88
|
+
for (const part of message.content) if (part.type === 'toolCall') {
|
|
89
|
+
const component = this.tool(part.id, part.name, part.arguments)
|
|
90
|
+
if (!streaming) component.setArgsComplete()
|
|
91
|
+
if (message.stopReason === 'error' || message.stopReason === 'aborted') component.updateResult({ content: [{ type: 'text', text: message.errorMessage ?? 'Operation aborted' }], isError: true })
|
|
92
|
+
}
|
|
93
|
+
} else if (message.role === 'toolResult') {
|
|
94
|
+
this.tools.get(message.toolCallId)?.updateResult(message)
|
|
95
|
+
} else if (message.role === 'custom') {
|
|
96
|
+
if (message.display) this.addChild(new CustomMessageComponent(message, session.extensionRunner.getMessageRenderer(message.customType), getMarkdownTheme(), 1))
|
|
97
|
+
} else if (message.role === 'compactionSummary') this.addChild(new CompactionSummaryMessageComponent(message, getMarkdownTheme(), 1))
|
|
98
|
+
else if (message.role === 'branchSummary') this.addChild(new BranchSummaryMessageComponent(message, getMarkdownTheme(), 1))
|
|
99
|
+
else if (message.role === 'bashExecution') {
|
|
100
|
+
this.addChild(new Text(this.theme().fg('muted', `$ ${message.command}\n${message.output}`), 1, 1))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
accept(event: AgentSessionEvent): void {
|
|
105
|
+
if (event.type === 'message_start') this.message(event.message, event.message.role === 'assistant')
|
|
106
|
+
else if (event.type === 'message_update' && event.message.role === 'assistant') {
|
|
107
|
+
if (!this.assistant) this.message(event.message, true)
|
|
108
|
+
else this.assistant.updateContent(event.message, true)
|
|
109
|
+
for (const part of event.message.content) if (part.type === 'toolCall') this.tool(part.id, part.name, part.arguments)
|
|
110
|
+
} else if (event.type === 'message_end') {
|
|
111
|
+
if (event.message.role === 'assistant') {
|
|
112
|
+
this.assistant?.updateContent(event.message, false)
|
|
113
|
+
this.assistant = undefined
|
|
114
|
+
for (const part of event.message.content) if (part.type === 'toolCall') this.tool(part.id, part.name, part.arguments).setArgsComplete()
|
|
115
|
+
} else if (event.message.role === 'toolResult') this.tools.get(event.message.toolCallId)?.updateResult(event.message)
|
|
116
|
+
} else if (event.type === 'tool_execution_start') this.tool(event.toolCallId, event.toolName, event.args).markExecutionStarted()
|
|
117
|
+
else if (event.type === 'tool_execution_update') this.tool(event.toolCallId, event.toolName, event.args).updateResult({ ...event.partialResult, isError: false }, true)
|
|
118
|
+
else if (event.type === 'tool_execution_end') this.tools.get(event.toolCallId)?.updateResult({ ...event.result, isError: event.isError, durationMs: event.durationMs })
|
|
119
|
+
else if (event.type === 'entry_appended' && event.entry.type === 'custom') {
|
|
120
|
+
const component = this.session!.extensionRunner.getEntryRenderer(event.entry.customType)?.(event.entry, { expanded: this.expanded }, this.theme())
|
|
121
|
+
if (component) this.addChild(component)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|