@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
@@ -6,21 +6,27 @@ import { randomUUID } from 'node:crypto'
6
6
  import manifest from '../../../package.json'
7
7
  import { promisify } from 'node:util'
8
8
  import { CustomEditor, copyToClipboard, initTheme, type AgentSessionEvent, type ExtensionUIContext, type KeybindingsManager, type ReadonlyFooterDataProvider, type Theme } from '@earendil-works/pi-coding-agent'
9
- import { CombinedAutocompleteProvider, ProcessTerminal, TuiMainScreen, Text, matchesKey, setKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi, type AutocompleteProvider, type Component, type EditorComponent, type TuiMouseEvent } from '@earendil-works/pi-tui'
9
+ import { CombinedAutocompleteProvider, Container, ProcessTerminal, Text, matchesKey, setKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi, type AutocompleteProvider, type Component, type EditorComponent, type ScrollView } from '@earendil-works/pi-tui'
10
10
 
11
11
  import { controlCall } from '../control'
12
12
  import { createTerminalCommands } from './commands'
13
- import { HarnessEditor } from './editor'
13
+ import { DialogPanel, SearchableSelect } from './dialogs'
14
+ import { COMPOSER_SUGGESTIONS, HarnessEditor } from './editor'
14
15
  import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
15
16
  import { loadPiAdapter, setPiTheme } from './piAdapter'
16
17
  import { createTerminalRuntime, type TerminalDecision, type TerminalRuntime } from './runtime'
18
+ import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences, type TerminalScreen } from './screen'
19
+ import { planModeTools } from './planMode'
17
20
  import { renderStatusBand, singleLine } from './status'
21
+ import { skillCompletions } from './skillInvocation'
18
22
  import { createHarnessLightTheme, createHarnessTheme } from './theme'
19
23
  import { TerminalTranscript } from './transcript'
20
24
  import { createTerminalUi, type TerminalUi } from './ui'
25
+ import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPINNER_INTERVAL_MS, spinnerFrameAt } from './workingStatus'
21
26
  import { HarnessWelcome } from './welcome'
22
27
  import { answerTerminalQuestion } from './questions'
23
28
  import type { QuestionAnswer } from '../../sessions/registry'
29
+ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
24
30
 
25
31
  const exec = promisify(execFile)
26
32
  type Mounted = Component & { dispose?(): void }
@@ -28,10 +34,49 @@ type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
28
34
  type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
29
35
  type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
30
36
 
37
+ /** The transcript document refreshes the thinking display from application state before each frame. */
38
+ class ChatDocument extends Container {
39
+ constructor(private readonly beforeRender: () => void) { super() }
40
+ override render(width: number): string[] {
41
+ this.beforeRender()
42
+ return super.render(width)
43
+ }
44
+ }
45
+
46
+ async function openExternal(url: string): Promise<void> {
47
+ const program = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
48
+ await exec(program, process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url])
49
+ }
50
+
31
51
  /** Sole owner of terminal lifecycle, layout, input dispatch, focus and screen transitions. */
