@xingular/harness 0.1.6 → 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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,653 @@
1
+ import { execFile, spawn } from 'node:child_process'
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { randomUUID } from 'node:crypto'
6
+ import manifest from '../../../package.json'
7
+ import { promisify } from 'node:util'
8
+ import { CustomEditor, copyToClipboard, initTheme, type AgentSessionEvent, type ExtensionUIContext, type KeybindingsManager, type ReadonlyFooterDataProvider, type Theme } from '@earendil-works/pi-coding-agent'
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
+
11
+ import { controlCall } from '../control'
12
+ import { createTerminalCommands } from './commands'
13
+ import { DialogPanel, SearchableSelect } from './dialogs'
14
+ import { COMPOSER_SUGGESTIONS, HarnessEditor } from './editor'
15
+ import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
16
+ import { loadPiAdapter, setPiTheme } from './piAdapter'
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'
20
+ import { renderStatusBand, singleLine } from './status'
21
+ import { skillCompletions } from './skillInvocation'
22
+ import { createHarnessLightTheme, createHarnessTheme } from './theme'
23
+ import { TerminalTranscript } from './transcript'
24
+ import { createTerminalUi, type TerminalUi } from './ui'
25
+ import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPINNER_INTERVAL_MS, spinnerFrameAt } from './workingStatus'
26
+ import { HarnessWelcome } from './welcome'
27
+ import { answerTerminalQuestion } from './questions'
28
+ import type { QuestionAnswer } from '../../sessions/registry'
29
+ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
30
+
31
+ const exec = promisify(execFile)
32
+ type Mounted = Component & { dispose?(): void }
33
+ type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
34
+ type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
35
+ type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
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
+
51
+ /** Sole owner of terminal lifecycle, layout, input dispatch, focus and screen transitions. */
52
+ export class TerminalApplication {
53
+ readonly terminal = new ProcessTerminal()
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: () => {} }
80
+ private runtime?: TerminalRuntime
81
+ private ui!: TerminalUi
82
+ private keybindings!: KeybindingsManager
83
+ private editor!: EditorComponent
84
+ private transcript!: TerminalTranscript
85
+ private commands!: ReturnType<typeof createTerminalCommands>
86
+ private header?: Mounted
87
+ private footer?: Mounted
88
+ private headerFactory?: HeaderFactory
89
+ private footerFactory?: FooterFactory
90
+ private editorFactory?: EditorFactory
91
+ private widgets = new Map<string, { component: Mounted; placement: string }>()
92
+ private statuses = new Map<string, string>()
93
+ private branch: string | null = null
94
+ private branchListeners = new Set<() => void>()
95
+ private baseAutocomplete?: AutocompleteProvider
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
102
+ private phase?: string
103
+ private viewport?: ScrollView
104
+ private toolsExpanded = false
105
+ private planMode = false
106
+ private normalTools?: string[]
107
+ private stopped = false
108
+ private initialized = false
109
+ private closing?: Promise<void>
110
+ private failure?: Error
111
+ private finish!: () => void
112
+ private done = new Promise<void>(resolve => { this.finish = resolve })
113
+ private cleanup: (() => void)[] = []
114
+ private finalCleanup: (() => void)[] = []
115
+ private draftSync = Promise.resolve()
116
+ private lastInput = ''
117
+ private commandBusy = false
118
+ private pendingPolling = false
119
+ private pendingBusy = false
120
+ private pendingAbort?: AbortController
121
+ private pendingId?: string
122
+ private dismissedDecisions = new Set<string>()
123
+ private suggestion = COMPOSER_SUGGESTIONS[Math.floor(Math.random() * COMPOSER_SUGGESTIONS.length)]
124
+
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
+ }
132
+ private get session() { if (!this.runtime) throw new Error('The terminal runtime is not ready.'); return this.runtime.session }
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 }
136
+ private change = (): void => { if (!this.stopped) this.tui.requestRender() }
137
+ private notify = (message: string, type = 'info'): void => {
138
+ if (message.startsWith('No models available.')) message = 'No model is configured. Use /accounts or /login to sign in.'
139
+ this.transcript?.notice(message, type)
140
+ this.change()
141
+ }
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) }
143
+
144
+ async run(): Promise<number> {
145
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('The Harness frontend requires an interactive terminal.')
146
+ if (!process.env.PI_CODING_AGENT_DIR || !process.env.XINGULAR_HARNESS_SOCKET) throw new Error('Prepare a Harness terminal session before starting its frontend.')
147
+ const onSignal = () => { void this.shutdown() }
148
+ const onFatal = (error: unknown) => {
149
+ this.failure ??= error instanceof Error ? error : new Error(String(error))
150
+ void this.shutdown()
151
+ }
152
+ const onContinue = () => { if (!this.stopped) { this.tui.start(); this.change() } }
153
+ try {
154
+ const adapter = await loadPiAdapter()
155
+ initTheme('dark', false)
156
+ const theme = createHarnessTheme({ getColorMode: () => process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit' ? 'truecolor' : '256color' } as Theme)
157
+ setPiTheme(theme)
158
+ this.keybindings = terminalKeybindings(process.env.PI_CODING_AGENT_DIR)
159
+ setKeybindings(this.keybindings)
160
+ this.transcript = new TerminalTranscript(this.tui, () => this.theme, adapter.withBuiltInRenderers)
161
+ this.chat.addChild(this.transcript)
162
+ this.ui = this.makeUi(theme)
163
+ this.mountEditor()
164
+ this.tui.setFocus(this.editor)
165
+ this.cleanup.push(this.tui.addInputListener(data => this.dispatchInput(data)))
166
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { process.on(signal, onSignal); this.cleanup.push(() => { process.off(signal, onSignal) }) }
167
+ process.on('uncaughtException', onFatal)
168
+ process.on('unhandledRejection', onFatal)
169
+ this.finalCleanup.push(() => { process.off('uncaughtException', onFatal); process.off('unhandledRejection', onFatal) })
170
+ process.on('SIGCONT', onContinue)
171
+ this.cleanup.push(() => { process.off('SIGCONT', onContinue) })
172
+ this.tui.setShowHardwareCursor(true)
173
+ this.tui.setClearOnShrink(true)
174
+ this.tui.start()
175
+ this.phase = 'Starting session…'
176
+ this.runtime = await createTerminalRuntime(this.args, { ui: this.ui, shutdown: () => { void this.shutdown() }, notify: this.notify, changed: this.change, resources: services => {
177
+ this.ui.setThemes(services.resourceLoader.getThemes().themes)
178
+ const name = services.settingsManager.getTheme()
179
+ const selected = name ? this.ui.getTheme(name) : undefined
180
+ if (selected && this.ui.theme.name !== selected.name) this.ui.setTheme(selected)
181
+ } })
182
+ if (this.stopped) { await this.runtime.dispose(); if (this.failure) throw this.failure; return 0 }
183
+ this.commands = createTerminalCommands({
184
+ session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
185
+ cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
186
+ refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
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 }) })
192
+ },
193
+ suspend: () => this.suspend(), hotkeys: () => this.hotkeys(),
194
+ changelog: async () => {
195
+ for (const path of ['../CHANGELOG.md', '../../../CHANGELOG.md']) {
196
+ try { return `${manifest.name} v${manifest.version}\n\n${(await readFile(new URL(path, import.meta.url), 'utf8')).split('\n').slice(0, 24).join('\n')}` } catch { /* Bundled hosts may omit package documentation. */ }
197
+ }
198
+ return `${manifest.name} v${manifest.version}\nRelease notes were not bundled with this installation.`
199
+ },
200
+ secretInput: (title, placeholder, options) => this.ui.secretInput(title, placeholder, options)
201
+ })
202
+ this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
203
+ this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
204
+ this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
205
+ this.initialized = true
206
+ this.phase = undefined
207
+ this.refresh(true)
208
+ const tick = setInterval(() => { if (this.startedAt !== null) this.change(); void this.decisions() }, 100)
209
+ const git = setInterval(() => { void this.refreshBranch() }, 3000)
210
+ this.cleanup.push(() => { clearInterval(tick); clearInterval(git) })
211
+ await this.refreshBranch()
212
+ await this.done
213
+ if (this.failure) throw this.failure
214
+ return 0
215
+ } catch (error) {
216
+ if (this.ui && !this.stopped) { this.notify(error instanceof Error ? error.message : String(error), 'error'); this.tui.renderNow(true) }
217
+ throw error
218
+ } finally { await this.shutdown() }
219
+ }
220
+
221
+ private makeUi(theme: Theme): TerminalUi {
222
+ return createTerminalUi({
223
+ tui: this.tui, theme, themes: [createHarnessLightTheme(theme)], keybindings: this.keybindings, changed: this.change, notify: this.notify,
224
+ setEditor: factory => this.mountEditor(factory), getEditor: () => this.editor,
225
+ getEditorText: () => this.editor?.getText() ?? '', setEditorText: value => { this.editor.setText(value); this.syncDraft(value) },
226
+ setStatus: (key, value) => { if (value === undefined) this.statuses.delete(key); else this.statuses.set(key, value) },
227
+ setWidget: (key, content, placement) => {
228
+ this.release(this.widgets.get(key)?.component)
229
+ if (!content) this.widgets.delete(key)
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()
232
+ },
233
+ setHeader: factory => { this.headerFactory = factory; this.release(this.header); this.header = factory?.(this.tui, this.theme) },
234
+ setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData); this.syncDock() },
235
+ getAutocompleteProvider: () => this.baseAutocomplete!,
236
+ setAutocompleteProvider: provider => this.editor.setAutocompleteProvider?.(provider),
237
+ themeChanged: next => { setPiTheme(next); this.remountPresentation() },
238
+ getToolsExpanded: () => this.toolsExpanded, setToolsExpanded: value => { this.toolsExpanded = value; this.transcript.setExpanded(value) }
239
+ })
240
+ }
241
+ private footerData: ReadonlyFooterDataProvider = {
242
+ getGitBranch: () => this.branch, getExtensionStatuses: () => this.statuses,
243
+ getAvailableProviderCount: () => new Set(this.runtime?.services.modelRuntime.getAvailableSnapshot().map(model => model.provider) ?? []).size,
244
+ onBranchChange: callback => { this.branchListeners.add(callback); return () => { this.branchListeners.delete(callback) } }
245
+ }
246
+ private async refreshBranch(): Promise<void> {
247
+ if (!this.runtime || this.stopped) return
248
+ let branch: string | null = null
249
+ try { branch = (await exec('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: this.runtime.cwd, timeout: 2000 })).stdout.trim() || null }
250
+ catch { /* Non-repositories and detached HEAD have no branch segment. */ }
251
+ if (branch !== this.branch) { this.branch = branch; for (const listener of this.branchListeners) listener(); this.change() }
252
+ }
253
+ private release(component?: Mounted): void {
254
+ try { component?.dispose?.() }
255
+ catch (error) { this.notify(`Plugin cleanup failed: ${error instanceof Error ? error.message : String(error)}`, 'error') }
256
+ }
257
+ private mountEditor(factory?: EditorFactory): void {
258
+ const draft = this.editor?.getText() ?? ''
259
+ this.release(this.editor as Mounted | undefined)
260
+ this.editorFactory = factory
261
+ const editorTheme = { borderColor: (text: string) => this.theme.fg('border', text), selectList: {
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)
263
+ } }
264
+ this.editor = factory?.(this.tui, editorTheme, this.keybindings) ?? new HarnessEditor(this.tui, editorTheme, this.keybindings, {
265
+ band: width => this.band(width), theme: () => this.theme,
266
+ placeholder: () => this.runtime?.snapshot().hasUserMessages || this.startedAt !== null ? undefined : this.suggestion
267
+ })
268
+ this.editor.setText(draft)
269
+ this.editor.onSubmit = text => this.submit(text)
270
+ const onChange = this.editor.onChange
271
+ this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.change() }
272
+ if (factory && this.editor instanceof CustomEditor) {
273
+ // A trusted editor may consume keys in its own mode before delegating to CustomEditor.
274
+ // Wire host actions into that delegation instead of taking the keys away in a listener.
275
+ const editor = this.editor
276
+ for (const action of Object.keys(HARNESS_KEYBINDINGS).filter(name => name.startsWith('app.'))) {
277
+ const id = action as Parameters<CustomEditor['onAction']>[0]
278
+ if (!editor.actionHandlers.has(id)) editor.onAction(id, () => { this.dispatchInput(this.lastInput, true) })
279
+ }
280
+ editor.onEscape ??= () => { this.dispatchInput(this.lastInput, true) }
281
+ editor.onCtrlD ??= () => { void this.shutdown() }
282
+ editor.onPasteImage ??= () => this.attempt(() => this.pasteClipboard())
283
+ editor.onExtensionShortcut ??= data => this.extensionShortcut(data)
284
+ }
285
+ if (this.baseAutocomplete) this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
286
+ this.syncDock()
287
+ this.tui.setFocus(this.editor)
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
+ }
403
+ private syncDraft(text: string): void {
404
+ this.draftSync = this.draftSync.then(() => controlCall('terminal.draft', { dirty: text.length > 0 })).then(() => {}).catch(error => this.notify(String(error), 'error'))
405
+ }
406
+ private band(width: number): string {
407
+ const state = this.runtime?.snapshot()
408
+ const usage = this.runtime?.session.getSessionStats().tokens
409
+ return renderStatusBand({ cwd: state?.cwd ?? process.cwd(), model: state?.model,
410
+ thinkingLevel: state?.thinkingLevel ?? 'off', branch: this.branch, account: state?.account?.label ?? 'account unknown',
411
+ inputTokens: (usage?.input ?? 0) + (usage?.cacheRead ?? 0), outputTokens: usage?.output ?? 0,
412
+ tokensPerSecond: this.lastTokensPerSecond
413
+ }, width, this.theme)
414
+ }
415
+ private submit(text: string, followUp = false): void {
416
+ if (!text.trim() || !this.initialized) return
417
+ if (this.commandBusy) { this.notify('Finish or cancel the active command first.', 'error'); return }
418
+ const command = text.trimStart().startsWith('/')
419
+ if (command) this.commandBusy = true
420
+ this.editor.addToHistory?.(text)
421
+ this.editor.setText(''); this.syncDraft(''); this.scrollToLatest()
422
+ this.attempt(async () => {
423
+ try {
424
+ await this.draftSync
425
+ if (this.stopped) return
426
+ if (command && await this.commands.execute(text)) return
427
+ if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
428
+ await this.runtime!.submit(this.commands.expandSkill(text), followUp)
429
+ } finally { if (command) this.commandBusy = false }
430
+ })
431
+ }
432
+ private dispatchInput(data: string, delegated = false) {
433
+ this.lastInput = data
434
+ if (this.stopped) return { consume: true }
435
+ if (this.tui.hasOverlay()) return
436
+ if (!this.initialized) { if (matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+d')) void this.shutdown(); return { consume: true } }
437
+ if (this.editorFactory && !delegated) return
438
+ if (this.extensionShortcut(data)) return { consume: true }
439
+ const is = (action: Parameters<KeybindingsManager['matches']>[1]) => this.keybindings.matches(data, action)
440
+ if (is('app.interrupt')) {
441
+ if (this.editor instanceof HarnessEditor && this.editor.isShowingAutocomplete()) return
442
+ this.attempt(() => this.runtime!.interrupt())
443
+ } else if (is('app.clear')) {
444
+ if (this.session.isStreaming) this.attempt(() => this.runtime!.interrupt())
445
+ else if (this.editor.getText()) { this.editor.setText(''); this.syncDraft('') }
446
+ else void this.shutdown()
447
+ } else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
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)
451
+ else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
452
+ else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
453
+ else if (is('app.model.select')) this.attempt(() => this.commands.execute('/model'))
454
+ else if (is('app.tools.expand')) this.ui.setToolsExpanded(!this.toolsExpanded)
455
+ else if (is('app.thinking.toggle')) { const hidden = !this.session.settingsManager.getHideThinkingBlock(); this.session.settingsManager.setHideThinkingBlock(hidden); this.transcript.setThinking(hidden) }
456
+ else if (is('app.message.followUp')) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true)
457
+ else if (is('app.message.dequeue')) { const pending = this.session.clearQueue(); this.editor.setText([...pending.steering, ...pending.followUp, this.editor.getText()].filter(Boolean).join('\n')); this.syncDraft(this.editor.getText()) }
458
+ else if (is('app.editor.external')) this.attempt(() => this.externalEditor())
459
+ else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
460
+ else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
461
+ else if (is('app.suspend')) this.attempt(() => this.suspend())
462
+ else if (data === '?' && this.editor.getText() === '') this.notify(this.hotkeys())
463
+ else return
464
+ this.change(); return { consume: true }
465
+ }
466
+ private extensionShortcut(data: string): boolean {
467
+ const shortcut = [...this.session.extensionRunner.getShortcuts(this.keybindings.getResolvedBindings()).entries()].find(([key]) => matchesKey(data, key))
468
+ if (!shortcut) return false
469
+ this.attempt(() => shortcut[1].handler(this.session.extensionRunner.createContext()))
470
+ return true
471
+ }
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
+ }
483
+ this.transcript.accept(event)
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
+ }
501
+ if (event.type === 'compaction_start') this.phase = 'Compacting context…'
502
+ if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
503
+ if (event.type === 'auto_retry_start') this.phase = `Retry ${event.attempt}/${event.maxAttempts} in ${(event.delayMs / 1000).toFixed(1)}s: ${event.errorMessage}`
504
+ if (event.type === 'auto_retry_end') this.phase = event.finalError
505
+ this.change()
506
+ }
507
+ private refresh(history = false): void {
508
+ if (!this.runtime) return
509
+ this.ui.setThemes(this.session.resourceLoader.getThemes().themes)
510
+ this.tui.setShowHardwareCursor(this.session.settingsManager.getShowHardwareCursor())
511
+ this.transcript.bind(this.session); this.transcript.setExpanded(this.toolsExpanded)
512
+ if (history) for (const message of this.session.state.messages) if (message.role === 'user') {
513
+ const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
514
+ if (text) this.editor.addToHistory?.(text)
515
+ }
516
+ this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd), this.commands.skills)
517
+ this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
518
+ this.change()
519
+ }
520
+ private resetPresentation(): void {
521
+ this.pendingAbort?.abort(); this.dismissedDecisions.clear()
522
+ this.resetWorkingStatus()
523
+ this.requestStartedAt = null; this.lastTokensPerSecond = null
524
+ this.transcript.clearSession()
525
+ this.ui.reset()
526
+ this.release(this.header); this.release(this.footer)
527
+ this.header = this.footer = undefined; this.headerFactory = this.footerFactory = undefined
528
+ for (const widget of this.widgets.values()) this.release(widget.component)
529
+ this.widgets.clear(); this.statuses.clear(); this.branchListeners.clear()
530
+ this.mountEditor()
531
+ // The UI object bound to the runtime stays stable; reset its session-owned state separately.
532
+ }
533
+ private remountPresentation(): void {
534
+ this.release(this.header); this.release(this.footer)
535
+ this.header = this.headerFactory?.(this.tui, this.theme)
536
+ this.footer = this.footerFactory?.(this.tui, this.theme, this.footerData)
537
+ this.mountEditor(this.editorFactory)
538
+ if (this.runtime) this.refresh()
539
+ }
540
+
541
+ private async decisions(): Promise<void> {
542
+ if (this.pendingPolling || !this.runtime || this.stopped) return
543
+ this.pendingPolling = true
544
+ let requests: TerminalDecision[]
545
+ try {
546
+ requests = await this.runtime.pending()
547
+ } catch (error) {
548
+ if (!this.stopped) this.notify(error instanceof Error ? error.message : String(error), 'error')
549
+ return
550
+ } finally { this.pendingPolling = false }
551
+ if (this.pendingId && !requests.some(request => request.requestId === this.pendingId)) this.pendingAbort?.abort()
552
+ if (this.pendingBusy) return
553
+ this.pendingBusy = true
554
+ try {
555
+ const request = requests.find(request => !this.dismissedDecisions.has(request.requestId))
556
+ if (!request || this.tui.hasOverlay()) return
557
+ const abort = new AbortController(); this.pendingAbort = abort; this.pendingId = request.requestId
558
+ this.dismissedDecisions.add(request.requestId)
559
+ if (request.type === 'approval') {
560
+ const choice = await this.chooseApproval(request, abort.signal)
561
+ if (!abort.signal.aborted) await this.runtime.approve(request.requestId, choice.decision, choice.alwaysAllow)
562
+ } else {
563
+ const group = request.batchId
564
+ ? requests.filter(item => item.type === 'question' && item.batchId === request.batchId).sort((a, b) => (a.batchIndex ?? 0) - (b.batchIndex ?? 0))
565
+ : [request]
566
+ const answers: { questionId: string; answer: QuestionAnswer }[] = []
567
+ for (const question of group) {
568
+ const answer = await answerTerminalQuestion(this.ui, question, abort.signal)
569
+ if (abort.signal.aborted) return
570
+ if (!answer) { await this.runtime!.interrupt(); return }
571
+ answers.push({ questionId: question.requestId, answer })
572
+ }
573
+ if (!abort.signal.aborted) {
574
+ if (request.batchId) await this.runtime.answerBatch(request.batchId, answers)
575
+ else await this.runtime.answer(request.requestId, answers[0]!.answer)
576
+ }
577
+ }
578
+ } catch (error) {
579
+ if (this.pendingId) this.dismissedDecisions.delete(this.pendingId)
580
+ if (!this.stopped) this.notify(error instanceof Error ? error.message : String(error), 'error')
581
+ }
582
+ finally { this.pendingBusy = false; this.pendingId = undefined; this.pendingAbort = undefined }
583
+ }
584
+ private hotkeys(): string {
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' : '')
586
+ }
587
+ private async pasteClipboard(): Promise<void> {
588
+ const adapter = await loadPiAdapter()
589
+ const files = await adapter.readClipboardFilePaths()
590
+ if (files?.length) {
591
+ if (files.some(file => /[\x00-\x1f\x7f]/.test(file))) throw new Error('Clipboard file paths contain control characters.')
592
+ this.ui.pasteToEditor(files.join('\n'))
593
+ return
594
+ }
595
+ const image = await adapter.readClipboardImage()
596
+ if (image) {
597
+ const directory = await mkdtemp(join(tmpdir(), 'harness-clipboard-'))
598
+ const file = join(directory, `${randomUUID()}.${adapter.extensionForImageMimeType(image.mimeType) ?? 'png'}`)
599
+ await writeFile(file, image.bytes, { mode: 0o600 })
600
+ this.cleanup.push(() => { void rm(directory, { recursive: true, force: true }) })
601
+ this.ui.pasteToEditor(file)
602
+ return
603
+ }
604
+ const text = await adapter.readClipboardText()
605
+ if (text) this.ui.pasteToEditor(text)
606
+ }
607
+ private async externalEditor(): Promise<void> {
608
+ const editor = process.env.VISUAL || process.env.EDITOR
609
+ if (!editor) throw new Error('Set VISUAL or EDITOR to open an external editor.')
610
+ const directory = await mkdtemp(join(tmpdir(), 'harness-editor-')), file = join(directory, 'prompt.md')
611
+ try {
612
+ await writeFile(file, this.editor.getExpandedText?.() ?? this.editor.getText(), { mode: 0o600 })
613
+ this.tui.stop()
614
+ await new Promise<void>((resolve, reject) => {
615
+ const child = spawn(process.env.SHELL || '/bin/sh', ['-c', `${editor} "$1"`, 'harness-editor', file], { stdio: 'inherit' })
616
+ child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Editor exited with ${code}.`)))
617
+ })
618
+ this.editor.setText(await readFile(file, 'utf8')); this.syncDraft(this.editor.getText())
619
+ } finally { await rm(directory, { recursive: true, force: true }); if (!this.stopped) { this.tui.start(); this.change() } }
620
+ }
621
+ private async suspend(): Promise<void> {
622
+ if (process.platform === 'win32') throw new Error('Suspending is unavailable on Windows.')
623
+ this.tui.stop(); process.kill(process.pid, 'SIGTSTP')
624
+ }
625
+ shutdown(): Promise<void> {
626
+ return this.closing ??= (async () => {
627
+ const protect = (action: () => void) => {
628
+ try { action() } catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
629
+ }
630
+ this.stopped = true
631
+ protect(() => this.pendingAbort?.abort())
632
+ protect(() => this.ui?.dispose())
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()
636
+ try { await this.runtime?.dispose() }
637
+ catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
638
+ finally {
639
+ await this.terminal.drainInput(200, 30).catch(() => {})
640
+ protect(() => this.tui.stop())
641
+ if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
642
+ protect(() => this.transcript?.clearSession())
643
+ protect(() => this.header?.dispose?.())
644
+ protect(() => this.footer?.dispose?.())
645
+ for (const widget of this.widgets.values()) protect(() => widget.component.dispose?.())
646
+ protect(() => (this.editor as Mounted | undefined)?.dispose?.())
647
+ for (const cleanup of this.finalCleanup.splice(0)) protect(cleanup)
648
+ this.finish()
649
+ }
650
+ })()
651
+ }
652
+ }
653
+ export function runTerminalFrontend(args: string[]): Promise<number> { return new TerminalApplication(args).run() }