@xingular/harness 0.1.7 → 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.
Files changed (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +100 -10
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
@@ -1,7 +1,6 @@
1
- // Presentation adapted from Oh My Pi 18.11.3's band composer and status line.
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 { homedir } from 'node:os'
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
- contextPercent: number | null
17
- contextWindow: number
18
- costUsd: number
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
- const EFFORT: Record<string, string> = { off: '⦸', minimal: '○', low: '◔', medium: '◑', high: '◒', xhigh: '◕', max: '◉' }
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 displayPath(cwd: string, home = homedir()): string {
41
- const inside = relative(home, cwd)
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
- export function formatContextWindow(tokens: number): string {
47
- if (tokens >= 1_000_000) return `${Number((tokens / 1_000_000).toFixed(1))}M`
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 elapsed(milliseconds: number): string {
53
- const seconds = Math.max(0, Math.floor(milliseconds / 1000))
54
- return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`
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 model = state.model
82
- const effort = model?.reasoning ? EFFORT[state.thinkingLevel] ?? '○' : '⬢'
83
- const name = model ? singleLine(model.name).replace(/^Claude /, '') : 'no-model'
84
- const modelText = color(theme, colors.electricBlue, `${effort} ${name}`)
85
- const brand = color(theme, state.running ? colors.electricBlue : '#6b7280', state.running
86
- ? `${SPINNER[Math.floor(state.elapsedMs / 80) % SPINNER.length]} ${elapsed(state.elapsedMs)}` : 'π')
87
- const path = singleLine(displayPath(state.cwd))
88
- const separator = ` ${color(theme, colors.subtleGray, '>')} `
89
- const account = state.account ? color(theme, colors.brightAluminum, singleLine(state.account)) : null
90
- const branch = state.branch ? color(theme, colors.readoutGreen, `⑂ ${singleLine(state.branch)}`) : null
91
- const cost = state.costUsd > 0 || state.subscription
92
- ? color(theme, colors.titaniumGold, `$${state.costUsd.toFixed(2)}${state.subscription ? ' (sub)' : ''}`) : null
93
- const overflow = [state.hiddenAbove ? `↑ ${state.hiddenAbove} more` : '', state.hiddenBelow ? `↓ ${state.hiddenBelow} more` : ''].filter(Boolean).join(' ')
94
- const extra = overflow ? color(theme, '#6b7280', overflow) : null
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: '#ffb347',
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: '#ffb347',
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: '#9a7326',
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
+ }
@@ -23,3 +23,17 @@ it('detaches all custom transcript components before their session is invalidate
23
23
  transcript.clearSession()
24
24
  expect(second.dispose).toHaveBeenCalledOnce()
25
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
+ })
@@ -13,6 +13,7 @@ export class TerminalTranscript extends Container {
13
13
  private hideThinking = false
14
14
  private expanded = false
15
15
  private hiddenLabel = 'Thinking…'
16
+ private lastNotice?: { component: Component; text: string; type: string }
16
17
 
17
18
  constructor(private readonly tui: TUI, private readonly theme: () => Theme, private readonly builtinRenderers: BuiltinRenderers) { super() }
18
19
 
@@ -26,6 +27,16 @@ export class TerminalTranscript extends Container {
26
27
  this.session = undefined
27
28
  }
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
+
29
40
  bind(session: AgentSession): void {
30
41
  this.clearSession()
31
42
  this.session = session
@@ -1,5 +1,5 @@
1
1
  import { Theme, type AutocompleteProviderFactory, type ExtensionUIContext, type KeybindingsManager, type WorkingIndicatorOptions } from '@earendil-works/pi-coding-agent'
2
- import { stripTerminalSequences, type AutocompleteProvider, type EditorComponent, type TuiMainScreen } from '@earendil-works/pi-tui'
2
+ import { stripTerminalSequences, type AutocompleteProvider, type EditorComponent, type TUI } from '@earendil-works/pi-tui'
3
3
 
4
4
  import { TerminalDialogs } from './dialogs'
5
5
 
@@ -18,7 +18,7 @@ export interface TerminalUiState {
18
18
  autocompleteFactories: AutocompleteProviderFactory[]
19
19
  }
20
20
  export interface TerminalUiOptions {
21
- tui: TuiMainScreen
21
+ tui: TUI
22
22
  theme: Theme
23
23
  keybindings: KeybindingsManager
24
24
  changed(): void
@@ -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