@xingular/harness 0.1.5 → 0.1.7

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 (133) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +118 -26
  4. package/dist/cli.js +6106 -912
  5. package/dist/terminal-pi.js +462 -11
  6. package/docs/README.md +12 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  9. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  10. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  11. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  12. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  13. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  14. package/docs/evidence/terminal-2026-10-17/verification.json +130 -0
  15. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  16. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  17. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  18. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  19. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  20. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  21. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  22. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  23. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  24. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  25. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  26. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  27. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +95 -0
  28. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  29. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  30. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  31. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  32. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +27 -0
  33. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  34. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +18 -0
  35. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
  36. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  37. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  38. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +60 -0
  39. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
  40. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  41. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +57 -0
  42. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  43. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  44. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +58 -0
  45. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  46. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +320 -0
  47. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
  48. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
  49. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
  50. package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
  51. package/docs/terminal-tui.md +254 -0
  52. package/package.json +8 -2
  53. package/src/accounts/accounts.test.ts +110 -0
  54. package/src/accounts/accounts.ts +146 -10
  55. package/src/cli.ts +7 -3
  56. package/src/harness.ts +16 -3
  57. package/src/hub/catalog.json +123 -0
  58. package/src/hub/cli.ts +59 -0
  59. package/src/hub/hub.test.ts +205 -0
  60. package/src/hub/hub.ts +256 -0
  61. package/src/hub/schema.ts +81 -0
  62. package/src/index.ts +6 -1
  63. package/src/memory/api.ts +16 -2
  64. package/src/memory/blocks.ts +3 -1
  65. package/src/memory/caller.ts +13 -1
  66. package/src/memory/curation.test.ts +163 -0
  67. package/src/memory/curation.ts +64 -0
  68. package/src/memory/maintenance.ts +199 -11
  69. package/src/memory/operations.ts +12 -0
  70. package/src/memory/proposals.ts +16 -8
  71. package/src/memory/recall.test.ts +11 -0
  72. package/src/memory/recall.ts +9 -2
  73. package/src/server/methods.ts +37 -2
  74. package/src/sessions/contracts.ts +29 -3
  75. package/src/sessions/conversation.test.ts +27 -0
  76. package/src/sessions/policy.ts +4 -0
  77. package/src/sessions/questionBatches.test.ts +110 -0
  78. package/src/sessions/registry.ts +109 -22
  79. package/src/sessions/runner.ts +36 -1
  80. package/src/sessions/sessions.test.ts +95 -1
  81. package/src/sessions/sessions.ts +53 -9
  82. package/src/skills/registry.ts +16 -10
  83. package/src/terminal/accountsMenu.test.ts +225 -0
  84. package/src/terminal/accountsMenu.ts +382 -0
  85. package/src/terminal/control.test.ts +23 -0
  86. package/src/terminal/control.ts +23 -0
  87. package/src/terminal/controller.ts +68 -4
  88. package/src/terminal/hubMenu.ts +24 -0
  89. package/src/terminal/launch.test.ts +5 -14
  90. package/src/terminal/launch.ts +2 -42
  91. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  92. package/src/terminal/piExtension.ts +50 -1
  93. package/src/terminal/socket.ts +13 -2
  94. package/src/terminal/standalone.test.ts +37 -0
  95. package/src/terminal/standalone.ts +16 -7
  96. package/src/terminal/terminal.test.ts +70 -1
  97. package/src/terminal/terminals.ts +122 -12
  98. package/src/terminal/tui/application.test.ts +114 -0
  99. package/src/terminal/tui/application.ts +491 -0
  100. package/src/terminal/tui/commands.test.ts +218 -0
  101. package/src/terminal/tui/commands.ts +361 -0
  102. package/src/terminal/tui/dialogs.ts +301 -0
  103. package/src/terminal/tui/editor.test.ts +84 -0
  104. package/src/terminal/tui/editor.ts +79 -0
  105. package/src/terminal/tui/keybindings.test.ts +22 -0
  106. package/src/terminal/tui/keybindings.ts +50 -0
  107. package/src/terminal/tui/piAdapter.ts +62 -0
  108. package/src/terminal/tui/presentation.test.ts +79 -0
  109. package/src/terminal/tui/questions.test.ts +41 -0
  110. package/src/terminal/tui/questions.ts +47 -0
  111. package/src/terminal/tui/runtime.socket.test.ts +104 -0
  112. package/src/terminal/tui/runtime.test.ts +215 -0
  113. package/src/terminal/tui/runtime.ts +388 -0
  114. package/src/terminal/tui/sessionUi.ts +24 -0
  115. package/src/terminal/tui/settings.test.ts +150 -0
  116. package/src/terminal/tui/settings.ts +188 -0
  117. package/src/terminal/tui/status.test.ts +70 -0
  118. package/src/terminal/tui/status.ts +109 -0
  119. package/src/terminal/tui/theme.ts +174 -0
  120. package/src/terminal/tui/tools.test.ts +145 -0
  121. package/src/terminal/tui/tools.ts +142 -0
  122. package/src/terminal/tui/transcript.test.ts +25 -0
  123. package/src/terminal/tui/transcript.ts +124 -0
  124. package/src/terminal/tui/ui.test.ts +313 -0
  125. package/src/terminal/tui/ui.ts +166 -0
  126. package/src/terminal/tui/welcome.ts +102 -0
  127. package/src/tools/askUser.test.ts +69 -0
  128. package/src/tools/askUser.ts +31 -17
  129. package/src/tools/maintenance.ts +24 -0
  130. package/src/tools/memoryTools.ts +1 -1
  131. package/src/tools/registryWrite.test.ts +101 -0
  132. package/src/tools/registryWrite.ts +182 -0
  133. package/src/tools/spawnSubagent.ts +92 -0