32
- export class TerminalApplication implements Component {
52
+ export class TerminalApplication {
33
53
  readonly terminal = new ProcessTerminal()
34
- readonly tui = new TuiMainScreen(this.terminal)
54
+ readonly tui: TerminalScreen
55
+ private readonly preferences: ScreenPreferences
56
+ private readonly chat = new ChatDocument(() => this.syncThinking())
57
+ private readonly above = new Container()
58
+ private readonly editorSlot = new Container()
59
+ private readonly below = new Container()
60
+ private readonly footerSlot = new Container()
61
+ private readonly dock: Component
62
+ private readonly hooks: ScreenHooks = {
63
+ theme: () => this.theme,
64
+ latestKey: () => this.keybindings?.getKeys('tui.altScreen.bottom')[0],
65
+ openUrl: url => this.attempt(() => openExternal(url)),
66
+ pasteClipboard: () => this.attempt(() => this.pasteClipboard())
67
+ }
68
+ private readonly masthead: Component = {
69
+ render: width => {
70
+ // A trusted extension's header owns the top of the transcript and is never hidden here.
71
+ if (this.header) return ['', ...this.header.render(width), '', '']
72
+ if (!this.showsMasthead) return []
73
+ return ['', ...new HarnessWelcome(this.theme, process.env.XINGULAR_HARNESS_NAME).render(width), '', '']
74
+ },
75
+ invalidate: () => this.header?.invalidate()
76
+ }
77
+ private readonly titleRow: Component = { render: width => this.titleLines(width), invalidate: () => {} }
78
+ private readonly queueRows: Component = { render: width => this.queuedMessages(width), invalidate: () => {} }
79
+ private readonly statusRow: Component = { render: width => this.statusLines(width), invalidate: () => {} }
35
80
  private runtime?: TerminalRuntime
36
81
  private ui!: TerminalUi
37
82
  private keybindings!: KeybindingsManager
@@ -48,13 +93,17 @@ export class TerminalApplication implements Component {
48
93
  private branch: string | null = null
49
94
  private branchListeners = new Set<() => void>()
50
95
  private baseAutocomplete?: AutocompleteProvider
51
- private notices: { message: string; type: string }[] = []
52
96
  private startedAt: number | null = null
97
+ private activeVerb?: string
98
+ private activeTurnId?: string
99
+ private completionStatus?: string
100
+ private requestStartedAt: number | null = null
101
+ private lastTokensPerSecond: number | null = null
53
102
  private phase?: string
54
- private scrollOffset = 0
55
- private totalLines = 0
103
+ private viewport?: ScrollView
56
104
  private toolsExpanded = false
57
- private thinkingChanges = 0
105
+ private planMode = false
106
+ private normalTools?: string[]
58
107
  private stopped = false
59
108
  private initialized = false
60
109
  private closing?: Promise<void>
@@ -71,16 +120,23 @@ export class TerminalApplication implements Component {
71
120
  private pendingAbort?: AbortController
72
121
  private pendingId?: string
73
122
  private dismissedDecisions = new Set<string>()
74
- private mouseRegions: { component: Component; start: number; height: number }[] = []
123
+ private suggestion = COMPOSER_SUGGESTIONS[Math.floor(Math.random() * COMPOSER_SUGGESTIONS.length)]
75
124
 
76
- constructor(private readonly args: string[]) {}
125
+ constructor(private readonly args: string[]) {
126
+ this.preferences = readScreenPreferences(process.env.PI_CODING_AGENT_DIR)
127
+ this.tui = createTerminalScreen(this.terminal, this.preferences, this.hooks)
128
+ this.chat.addChild(this.masthead)
129
+ this.dock = createDock({ title: this.titleRow, above: this.above, queue: this.queueRows, editor: this.editorSlot, below: this.below, footer: this.footerSlot })
130
+ this.viewport = mountTerminalLayout(this.tui, { document: this.chat, dock: this.dock }, this.preferences, this.hooks)
131
+ }
77
132
  private get session() { if (!this.runtime) throw new Error('The terminal runtime is not ready.'); return this.runtime.session }
78
133
  private get theme(): Theme { return this.ui.theme }
134
+ /** The product lockup stays only until the chat has a user message; a resumed session starts without it. */
135
+ private get showsMasthead(): boolean { return !this.runtime?.snapshot().hasUserMessages }
79
136
  private change = (): void => { if (!this.stopped) this.tui.requestRender() }
80
137
  private notify = (message: string, type = 'info'): void => {
81
138
  if (message.startsWith('No models available.')) message = 'No model is configured. Use /accounts or /login to sign in.'
82
- this.notices.push({ message, type })
83
- this.notices = this.notices.slice(-8)
139
+ this.transcript?.notice(message, type)
84
140
  this.change()
85
141
  }
86
142
  private attempt = (action: () => Promise<unknown> | unknown): void => { void Promise.resolve().then(action).catch(error => this.notify(error instanceof Error ? error.message : String(error), 'error')).finally(this.change) }
@@ -102,9 +158,9 @@ export class TerminalApplication implements Component {
102
158
  this.keybindings = terminalKeybindings(process.env.PI_CODING_AGENT_DIR)
103
159
  setKeybindings(this.keybindings)
104
160
  this.transcript = new TerminalTranscript(this.tui, () => this.theme, adapter.withBuiltInRenderers)
161
+ this.chat.addChild(this.transcript)
105
162
  this.ui = this.makeUi(theme)
106
163
  this.mountEditor()
107
- this.tui.addChild(this)
108
164
  this.tui.setFocus(this.editor)
109
165
  this.cleanup.push(this.tui.addInputListener(data => this.dispatchInput(data)))
110
166
  for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { process.on(signal, onSignal); this.cleanup.push(() => { process.off(signal, onSignal) }) }
@@ -128,9 +184,11 @@ export class TerminalApplication implements Component {
128
184
  session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
129
185
  cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
130
186
  refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
131
- copyText: copyToClipboard, openExternal: async url => {
132
- const program = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
133
- await exec(program, process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url])
187
+ copyText: copyToClipboard, openExternal,
188
+ changed: this.change,
189
+ trajectory: {
190
+ list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
191
+ read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
134
192
  },
135
193
  suspend: () => this.suspend(), hotkeys: () => this.hotkeys(),
136
194
  changelog: async () => {
@@ -143,7 +201,7 @@ export class TerminalApplication implements Component {
143
201
  })
144
202
  this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
145
203
  this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
146
- this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.startedAt = null; this.phase = undefined; this.scrollOffset = 0; this.thinkingChanges = 0; this.refresh(true) }))
204
+ this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
147
205
  this.initialized = true
148
206
  this.phase = undefined
149
207
  this.refresh(true)
@@ -170,9 +228,10 @@ export class TerminalApplication implements Component {
170
228
  this.release(this.widgets.get(key)?.component)
171
229
  if (!content) this.widgets.delete(key)
172
230
  else this.widgets.set(key, { component: typeof content === 'function' ? content(this.tui, this.theme) : new Text(content.join('\n'), 0, 0), placement: placement ?? 'aboveEditor' })
231
+ this.syncDock()
173
232
  },
174
233
  setHeader: factory => { this.headerFactory = factory; this.release(this.header); this.header = factory?.(this.tui, this.theme) },
175
- setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData) },
234
+ setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData); this.syncDock() },
176
235
  getAutocompleteProvider: () => this.baseAutocomplete!,
