@xingular/harness 0.1.6 → 2.0.0
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 +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- 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 +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- 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 +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- 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 +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- 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 +134 -7
- 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 +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -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 +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -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/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -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 +47 -0
- package/src/tools/types.ts +2 -0
|
@@ -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,99 @@
|
|
|
1
|
+
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { stripTerminalSequences, visibleWidth, type TUI } from '@earendil-works/pi-tui'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import type { RunEvent } from '../../sessions/contracts'
|
|
6
|
+
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
7
|
+
import { TrajectoryPanel } from './trajectoryPanel'
|
|
8
|
+
import { createHarnessTheme } from './theme'
|
|
9
|
+
|
|
10
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
11
|
+
const run = (id: string, status = 'completed'): TerminalTrajectoryRun => ({
|
|
12
|
+
id, mode: 'code', goal: `Goal for ${id}`, startedAt: '2026-10-18T00:00:00.000Z', endedAt: status === 'running' ? null : '2026-10-18T00:00:02.000Z', status
|
|
13
|
+
})
|
|
14
|
+
const event = (sequence: number, type: string, payload: unknown): RunEvent => ({
|
|
15
|
+
id: `event-${sequence}`, runId: 'run-1', sessionId: 'terminal-1', sequence,
|
|
16
|
+
timestamp: `2026-10-18T00:00:0${sequence}.000Z`, type: type as RunEvent['type'], payload
|
|
17
|
+
})
|
|
18
|
+
function setup(options: { runs?: TerminalTrajectoryRun[]; snapshot?: TerminalTrajectorySnapshot; read?: (runId: string, afterSequence?: number) => Promise<TerminalTrajectorySnapshot>; rows?: number } = {}) {
|
|
19
|
+
const events = [
|
|
20
|
+
event(0, 'context.snapshot', { systemPrompt: 'prompt', tools: [{ name: 'read' }] }),
|
|
21
|
+
event(1, 'tool.started', { toolCallId: 'tool-1', name: 'read', input: '{"path":"README.md"}' }),
|
|
22
|
+
event(2, 'tool.completed', { toolCallId: 'tool-1', name: 'read', output: 'file contents', ok: true })
|
|
23
|
+
]
|
|
24
|
+
const runs = options.runs ?? [run('run-1')]
|
|
25
|
+
const initial = options.snapshot ?? { run: runs[0]!, events }
|
|
26
|
+
const read = options.read ?? vi.fn(async () => initial)
|
|
27
|
+
const copy = vi.fn(async (_text: string) => {})
|
|
28
|
+
const changed = vi.fn()
|
|
29
|
+
const notify = vi.fn()
|
|
30
|
+
const close = vi.fn()
|
|
31
|
+
const panel = new TrajectoryPanel({
|
|
32
|
+
tui: { terminal: { rows: options.rows ?? 28 } } as unknown as TUI,
|
|
33
|
+
theme, runs, initialIndex: 0, initial, read, copy, changed, notify, close
|
|
34
|
+
})
|
|
35
|
+
return { panel, events, runs, read, copy, changed, notify, close }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => vi.useRealTimers())
|
|
39
|
+
|
|
40
|
+
describe('the terminal Trajectory panel', () => {
|
|
41
|
+
it('shows run usage, a selectable event ledger, and the selected event payload', () => {
|
|
42
|
+
const { panel } = setup()
|
|
43
|
+
const text = panel.render(100).map(stripTerminalSequences).join('\n')
|
|
44
|
+
expect(text).toContain('Run 1/1 · run-1 · completed')
|
|
45
|
+
expect(text).toContain('context.snapshot')
|
|
46
|
+
expect(text).toContain('tool.completed')
|
|
47
|
+
expect(text).toContain('file contents')
|
|
48
|
+
expect(panel.selectedEvent?.sequence).toBe(2)
|
|
49
|
+
|
|
50
|
+
panel.handleInput('\x1b[A')
|
|
51
|
+
expect(panel.selectedEvent?.sequence).toBe(1)
|
|
52
|
+
expect(panel.render(100).map(stripTerminalSequences).join('\n')).toContain('README.md')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('moves between runs, closes, and copies the selected event', async () => {
|
|
56
|
+
const first = run('run-1')
|
|
57
|
+
const second = run('run-2')
|
|
58
|
+
const secondSnapshot = { run: second, events: [event(0, 'message.completed', { role: 'assistant', text: 'Second run' })] }
|
|
59
|
+
const { panel, read, copy, close } = setup({ runs: [first, second], read: vi.fn(async (id) => id === 'run-2' ? secondSnapshot : { run: first, events: [] }) })
|
|
60
|
+
panel.handleInput('\x1b[D')
|
|
61
|
+
await vi.waitFor(() => expect(panel.selectedRunId).toBe('run-2'))
|
|
62
|
+
expect(read).toHaveBeenCalledWith('run-2')
|
|
63
|
+
expect(panel.selectedEvent?.type).toBe('message.completed')
|
|
64
|
+
panel.handleInput('c')
|
|
65
|
+
await vi.waitFor(() => expect(copy).toHaveBeenCalledOnce())
|
|
66
|
+
expect(JSON.parse(copy.mock.calls[0]![0])).toMatchObject({ type: 'message.completed', sequence: 0 })
|
|
67
|
+
panel.handleInput('\x1b')
|
|
68
|
+
expect(close).toHaveBeenCalledOnce()
|
|
69
|
+
panel.dispose()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('refreshes active runs and stops polling when disposed', async () => {
|
|
73
|
+
vi.useFakeTimers()
|
|
74
|
+
const active = run('run-1', 'running')
|
|
75
|
+
const completed = run('run-1')
|
|
76
|
+
const initial = { run: active, events: [event(0, 'started', {})] }
|
|
77
|
+
const updated = { run: completed, events: [event(1, 'completed', {})] }
|
|
78
|
+
const read = vi.fn(async (_id: string, afterSequence?: number) => afterSequence === 0 ? updated : initial)
|
|
79
|
+
const { panel } = setup({ snapshot: initial, read })
|
|
80
|
+
await vi.advanceTimersByTimeAsync(2_000)
|
|
81
|
+
expect(read).toHaveBeenCalledWith('run-1', 0)
|
|
82
|
+
expect(panel.render(100).map(stripTerminalSequences).join('\n')).toContain('completed')
|
|
83
|
+
const calls = read.mock.calls.length
|
|
84
|
+
panel.dispose()
|
|
85
|
+
await vi.advanceTimersByTimeAsync(4_000)
|
|
86
|
+
expect(read).toHaveBeenCalledTimes(calls)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('bounds output to narrow terminals and handles runs without recorded events', () => {
|
|
90
|
+
const { panel } = setup({ snapshot: { run: run('run-1'), events: [] }, rows: 10 })
|
|
91
|
+
const rows = panel.render(24)
|
|
92
|
+
expect(rows.length).toBeLessThanOrEqual(5)
|
|
93
|
+
for (const row of rows) expect(visibleWidth(row)).toBeLessThanOrEqual(24)
|
|
94
|
+
expect(rows.map(stripTerminalSequences).join('\n')).toContain('No recorded events')
|
|
95
|
+
panel.handleInput('\x1b[B')
|
|
96
|
+
expect(panel.selectedEvent).toBeUndefined()
|
|
97
|
+
panel.dispose()
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,226 @@
|
|
|
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 type { RunEvent } from '../../sessions/contracts'
|
|
5
|
+
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
6
|
+
|
|
7
|
+
const ACTIVE = new Set(['queued', 'running', 'waiting_for_approval', 'waiting_for_input'])
|
|
8
|
+
type Payload = Record<string, unknown>
|
|
9
|
+
|
|
10
|
+
const payloadOf = (event: RunEvent): Payload => event.payload && typeof event.payload === 'object' ? event.payload as Payload : {}
|
|
11
|
+
const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined
|
|
12
|
+
const numberOf = (value: unknown): number | undefined => typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
13
|
+
|
|
14
|
+
function safe(value: string): string {
|
|
15
|
+
return stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ' ')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function timeOf(value: string): string {
|
|
19
|
+
const parsed = Date.parse(value)
|
|
20
|
+
return Number.isFinite(parsed) ? new Date(parsed).toLocaleTimeString() : value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function eventDescription(event: RunEvent): string {
|
|
24
|
+
const payload = payloadOf(event)
|
|
25
|
+
switch (event.type) {
|
|
26
|
+
case 'message.completed':
|
|
27
|
+
case 'message.delta':
|
|
28
|
+
return `${stringOf(payload.role) ?? 'message'}: ${stringOf(payload.text)?.replace(/\s+/g, ' ').slice(0, 90) ?? ''}`
|
|
29
|
+
case 'tool.started':
|
|
30
|
+
case 'tool.completed':
|
|
31
|
+
return `${stringOf(payload.name) ?? 'tool'}${stringOf(payload.summary) ? ` · ${stringOf(payload.summary)}` : ''}`
|
|
32
|
+
case 'usage.updated': {
|
|
33
|
+
const request = payload.request && typeof payload.request === 'object' ? payload.request as Payload : {}
|
|
34
|
+
return `${stringOf(request.model) ?? stringOf(payload.model) ?? 'model'} · request ${numberOf(request.index) ?? numberOf(payload.requests) ?? '?'} · ${numberOf(request.durationMs) ?? 0} ms`
|
|
35
|
+
}
|
|
36
|
+
case 'status.changed':
|
|
37
|
+
return stringOf(payload.status) ?? 'status update'
|
|
38
|
+
case 'retry.started':
|
|
39
|
+
case 'retry.ended':
|
|
40
|
+
return `attempt ${numberOf(payload.attempt) ?? 1}${stringOf(payload.error) ? ` · ${stringOf(payload.error)}` : ''}`
|
|
41
|
+
case 'compaction.started':
|
|
42
|
+
case 'compaction.ended':
|
|
43
|
+
return stringOf(payload.reason) ?? 'context compaction'
|
|
44
|
+
case 'extension.notice':
|
|
45
|
+
return stringOf(payload.text) ?? 'extension notice'
|
|
46
|
+
case 'context.snapshot':
|
|
47
|
+
return 'Recorded prompt and tool definitions'
|
|
48
|
+
case 'failed':
|
|
49
|
+
return stringOf(payload.message) ?? stringOf(payload.code) ?? 'run failed'
|
|
50
|
+
default:
|
|
51
|
+
return stringOf(payload.text) ?? stringOf(payload.name) ?? ''
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function usageLine(events: readonly RunEvent[]): string {
|
|
56
|
+
const event = [...events].reverse().find(item => item.type === 'usage.updated')
|
|
57
|
+
if (!event) return 'Usage not recorded'
|
|
58
|
+
const payload = payloadOf(event)
|
|
59
|
+
const tokens = (name: string) => numberOf(payload[name])?.toLocaleString() ?? '—'
|
|
60
|
+
const cost = numberOf(payload.costUsd)
|
|
61
|
+
const requests = numberOf(payload.requests)
|
|
62
|
+
return `Input ${tokens('inputTokens')} · Output ${tokens('outputTokens')} · Cache read ${tokens('cacheReadTokens')} · ${requests ?? 0} requests · ${cost === undefined ? 'cost —' : `$${cost.toFixed(4)}`}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function durationLine(run: TerminalTrajectoryRun): string {
|
|
66
|
+
const start = Date.parse(run.startedAt)
|
|
67
|
+
const end = run.endedAt ? Date.parse(run.endedAt) : Date.now()
|
|
68
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return run.startedAt
|
|
69
|
+
return `${((Math.max(0, end - start)) / 1000).toFixed(1)} s · ${timeOf(run.startedAt)}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TrajectoryPanelOptions {
|
|
73
|
+
tui: TUI
|
|
74
|
+
theme: Theme
|
|
75
|
+
runs: TerminalTrajectoryRun[]
|
|
76
|
+
initialIndex: number
|
|
77
|
+
initial: TerminalTrajectorySnapshot
|
|
78
|
+
read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
|
|
79
|
+
copy(text: string): Promise<void>
|
|
80
|
+
close(): void
|
|
81
|
+
changed(): void
|
|
82
|
+
notify(message: string, type?: string): void
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read-only view of saved run evidence, scoped by its source before it reaches this component. */
|
|
86
|
+
export class TrajectoryPanel implements Component, Focusable {
|
|
87
|
+
private runIndex: number
|
|
88
|
+
private run: TerminalTrajectoryRun
|
|
89
|
+
private events: RunEvent[]
|
|
90
|
+
private selected = 0
|
|
91
|
+
private detailOffset = 0
|
|
92
|
+
private focusedValue = false
|
|
93
|
+
private loading = false
|
|
94
|
+
private disposed = false
|
|
95
|
+
private timer: ReturnType<typeof setInterval>
|
|
96
|
+
private error?: string
|
|
97
|
+
|
|
98
|
+
constructor(private readonly options: TrajectoryPanelOptions) {
|
|
99
|
+
this.runIndex = Math.max(0, Math.min(options.runs.length - 1, options.initialIndex))
|
|
100
|
+
this.run = options.initial.run
|
|
101
|
+
this.events = options.initial.events.slice().sort((left, right) => left.sequence - right.sequence)
|
|
102
|
+
this.selected = Math.max(0, this.events.length - 1)
|
|
103
|
+
this.timer = setInterval(() => { void this.refresh() }, 2_000)
|
|
104
|
+
this.timer.unref?.()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get focused(): boolean { return this.focusedValue }
|
|
108
|
+
set focused(value: boolean) { this.focusedValue = value }
|
|
109
|
+
get selectedRunId(): string { return this.run.id }
|
|
110
|
+
get selectedEvent(): RunEvent | undefined { return this.events[this.selected] }
|
|
111
|
+
|
|
112
|
+
invalidate(): void { this.options.changed() }
|
|
113
|
+
|
|
114
|
+
private async refresh(): Promise<void> {
|
|
115
|
+
if (this.disposed || this.loading || !ACTIVE.has(this.run.status)) return
|
|
116
|
+
this.loading = true
|
|
117
|
+
const lastSequence = this.events.at(-1)?.sequence
|
|
118
|
+
const followedLatest = this.selected === this.events.length - 1
|
|
119
|
+
try {
|
|
120
|
+
const snapshot = await this.options.read(this.run.id, lastSequence)
|
|
121
|
+
if (this.disposed) return
|
|
122
|
+
this.run = snapshot.run
|
|
123
|
+
if (lastSequence === undefined) this.events = snapshot.events.slice().sort((left, right) => left.sequence - right.sequence)
|
|
124
|
+
else {
|
|
125
|
+
const existing = new Set(this.events.map(event => event.sequence))
|
|
126
|
+
this.events = [...this.events, ...snapshot.events.filter(event => !existing.has(event.sequence))].sort((left, right) => left.sequence - right.sequence)
|
|
127
|
+
}
|
|
128
|
+
if (followedLatest && this.events.length) this.selected = this.events.length - 1
|
|
129
|
+
this.error = undefined
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!this.disposed) this.error = error instanceof Error ? error.message : String(error)
|
|
132
|
+
} finally {
|
|
133
|
+
this.loading = false
|
|
134
|
+
if (!this.disposed) this.options.changed()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private async changeRun(direction: -1 | 1): Promise<void> {
|
|
139
|
+
const next = this.runIndex + direction
|
|
140
|
+
if (next < 0 || next >= this.options.runs.length || this.loading) return
|
|
141
|
+
this.runIndex = next
|
|
142
|
+
this.run = this.options.runs[next]!
|
|
143
|
+
this.events = []
|
|
144
|
+
this.selected = 0
|
|
145
|
+
this.detailOffset = 0
|
|
146
|
+
this.error = undefined
|
|
147
|
+
this.options.changed()
|
|
148
|
+
this.loading = true
|
|
149
|
+
try {
|
|
150
|
+
const snapshot = await this.options.read(this.run.id)
|
|
151
|
+
if (this.disposed || this.run.id !== snapshot.run.id) return
|
|
152
|
+
this.run = snapshot.run
|
|
153
|
+
this.events = snapshot.events.slice().sort((left, right) => left.sequence - right.sequence)
|
|
154
|
+
this.selected = Math.max(0, this.events.length - 1)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (!this.disposed) this.error = error instanceof Error ? error.message : String(error)
|
|
157
|
+
} finally {
|
|
158
|
+
this.loading = false
|
|
159
|
+
if (!this.disposed) this.options.changed()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
handleInput(data: string): void {
|
|
164
|
+
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) { this.options.close(); return }
|
|
165
|
+
if (matchesKey(data, 'left')) { void this.changeRun(1); return }
|
|
166
|
+
if (matchesKey(data, 'right')) { void this.changeRun(-1); return }
|
|
167
|
+
if (matchesKey(data, 'up')) { this.selected = Math.max(0, this.selected - 1); this.detailOffset = 0; this.options.changed(); return }
|
|
168
|
+
if (matchesKey(data, 'down')) { this.selected = this.events.length ? Math.min(this.events.length - 1, this.selected + 1) : 0; this.detailOffset = 0; this.options.changed(); return }
|
|
169
|
+
if (matchesKey(data, 'pageUp')) { this.detailOffset = Math.max(0, this.detailOffset - 8); this.options.changed(); return }
|
|
170
|
+
if (matchesKey(data, 'pageDown')) { this.detailOffset += 8; this.options.changed(); return }
|
|
171
|
+
if (data.toLowerCase() === 'c' && this.selectedEvent) {
|
|
172
|
+
const text = JSON.stringify(this.selectedEvent, null, 2)
|
|
173
|
+
void this.options.copy(text).then(() => this.options.notify('Copied selected trajectory event')).catch(error => this.options.notify(String(error), 'error'))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
render(width: number): string[] {
|
|
178
|
+
width = Math.max(0, Math.trunc(width))
|
|
179
|
+
const bodyRows = Math.max(0, Math.floor(this.options.tui.terminal.rows * 0.8) - 3)
|
|
180
|
+
if (!width || !bodyRows) return []
|
|
181
|
+
const { theme } = this.options
|
|
182
|
+
const runPosition = `${this.runIndex + 1}/${this.options.runs.length}`
|
|
183
|
+
const modelEvent = [...this.events].reverse().find(event => event.type === 'usage.updated')
|
|
184
|
+
const usage = modelEvent ? payloadOf(modelEvent) : {}
|
|
185
|
+
const model = stringOf(usage.model) ?? 'model unavailable'
|
|
186
|
+
const lines = [theme.bold(theme.fg('accent', `Run ${runPosition} · ${this.run.id} · ${this.run.status}${this.loading ? ' · loading' : ''}`))]
|
|
187
|
+
if (bodyRows >= 9) {
|
|
188
|
+
lines.push(theme.fg('muted', `${model} · ${durationLine(this.run)} · ${this.run.mode}`))
|
|
189
|
+
lines.push(theme.fg('muted', usageLine(this.events)))
|
|
190
|
+
lines.push(this.run.goal ? theme.fg('dim', `Prompt: ${this.run.goal.replace(/\s+/g, ' ').slice(0, 140)}`) : theme.fg('dim', 'Prompt text unavailable'))
|
|
191
|
+
} else if (bodyRows >= 6) {
|
|
192
|
+
lines.push(theme.fg('muted', `${model} · ${durationLine(this.run)}`))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
lines.push(theme.bold('Events'))
|
|
196
|
+
if (this.error) lines.push(theme.fg('error', `Read error: ${this.error}`))
|
|
197
|
+
if (!this.events.length) lines.push(theme.fg('dim', this.loading ? 'Loading recorded events…' : 'No recorded events'))
|
|
198
|
+
const structural = lines.length + 2
|
|
199
|
+
const available = Math.max(0, bodyRows - structural - 1)
|
|
200
|
+
const ledgerBudget = this.events.length ? Math.min(this.events.length, Math.max(1, Math.floor(available * 0.4))) : 0
|
|
201
|
+
const detailBudget = Math.max(0, available - ledgerBudget)
|
|
202
|
+
const firstEvent = Math.max(0, Math.min(this.events.length - ledgerBudget, this.selected - Math.floor(ledgerBudget / 2)))
|
|
203
|
+
for (let offset = 0; offset < ledgerBudget; offset++) {
|
|
204
|
+
const index = firstEvent + offset
|
|
205
|
+
const event = this.events[index]!
|
|
206
|
+
const prefix = index === this.selected ? '❯' : ' '
|
|
207
|
+
const label = `${prefix} ${timeOf(event.timestamp)} ${event.type} ${eventDescription(event)}`
|
|
208
|
+
lines.push(index === this.selected ? theme.bg('selectedBg', theme.fg('text', label)) : theme.fg('muted', label))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const selected = this.selectedEvent
|
|
212
|
+
lines.push(theme.bold(selected ? `Selected event · ${selected.type} · #${selected.sequence}` : 'Selected event'))
|
|
213
|
+
if (selected) {
|
|
214
|
+
const details = wrapTextWithAnsi(safe(JSON.stringify({ payload: selected.payload, type: selected.type, sequence: selected.sequence, timestamp: selected.timestamp }, null, 2)), width)
|
|
215
|
+
const maxOffset = Math.max(0, details.length - detailBudget)
|
|
216
|
+
this.detailOffset = Math.min(this.detailOffset, maxOffset)
|
|
217
|
+
lines.push(...details.slice(this.detailOffset, this.detailOffset + detailBudget).map(line => theme.fg('dim', line)))
|
|
218
|
+
}
|
|
219
|
+
return lines.slice(0, bodyRows).map(line => truncateToWidth(line, width, ''))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
dispose(): void {
|
|
223
|
+
this.disposed = true
|
|
224
|
+
clearInterval(this.timer)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps notices in the conversation order and drops them with the session', () => {
|
|
28
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
29
|
+
const transcript = new TerminalTranscript({} as TUI, () => theme, () => undefined)
|
|
30
|
+
transcript.notice('Plan mode: only read-only tools are available.', 'info')
|
|
31
|
+
transcript.notice('Plan mode could not start.', 'error')
|
|
32
|
+
transcript.notice('Plan mode could not start.', 'error')
|
|
33
|
+
const lines = transcript.render(80).map(line => line.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean)
|
|
34
|
+
expect(lines).toEqual(['Plan mode: only read-only tools are available.', 'Plan mode could not start.'])
|
|
35
|
+
transcript.notice('Plan mode: only read-only tools are available.', 'info')
|
|
36
|
+
expect(transcript.render(80).map(line => line.replace(/\x1b\[[0-9;]*m/g, '').trim()).filter(Boolean)).toHaveLength(3)
|
|
37
|
+
transcript.clearSession()
|
|
38
|
+
expect(transcript.render(80)).toEqual([])
|
|
39
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
private lastNotice?: { component: Component; text: string; type: string }
|
|
17
|
+
|
|
18
|
+
constructor(private readonly tui: TUI, private readonly theme: () => Theme, private readonly builtinRenderers: BuiltinRenderers) { super() }
|
|
19
|
+
|
|
20
|
+
clearSession(): void {
|
|
21
|
+
for (const component of this.children) {
|
|
22
|
+
try { (component as Component & { dispose?(): void }).dispose?.() } catch { /* Detach every component even when a plugin's cleanup fails. */ }
|
|
23
|
+
}
|
|
24
|
+
this.clear()
|
|
25
|
+
this.tools.clear()
|
|
26
|
+
this.assistant = undefined
|
|
27
|
+
this.session = undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A line of terminal feedback, such as a mode change or an error. It sits in the conversation where it happened, not in the session. */
|
|
31
|
+
notice(text: string, type: string): void {
|
|
32
|
+
// A failure that repeats with nothing after it, such as a draft that cannot be saved on each keystroke, is shown once.
|
|
33
|
+
const last = this.lastNotice
|
|
34
|
+
if (last && last.text === text && last.type === type && this.children.at(-1) === last.component) return
|
|
35
|
+
const component = new Text(this.theme().fg(type === 'error' ? 'error' : type === 'warning' ? 'warning' : 'muted', text), 1, 1)
|
|
36
|
+
this.addChild(component)
|
|
37
|
+
this.lastNotice = { component, text, type }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
bind(session: AgentSession): void {
|
|
41
|
+
this.clearSession()
|
|
42
|
+
this.session = session
|
|
43
|
+
this.hideThinking = session.settingsManager.getHideThinkingBlock()
|
|
44
|
+
for (const projected of session.sessionManager.buildSessionProjection().entries) {
|
|
45
|
+
if (projected.sourceEntry.type === 'custom') {
|
|
46
|
+
const entry = projected.sourceEntry
|
|
47
|
+
const component = session.extensionRunner.getEntryRenderer(entry.customType)?.(entry, { expanded: this.expanded }, this.theme())
|
|
48
|
+
if (component) this.addChild(component)
|
|
49
|
+
}
|
|
50
|
+
for (const message of projected.messages) this.message(message, false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
setExpanded(expanded: boolean): void {
|
|
55
|
+
this.expanded = expanded
|
|
56
|
+
for (const component of this.children) {
|
|
57
|
+
if ('setExpanded' in component) (component as Component & { setExpanded(value: boolean): void }).setExpanded(expanded)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setThinking(hidden: boolean, label = this.hiddenLabel): void {
|
|
62
|
+
if (this.hideThinking === hidden && this.hiddenLabel === label) return
|
|
63
|
+
this.hideThinking = hidden
|
|
64
|
+
this.hiddenLabel = label
|
|
65
|
+
for (const component of this.children) if (component instanceof AssistantMessageComponent) {
|
|
66
|
+
component.setHideThinkingBlock(hidden)
|
|
67
|
+
component.setHiddenThinkingLabel(label)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private tool(id: string, name: string, args: unknown): ToolExecutionComponent {
|
|
72
|
+
let component = this.tools.get(id)
|
|
73
|
+
if (!component) {
|
|
74
|
+
const session = this.session!
|
|
75
|
+
const renderer = harnessToolRenderers(name, () => session.extensionRunner.resolveToolRenderers(name, () => this.builtinRenderers(name, session.getToolDefinition(name))))
|
|
76
|
+
component = new ToolExecutionComponent(name, id, args, {
|
|
77
|
+
showImages: session.settingsManager.getShowImages(), imageWidthCells: session.settingsManager.getImageWidthCells(), outputPad: 1
|
|
78
|
+
}, renderer, this.tui, session.sessionManager.getCwd())
|
|
79
|
+
component.setExpanded(this.expanded)
|
|
80
|
+
this.tools.set(id, component)
|
|
81
|
+
this.addChild(component)
|
|
82
|
+
} else component.updateArgs(args)
|
|
83
|
+
return component
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private message(message: AgentMessage, streaming: boolean): void {
|
|
87
|
+
const session = this.session!
|
|
88
|
+
const transformers = session.extensionRunner.getMarkdownTransformers()
|
|
89
|
+
if (message.role === 'user') {
|
|
90
|
+
const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
|
91
|
+
const images = typeof message.content === 'string' ? 0 : message.content.filter(part => part.type === 'image').length
|
|
92
|
+
const content = [text, images ? `[${images} attached image${images === 1 ? '' : 's'}]` : ''].filter(Boolean).join('\n')
|
|
93
|
+
if (content) this.addChild(new UserMessageComponent(content, getMarkdownTheme(), 1, transformers))
|
|
94
|
+
} else if (message.role === 'assistant') {
|
|
95
|
+
const assistant = new AssistantMessageComponent(message, this.hideThinking, getMarkdownTheme(), this.hiddenLabel, 1, transformers)
|
|
96
|
+
assistant.updateContent(message, streaming)
|
|
97
|
+
this.addChild(assistant)
|
|
98
|
+
this.assistant = streaming ? assistant : undefined
|
|
99
|
+
for (const part of message.content) if (part.type === 'toolCall') {
|
|
100
|
+
const component = this.tool(part.id, part.name, part.arguments)
|
|
101
|
+
if (!streaming) component.setArgsComplete()
|
|
102
|
+
if (message.stopReason === 'error' || message.stopReason === 'aborted') component.updateResult({ content: [{ type: 'text', text: message.errorMessage ?? 'Operation aborted' }], isError: true })
|
|
103
|
+
}
|
|
104
|
+
} else if (message.role === 'toolResult') {
|
|
105
|
+
this.tools.get(message.toolCallId)?.updateResult(message)
|
|
106
|
+
} else if (message.role === 'custom') {
|
|
107
|
+
if (message.display) this.addChild(new CustomMessageComponent(message, session.extensionRunner.getMessageRenderer(message.customType), getMarkdownTheme(), 1))
|
|
108
|
+
} else if (message.role === 'compactionSummary') this.addChild(new CompactionSummaryMessageComponent(message, getMarkdownTheme(), 1))
|
|
109
|
+
else if (message.role === 'branchSummary') this.addChild(new BranchSummaryMessageComponent(message, getMarkdownTheme(), 1))
|
|
110
|
+
else if (message.role === 'bashExecution') {
|
|
111
|
+
this.addChild(new Text(this.theme().fg('muted', `$ ${message.command}\n${message.output}`), 1, 1))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
accept(event: AgentSessionEvent): void {
|
|
116
|
+
if (event.type === 'message_start') this.message(event.message, event.message.role === 'assistant')
|
|
117
|
+
else if (event.type === 'message_update' && event.message.role === 'assistant') {
|
|
118
|
+
if (!this.assistant) this.message(event.message, true)
|
|
119
|
+
else this.assistant.updateContent(event.message, true)
|
|
120
|
+
for (const part of event.message.content) if (part.type === 'toolCall') this.tool(part.id, part.name, part.arguments)
|
|
121
|
+
} else if (event.type === 'message_end') {
|
|
122
|
+
if (event.message.role === 'assistant') {
|
|
123
|
+
this.assistant?.updateContent(event.message, false)
|
|
124
|
+
this.assistant = undefined
|
|
125
|
+
for (const part of event.message.content) if (part.type === 'toolCall') this.tool(part.id, part.name, part.arguments).setArgsComplete()
|
|
126
|
+
} else if (event.message.role === 'toolResult') this.tools.get(event.message.toolCallId)?.updateResult(event.message)
|
|
127
|
+
} else if (event.type === 'tool_execution_start') this.tool(event.toolCallId, event.toolName, event.args).markExecutionStarted()
|
|
128
|
+
else if (event.type === 'tool_execution_update') this.tool(event.toolCallId, event.toolName, event.args).updateResult({ ...event.partialResult, isError: false }, true)
|
|
129
|
+
else if (event.type === 'tool_execution_end') this.tools.get(event.toolCallId)?.updateResult({ ...event.result, isError: event.isError, durationMs: event.durationMs })
|
|
130
|
+
else if (event.type === 'entry_appended' && event.entry.type === 'custom') {
|
|
131
|
+
const component = this.session!.extensionRunner.getEntryRenderer(event.entry.customType)?.(event.entry, { expanded: this.expanded }, this.theme())
|
|
132
|
+
if (component) this.addChild(component)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|