@@ -0,0 +1,114 @@
1
+ import { CustomEditor, type ExtensionUIContext, type Theme } from '@earendil-works/pi-coding-agent'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
5
+ import { controlCall } from '../control'
6
+ import { TerminalApplication } from './application'
7
+ import { terminalKeybindings } from './keybindings'
8
+ import { createHarnessTheme } from './theme'
9
+
10
+ function setup() {
11
+ const application = new TerminalApplication([])
12
+ const app = application as unknown as {
13
+ ui: ExtensionUIContext & { wrapAutocomplete<T>(value: T): T; dispose(): void }
14
+ keybindings: ReturnType<typeof terminalKeybindings>
15
+ editor: CustomEditor
16
+ runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; session: unknown }
17
+ commands: { execute: ReturnType<typeof vi.fn> }
18
+ initialized: boolean
19
+ commandBusy: boolean
20
+ mountEditor(factory?: Parameters<ExtensionUIContext['setEditorComponent']>[0]): void
21
+ dispatchInput(data: string): { consume?: boolean } | undefined
22
+ submit(text: string): void
23
+ }
24
+ vi.spyOn(application.tui, 'requestRender').mockImplementation(() => {})
25
+ vi.spyOn(application.tui, 'stop').mockImplementation(() => {})
26
+ vi.spyOn(application.terminal, 'drainInput').mockResolvedValue()
27
+ const ui = { theme: createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme), wrapAutocomplete: <T>(value: T) => value,
28
+ setToolsExpanded: vi.fn(), dispose: vi.fn() } as unknown as typeof app.ui
29
+ app.ui = ui
30
+ app.keybindings = terminalKeybindings('/unused/isolated-account')
31
+ app.initialized = true
32
+ const context = {}
33
+ app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), session: {
34
+ extensionRunner: { getShortcuts: () => new Map(), createContext: () => context },
35
+ state: { messages: [] }, settingsManager: {}
36
+ } }
37
+ application.tui.addChild(application)
38
+ return { application, app, ui }
39
+ }
40
+
41
+ describe('the owned terminal application', () => {
42
+ it('lets trusted custom editor modes consume Escape before delegating host actions', async () => {
43
+ const { application, app } = setup()
44
+ let normalMode = false
45
+ class ModalEditor extends CustomEditor {
46
+ override handleInput(data: string) {
47
+ if (data === '\x1b') { normalMode = true; return }
48
+ super.handleInput(data)
49
+ }
50
+ }
51
+ app.mountEditor((tui, theme, keys) => new ModalEditor(tui, theme, keys))
52
+ expect(app.dispatchInput('\x1b')).toBeUndefined()
53
+ app.editor.handleInput('\x1b')
54
+ expect(normalMode).toBe(true)
55
+ expect(app.runtime.interrupt).not.toHaveBeenCalled()
56
+ expect(app.dispatchInput('\x0f')).toBeUndefined()
57
+ app.editor.handleInput('\x0f')
58
+ expect(app.ui.setToolsExpanded).toHaveBeenCalledWith(true)
59
+ await application.shutdown()
60
+ })
61
+
62
+ it('preserves custom editor change callbacks and reports actual draft contents', async () => {
63
+ const { application, app } = setup()
64
+ const changed = vi.fn()
65
+ app.mountEditor((tui, theme, keys) => { const editor = new CustomEditor(tui, theme, keys); editor.onChange = changed; return editor })
66
+ app.dispatchInput('draft')
67
+ app.editor.handleInput('draft')
68
+ await vi.waitFor(() => expect(controlCall).toHaveBeenCalledWith('terminal.draft', { dirty: true }))
69
+ expect(changed).toHaveBeenCalledWith('draft')
70
+ expect(app.editor.getText()).toBe('draft')
71
+ await application.shutdown()
72
+ })
73
+
74
+ it('routes leading-space commands and serializes command dialogs', async () => {
75
+ const { application, app } = setup()
76
+ app.mountEditor()
77
+ let resolve!: (value: boolean) => void
78
+ app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })) }
79
+ app.submit(' /settings')
80
+ await vi.waitFor(() => expect(app.commands.execute).toHaveBeenCalledWith(' /settings'))
81
+ app.submit('/model')
82
+ await Promise.resolve(); await Promise.resolve()
83
+ expect(app.commands.execute).toHaveBeenCalledTimes(1)
84
+ resolve(true)
85
+ await vi.waitFor(() => expect(app.commandBusy).toBe(false))
86
+ await application.shutdown()
87
+ })
88
+
89
+ it('restores terminal modes and finishes even when runtime and plugin cleanup fail', async () => {
90
+ const { application, app } = setup()
91
+ const header = { dispose: vi.fn(() => { throw new Error('header cleanup failed') }) }
92
+ const footer = { dispose: vi.fn() }
93
+ Object.assign(app, { header, footer })
94
+ app.ui.dispose = vi.fn(() => { throw new Error('dialog cleanup failed') })
95
+ app.runtime.dispose.mockRejectedValue(new Error('runtime cleanup failed'))
96
+ await expect(application.shutdown()).resolves.toBeUndefined()
97
+ expect(header.dispose).toHaveBeenCalledOnce()
98
+ expect(footer.dispose).toHaveBeenCalledOnce()
99
+ expect(app.runtime.dispose).toHaveBeenCalledOnce()
100
+ expect(application.terminal.drainInput).toHaveBeenCalledOnce()
101
+ expect(application.tui.stop).toHaveBeenCalledOnce()
102
+ })
103
+
104
+ it('has one shutdown that disposes the runtime and restores the terminal', async () => {
105
+ const { application, app } = setup()
106
+ const first = application.shutdown()
107
+ expect(application.shutdown()).toBe(first)
108
+ await first
109
+ expect(app.ui.dispose).toHaveBeenCalledOnce()
110
+ expect(app.runtime.dispose).toHaveBeenCalledOnce()
111
+ expect(application.terminal.drainInput).toHaveBeenCalledOnce()
112
+ expect(application.tui.stop).toHaveBeenCalledOnce()
113
+ })
114
+ })
@@ -0,0 +1,491 @@
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, ProcessTerminal, TuiMainScreen, Text, matchesKey, setKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi, type AutocompleteProvider, type Component, type EditorComponent, type TuiMouseEvent } from '@earendil-works/pi-tui'
10
+
11
+ import { controlCall } from '../control'
12
+ import { createTerminalCommands } from './commands'
13
+ import { HarnessEditor } from './editor'
14
+ import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
15
+ import { loadPiAdapter, setPiTheme } from './piAdapter'
16
+ import { createTerminalRuntime, type TerminalDecision, type TerminalRuntime } from './runtime'
17
+ import { renderStatusBand, singleLine } from './status'
18
+ import { createHarnessLightTheme, createHarnessTheme } from './theme'
19
+ import { TerminalTranscript } from './transcript'
20
+ import { createTerminalUi, type TerminalUi } from './ui'
21
+ import { HarnessWelcome } from './welcome'
22
+ import { answerTerminalQuestion } from './questions'
23
+ import type { QuestionAnswer } from '../../sessions/registry'
24
+
25
+ const exec = promisify(execFile)
26
+ type Mounted = Component & { dispose?(): void }
27
+ type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
28
+ type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
29
+ type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
30
+
31
+ /** Sole owner of terminal lifecycle, layout, input dispatch, focus and screen transitions. */
32
+ export class TerminalApplication implements Component {
33
+ readonly terminal = new ProcessTerminal()
34
+ readonly tui = new TuiMainScreen(this.terminal)
35
+ private runtime?: TerminalRuntime
36
+ private ui!: TerminalUi
37
+ private keybindings!: KeybindingsManager
38
+ private editor!: EditorComponent
39
+ private transcript!: TerminalTranscript
40
+ private commands!: ReturnType<typeof createTerminalCommands>
41
+ private header?: Mounted
42
+ private footer?: Mounted
43
+ private headerFactory?: HeaderFactory
44
+ private footerFactory?: FooterFactory
45
+ private editorFactory?: EditorFactory
46
+ private widgets = new Map<string, { component: Mounted; placement: string }>()
47
+ private statuses = new Map<string, string>()
48
+ private branch: string | null = null
49
+ private branchListeners = new Set<() => void>()
50
+ private baseAutocomplete?: AutocompleteProvider
51
+ private notices: { message: string; type: string }[] = []
52
+ private startedAt: number | null = null
53
+ private phase?: string
54
+ private scrollOffset = 0
55
+ private totalLines = 0
56
+ private toolsExpanded = false
57
+ private thinkingChanges = 0
58
+ private stopped = false
59
+ private initialized = false
60
+ private closing?: Promise<void>
61
+ private failure?: Error
62
+ private finish!: () => void
63
+ private done = new Promise<void>(resolve => { this.finish = resolve })
64
+ private cleanup: (() => void)[] = []
65
+ private finalCleanup: (() => void)[] = []
66
+ private draftSync = Promise.resolve()
67
+ private lastInput = ''
68
+ private commandBusy = false
69
+ private pendingPolling = false
70
+ private pendingBusy = false
71
+ private pendingAbort?: AbortController
72
+ private pendingId?: string
73
+ private dismissedDecisions = new Set<string>()
74
+ private mouseRegions: { component: Component; start: number; height: number }[] = []
75
+
76
+ constructor(private readonly args: string[]) {}
77
+ private get session() { if (!this.runtime) throw new Error('The terminal runtime is not ready.'); return this.runtime.session }
78
+ private get theme(): Theme { return this.ui.theme }
79
+ private change = (): void => { if (!this.stopped) this.tui.requestRender() }
80
+ private notify = (message: string, type = 'info'): void => {
81
+ 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)
84
+ this.change()
85
+ }
86
+ 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) }
87
+
88
+ async run(): Promise<number> {
89
+ if (!process.stdin.isTTY || !process.stdout.isTTY) throw new Error('The Harness frontend requires an interactive terminal.')
90
+ if (!process.env.PI_CODING_AGENT_DIR || !process.env.XINGULAR_HARNESS_SOCKET) throw new Error('Prepare a Harness terminal session before starting its frontend.')
91
+ const onSignal = () => { void this.shutdown() }
92
+ const onFatal = (error: unknown) => {
93
+ this.failure ??= error instanceof Error ? error : new Error(String(error))
94
+ void this.shutdown()
95
+ }
96
+ const onContinue = () => { if (!this.stopped) { this.tui.start(); this.change() } }
97
+ try {
98
+ const adapter = await loadPiAdapter()
99
+ initTheme('dark', false)
100
+ const theme = createHarnessTheme({ getColorMode: () => process.env.COLORTERM === 'truecolor' || process.env.COLORTERM === '24bit' ? 'truecolor' : '256color' } as Theme)
101
+ setPiTheme(theme)
102
+ this.keybindings = terminalKeybindings(process.env.PI_CODING_AGENT_DIR)
103
+ setKeybindings(this.keybindings)
104
+ this.transcript = new TerminalTranscript(this.tui, () => this.theme, adapter.withBuiltInRenderers)
105
+ this.ui = this.makeUi(theme)
106
+ this.mountEditor()
107
+ this.tui.addChild(this)
108
+ this.tui.setFocus(this.editor)
109
+ this.cleanup.push(this.tui.addInputListener(data => this.dispatchInput(data)))
110
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { process.on(signal, onSignal); this.cleanup.push(() => { process.off(signal, onSignal) }) }
111
+ process.on('uncaughtException', onFatal)
112
+ process.on('unhandledRejection', onFatal)
113
+ this.finalCleanup.push(() => { process.off('uncaughtException', onFatal); process.off('unhandledRejection', onFatal) })
114
+ process.on('SIGCONT', onContinue)
115
+ this.cleanup.push(() => { process.off('SIGCONT', onContinue) })
116
+ this.tui.setShowHardwareCursor(true)
117
+ this.tui.setClearOnShrink(true)
118
+ this.tui.start()
119
+ this.phase = 'Starting session…'
120
+ this.runtime = await createTerminalRuntime(this.args, { ui: this.ui, shutdown: () => { void this.shutdown() }, notify: this.notify, changed: this.change, resources: services => {
121
+ this.ui.setThemes(services.resourceLoader.getThemes().themes)
122
+ const name = services.settingsManager.getTheme()
123
+ const selected = name ? this.ui.getTheme(name) : undefined
124
+ if (selected && this.ui.theme.name !== selected.name) this.ui.setTheme(selected)
125
+ } })
126
+ if (this.stopped) { await this.runtime.dispose(); if (this.failure) throw this.failure; return 0 }
127
+ this.commands = createTerminalCommands({
128
+ session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
129
+ cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
130
+ 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])
134
+ },
135
+ suspend: () => this.suspend(), hotkeys: () => this.hotkeys(),
136
+ changelog: async () => {
137
+ for (const path of ['../CHANGELOG.md', '../../../CHANGELOG.md']) {
138
+ 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. */ }
139
+ }
140
+ return `${manifest.name} v${manifest.version}\nRelease notes were not bundled with this installation.`
141
+ },
142
+ secretInput: (title, placeholder, options) => this.ui.secretInput(title, placeholder, options)
143
+ })
144
+ this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
145
+ 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) }))
147
+ this.initialized = true
148
+ this.phase = undefined
149
+ this.refresh(true)
150
+ const tick = setInterval(() => { if (this.startedAt !== null) this.change(); void this.decisions() }, 100)
151
+ const git = setInterval(() => { void this.refreshBranch() }, 3000)
152
+ this.cleanup.push(() => { clearInterval(tick); clearInterval(git) })
153
+ await this.refreshBranch()
154
+ await this.done
155
+ if (this.failure) throw this.failure
156
+ return 0
157
+ } catch (error) {
158
+ if (this.ui && !this.stopped) { this.notify(error instanceof Error ? error.message : String(error), 'error'); this.tui.renderNow(true) }
159
+ throw error
160
+ } finally { await this.shutdown() }
161
+ }
162
+
163
+ private makeUi(theme: Theme): TerminalUi {
164
+ return createTerminalUi({
165
+ tui: this.tui, theme, themes: [createHarnessLightTheme(theme)], keybindings: this.keybindings, changed: this.change, notify: this.notify,
166
+ setEditor: factory => this.mountEditor(factory), getEditor: () => this.editor,
167
+ getEditorText: () => this.editor?.getText() ?? '', setEditorText: value => { this.editor.setText(value); this.syncDraft(value) },
168
+ setStatus: (key, value) => { if (value === undefined) this.statuses.delete(key); else this.statuses.set(key, value) },
169
+ setWidget: (key, content, placement) => {
170
+ this.release(this.widgets.get(key)?.component)
171
+ if (!content) this.widgets.delete(key)
172
+ else this.widgets.set(key, { component: typeof content === 'function' ? content(this.tui, this.theme) : new Text(content.join('\n'), 0, 0), placement: placement ?? 'aboveEditor' })
173
+ },
174
+ 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) },
176
+ getAutocompleteProvider: () => this.baseAutocomplete!,
177
+ setAutocompleteProvider: provider => this.editor.setAutocompleteProvider?.(provider),
178
+ themeChanged: next => { setPiTheme(next); this.remountPresentation() },
179
+ getToolsExpanded: () => this.toolsExpanded, setToolsExpanded: value => { this.toolsExpanded = value; this.transcript.setExpanded(value) }
180
+ })
181
+ }
182
+ private footerData: ReadonlyFooterDataProvider = {
183
+ getGitBranch: () => this.branch, getExtensionStatuses: () => this.statuses,
184
+ getAvailableProviderCount: () => new Set(this.runtime?.services.modelRuntime.getAvailableSnapshot().map(model => model.provider) ?? []).size,
185
+ onBranchChange: callback => { this.branchListeners.add(callback); return () => { this.branchListeners.delete(callback) } }
186
+ }
187
+ private async refreshBranch(): Promise<void> {
188
+ if (!this.runtime || this.stopped) return
189
+ let branch: string | null = null
190
+ try { branch = (await exec('git', ['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: this.runtime.cwd, timeout: 2000 })).stdout.trim() || null }
191
+ catch { /* Non-repositories and detached HEAD have no branch segment. */ }
192
+ if (branch !== this.branch) { this.branch = branch; for (const listener of this.branchListeners) listener(); this.change() }
193
+ }
194
+ private release(component?: Mounted): void {
195
+ try { component?.dispose?.() }
196
+ catch (error) { this.notify(`Plugin cleanup failed: ${error instanceof Error ? error.message : String(error)}`, 'error') }
197
+ }
198
+ private mountEditor(factory?: EditorFactory): void {
199
+ const draft = this.editor?.getText() ?? ''
200
+ this.release(this.editor as Mounted | undefined)
201
+ this.editorFactory = factory
202
+ const editorTheme = { borderColor: (text: string) => this.theme.fg('border', text), selectList: {
203
+ 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
+ } }
205
+ 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('/')
209
+ })
210
+ this.editor.setText(draft)
211
+ this.editor.onSubmit = text => this.submit(text)
212
+ const onChange = this.editor.onChange
213
+ this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.scrollOffset = 0; this.change() }
214
+ if (factory && this.editor instanceof CustomEditor) {
215
+ // A trusted editor may consume keys in its own mode before delegating to CustomEditor.
216
+ // Wire host actions into that delegation instead of taking the keys away in a listener.
217
+ const editor = this.editor
218
+ for (const action of Object.keys(HARNESS_KEYBINDINGS).filter(name => name.startsWith('app.'))) {
219
+ const id = action as Parameters<CustomEditor['onAction']>[0]
220
+ if (!editor.actionHandlers.has(id)) editor.onAction(id, () => { this.dispatchInput(this.lastInput, true) })
221
+ }
222
+ editor.onEscape ??= () => { this.dispatchInput(this.lastInput, true) }
223
+ editor.onCtrlD ??= () => { void this.shutdown() }
224
+ editor.onPasteImage ??= () => this.attempt(() => this.pasteClipboard())
225
+ editor.onExtensionShortcut ??= data => this.extensionShortcut(data)
226
+ }
227
+ if (this.baseAutocomplete) this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
228
+ this.tui.setFocus(this.editor)
229
+ }
230
+ private syncDraft(text: string): void {
231
+ this.draftSync = this.draftSync.then(() => controlCall('terminal.draft', { dirty: text.length > 0 })).then(() => {}).catch(error => this.notify(String(error), 'error'))
232
+ }
233
+ private band(width: number, above = 0, below = 0): string {
234
+ const state = this.runtime?.snapshot()
235
+ return renderStatusBand({ cwd: state?.cwd ?? process.cwd(), model: state?.model,
236
+ 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
240
+ }, width, this.theme)
241
+ }
242
+ private submit(text: string, followUp = false): void {
243
+ if (!text.trim() || !this.initialized) return
244
+ if (this.commandBusy) { this.notify('Finish or cancel the active command first.', 'error'); return }
245
+ const command = text.trimStart().startsWith('/')
246
+ if (command) this.commandBusy = true
247
+ this.editor.addToHistory?.(text)
248
+ this.editor.setText(''); this.syncDraft(''); this.notices = []; this.scrollOffset = 0
249
+ this.attempt(async () => {
250
+ try {
251
+ await this.draftSync
252
+ if (this.stopped) return
253
+ if (command && await this.commands.execute(text)) return
254
+ if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
255
+ await this.runtime!.submit(text, followUp)
256
+ } finally { if (command) this.commandBusy = false }
257
+ })
258
+ }
259
+ private dispatchInput(data: string, delegated = false) {
260
+ this.lastInput = data
261
+ if (this.stopped) return { consume: true }
262
+ if (this.tui.hasOverlay()) return
263
+ if (!this.initialized) { if (matchesKey(data, 'ctrl+c') || matchesKey(data, 'ctrl+d')) void this.shutdown(); return { consume: true } }
264
+ if (this.editorFactory && !delegated) return
265
+ if (this.extensionShortcut(data)) return { consume: true }
266
+ const is = (action: Parameters<KeybindingsManager['matches']>[1]) => this.keybindings.matches(data, action)
267
+ if (is('app.interrupt')) {
268
+ if (this.editor instanceof HarnessEditor && this.editor.isShowingAutocomplete()) return
269
+ this.attempt(() => this.runtime!.interrupt())
270
+ } else if (is('app.clear')) {
271
+ if (this.session.isStreaming) this.attempt(() => this.runtime!.interrupt())
272
+ else if (this.editor.getText()) { this.editor.setText(''); this.syncDraft('') }
273
+ else void this.shutdown()
274
+ } else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
275
+ else if (is('app.thinking.cycle')) { this.session.cycleThinkingLevel(); this.thinkingChanges += 1 }
276
+ else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
277
+ else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
278
+ else if (is('app.model.select')) this.attempt(() => this.commands.execute('/model'))
279
+ else if (is('app.tools.expand')) this.ui.setToolsExpanded(!this.toolsExpanded)
280
+ else if (is('app.thinking.toggle')) { const hidden = !this.session.settingsManager.getHideThinkingBlock(); this.session.settingsManager.setHideThinkingBlock(hidden); this.transcript.setThinking(hidden) }
281
+ else if (is('app.message.followUp')) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true)
282
+ 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()) }
283
+ else if (is('app.editor.external')) this.attempt(() => this.externalEditor())
284
+ else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
285
+ else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
286
+ 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
291
+ else return
292
+ this.change(); return { consume: true }
293
+ }
294
+ private extensionShortcut(data: string): boolean {
295
+ const shortcut = [...this.session.extensionRunner.getShortcuts(this.keybindings.getResolvedBindings()).entries()].find(([key]) => matchesKey(data, key))
296
+ if (!shortcut) return false
297
+ this.attempt(() => shortcut[1].handler(this.session.extensionRunner.createContext()))
298
+ return true
299
+ }
300
+ private accept(event: AgentSessionEvent): void {
301
+ 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) }
304
+ if (event.type === 'compaction_start') this.phase = 'Compacting context…'
305
+ if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
306
+ if (event.type === 'auto_retry_start') this.phase = `Retry ${event.attempt}/${event.maxAttempts} in ${(event.delayMs / 1000).toFixed(1)}s: ${event.errorMessage}`
307
+ if (event.type === 'auto_retry_end') this.phase = event.finalError
308
+ this.change()
309
+ }
310
+ private refresh(history = false): void {
311
+ if (!this.runtime) return
312
+ this.ui.setThemes(this.session.resourceLoader.getThemes().themes)
313
+ this.tui.setShowHardwareCursor(this.session.settingsManager.getShowHardwareCursor())
314
+ this.transcript.bind(this.session); this.transcript.setExpanded(this.toolsExpanded)
315
+ if (history) for (const message of this.session.state.messages) if (message.role === 'user') {
316
+ const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
317
+ if (text) this.editor.addToHistory?.(text)
318
+ }
319
+ this.baseAutocomplete = new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd)
320
+ this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
321
+ this.change()
322
+ }
323
+ private resetPresentation(): void {
324
+ this.pendingAbort?.abort(); this.dismissedDecisions.clear()
325
+ this.transcript.clearSession()
326
+ this.ui.reset()
327
+ this.release(this.header); this.release(this.footer)
328
+ this.header = this.footer = undefined; this.headerFactory = this.footerFactory = undefined
329
+ for (const widget of this.widgets.values()) this.release(widget.component)
330
+ this.widgets.clear(); this.statuses.clear(); this.branchListeners.clear()
331
+ this.mountEditor()
332
+ // The UI object bound to the runtime stays stable; reset its session-owned state separately.
333
+ }
334
+ private remountPresentation(): void {
335
+ this.release(this.header); this.release(this.footer)
336
+ this.header = this.headerFactory?.(this.tui, this.theme)
337
+ this.footer = this.footerFactory?.(this.tui, this.theme, this.footerData)
338
+ this.mountEditor(this.editorFactory)
339
+ if (this.runtime) this.refresh()
340
+ }
341
+
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
+ private async decisions(): Promise<void> {
383
+ if (this.pendingPolling || !this.runtime || this.stopped) return
384
+ this.pendingPolling = true
385
+ let requests: TerminalDecision[]
386
+ try {
387
+ requests = await this.runtime.pending()
388
+ } catch (error) {
389
+ if (!this.stopped) this.notify(error instanceof Error ? error.message : String(error), 'error')
390
+ return
391
+ } finally { this.pendingPolling = false }
392
+ if (this.pendingId && !requests.some(request => request.requestId === this.pendingId)) this.pendingAbort?.abort()
393
+ if (this.pendingBusy) return
394
+ this.pendingBusy = true
395
+ try {
396
+ const request = requests.find(request => !this.dismissedDecisions.has(request.requestId))
397
+ if (!request || this.tui.hasOverlay()) return
398
+ const abort = new AbortController(); this.pendingAbort = abort; this.pendingId = request.requestId
399
+ this.dismissedDecisions.add(request.requestId)
400
+ 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')
403
+ } else {
404
+ const group = request.batchId
405
+ ? requests.filter(item => item.type === 'question' && item.batchId === request.batchId).sort((a, b) => (a.batchIndex ?? 0) - (b.batchIndex ?? 0))
406
+ : [request]
407
+ const answers: { questionId: string; answer: QuestionAnswer }[] = []
408
+ for (const question of group) {
409
+ const answer = await answerTerminalQuestion(this.ui, question, abort.signal)
410
+ if (abort.signal.aborted) return
411
+ if (!answer) { await this.runtime!.interrupt(); return }
412
+ answers.push({ questionId: question.requestId, answer })
413
+ }
414
+ if (!abort.signal.aborted) {
415
+ if (request.batchId) await this.runtime.answerBatch(request.batchId, answers)
416
+ else await this.runtime.answer(request.requestId, answers[0]!.answer)
417
+ }
418
+ }
419
+ } catch (error) {
420
+ if (this.pendingId) this.dismissedDecisions.delete(this.pendingId)
421
+ if (!this.stopped) this.notify(error instanceof Error ? error.message : String(error), 'error')
422
+ }
423
+ finally { this.pendingBusy = false; this.pendingId = undefined; this.pendingAbort = undefined }
424
+ }
425
+ 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'
427
+ }
428
+ private async pasteClipboard(): Promise<void> {
429
+ const adapter = await loadPiAdapter()
430
+ const files = await adapter.readClipboardFilePaths()
431
+ if (files?.length) {
432
+ if (files.some(file => /[\x00-\x1f\x7f]/.test(file))) throw new Error('Clipboard file paths contain control characters.')
433
+ this.ui.pasteToEditor(files.join('\n'))
434
+ return
435
+ }
436
+ const image = await adapter.readClipboardImage()
437
+ if (image) {
438
+ const directory = await mkdtemp(join(tmpdir(), 'harness-clipboard-'))
439
+ const file = join(directory, `${randomUUID()}.${adapter.extensionForImageMimeType(image.mimeType) ?? 'png'}`)
440
+ await writeFile(file, image.bytes, { mode: 0o600 })
441
+ this.cleanup.push(() => { void rm(directory, { recursive: true, force: true }) })
442
+ this.ui.pasteToEditor(file)
443
+ return
444
+ }
445
+ const text = await adapter.readClipboardText()
446
+ if (text) this.ui.pasteToEditor(text)
447
+ }
448
+ private async externalEditor(): Promise<void> {
449
+ const editor = process.env.VISUAL || process.env.EDITOR
450
+ if (!editor) throw new Error('Set VISUAL or EDITOR to open an external editor.')
451
+ const directory = await mkdtemp(join(tmpdir(), 'harness-editor-')), file = join(directory, 'prompt.md')
452
+ try {
453
+ await writeFile(file, this.editor.getExpandedText?.() ?? this.editor.getText(), { mode: 0o600 })
454
+ this.tui.stop()
455
+ await new Promise<void>((resolve, reject) => {
456
+ const child = spawn(process.env.SHELL || '/bin/sh', ['-c', `${editor} "$1"`, 'harness-editor', file], { stdio: 'inherit' })
457
+ child.once('error', reject); child.once('exit', code => code === 0 ? resolve() : reject(new Error(`Editor exited with ${code}.`)))
458
+ })
459
+ this.editor.setText(await readFile(file, 'utf8')); this.syncDraft(this.editor.getText())
460
+ } finally { await rm(directory, { recursive: true, force: true }); if (!this.stopped) { this.tui.start(); this.change() } }
461
+ }
462
+ private async suspend(): Promise<void> {
463
+ if (process.platform === 'win32') throw new Error('Suspending is unavailable on Windows.')
464
+ this.tui.stop(); process.kill(process.pid, 'SIGTSTP')
465
+ }
466
+ shutdown(): Promise<void> {
467
+ return this.closing ??= (async () => {
468
+ const protect = (action: () => void) => {
469
+ try { action() } catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
470
+ }
471
+ this.stopped = true
472
+ protect(() => this.pendingAbort?.abort())
473
+ protect(() => this.ui?.dispose())
474
+ for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
475
+ try { await this.runtime?.dispose() }
476
+ catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
477
+ finally {
478
+ protect(() => this.transcript?.clearSession())
479
+ protect(() => this.header?.dispose?.())
480
+ protect(() => this.footer?.dispose?.())
481
+ for (const widget of this.widgets.values()) protect(() => widget.component.dispose?.())
482
+ protect(() => (this.editor as Mounted | undefined)?.dispose?.())
483
+ await this.terminal.drainInput(200, 30).catch(() => {})
484
+ protect(() => this.tui.stop())
485
+ for (const cleanup of this.finalCleanup.splice(0)) protect(cleanup)
486
+ this.finish()
487
+ }
488
+ })()
489
+ }
490
+ }
491
+ export function runTerminalFrontend(args: string[]): Promise<number> { return new TerminalApplication(args).run() }