@xingular/harness 0.1.7 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  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 +70 -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 +109 -13
  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/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. 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,12 @@ 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
+ planMode: () => this.planMode, setPlanMode: enabled => this.setPlanMode(enabled),
188
+ copyText: copyToClipboard, openExternal,
189
+ changed: this.change,
190
+ trajectory: {
191
+ list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
192
+ read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
134
193
  },
135
194
  suspend: () => this.suspend(), hotkeys: () => this.hotkeys(),
136
195
  changelog: async () => {
@@ -143,7 +202,7 @@ export class TerminalApplication implements Component {
143
202
  })
144
203
  this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
145
204
  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) }))
205
+ this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
147
206
  this.initialized = true
148
207
  this.phase = undefined
149
208
  this.refresh(true)
@@ -170,9 +229,10 @@ export class TerminalApplication implements Component {
170
229
  this.release(this.widgets.get(key)?.component)
171
230
  if (!content) this.widgets.delete(key)
172
231
  else this.widgets.set(key, { component: typeof content === 'function' ? content(this.tui, this.theme) : new Text(content.join('\n'), 0, 0), placement: placement ?? 'aboveEditor' })
232
+ this.syncDock()
173
233
  },
174
234
  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) },
235
+ setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData); this.syncDock() },
176
236
  getAutocompleteProvider: () => this.baseAutocomplete!,
177
237
  setAutocompleteProvider: provider => this.editor.setAutocompleteProvider?.(provider),
178
238
  themeChanged: next => { setPiTheme(next); this.remountPresentation() },
@@ -203,14 +263,13 @@ export class TerminalApplication implements Component {
203
263
  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
264
  } }
205
265
  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('/')
266
+ band: width => this.band(width), theme: () => this.theme,
267
+ placeholder: () => this.runtime?.snapshot().hasUserMessages || this.startedAt !== null ? undefined : this.suggestion
209
268
  })
210
269
  this.editor.setText(draft)
211
270
  this.editor.onSubmit = text => this.submit(text)
212
271
  const onChange = this.editor.onChange
213
- this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.scrollOffset = 0; this.change() }
272
+ this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.change() }
214
273
  if (factory && this.editor instanceof CustomEditor) {
215
274
  // A trusted editor may consume keys in its own mode before delegating to CustomEditor.
216
275
  // Wire host actions into that delegation instead of taking the keys away in a listener.
@@ -225,18 +284,134 @@ export class TerminalApplication implements Component {
225
284
  editor.onExtensionShortcut ??= data => this.extensionShortcut(data)
226
285
  }
227
286
  if (this.baseAutocomplete) this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
287
+ this.syncDock()
228
288
  this.tui.setFocus(this.editor)
229
289
  }