177
236
  setAutocompleteProvider: provider => this.editor.setAutocompleteProvider?.(provider),
178
237
  themeChanged: next => { setPiTheme(next); this.remountPresentation() },
@@ -203,14 +262,13 @@ export class TerminalApplication implements Component {
203
262
  selectedPrefix: (text: string) => this.theme.fg('accent', text), selectedText: (text: string) => this.theme.fg('accent', text), description: (text: string) => this.theme.fg('muted', text), scrollInfo: (text: string) => this.theme.fg('dim', text), noMatch: (text: string) => this.theme.fg('muted', text)
204
263
  } }
205
264
  this.editor = factory?.(this.tui, editorTheme, this.keybindings) ?? new HarnessEditor(this.tui, editorTheme, this.keybindings, {
206
- band: (width, above, below) => this.band(width, above, below), theme: () => this.theme,
207
- showHint: () => this.thinkingChanges < 3 && !this.runtime?.snapshot().hasUserMessages && this.startedAt === null,
208
- thinkingKey: this.keybindings.getKeys('app.thinking.cycle').map(key => key === 'shift+tab' ? '⇧⇥' : key).join('/')
265
+ band: width => this.band(width), theme: () => this.theme,
266
+ placeholder: () => this.runtime?.snapshot().hasUserMessages || this.startedAt !== null ? undefined : this.suggestion
209
267
  })
210
268
  this.editor.setText(draft)
211
269
  this.editor.onSubmit = text => this.submit(text)
212
270
  const onChange = this.editor.onChange
213
- this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.scrollOffset = 0; this.change() }
271
+ this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.change() }
214
272
  if (factory && this.editor instanceof CustomEditor) {
215
273
  // A trusted editor may consume keys in its own mode before delegating to CustomEditor.
216
274
  // Wire host actions into that delegation instead of taking the keys away in a listener.
@@ -225,18 +283,133 @@ export class TerminalApplication implements Component {
225
283
  editor.onExtensionShortcut ??= data => this.extensionShortcut(data)
226
284
  }
227
285
  if (this.baseAutocomplete) this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
286
+ this.syncDock()
228
287
  this.tui.setFocus(this.editor)
229
288
  }
