@xingular/harness 0.1.7 → 2.1.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 +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -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 +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -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/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- 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 +8 -0
- 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/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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 +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -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 +0 -130
- 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 +0 -95
- 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 +0 -27
- 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 +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- 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 +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- 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 +0 -57
- 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 +0 -58
- 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 +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -2,69 +2,47 @@ import { initTheme, Theme } from '@earendil-works/pi-coding-agent'
|
|
|
2
2
|
import { stripTerminalSequences, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { displayProjectName, renderStatusBand, type BandState } from './status'
|
|
6
6
|
import { createHarnessTheme } from './theme'
|
|
7
7
|
|
|
8
8
|
initTheme('dark', false)
|
|
9
9
|
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
10
|
const state: BandState = {
|
|
11
11
|
cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
|
|
12
|
-
thinkingLevel: 'high', branch: 'main',
|
|
13
|
-
|
|
12
|
+
thinkingLevel: 'high', branch: 'main', account: 'Tanny', inputTokens: 1300, outputTokens: 50,
|
|
13
|
+
tokensPerSecond: 30
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
describe('
|
|
17
|
-
it('renders the
|
|
16
|
+
describe('composer status band', () => {
|
|
17
|
+
it('renders the four text groups in order with distinct colors', () => {
|
|
18
18
|
const line = renderStatusBand(state, 120, theme)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
expect(visibleWidth(line)).toBe(
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
expect(stripTerminalSequences(line)).toBe('project | main | Tanny · Sonnet 4.6 · high | 30 tok/s in 1,300 out 50')
|
|
20
|
+
for (const color of ['38;2;232;236;244', '38;2;0;255;136', '38;2;0;180;255', '38;2;212;192;144']) {
|
|
21
|
+
expect(line).toContain(color)
|
|
22
|
+
}
|
|
23
|
+
expect(visibleWidth(line)).toBe(stripTerminalSequences(line).length)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('uses the last path component and a readable root fallback', () => {
|
|
27
|
+
expect(displayProjectName('/work/a/long-project/')).toBe('long-project')
|
|
28
|
+
expect(displayProjectName('/')).toBe('/')
|
|
26
29
|
})
|
|
27
30
|
|
|
28
|
-
it('
|
|
31
|
+
it('shows text fallbacks when branch, account, model, and rate are unavailable', () => {
|
|
32
|
+
const line = renderStatusBand({ ...state, branch: null, account: undefined, model: undefined, tokensPerSecond: null }, 120, theme)
|
|
33
|
+
expect(stripTerminalSequences(line)).toBe('project | — | account unknown · no-model · high | — tok/s in 1,300 out 50')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('stays within narrow terminal widths and sanitizes metadata', () => {
|
|
29
37
|
for (let width = 1; width <= 180; width++) {
|
|
30
38
|
const line = renderStatusBand({ ...state,
|
|
31
|
-
cwd: '/work/日本語/long-project-name
|
|
39
|
+
cwd: '/work/日本語/long-project-name',
|
|
32
40
|
branch: '\x1b]2;malicious title\x07main\nother',
|
|
33
41
|
model: { ...state.model!, name: '非常に長い名前'.repeat(20) }
|
|
34
42
|
}, width, theme)
|
|
35
43
|
expect(visibleWidth(line), `width ${width}`).toBeLessThanOrEqual(width)
|
|
36
|
-
expect(line).not.toContain('malicious title')
|
|
37
|
-
expect(line).not.toContain('\n')
|
|
38
|
-
}
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('never invents model, quota, usage, or git data while startup context is unknown', () => {
|
|
42
|
-
const plain = stripTerminalSequences(renderStatusBand({ ...state,
|
|
43
|
-
model: undefined, branch: null, contextWindow: 0, contextPercent: null, costUsd: 0
|
|
44
|
-
}, 80, theme))
|
|
45
|
-
expect(plain).toContain('⬢ no-model')
|
|
46
|
-
expect(plain).not.toMatch(/0%|200K|main|\$|quota|plan|collab/i)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('clamps gauge values and omits an unknown percentage while retaining the window', () => {
|
|
50
|
-
for (const percent of [null, -20, 0, 50, 100, 120, NaN]) {
|
|
51
|
-
for (const width of [0, 1, 5, 16, 60]) {
|
|
52
|
-
const gauge = contextGauge(width, percent, 200_000, theme)
|
|
53
|
-
expect(visibleWidth(gauge)).toBe(width)
|
|
54
|
-
expect(stripTerminalSequences(gauge)).not.toContain('NaN')
|
|
55
|
-
}
|
|
44
|
+
expect(stripTerminalSequences(line)).not.toContain('malicious title')
|
|
45
|
+
expect(stripTerminalSequences(line)).not.toContain('\n')
|
|
56
46
|
}
|
|
57
|
-
expect(stripTerminalSequences(contextGauge(20, null, 200_000, theme))).not.toContain('%')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('keeps sibling directories outside home and shows existing subscription and overflow states', () => {
|
|
61
|
-
expect(displayPath('/home/test/project', '/home/test')).toBe('~/project')
|
|
62
|
-
expect(displayPath('/home/test-other/project', '/home/test')).toBe('/home/test-other/project')
|
|
63
|
-
const line = stripTerminalSequences(renderStatusBand({ ...state,
|
|
64
|
-
subscription: true, costUsd: 0, running: true, elapsedMs: 65_000, hiddenAbove: 4
|
|
65
|
-
}, 160, theme))
|
|
66
|
-
expect(line).toContain('1m 5s')
|
|
67
|
-
expect(line).toContain('$0.00 (sub)')
|
|
68
|
-
expect(line).toContain('↑ 4 more')
|
|
69
47
|
})
|
|
70
48
|
})
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
// Presentation adapted from Oh My Pi 18.11.3's
|
|
1
|
+
// Presentation adapted from Oh My Pi 18.11.3's status line; it sits in the footer below the prompt frame.
|
|
2
2
|
// Upstream MIT license and evidence are recorded in NOTICE.md and docs/terminal-tui.md.
|
|
3
|
-
import {
|
|
4
|
-
import { isAbsolute, relative, sep } from 'node:path'
|
|
3
|
+
import { basename, sep } from 'node:path'
|
|
5
4
|
import type { Theme } from '@earendil-works/pi-coding-agent'
|
|
6
5
|
import { parseColor, stripTerminalSequences, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
7
6
|
|
|
@@ -13,14 +12,9 @@ export interface BandState {
|
|
|
13
12
|
thinkingLevel: string
|
|
14
13
|
branch: string | null
|
|
15
14
|
account?: string
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
subscription: boolean
|
|
20
|
-
running: boolean
|
|
21
|
-
elapsedMs: number
|
|
22
|
-
hiddenAbove?: number
|
|
23
|
-
hiddenBelow?: number
|
|
15
|
+
inputTokens: number
|
|
16
|
+
outputTokens: number
|
|
17
|
+
tokensPerSecond: number | null
|
|
24
18
|
}
|
|
25
19
|
|
|
26
20
|
// OMP's shipped light theme supplies status colors separately from transcript colors.
|
|
@@ -30,80 +24,59 @@ const LIGHT_STATUS = {
|
|
|
30
24
|
}
|
|
31
25
|
const palette = (theme: Theme) => theme.appearance === 'light' ? LIGHT_STATUS : TITANIUM
|
|
32
26
|
|
|
33
|
-
|
|
34
|
-
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
27
|
+
type StatusPart = { text: string; color: string }
|
|
35
28
|
|
|
36
29
|
export function singleLine(text: string): string {
|
|
37
30
|
return stripTerminalSequences(text).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ').replace(/ +/g, ' ').trim()
|
|
38
31
|
}
|
|
39
32
|
|
|
40
|
-
export function
|
|
41
|
-
|
|
42
|
-
if (inside === '') return '~'
|
|
43
|
-
return inside !== '..' && !inside.startsWith(`..${sep}`) && !isAbsolute(inside) ? `~${sep}${inside}` : cwd
|
|
33
|
+
export function displayProjectName(cwd: string): string {
|
|
34
|
+
return singleLine(basename(cwd) || cwd || sep)
|
|
44
35
|
}
|
|
45
36
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (tokens >= 1000) return `${Number((tokens / 1000).toFixed(1))}K`
|
|
49
|
-
return String(Math.max(0, Math.round(tokens)))
|
|
37
|
+
function tokenCount(value: number): string {
|
|
38
|
+
return Math.max(0, Number.isFinite(value) ? Math.round(value) : 0).toLocaleString('en-US')
|
|
50
39
|
}
|
|
51
40
|
|
|
52
|
-
function
|
|
53
|
-
const
|
|
54
|
-
|
|
41
|
+
function fitParts(parts: StatusPart[], width: number): StatusPart[] {
|
|
42
|
+
const fitted = parts.map(part => ({ ...part }))
|
|
43
|
+
const separator = ' | '
|
|
44
|
+
const plain = () => fitted.map(part => part.text).join(separator)
|
|
45
|
+
while (fitted.length > 1 && visibleWidth(plain()) > width) {
|
|
46
|
+
const last = fitted[fitted.length - 1]
|
|
47
|
+
const otherWidth = visibleWidth(plain()) - visibleWidth(separator) - visibleWidth(last.text)
|
|
48
|
+
const available = width - otherWidth - visibleWidth(separator)
|
|
49
|
+
if (available > 0 && available < visibleWidth(last.text)) {
|
|
50
|
+
last.text = truncateToWidth(last.text, available, '', false)
|
|
51
|
+
if (visibleWidth(plain()) <= width) return fitted
|
|
52
|
+
}
|
|
53
|
+
fitted.pop()
|
|
54
|
+
}
|
|
55
|
+
if (fitted.length && visibleWidth(plain()) > width) {
|
|
56
|
+
fitted[0].text = truncateToWidth(fitted[0].text, width, '', false)
|
|
57
|
+
}
|
|
58
|
+
return fitted
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
function color(theme: Theme, hex: string, text: string): string {
|
|
58
62
|
return theme.style(text, { fg: parseColor(hex) })
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
export function contextGauge(width: number, percent: number | null, window: number, theme: Theme): string {
|
|
62
|
-
const colors = palette(theme)
|
|
63
|
-
const size = Math.max(0, Math.floor(width))
|
|
64
|
-
if (!size) return ''
|
|
65
|
-
const bounded = percent === null || !Number.isFinite(percent) ? null : Math.max(0, Math.min(100, percent))
|
|
66
|
-
const used = window > 0 ? Math.min(size, Math.max(1, Math.round((bounded ?? 0) / 100 * size))) : 0
|
|
67
|
-
const cells = Array.from({ length: size }, () => '─')
|
|
68
|
-
const percentLabel = bounded === null ? '' : `${Math.round(bounded)}%`
|
|
69
|
-
const windowLabel = window > 0 ? formatContextWindow(window) : ''
|
|
70
|
-
const end = size - windowLabel.length - 1
|
|
71
|
-
const start = Math.min(used, Math.max(1, end - percentLabel.length - 1))
|
|
72
|
-
if (percentLabel && start + percentLabel.length < end) cells.splice(start, percentLabel.length, ...percentLabel)
|
|
73
|
-
if (windowLabel && end > percentLabel.length + 2) cells.splice(end, windowLabel.length, ...windowLabel)
|
|
74
|
-
return cells.map((cell, index) => color(theme, index < used ? colors.electricBlue : colors.subtleGray, cell)).join('')
|
|
75
|
-
}
|
|
76
|
-
|
|
77
65
|
export function renderStatusBand(state: BandState, width: number, theme: Theme): string {
|
|
78
|
-
const colors = palette(theme)
|
|
79
66
|
const size = Math.max(0, Math.floor(width))
|
|
80
67
|
if (!size) return ''
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
? `${
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
let pathWidth = Math.min(40, visibleWidth(path))
|
|
96
|
-
let parts = [brand, modelText, color(theme, colors.brightAluminum, `📁 ${truncateToWidth(path, pathWidth)}`), account, branch, cost, extra].filter((part): part is string => part !== null)
|
|
97
|
-
const minimumGauge = state.contextWindow > 0 ? Math.min(12, Math.floor(size / 3)) : 1
|
|
98
|
-
const groupSize = () => visibleWidth(parts.join(separator)) + 3
|
|
99
|
-
while (groupSize() + minimumGauge > size && pathWidth > 8) {
|
|
100
|
-
pathWidth -= 1
|
|
101
|
-
parts[2] = color(theme, colors.brightAluminum, `📁 ${truncateToWidth(path, pathWidth)}`)
|
|
102
|
-
}
|
|
103
|
-
while (groupSize() + minimumGauge > size && parts.length > 2) parts.pop()
|
|
104
|
-
if (groupSize() + minimumGauge > size) parts = [modelText]
|
|
105
|
-
const group = truncateToWidth(` ${parts.join(separator)} `, Math.max(0, size - 1), '', false)
|
|
106
|
-
const cap = color(theme, colors.darkTitanium, '▶')
|
|
107
|
-
const gauge = contextGauge(Math.max(0, size - visibleWidth(group) - 1), state.contextPercent, state.contextWindow, theme)
|
|
108
|
-
return theme.style(group, { bg: parseColor(colors.darkTitanium) }) + cap + gauge
|
|
68
|
+
const colors = palette(theme)
|
|
69
|
+
const model = state.model ? singleLine(state.model.name).replace(/^Claude /, '') || 'no-model' : 'no-model'
|
|
70
|
+
const account = singleLine(state.account ?? '') || 'account unknown'
|
|
71
|
+
const effort = singleLine(state.thinkingLevel) || 'off'
|
|
72
|
+
const rate = state.tokensPerSecond === null || !Number.isFinite(state.tokensPerSecond)
|
|
73
|
+
? '— tok/s' : `${Math.max(0, Math.round(state.tokensPerSecond))} tok/s`
|
|
74
|
+
const parts = fitParts([
|
|
75
|
+
{ text: displayProjectName(state.cwd), color: colors.brightAluminum },
|
|
76
|
+
{ text: singleLine(state.branch ?? '') || '—', color: colors.readoutGreen },
|
|
77
|
+
{ text: `${account} · ${model} · ${effort}`, color: colors.electricBlue },
|
|
78
|
+
{ text: `${rate} in ${tokenCount(state.inputTokens)} out ${tokenCount(state.outputTokens)}`, color: colors.titaniumGold }
|
|
79
|
+
], size)
|
|
80
|
+
const separator = color(theme, colors.subtleGray, ' | ')
|
|
81
|
+
return parts.map(part => color(theme, part.color, part.text)).join(separator)
|
|
109
82
|
}
|
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
* Adapted from @oh-my-pi/pi-tui 18.11.3 (https://github.com/can1357/oh-my-pi).
|
|
3
3
|
*
|
|
4
4
|
* MIT License
|
|
5
|
-
*
|
|
5
|
+
*
|
|
6
6
|
* Copyright (c) 2025 Mario Zechner
|
|
7
7
|
* Copyright (c) 2025-2026 Can Bölük
|
|
8
8
|
* Copyright (c) 2026 Stencil Labs, Inc.
|
|
9
|
-
*
|
|
9
|
+
*
|
|
10
10
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
11
|
* of this software and associated documentation files (the "Software"), to deal
|
|
12
12
|
* in the Software without restriction, including without limitation the rights
|
|
13
13
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
14
|
* copies of the Software, and to permit persons to whom the Software is
|
|
15
15
|
* furnished to do so, subject to the following conditions:
|
|
16
|
-
*
|
|
16
|
+
*
|
|
17
17
|
* The above copyright notice and this permission notice shall be included in all
|
|
18
18
|
* copies or substantial portions of the Software.
|
|
19
|
-
*
|
|
19
|
+
*
|
|
20
20
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
21
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
22
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { Theme, type ThemeBg, type ThemeColor } from '@earendil-works/pi-coding-agent'
|
|
29
29
|
|
|
30
|
+
const orange = { dark: '#ffb347', light: '#9a7326' } as const
|
|
31
|
+
|
|
30
32
|
export const TITANIUM = {
|
|
31
33
|
brushedTitanium: '#151820',
|
|
32
34
|
darkTitanium: '#0f1216',
|
|
@@ -35,7 +37,7 @@ export const TITANIUM = {
|
|
|
35
37
|
titaniumGold: '#d4c090',
|
|
36
38
|
brightAluminum: '#e8ecf4',
|
|
37
39
|
dimAluminum: '#9ca3b0',
|
|
38
|
-
warningAmber:
|
|
40
|
+
warningAmber: orange.dark,
|
|
39
41
|
readoutGreen: '#00ff88',
|
|
40
42
|
alertRed: '#ff4757',
|
|
41
43
|
subtleGray: '#2a3038'
|
|
@@ -48,7 +50,7 @@ const foreground = {
|
|
|
48
50
|
borderMuted: '#1f252d',
|
|
49
51
|
success: '#00ff88',
|
|
50
52
|
error: '#ff4757',
|
|
51
|
-
warning:
|
|
53
|
+
warning: orange.dark,
|
|
52
54
|
muted: '#9ca3b0',
|
|
53
55
|
dim: '#6b7280',
|
|
54
56
|
text: '',
|
|
@@ -114,7 +116,7 @@ const lightForeground = {
|
|
|
114
116
|
borderMuted: '#b0b0b0',
|
|
115
117
|
success: '#588458',
|
|
116
118
|
error: '#aa5555',
|
|
117
|
-
warning:
|
|
119
|
+
warning: orange.light,
|
|
118
120
|
muted: '#6c6c6c',
|
|
119
121
|
dim: '#767676',
|
|
120
122
|
text: '',
|
|
@@ -172,3 +174,12 @@ const lightBackground = {
|
|
|
172
174
|
export function createHarnessLightTheme(base: Theme): Theme {
|
|
173
175
|
return new Theme(lightForeground, lightBackground, base.getColorMode(), { name: 'light', appearance: 'light' })
|
|
174
176
|
}
|
|
177
|
+
|
|
178
|
+
// The composer keeps its neutral frame; the slash completion divider uses warning amber.
|
|
179
|
+
export function composerRuleColor(theme: Theme): string {
|
|
180
|
+
return theme.appearance === 'light' ? '#8a8a8a' : '#888888'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function autocompleteRuleColor(theme: Theme): string {
|
|
184
|
+
return theme.appearance === 'light' ? orange.light : orange.dark
|
|
185
|
+
}
|
|
@@ -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
|
+
}
|