290
+ /** The dock's slots follow the current editor, widgets and footer; their rows are read at draw time. */
291
+ private syncDock(): void {
292
+ this.above.clear(); this.below.clear()
293
+ for (const widget of this.widgets.values()) (widget.placement === 'belowEditor' ? this.below : this.above).addChild(widget.component)
294
+ this.editorSlot.clear()
295
+ if (this.editor) this.editorSlot.addChild(this.editor)
296
+ this.footerSlot.clear()
297
+ this.footerSlot.addChild(this.footer ?? this.statusRow)
298
+ }
299
+ private syncThinking(): void {
300
+ if (!this.ui) return
301
+ this.transcript?.setThinking(this.runtime?.snapshot().hideThinking ?? false, this.ui.state.hiddenThinkingLabel ?? 'Thinking…')
302
+ }
303
+ private resetWorkingStatus(): void {
304
+ this.startedAt = null
305
+ this.activeVerb = undefined
306
+ this.activeTurnId = undefined
307
+ this.completionStatus = undefined
308
+ this.phase = undefined
309
+ }
310
+ /** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
311
+ private setPlanMode(enabled: boolean): void {
312
+ if (!this.runtime) return
313
+ if (this.planMode === enabled) return
314
+ if (this.planMode) {
315
+ this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
316
+ this.normalTools = undefined
317
+ } else {
318
+ this.normalTools = this.session.getActiveToolNames()
319
+ this.session.setActiveToolsByName(planModeTools(this.normalTools))
320
+ }
321
+ this.planMode = enabled
322
+ this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Use /plan off to return to normal.' : 'Normal mode: all tools are available.')
323
+ this.change()
324
+ }
325
+ private scrollToLatest(): void { this.viewport?.scrollToEnd() }
326
+ private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
327
+ if (signal.aborted) return { decision: 'denied', alwaysAllow: false }
328
+ const viewport = this.viewport
329
+ const scrollTop = viewport?.scrollTop
330
+ const wasFollowingEnd = viewport?.isFollowingEnd
331
+ const tool = request.target ?? 'action'
332
+ const choices = [
333
+ { value: 'once', label: 'Allow once' },
334
+ ...(request.rememberable ? [{ value: 'global', label: `Always allow ${tool} everywhere` }] : []),
335
+ { value: 'deny', label: 'Deny' }
336
+ ]
337
+ const details = [
338
+ request.rememberable
339
+ ? `Always allow applies to every future ${tool} call in Pi terminal sessions sharing this local Harness state.`
340
+ : 'This approval cannot be saved as a tool grant.',
341
+ '',
342
+ request.action ?? ''
343
+ ].join('\n')
344
+ let abortListener: (() => void) | undefined
345
+ try {
346
+ const choice = await this.ui.custom<string | undefined>((tui, theme, _keys, done) => {
347
+ const finish = (value: string | undefined) => {
348
+ if (abortListener) signal.removeEventListener('abort', abortListener)
349
+ abortListener = undefined
350
+ done(value)
351
+ }
352
+ abortListener = () => finish(undefined)
353
+ signal.addEventListener('abort', abortListener, { once: true })
354
+ if (signal.aborted) abortListener()
355
+ const list = new SearchableSelect(choices.map(item => item.label), tui, () => theme,
356
+ index => finish(choices[index]?.value), () => finish(undefined))
357
+ return new DialogPanel(`Approve ${tool}?`, list, () => theme, 'Enter choose · Esc deny', details)
358
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
359
+ return { decision: choice === 'once' || choice === 'global' ? 'approved' : 'denied', alwaysAllow: choice === 'global' }
360
+ } finally {
361
+ if (abortListener) signal.removeEventListener('abort', abortListener)
362
+ if (viewport && viewport === this.viewport && scrollTop !== undefined && wasFollowingEnd !== undefined) {
363
+ if (wasFollowingEnd) viewport.scrollToEnd()
364
+ else viewport.scrollTo(scrollTop, { disableFollow: true })
365
+ }
366
+ }
367
+ }
368
+ /** Steps through the levels this model supports and stops at either end, unlike the wrapping cycle. */
369
+ private stepThinking(direction: 1 | -1): void {
370
+ const levels = this.session.getAvailableThinkingLevels()
371
+ const next = levels[Math.min(levels.length - 1, Math.max(0, levels.indexOf(this.session.thinkingLevel) + direction))]
372
+ if (!this.session.supportsThinking() || next === undefined || next === this.session.thinkingLevel) return
373
+ this.session.setThinkingLevel(next)
374
+ }
375
+ private exitScrollback(): string[] | undefined {
376
+ if (this.preferences.mode !== 'fullscreen' || this.preferences.exitOutput !== 'transcript' || !this.runtime?.snapshot().hasUserMessages) return undefined
377
+ return scrollbackLines(this.chat, this.terminal.columns)
378
+ }
379
+ private titleLines(width: number): string[] {
380
+ if (!this.ui) return []
381
+ const title = this.runtime?.snapshot().name
382
+ const active = this.startedAt !== null
383
+ const working = this.phase ?? (active
384
+ ? this.ui.state.workingVisible ? this.ui.state.workingMessage ?? this.activeVerb ?? 'Working…' : ''
385
+ : this.completionStatus ?? '')
386
+ const titleText = title ? this.theme.italic(this.theme.fg('dim', singleLine(title))) : ''
387
+ const indicator = this.ui.state.workingIndicator
388
+ const frames = indicator?.frames ?? getWorkingSpinnerFrames(process.env.TERM)
389
+ const frame = active && frames.length
390
+ ? spinnerFrameAt(Date.now() - this.startedAt!, frames, indicator?.intervalMs ?? SPINNER_INTERVAL_MS)
391
+ : ''
392
+ const defaultWorking = active && !this.phase && this.ui.state.workingVisible && !this.ui.state.workingMessage && working === this.activeVerb
393
+ const workingColor = defaultWorking ? 'warning' : 'muted'
394
+ const workText = working ? this.theme.fg(workingColor, (frame ? frame + ' ' : '') + singleLine(working)) : ''
395
+ return titleText || workText ? [truncateToWidth(workText, width, '') + ' '.repeat(Math.max(0, width - visibleWidth(workText) - visibleWidth(titleText))) + titleText] : []
396
+ }
397
+ private queuedMessages(width: number): string[] {
398
+ if (!this.ui) return []
399
+ const state = this.runtime?.snapshot()
400
+ 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)) : []
401
+ }
402
+ private statusLines(width: number): string[] {
403
+ return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
404
+ }
230
405
  private syncDraft(text: string): void {
231
406
  this.draftSync = this.draftSync.then(() => controlCall('terminal.draft', { dirty: text.length > 0 })).then(() => {}).catch(error => this.notify(String(error), 'error'))
232
407
  }
