@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.
Files changed (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. 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
+ }