289
+ /** The dock's slots follow the current editor, widgets and footer; their rows are read at draw time. */
290
+ private syncDock(): void {
291
+ this.above.clear(); this.below.clear()
292
+ for (const widget of this.widgets.values()) (widget.placement === 'belowEditor' ? this.below : this.above).addChild(widget.component)
293
+ this.editorSlot.clear()
294
+ if (this.editor) this.editorSlot.addChild(this.editor)
295
+ this.footerSlot.clear()
296
+ this.footerSlot.addChild(this.footer ?? this.statusRow)
297
+ }
298
+ private syncThinking(): void {
299
+ if (!this.ui) return
300
+ this.transcript?.setThinking(this.runtime?.snapshot().hideThinking ?? false, this.ui.state.hiddenThinkingLabel ?? 'Thinking…')
301
+ }
302
+ private resetWorkingStatus(): void {
303
+ this.startedAt = null
304
+ this.activeVerb = undefined
305
+ this.activeTurnId = undefined
306
+ this.completionStatus = undefined
307
+ this.phase = undefined
308
+ }
309
+ /** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
310
+ private togglePlanMode(): void {
311
+ if (!this.runtime) return
312
+ if (this.planMode) {
313
+ this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
314
+ this.normalTools = undefined
315
+ } else {
316
+ this.normalTools = this.session.getActiveToolNames()
317
+ this.session.setActiveToolsByName(planModeTools(this.normalTools))
318
+ }
319
+ this.planMode = !this.planMode
320
+ this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Shift+Tab returns to normal.' : 'Normal mode: all tools are available.')
321
+ this.change()
322
+ }
323
+ private scrollToLatest(): void { this.viewport?.scrollToEnd() }
324
+ private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
325
+ if (signal.aborted) return { decision: 'denied', alwaysAllow: false }
326
+ const viewport = this.viewport
327
+ const scrollTop = viewport?.scrollTop
328
+ const wasFollowingEnd = viewport?.isFollowingEnd
329
+ const tool = request.target ?? 'action'
330
+ const choices = [
331
+ { value: 'once', label: 'Allow once' },
332
+ ...(request.rememberable ? [{ value: 'global', label: `Always allow ${tool} everywhere` }] : []),
333
+ { value: 'deny', label: 'Deny' }
334
+ ]
335
+ const details = [
336
+ request.rememberable
337
+ ? `Always allow applies to every future ${tool} call in Pi terminal sessions sharing this local Harness state.`
338
+ : 'This approval cannot be saved as a tool grant.',
339
+ '',
340
+ request.action ?? ''
341
+ ].join('\n')
342
+ let abortListener: (() => void) | undefined
343
+ try {
344
+ const choice = await this.ui.custom<string | undefined>((tui, theme, _keys, done) => {
345
+ const finish = (value: string | undefined) => {
346
+ if (abortListener) signal.removeEventListener('abort', abortListener)
347
+ abortListener = undefined
348
+ done(value)
349
+ }
350
+ abortListener = () => finish(undefined)
351
+ signal.addEventListener('abort', abortListener, { once: true })
352
+ if (signal.aborted) abortListener()
353
+ const list = new SearchableSelect(choices.map(item => item.label), tui, () => theme,
354
+ index => finish(choices[index]?.value), () => finish(undefined))
355
+ return new DialogPanel(`Approve ${tool}?`, list, () => theme, 'Enter choose · Esc deny', details)
356
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
357
+ return { decision: choice === 'once' || choice === 'global' ? 'approved' : 'denied', alwaysAllow: choice === 'global' }
358
+ } finally {
359
+ if (abortListener) signal.removeEventListener('abort', abortListener)
360
+ if (viewport && viewport === this.viewport && scrollTop !== undefined && wasFollowingEnd !== undefined) {
361
+ if (wasFollowingEnd) viewport.scrollToEnd()
362
+ else viewport.scrollTo(scrollTop, { disableFollow: true })
363
+ }
364
+ }
365
+ }
366
+ /** Steps through the levels this model supports and stops at either end, unlike the wrapping cycle. */
367
+ private stepThinking(direction: 1 | -1): void {
368
+ const levels = this.session.getAvailableThinkingLevels()
369
+ const next = levels[Math.min(levels.length - 1, Math.max(0, levels.indexOf(this.session.thinkingLevel) + direction))]
370
+ if (!this.session.supportsThinking() || next === undefined || next === this.session.thinkingLevel) return
371
+ this.session.setThinkingLevel(next)
372
+ }
373
+ private exitScrollback(): string[] | undefined {
374
+ if (this.preferences.mode !== 'fullscreen' || this.preferences.exitOutput !== 'transcript' || !this.runtime?.snapshot().hasUserMessages) return undefined
375
+ return scrollbackLines(this.chat, this.terminal.columns)
376
+ }
377
+ private titleLines(width: number): string[] {
378
+ if (!this.ui) return []
379
+ const title = this.runtime?.snapshot().name
380
+ const active = this.startedAt !== null
381
+ const working = this.phase ?? (active
382
+ ? this.ui.state.workingVisible ? this.ui.state.workingMessage ?? this.activeVerb ?? 'Working…' : ''
383
+ : this.completionStatus ?? '')
384
+ const titleText = title ? this.theme.italic(this.theme.fg('dim', singleLine(title))) : ''
385
+ const indicator = this.ui.state.workingIndicator
386
+ const frames = indicator?.frames ?? getWorkingSpinnerFrames(process.env.TERM)
387
+ const frame = active && frames.length
388
+ ? spinnerFrameAt(Date.now() - this.startedAt!, frames, indicator?.intervalMs ?? SPINNER_INTERVAL_MS)
389
+ : ''
390
+ const defaultWorking = active && !this.phase && this.ui.state.workingVisible && !this.ui.state.workingMessage && working === this.activeVerb
391
+ const workingColor = defaultWorking ? 'warning' : 'muted'
392
+ const workText = working ? this.theme.fg(workingColor, (frame ? frame + ' ' : '') + singleLine(working)) : ''
393
+ return titleText || workText ? [truncateToWidth(workText, width, '') + ' '.repeat(Math.max(0, width - visibleWidth(workText) - visibleWidth(titleText))) + titleText] : []
394
+ }
395
+ private queuedMessages(width: number): string[] {
396
+ if (!this.ui) return []
397
+ const state = this.runtime?.snapshot()
398
+ return state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width)) : []
399
+ }
400
+ private statusLines(width: number): string[] {
401
+ return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
402
+ }
230
403
  private syncDraft(text: string): void {
231
404
  this.draftSync = this.draftSync.then(() => controlCall('terminal.draft', { dirty: text.length > 0 })).then(() => {}).catch(error => this.notify(String(error), 'error'))
232
405
  }