233
- private band(width: number, above = 0, below = 0): string {
408
+ private band(width: number): string {
234
409
  const state = this.runtime?.snapshot()
410
+ const usage = this.runtime?.session.getSessionStats().tokens
235
411
  return renderStatusBand({ cwd: state?.cwd ?? process.cwd(), model: state?.model,
236
412
  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
413
+ inputTokens: (usage?.input ?? 0) + (usage?.cacheRead ?? 0), outputTokens: usage?.output ?? 0,
414
+ tokensPerSecond: this.lastTokensPerSecond
240
415
  }, width, this.theme)
241
416
  }
242
417
  private submit(text: string, followUp = false): void {
@@ -245,14 +420,14 @@ export class TerminalApplication implements Component {
245
420
  const command = text.trimStart().startsWith('/')
246
421
  if (command) this.commandBusy = true
247
422
  this.editor.addToHistory?.(text)
248
- this.editor.setText(''); this.syncDraft(''); this.notices = []; this.scrollOffset = 0
423
+ this.editor.setText(''); this.syncDraft(''); this.scrollToLatest()
249
424
  this.attempt(async () => {
250
425
  try {
251
426
  await this.draftSync
252
427
  if (this.stopped) return
253
428
  if (command && await this.commands.execute(text)) return
254
429
  if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
255
- await this.runtime!.submit(text, followUp)
430
+ await this.runtime!.submit(this.commands.expandSkill(text), followUp)
256
431
  } finally { if (command) this.commandBusy = false }
257
432
  })
258
433
  }
@@ -272,7 +447,8 @@ export class TerminalApplication implements Component {
272
447
  else if (this.editor.getText()) { this.editor.setText(''); this.syncDraft('') }
273
448
  else void this.shutdown()
274
449
  } else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
275
- else if (is('app.thinking.cycle')) { this.session.cycleThinkingLevel(); this.thinkingChanges += 1 }
450
+ else if (is('app.thinking.cycle')) this.session.cycleThinkingLevel()
451
+ else if (is('app.thinking.increase') || is('app.thinking.decrease')) this.stepThinking(is('app.thinking.increase') ? 1 : -1)
276
452
  else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
277
453
  else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
278
454
  else if (is('app.model.select')) this.attempt(() => this.commands.execute('/model'))
@@ -284,10 +460,7 @@ export class TerminalApplication implements Component {
284
460
  else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
285
461
  else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
286
462
  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
463
+ else if (data === '?' && this.editor.getText() === '') this.notify(this.hotkeys())
291
464
  else return
292
465
  this.change(); return { consume: true }
293
466
  }
@@ -298,9 +471,34 @@ export class TerminalApplication implements Component {
298
471
  return true
299
472
  }
300
473
  private accept(event: AgentSessionEvent): void {
474
+ if (event.type === 'turn_start') this.requestStartedAt = performance.now()
475
+ if (event.type === 'message_end' && event.message.role === 'assistant') {
476
+ const outputTokens = event.message.usage?.output
477
+ const startedAt = this.requestStartedAt
478
+ if (startedAt !== null && typeof outputTokens === 'number') {
479
+ const duration = performance.now() - startedAt
480
+ if (duration > 0) this.lastTokensPerSecond = outputTokens * 1000 / duration
481
+ }
482
+ this.requestStartedAt = null
483
+ }
301
484
  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) }
485
+ if (event.type === 'agent_start') {
486
+ this.startedAt = Date.now()
487
+ this.activeVerb = chooseWorkingVerb()
488
+ this.activeTurnId = randomUUID()
489
+ this.completionStatus = undefined
490
+ this.phase = undefined
491
+ this.terminal.setProgress(this.session.settingsManager.getShowTerminalProgress())
492
+ }
493
+ if (event.type === 'agent_settled') {
494
+ if (this.startedAt !== null) this.completionStatus = createCompletionStatus(this.activeTurnId ?? randomUUID(), Date.now() - this.startedAt)
495
+ this.startedAt = null
496
+ this.activeVerb = undefined
497
+ this.activeTurnId = undefined
498
+ this.requestStartedAt = null
499
+ this.phase = undefined
500
+ this.terminal.setProgress(false)
501
+ }
304
502
  if (event.type === 'compaction_start') this.phase = 'Compacting context…'
305
503
  if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
306
504
  if (event.type === 'auto_retry_start') this.phase = `Retry ${event.attempt}/${event.maxAttempts} in ${(event.delayMs / 1000).toFixed(1)}s: ${event.errorMessage}`
@@ -316,12 +514,21 @@ export class TerminalApplication implements Component {
316
514
  const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
317
515
  if (text) this.editor.addToHistory?.(text)
318
516
  }
319
- this.baseAutocomplete = new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd)
517
+ const commands = this.commands.completions()
518
+ if (this.editor instanceof HarnessEditor) {
519
+ this.editor.setValidCommands([
520
+ ...commands.map(command => command.name),
521
+ ...this.commands.skills().map(skill => 'skill:' + skill.name)
522
+ ])
523
+ }
524
+ this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(commands, this.runtime.cwd), this.commands.skills)
320
525
  this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
