@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- 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() }
|