233
- private band(width: number, above = 0, below = 0): string {
406
+ private band(width: number): string {
234
407
  const state = this.runtime?.snapshot()
408
+ const usage = this.runtime?.session.getSessionStats().tokens
235
409
  return renderStatusBand({ cwd: state?.cwd ?? process.cwd(), model: state?.model,
236
410
  thinkingLevel: state?.thinkingLevel ?? 'off', branch: this.branch, account: state?.account?.label ?? 'account unknown',
237
- contextPercent: state?.context.percent ?? null, contextWindow: state?.context.window ?? 0,
238
- costUsd: state?.costUsd ?? 0, subscription: state?.subscription ?? false,
239
- running: this.startedAt !== null, elapsedMs: this.startedAt === null ? 0 : Date.now() - this.startedAt, hiddenAbove: above, hiddenBelow: below
411
+ inputTokens: (usage?.input ?? 0) + (usage?.cacheRead ?? 0), outputTokens: usage?.output ?? 0,
412
+ tokensPerSecond: this.lastTokensPerSecond
240
413
  }, width, this.theme)
241
414
  }
242
415
  private submit(text: string, followUp = false): void {
@@ -245,14 +418,14 @@ export class TerminalApplication implements Component {
245
418
  const command = text.trimStart().startsWith('/')
246
419
  if (command) this.commandBusy = true
247
420
  this.editor.addToHistory?.(text)
248
- this.editor.setText(''); this.syncDraft(''); this.notices = []; this.scrollOffset = 0
421
+ this.editor.setText(''); this.syncDraft(''); this.scrollToLatest()
249
422
  this.attempt(async () => {
250
423
  try {
251
424
  await this.draftSync
252
425
  if (this.stopped) return
253
426
  if (command && await this.commands.execute(text)) return
254
427
  if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
255
- await this.runtime!.submit(text, followUp)
428
+ await this.runtime!.submit(this.commands.expandSkill(text), followUp)
256
429
  } finally { if (command) this.commandBusy = false }
257
430
  })
258
431
  }
@@ -272,7 +445,9 @@ export class TerminalApplication implements Component {
272
445
  else if (this.editor.getText()) { this.editor.setText(''); this.syncDraft('') }
273
446
  else void this.shutdown()
274
447
  } else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
275
- else if (is('app.thinking.cycle')) { this.session.cycleThinkingLevel(); this.thinkingChanges += 1 }
448
+ else if (is('app.thinking.cycle')) this.session.cycleThinkingLevel()
449
+ else if (is('app.plan.toggle')) this.togglePlanMode()
450
+ else if (is('app.thinking.increase') || is('app.thinking.decrease')) this.stepThinking(is('app.thinking.increase') ? 1 : -1)
276
451
  else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
277
452
  else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
278
453
  else if (is('app.model.select')) this.attempt(() => this.commands.execute('/model'))
@@ -284,10 +459,7 @@ export class TerminalApplication implements Component {
284
459
  else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
285
460
  else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
286
461
  else if (is('app.suspend')) this.attempt(() => this.suspend())
287
- else if (matchesKey(data, 'pageUp') || matchesKey(data, 'shift+pageUp')) this.scrollOffset = Math.min(Math.max(0, this.totalLines - 1), this.scrollOffset + Math.max(1, this.terminal.rows - 4))
288
- else if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+pageDown')) this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(1, this.terminal.rows - 4))
289
- else if (matchesKey(data, 'ctrl+home')) this.scrollOffset = Math.max(0, this.totalLines - 1)
290
- else if (matchesKey(data, 'ctrl+end')) this.scrollOffset = 0
462
+ else if (data === '?' && this.editor.getText() === '') this.notify(this.hotkeys())
291
463
  else return
292
464
  this.change(); return { consume: true }
293
465
  }
@@ -298,9 +470,34 @@ export class TerminalApplication implements Component {
298
470
  return true
299
471
  }
300
472
  private accept(event: AgentSessionEvent): void {
473
+ if (event.type === 'turn_start') this.requestStartedAt = performance.now()
474
+ if (event.type === 'message_end' && event.message.role === 'assistant') {
475
+ const outputTokens = event.message.usage?.output
476
+ const startedAt = this.requestStartedAt
477
+ if (startedAt !== null && typeof outputTokens === 'number') {
478
+ const duration = performance.now() - startedAt
479
+ if (duration > 0) this.lastTokensPerSecond = outputTokens * 1000 / duration
480
+ }
481
+ this.requestStartedAt = null
482
+ }
301
483
  this.transcript.accept(event)
302
- if (event.type === 'agent_start') { this.startedAt = Date.now(); this.phase = undefined; this.terminal.setProgress(this.session.settingsManager.getShowTerminalProgress()) }
303
- if (event.type === 'agent_settled') { this.startedAt = null; this.phase = undefined; this.terminal.setProgress(false) }
484
+ if (event.type === 'agent_start') {
485
+ this.startedAt = Date.now()
486
+ this.activeVerb = chooseWorkingVerb()
487
+ this.activeTurnId = randomUUID()
488
+ this.completionStatus = undefined
489
+ this.phase = undefined
490
+ this.terminal.setProgress(this.session.settingsManager.getShowTerminalProgress())
491
+ }
492
+ if (event.type === 'agent_settled') {
493
+ if (this.startedAt !== null) this.completionStatus = createCompletionStatus(this.activeTurnId ?? randomUUID(), Date.now() - this.startedAt)
494
+ this.startedAt = null
495
+ this.activeVerb = undefined
496
+ this.activeTurnId = undefined
497
+ this.requestStartedAt = null
498
+ this.phase = undefined
499
+ this.terminal.setProgress(false)
500
+ }
304
501
  if (event.type === 'compaction_start') this.phase = 'Compacting context…'
305
502
  if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
306
503
  if (event.type === 'auto_retry_start') this.phase = `Retry ${event.attempt}/${event.maxAttempts} in ${(event.delayMs / 1000).toFixed(1)}s: ${event.errorMessage}`
@@ -316,12 +513,14 @@ export class TerminalApplication implements Component {
316
513
  const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
317
514
  if (text) this.editor.addToHistory?.(text)
318
515
  }
319
- this.baseAutocomplete = new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd)
516
+ this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd), this.commands.skills)
320
517
  this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
321
518
  this.change()
322
519
  }