321
526
  this.change()
322
527
  }
323
528
  private resetPresentation(): void {
324
529
  this.pendingAbort?.abort(); this.dismissedDecisions.clear()
530
+ this.resetWorkingStatus()
531
+ this.requestStartedAt = null; this.lastTokensPerSecond = null
325
532
  this.transcript.clearSession()
326
533
  this.ui.reset()
327
534
  this.release(this.header); this.release(this.footer)
@@ -339,46 +546,6 @@ export class TerminalApplication implements Component {
339
546
  if (this.runtime) this.refresh()
340
547
  }
341
548
 
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
549
  private async decisions(): Promise<void> {
383
550
  if (this.pendingPolling || !this.runtime || this.stopped) return
384
551
  this.pendingPolling = true
@@ -398,8 +565,8 @@ export class TerminalApplication implements Component {
398
565
  const abort = new AbortController(); this.pendingAbort = abort; this.pendingId = request.requestId
399
566
  this.dismissedDecisions.add(request.requestId)
400
567
  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')
568
+ const choice = await this.chooseApproval(request, abort.signal)
569
+ if (!abort.signal.aborted) await this.runtime.approve(request.requestId, choice.decision, choice.alwaysAllow)
403
570
  } else {
404
571
  const group = request.batchId
405
572
  ? requests.filter(item => item.type === 'question' && item.batchId === request.batchId).sort((a, b) => (a.batchIndex ?? 0) - (b.batchIndex ?? 0))
@@ -423,7 +590,7 @@ export class TerminalApplication implements Component {
423
590
  finally { this.pendingBusy = false; this.pendingId = undefined; this.pendingAbort = undefined }
424
591
  }
425
592
  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'
593
+ 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
594
  }
428
595
  private async pasteClipboard(): Promise<void> {
429
596
  const adapter = await loadPiAdapter()
@@ -472,16 +639,19 @@ export class TerminalApplication implements Component {
472
639
  protect(() => this.pendingAbort?.abort())
473
640
  protect(() => this.ui?.dispose())
474
641
  for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
642
+ // Captured while the runtime and its components still exist; written only after the alternate screen is left.
643
+ const scrollback = this.exitScrollback()
475
644
  try { await this.runtime?.dispose() }
476
645
  catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
477
646
  finally {
647
+ await this.terminal.drainInput(200, 30).catch(() => {})
648
+ protect(() => this.tui.stop())
649
+ if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
478
650
  protect(() => this.transcript?.clearSession())
479
651
  protect(() => this.header?.dispose?.())
480
652
  protect(() => this.footer?.dispose?.())
481
653
  for (const widget of this.widgets.values()) protect(() => widget.component.dispose?.())
482
654
  protect(() => (this.editor as Mounted | undefined)?.dispose?.())
483
- await this.terminal.drainInput(200, 30).catch(() => {})
484
- protect(() => this.tui.stop())
485
655
  for (const cleanup of this.finalCleanup.splice(0)) protect(cleanup)
486
656
  this.finish()
487
657
  }