323
520
  private resetPresentation(): void {
324
521
  this.pendingAbort?.abort(); this.dismissedDecisions.clear()
522
+ this.resetWorkingStatus()
523
+ this.requestStartedAt = null; this.lastTokensPerSecond = null
325
524
  this.transcript.clearSession()
326
525
  this.ui.reset()
327
526
  this.release(this.header); this.release(this.footer)
@@ -339,46 +538,6 @@ export class TerminalApplication implements Component {
339
538
  if (this.runtime) this.refresh()
340
539
  }
341
540
 
342
- render(width: number): string[] {
343
- if (width < 1 || !this.ui) return []
344
- this.mouseRegions = []
345
- const welcome = this.header ?? new HarnessWelcome(this.theme, process.env.XINGULAR_HARNESS_NAME)
346
- const history = ['', ...welcome.render(width), '', '']
347
- const state = this.runtime?.snapshot()
348
- this.transcript.setThinking(state?.hideThinking ?? false, this.ui.state.hiddenThinkingLabel ?? 'Thinking…')
349
- const transcriptRows = this.transcript.render(Math.max(3, width))
350
- this.mouseRegions.push({ component: this.transcript, start: history.length, height: transcriptRows.length })
351
- history.push(...transcriptRows); this.totalLines = history.length
352
- const title = state?.name
353
- const working = this.phase ?? (this.startedAt !== null && this.ui.state.workingVisible ? this.ui.state.workingMessage ?? 'Working…' : '')
354
- const titleText = title ? this.theme.italic(this.theme.fg('dim', singleLine(title))) : ''
355
- const indicator = this.ui.state.workingIndicator
356
- const frames = indicator?.frames
357
- const frame = this.startedAt !== null && frames?.length ? frames[Math.floor((Date.now() - this.startedAt) / Math.max(1, indicator?.intervalMs ?? 80)) % frames.length] : ''
358
- const workText = working ? (frame ? frame + ' ' : '') + this.theme.fg('muted', singleLine(working)) : ''
359
- const composer = titleText || workText ? [truncateToWidth(workText, width, '') + ' '.repeat(Math.max(0, width - visibleWidth(workText) - visibleWidth(titleText))) + titleText] : []
360
- const append = (component: Component) => { const rows = component.render(width); this.mouseRegions.push({ component, start: history.length + composer.length, height: rows.length }); composer.push(...rows) }
361
- for (const widget of this.widgets.values()) if (widget.placement !== 'belowEditor') append(widget.component)
362
- if (state) for (const text of [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)]) composer.push(...wrapTextWithAnsi(this.theme.fg('muted', text), width))
363
- for (const { message, type } of this.notices) composer.push(...wrapTextWithAnsi(this.theme.fg(type === 'error' ? 'error' : type === 'warning' ? 'warning' : 'muted', message), width))
364
- append(this.editor)
365
- for (const widget of this.widgets.values()) if (widget.placement === 'belowEditor') append(widget.component)
366
- if (this.footer) append(this.footer)
367
- else if (this.statuses.size) composer.push(...wrapTextWithAnsi([...this.statuses.values()].join(' '), width))
368
- const available = Math.max(1, this.terminal.rows - composer.length - 1)
369
- const end = Math.max(1, history.length - this.scrollOffset)
370
- const start = this.scrollOffset ? Math.max(0, end - available) : 0
371
- for (const region of this.mouseRegions) region.start -= region.component === this.transcript ? start : this.scrollOffset ? history.length - (end - start + 1) : 0
372
- const content = this.scrollOffset ? [...history.slice(start, end), this.theme.fg('dim', `↑ Scrollback · ${this.scrollOffset} rows from latest · PgDn to return`)] : history
373
- return [...content, ...composer].map(line => truncateToWidth(line, width, ''))
374
- }
375
- invalidate(): void { this.header?.invalidate(); this.footer?.invalidate(); this.transcript?.invalidate(); this.editor?.invalidate(); for (const widget of this.widgets.values()) widget.component.invalidate() }
376
- handleMouse(event: TuiMouseEvent) {
377
- if (event.type === 'wheel') { this.scrollOffset = Math.max(0, Math.min(this.totalLines - 1, this.scrollOffset - (event.wheelDelta ?? 0))); this.change(); return { handled: true } }
378
- const region = this.mouseRegions.find(region => event.y >= region.start && event.y < region.start + region.height)
379
- return region?.component.handleMouse?.({ ...event, y: event.y - region.start, height: region.height })
380
- }
381
-
382
541
  private async decisions(): Promise<void> {
383
542
  if (this.pendingPolling || !this.runtime || this.stopped) return
384
543
  this.pendingPolling = true
@@ -398,8 +557,8 @@ export class TerminalApplication implements Component {
398
557
  const abort = new AbortController(); this.pendingAbort = abort; this.pendingId = request.requestId
399
558
  this.dismissedDecisions.add(request.requestId)
400
559
  if (request.type === 'approval') {
401
- const approved = await this.ui.confirm(`Allow ${request.target ?? 'action'}?`, request.action ?? '', { signal: abort.signal })
402
- if (!abort.signal.aborted) await this.runtime.approve(request.requestId, approved ? 'approved' : 'denied')
560
+ const choice = await this.chooseApproval(request, abort.signal)
561
+ if (!abort.signal.aborted) await this.runtime.approve(request.requestId, choice.decision, choice.alwaysAllow)
403
562
  } else {
404
563
  const group = request.batchId
405
564
  ? requests.filter(item => item.type === 'question' && item.batchId === request.batchId).sort((a, b) => (a.batchIndex ?? 0) - (b.batchIndex ?? 0))
@@ -423,7 +582,7 @@ export class TerminalApplication implements Component {
423
582
  finally { this.pendingBusy = false; this.pendingId = undefined; this.pendingAbort = undefined }
424
583
  }
425
584
  private hotkeys(): string {
426
- return Object.entries(this.keybindings.getResolvedBindings()).filter(([name, keys]) => name.startsWith('app.') && (!Array.isArray(keys) || keys.length)).map(([name, keys]) => `${Array.isArray(keys) ? keys.join(', ') : keys}: ${name.slice(4).replaceAll('.', ' ')}`).join('\n') + '\nPgUp/PgDn: scroll transcript\nCtrl+Home/End: oldest/latest transcript'
585
+ return Object.entries(this.keybindings.getResolvedBindings()).filter(([name, keys]) => name.startsWith('app.') && (!Array.isArray(keys) || keys.length)).map(([name, keys]) => `${Array.isArray(keys) ? keys.join(', ') : keys}: ${name.slice(4).replaceAll('.', ' ')}`).join('\n') + (this.tui.mode === 'fullscreen' ? '\nPgUp/PgDn: scroll transcript\nCtrl+Home/End: oldest/latest transcript' : '')
427
586
  }
428
587
  private async pasteClipboard(): Promise<void> {
429
588
  const adapter = await loadPiAdapter()
@@ -472,16 +631,19 @@ export class TerminalApplication implements Component {
472
631
  protect(() => this.pendingAbort?.abort())
473
632
  protect(() => this.ui?.dispose())
474
633
  for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
634
+ // Captured while the runtime and its components still exist; written only after the alternate screen is left.
635
+ const scrollback = this.exitScrollback()
475
636
  try { await this.runtime?.dispose() }
476
637
  catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
477
638
  finally {
639
+ await this.terminal.drainInput(200, 30).catch(() => {})
640
+ protect(() => this.tui.stop())
641
+ if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
478
642
  protect(() => this.transcript?.clearSession())
479
643
  protect(() => this.header?.dispose?.())
480
644
  protect(() => this.footer?.dispose?.())
481
645
  for (const widget of this.widgets.values()) protect(() => widget.component.dispose?.())
482
646
  protect(() => (this.editor as Mounted | undefined)?.dispose?.())
483
- await this.terminal.drainInput(200, 30).catch(() => {})
484
- protect(() => this.tui.stop())
485
647
  for (const cleanup of this.finalCleanup.splice(0)) protect(cleanup)
486
648
  this.finish()
487
649
  }
@@ -4,7 +4,7 @@ import { createTerminalCommands, type TerminalCommandHost } from './commands'
4
4
 
5
5
  function setup() {
6
6
  const ui = {
7
- notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(),
7
+ notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(), custom: vi.fn(async () => undefined),
8
8
  theme: { name: 'harness-titanium' }
9
9
  } as unknown as ExtensionUIContext
10
10
  const session = {
@@ -15,16 +15,27 @@ function setup() {
15
15
  },
16
16
  settingsManager: { getEnableSkillCommands: () => true },
17
17
  compact: vi.fn(), reload: vi.fn(), setSessionName: vi.fn(),
18
+ getActiveToolNames: () => ['read', 'bash'],
18
19
  getLastAssistantText: vi.fn(() => 'Actual assistant response'),
19
20
  getSessionStats: () => ({ sessionId: 'real-session', tokens: { input: 7 } }),
20
21
  exportToHtml: vi.fn(async (path: string) => path), exportToJsonl: vi.fn((path: string) => path)
21
22
  } as unknown as AgentSession
22
- const runtime = { newSession: vi.fn(), switchSession: vi.fn(), fork: vi.fn(), importFromJsonl: vi.fn(), reload: vi.fn(), navigateTree: vi.fn(), installationDeviceId: vi.fn(async () => '12345678-1234-4234-8234-123456789abc') }
23
+ const runtime = {
24
+ newSession: vi.fn(), switchSession: vi.fn(), fork: vi.fn(), importFromJsonl: vi.fn(), reload: vi.fn(), navigateTree: vi.fn(),
25
+ installationDeviceId: vi.fn(async () => '12345678-1234-4234-8234-123456789abc'),
26
+ permissions: vi.fn(async () => ({ full: false, globalTools: [] as string[] })),
27
+ setFullPermission: vi.fn(async () => {}), setGlobalTool: vi.fn(async () => {})
28
+ }
29
+ const trajectoryRun = { id: 'run-1', mode: 'code', goal: 'Review the change', startedAt: '2026-10-18T00:00:00.000Z', endedAt: '2026-10-18T00:00:01.000Z', status: 'completed' }
23
30
  const host: TerminalCommandHost = {
24
31
  session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
25
32
  cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
26
- refresh: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
27
- hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes'
33
+ refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
34
+ hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
35
+ trajectory: {
36
+ list: vi.fn(async () => [trajectoryRun]),
37
+ read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
38
+ }
28
39
  }
29
40
  return { host, session, runtime, ui, commands: createTerminalCommands(host) }
30
41
  }
@@ -47,14 +58,21 @@ describe('owned terminal commands', () => {
47
58
  it('populates completion from builtins and loaded resources', () => {
48
59
  const { commands } = setup()
49
60
  const names = commands.completions().map(item => item.name)
50
- expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'skill:diagnose']))
61
+ expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
62
+ expect(names).not.toContain('thinking')
63
+ expect(names).not.toContain('skill:diagnose')
51
64
  expect(new Set(names).size).toBe(names.length)
52
65
  })
53
66
 
54
- it('respects disabled skill commands', () => {
67
+ it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
55
68
  const { commands, session } = setup()
69
+ expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
70
+ expect(commands.expandSkill('$diagnose the trace')).toBe('/skill:diagnose the trace')
71
+ expect(commands.expandSkill('$unknown the trace')).toBe('$unknown the trace')
72
+ expect(commands.expandSkill('/skill:diagnose trace')).toBe('/skill:diagnose trace')
56
73
  vi.spyOn(session.settingsManager, 'getEnableSkillCommands').mockReturnValue(false)
57
- expect(commands.completions().map(item => item.name)).not.toContain('skill:diagnose')
74
+ expect(commands.skills()).toEqual([])
75
+ expect(commands.expandSkill('$diagnose the trace')).toBe('$diagnose the trace')
58
76
  })
59
77
 
60
78
  it('uses the scope checked runtime for explicit resume and import paths', async () => {
@@ -129,6 +147,48 @@ describe('owned terminal commands', () => {
129
147
  expect(ui.notify).toHaveBeenCalledWith('Harness release notes', 'info')
130
148
  })
131
149
 
150
+ it('opens the latest or requested trajectory run in the interactive overlay', async () => {
151
+ const { commands, host, ui } = setup()
152
+ await commands.execute('/trajectory')
153
+ expect(host.trajectory.list).toHaveBeenCalledOnce()
154
+ expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
155
+ expect(ui.custom).toHaveBeenCalledOnce()
156
+ expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
157
+
158
+ await commands.execute('/trajectory run-1')
159
+ expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
160
+ })
161
+
162
+ it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
163
+ const { commands, host, runtime, ui } = setup()
164
+ vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
165
+ .mockImplementationOnce(async () => 'done' as never)
166
+ await commands.execute('/permissions')
167
+ expect(runtime.permissions).toHaveBeenCalled()
168
+ expect(runtime.setGlobalTool).toHaveBeenCalledWith('bash', true)
169
+
170
+ vi.mocked(ui.custom).mockImplementationOnce(async () => 'full' as never)
171
+ .mockImplementationOnce(async () => 'done' as never)
172
+ vi.mocked(ui.confirm).mockResolvedValueOnce(true)
173
+ await commands.execute('/permissions')
174
+ expect(ui.confirm).toHaveBeenCalledWith('Enable Full Permission?', expect.stringContaining('Hard denials'))
175
+ expect(runtime.setFullPermission).toHaveBeenCalledWith(true)
176
+ expect(host.refresh).toHaveBeenCalled()
177
+ })
178
+
179
+ it('reports empty history and refuses run IDs outside the current native session', async () => {
180
+ const { commands, host, ui } = setup()
181
+ vi.mocked(host.trajectory.list).mockResolvedValue([])
182
+ await commands.execute('/trajectory')
183
+ expect(ui.notify).toHaveBeenCalledWith('No runs recorded for this native session yet', 'info')
184
+ expect(ui.custom).not.toHaveBeenCalled()
185
+
186
+ vi.mocked(host.trajectory.list).mockResolvedValue([{ id: 'run-1', mode: 'code', goal: null, startedAt: '2026-10-18T00:00:00.000Z', endedAt: null, status: 'running' }])
187
+ await commands.execute('/trajectory other-run')
188
+ expect(host.trajectory.read).not.toHaveBeenCalled()
189
+ expect(ui.notify).toHaveBeenLastCalledWith('Run not found in this native session', 'error')
190
+ })
191
+
132
192
  it('exits through the application lifecycle', async () => {
133
193
  const { commands, host } = setup()
134
194
  await commands.execute('/quit')
@@ -137,9 +197,9 @@ describe('owned terminal commands', () => {
137
197
  it('validates thinking levels before mutating the session', async () => {
138
198
  const { commands, session, ui } = setup()
139
199
  Object.assign(session, { getAvailableThinkingLevels: () => ['off', 'high'], setThinkingLevel: vi.fn() })
140
- await commands.execute('/thinking high')
200
+ await commands.execute('/effort high')
141
201
  expect(session.setThinkingLevel).toHaveBeenCalledWith('high', { persist: true })
142
- await commands.execute('/thinking fabricated')
202
+ await commands.execute('/effort fabricated')
143
203
  expect(session.setThinkingLevel).toHaveBeenCalledTimes(1)
144
204
  expect(ui.notify).toHaveBeenCalledWith('Available thinking levels: off, high', 'error')
145
205
  })