@xingular/harness 0.1.7 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -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 +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -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/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- 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 +8 -0
- 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/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -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 +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -6,21 +6,27 @@ import { randomUUID } from 'node:crypto'
|
|
|
6
6
|
import manifest from '../../../package.json'
|
|
7
7
|
import { promisify } from 'node:util'
|
|
8
8
|
import { CustomEditor, copyToClipboard, initTheme, type AgentSessionEvent, type ExtensionUIContext, type KeybindingsManager, type ReadonlyFooterDataProvider, type Theme } from '@earendil-works/pi-coding-agent'
|
|
9
|
-
import { CombinedAutocompleteProvider,
|
|
9
|
+
import { CombinedAutocompleteProvider, Container, ProcessTerminal, Text, matchesKey, setKeybindings, truncateToWidth, visibleWidth, wrapTextWithAnsi, type AutocompleteProvider, type Component, type EditorComponent, type ScrollView } from '@earendil-works/pi-tui'
|
|
10
10
|
|
|
11
11
|
import { controlCall } from '../control'
|
|
12
12
|
import { createTerminalCommands } from './commands'
|
|
13
|
-
import {
|
|
13
|
+
import { DialogPanel, SearchableSelect } from './dialogs'
|
|
14
|
+
import { COMPOSER_SUGGESTIONS, HarnessEditor } from './editor'
|
|
14
15
|
import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
|
|
15
16
|
import { loadPiAdapter, setPiTheme } from './piAdapter'
|
|
16
17
|
import { createTerminalRuntime, type TerminalDecision, type TerminalRuntime } from './runtime'
|
|
18
|
+
import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences, type TerminalScreen } from './screen'
|
|
19
|
+
import { planModeTools } from './planMode'
|
|
17
20
|
import { renderStatusBand, singleLine } from './status'
|
|
21
|
+
import { skillCompletions } from './skillInvocation'
|
|
18
22
|
import { createHarnessLightTheme, createHarnessTheme } from './theme'
|
|
19
23
|
import { TerminalTranscript } from './transcript'
|
|
20
24
|
import { createTerminalUi, type TerminalUi } from './ui'
|
|
25
|
+
import { chooseWorkingVerb, createCompletionStatus, getWorkingSpinnerFrames, SPINNER_INTERVAL_MS, spinnerFrameAt } from './workingStatus'
|
|
21
26
|
import { HarnessWelcome } from './welcome'
|
|
22
27
|
import { answerTerminalQuestion } from './questions'
|
|
23
28
|
import type { QuestionAnswer } from '../../sessions/registry'
|
|
29
|
+
import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
|
|
24
30
|
|
|
25
31
|
const exec = promisify(execFile)
|
|
26
32
|
type Mounted = Component & { dispose?(): void }
|
|
@@ -28,10 +34,49 @@ type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
|
|
|
28
34
|
type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
|
|
29
35
|
type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
|
|
30
36
|
|
|
37
|
+
/** The transcript document refreshes the thinking display from application state before each frame. */
|
|
38
|
+
class ChatDocument extends Container {
|
|
39
|
+
constructor(private readonly beforeRender: () => void) { super() }
|
|
40
|
+
override render(width: number): string[] {
|
|
41
|
+
this.beforeRender()
|
|
42
|
+
return super.render(width)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function openExternal(url: string): Promise<void> {
|
|
47
|
+
const program = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open'
|
|
48
|
+
await exec(program, process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url])
|
|
49
|
+
}
|
|
50
|
+
|
|
31
51
|
/** Sole owner of terminal lifecycle, layout, input dispatch, focus and screen transitions. */
|
|
32
|
-
export class TerminalApplication
|
|
52
|
+
export class TerminalApplication {
|
|
33
53
|
readonly terminal = new ProcessTerminal()
|
|
34
|
-
readonly tui
|
|
54
|
+
readonly tui: TerminalScreen
|
|
55
|
+
private readonly preferences: ScreenPreferences
|
|
56
|
+
private readonly chat = new ChatDocument(() => this.syncThinking())
|
|
57
|
+
private readonly above = new Container()
|
|
58
|
+
private readonly editorSlot = new Container()
|
|
59
|
+
private readonly below = new Container()
|
|
60
|
+
private readonly footerSlot = new Container()
|
|
61
|
+
private readonly dock: Component
|
|
62
|
+
private readonly hooks: ScreenHooks = {
|
|
63
|
+
theme: () => this.theme,
|
|
64
|
+
latestKey: () => this.keybindings?.getKeys('tui.altScreen.bottom')[0],
|
|
65
|
+
openUrl: url => this.attempt(() => openExternal(url)),
|
|
66
|
+
pasteClipboard: () => this.attempt(() => this.pasteClipboard())
|
|
67
|
+
}
|
|
68
|
+
private readonly masthead: Component = {
|
|
69
|
+
render: width => {
|
|
70
|
+
// A trusted extension's header owns the top of the transcript and is never hidden here.
|
|
71
|
+
if (this.header) return ['', ...this.header.render(width), '', '']
|
|
72
|
+
if (!this.showsMasthead) return []
|
|
73
|
+
return ['', ...new HarnessWelcome(this.theme, process.env.XINGULAR_HARNESS_NAME).render(width), '', '']
|
|
74
|
+
},
|
|
75
|
+
invalidate: () => this.header?.invalidate()
|
|
76
|
+
}
|
|
77
|
+
private readonly titleRow: Component = { render: width => this.titleLines(width), invalidate: () => {} }
|
|
78
|
+
private readonly queueRows: Component = { render: width => this.queuedMessages(width), invalidate: () => {} }
|
|
79
|
+
private readonly statusRow: Component = { render: width => this.statusLines(width), invalidate: () => {} }
|
|
35
80
|
private runtime?: TerminalRuntime
|
|
36
81
|
private ui!: TerminalUi
|
|
37
82
|
private keybindings!: KeybindingsManager
|
|
@@ -48,13 +93,17 @@ export class TerminalApplication implements Component {
|
|
|
48
93
|
private branch: string | null = null
|
|
49
94
|
private branchListeners = new Set<() => void>()
|
|
50
95
|
private baseAutocomplete?: AutocompleteProvider
|
|
51
|
-
private notices: { message: string; type: string }[] = []
|
|
52
96
|
private startedAt: number | null = null
|
|
97
|
+
private activeVerb?: string
|
|
98
|
+
private activeTurnId?: string
|
|
99
|
+
private completionStatus?: string
|
|
100
|
+
private requestStartedAt: number | null = null
|
|
101
|
+
private lastTokensPerSecond: number | null = null
|
|
53
102
|
private phase?: string
|
|
54
|
-
private
|
|
55
|
-
private totalLines = 0
|
|
103
|
+
private viewport?: ScrollView
|
|
56
104
|
private toolsExpanded = false
|
|
57
|
-
private
|
|
105
|
+
private planMode = false
|
|
106
|
+
private normalTools?: string[]
|
|
58
107
|
private stopped = false
|
|
59
108
|
private initialized = false
|
|
60
109
|
private closing?: Promise<void>
|
|
@@ -71,16 +120,23 @@ export class TerminalApplication implements Component {
|
|
|
71
120
|
private pendingAbort?: AbortController
|
|
72
121
|
private pendingId?: string
|
|
73
122
|
private dismissedDecisions = new Set<string>()
|
|
74
|
-
private
|
|
123
|
+
private suggestion = COMPOSER_SUGGESTIONS[Math.floor(Math.random() * COMPOSER_SUGGESTIONS.length)]
|
|
75
124
|
|
|
76
|
-
constructor(private readonly args: string[]) {
|
|
125
|
+
constructor(private readonly args: string[]) {
|
|
126
|
+
this.preferences = readScreenPreferences(process.env.PI_CODING_AGENT_DIR)
|
|
127
|
+
this.tui = createTerminalScreen(this.terminal, this.preferences, this.hooks)
|
|
128
|
+
this.chat.addChild(this.masthead)
|
|
129
|
+
this.dock = createDock({ title: this.titleRow, above: this.above, queue: this.queueRows, editor: this.editorSlot, below: this.below, footer: this.footerSlot })
|
|
130
|
+
this.viewport = mountTerminalLayout(this.tui, { document: this.chat, dock: this.dock }, this.preferences, this.hooks)
|
|
131
|
+
}
|
|
77
132
|
private get session() { if (!this.runtime) throw new Error('The terminal runtime is not ready.'); return this.runtime.session }
|
|
78
133
|
private get theme(): Theme { return this.ui.theme }
|
|
134
|
+
/** The product lockup stays only until the chat has a user message; a resumed session starts without it. */
|
|
135
|
+
private get showsMasthead(): boolean { return !this.runtime?.snapshot().hasUserMessages }
|
|
79
136
|
private change = (): void => { if (!this.stopped) this.tui.requestRender() }
|
|
80
137
|
private notify = (message: string, type = 'info'): void => {
|
|
81
138
|
if (message.startsWith('No models available.')) message = 'No model is configured. Use /accounts or /login to sign in.'
|
|
82
|
-
this.
|
|
83
|
-
this.notices = this.notices.slice(-8)
|
|
139
|
+
this.transcript?.notice(message, type)
|
|
84
140
|
this.change()
|
|
85
141
|
}
|
|
86
142
|
private attempt = (action: () => Promise<unknown> | unknown): void => { void Promise.resolve().then(action).catch(error => this.notify(error instanceof Error ? error.message : String(error), 'error')).finally(this.change) }
|
|
@@ -102,9 +158,9 @@ export class TerminalApplication implements Component {
|
|
|
102
158
|
this.keybindings = terminalKeybindings(process.env.PI_CODING_AGENT_DIR)
|
|
103
159
|
setKeybindings(this.keybindings)
|
|
104
160
|
this.transcript = new TerminalTranscript(this.tui, () => this.theme, adapter.withBuiltInRenderers)
|
|
161
|
+
this.chat.addChild(this.transcript)
|
|
105
162
|
this.ui = this.makeUi(theme)
|
|
106
163
|
this.mountEditor()
|
|
107
|
-
this.tui.addChild(this)
|
|
108
164
|
this.tui.setFocus(this.editor)
|
|
109
165
|
this.cleanup.push(this.tui.addInputListener(data => this.dispatchInput(data)))
|
|
110
166
|
for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) { process.on(signal, onSignal); this.cleanup.push(() => { process.off(signal, onSignal) }) }
|
|
@@ -128,9 +184,12 @@ export class TerminalApplication implements Component {
|
|
|
128
184
|
session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
|
|
129
185
|
cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
|
|
130
186
|
refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
187
|
+
planMode: () => this.planMode, setPlanMode: enabled => this.setPlanMode(enabled),
|
|
188
|
+
copyText: copyToClipboard, openExternal,
|
|
189
|
+
changed: this.change,
|
|
190
|
+
trajectory: {
|
|
191
|
+
list: async () => (await controlCall<{ runs: TerminalTrajectoryRun[] }>('terminal.trajectory.list')).runs,
|
|
192
|
+
read: (runId, afterSequence) => controlCall<TerminalTrajectorySnapshot>('terminal.trajectory.events', { runId, ...(afterSequence === undefined ? {} : { afterSequence }) })
|
|
134
193
|
},
|
|
135
194
|
suspend: () => this.suspend(), hotkeys: () => this.hotkeys(),
|
|
136
195
|
changelog: async () => {
|
|
@@ -143,7 +202,7 @@ export class TerminalApplication implements Component {
|
|
|
143
202
|
})
|
|
144
203
|
this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
|
|
145
204
|
this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
|
|
146
|
-
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.
|
|
205
|
+
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
|
|
147
206
|
this.initialized = true
|
|
148
207
|
this.phase = undefined
|
|
149
208
|
this.refresh(true)
|
|
@@ -170,9 +229,10 @@ export class TerminalApplication implements Component {
|
|
|
170
229
|
this.release(this.widgets.get(key)?.component)
|
|
171
230
|
if (!content) this.widgets.delete(key)
|
|
172
231
|
else this.widgets.set(key, { component: typeof content === 'function' ? content(this.tui, this.theme) : new Text(content.join('\n'), 0, 0), placement: placement ?? 'aboveEditor' })
|
|
232
|
+
this.syncDock()
|
|
173
233
|
},
|
|
174
234
|
setHeader: factory => { this.headerFactory = factory; this.release(this.header); this.header = factory?.(this.tui, this.theme) },
|
|
175
|
-
setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData) },
|
|
235
|
+
setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData); this.syncDock() },
|
|
176
236
|
getAutocompleteProvider: () => this.baseAutocomplete!,
|
|
177
237
|
setAutocompleteProvider: provider => this.editor.setAutocompleteProvider?.(provider),
|
|
178
238
|
themeChanged: next => { setPiTheme(next); this.remountPresentation() },
|
|
@@ -203,14 +263,13 @@ export class TerminalApplication implements Component {
|
|
|
203
263
|
selectedPrefix: (text: string) => this.theme.fg('accent', text), selectedText: (text: string) => this.theme.fg('accent', text), description: (text: string) => this.theme.fg('muted', text), scrollInfo: (text: string) => this.theme.fg('dim', text), noMatch: (text: string) => this.theme.fg('muted', text)
|
|
204
264
|
} }
|
|
205
265
|
this.editor = factory?.(this.tui, editorTheme, this.keybindings) ?? new HarnessEditor(this.tui, editorTheme, this.keybindings, {
|
|
206
|
-
band:
|
|
207
|
-
|
|
208
|
-
thinkingKey: this.keybindings.getKeys('app.thinking.cycle').map(key => key === 'shift+tab' ? '⇧⇥' : key).join('/')
|
|
266
|
+
band: width => this.band(width), theme: () => this.theme,
|
|
267
|
+
placeholder: () => this.runtime?.snapshot().hasUserMessages || this.startedAt !== null ? undefined : this.suggestion
|
|
209
268
|
})
|
|
210
269
|
this.editor.setText(draft)
|
|
211
270
|
this.editor.onSubmit = text => this.submit(text)
|
|
212
271
|
const onChange = this.editor.onChange
|
|
213
|
-
this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.
|
|
272
|
+
this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.change() }
|
|
214
273
|
if (factory && this.editor instanceof CustomEditor) {
|
|
215
274
|
// A trusted editor may consume keys in its own mode before delegating to CustomEditor.
|
|
216
275
|
// Wire host actions into that delegation instead of taking the keys away in a listener.
|
|
@@ -225,18 +284,134 @@ export class TerminalApplication implements Component {
|
|
|
225
284
|
editor.onExtensionShortcut ??= data => this.extensionShortcut(data)
|
|
226
285
|
}
|
|
227
286
|
if (this.baseAutocomplete) this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
|
|
287
|
+
this.syncDock()
|
|
228
288
|
this.tui.setFocus(this.editor)
|
|
229
289
|
}
|
|
290
|
+
/** The dock's slots follow the current editor, widgets and footer; their rows are read at draw time. */
|
|
291
|
+
private syncDock(): void {
|
|
292
|
+
this.above.clear(); this.below.clear()
|
|
293
|
+
for (const widget of this.widgets.values()) (widget.placement === 'belowEditor' ? this.below : this.above).addChild(widget.component)
|
|
294
|
+
this.editorSlot.clear()
|
|
295
|
+
if (this.editor) this.editorSlot.addChild(this.editor)
|
|
296
|
+
this.footerSlot.clear()
|
|
297
|
+
this.footerSlot.addChild(this.footer ?? this.statusRow)
|
|
298
|
+
}
|
|
299
|
+
private syncThinking(): void {
|
|
300
|
+
if (!this.ui) return
|
|
301
|
+
this.transcript?.setThinking(this.runtime?.snapshot().hideThinking ?? false, this.ui.state.hiddenThinkingLabel ?? 'Thinking…')
|
|
302
|
+
}
|
|
303
|
+
private resetWorkingStatus(): void {
|
|
304
|
+
this.startedAt = null
|
|
305
|
+
this.activeVerb = undefined
|
|
306
|
+
this.activeTurnId = undefined
|
|
307
|
+
this.completionStatus = undefined
|
|
308
|
+
this.phase = undefined
|
|
309
|
+
}
|
|
310
|
+
/** Plan mode narrows the session to read-only tools; leaving it restores the set the session had before. */
|
|
311
|
+
private setPlanMode(enabled: boolean): void {
|
|
312
|
+
if (!this.runtime) return
|
|
313
|
+
if (this.planMode === enabled) return
|
|
314
|
+
if (this.planMode) {
|
|
315
|
+
this.session.setActiveToolsByName(this.normalTools ?? this.session.getActiveToolNames())
|
|
316
|
+
this.normalTools = undefined
|
|
317
|
+
} else {
|
|
318
|
+
this.normalTools = this.session.getActiveToolNames()
|
|
319
|
+
this.session.setActiveToolsByName(planModeTools(this.normalTools))
|
|
320
|
+
}
|
|
321
|
+
this.planMode = enabled
|
|
322
|
+
this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Use /plan off to return to normal.' : 'Normal mode: all tools are available.')
|
|
323
|
+
this.change()
|
|
324
|
+
}
|
|
325
|
+
private scrollToLatest(): void { this.viewport?.scrollToEnd() }
|
|
326
|
+
private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
|
|
327
|
+
if (signal.aborted) return { decision: 'denied', alwaysAllow: false }
|
|
328
|
+
const viewport = this.viewport
|
|
329
|
+
const scrollTop = viewport?.scrollTop
|
|
330
|
+
const wasFollowingEnd = viewport?.isFollowingEnd
|
|
331
|
+
const tool = request.target ?? 'action'
|
|
332
|
+
const choices = [
|
|
333
|
+
{ value: 'once', label: 'Allow once' },
|
|
334
|
+
...(request.rememberable ? [{ value: 'global', label: `Always allow ${tool} everywhere` }] : []),
|
|
335
|
+
{ value: 'deny', label: 'Deny' }
|
|
336
|
+
]
|
|
337
|
+
const details = [
|
|
338
|
+
request.rememberable
|
|
339
|
+
? `Always allow applies to every future ${tool} call in Pi terminal sessions sharing this local Harness state.`
|
|
340
|
+
: 'This approval cannot be saved as a tool grant.',
|
|
341
|
+
'',
|
|
342
|
+
request.action ?? ''
|
|
343
|
+
].join('\n')
|
|
344
|
+
let abortListener: (() => void) | undefined
|
|
345
|
+
try {
|
|
346
|
+
const choice = await this.ui.custom<string | undefined>((tui, theme, _keys, done) => {
|
|
347
|
+
const finish = (value: string | undefined) => {
|
|
348
|
+
if (abortListener) signal.removeEventListener('abort', abortListener)
|
|
349
|
+
abortListener = undefined
|
|
350
|
+
done(value)
|
|
351
|
+
}
|
|
352
|
+
abortListener = () => finish(undefined)
|
|
353
|
+
signal.addEventListener('abort', abortListener, { once: true })
|
|
354
|
+
if (signal.aborted) abortListener()
|
|
355
|
+
const list = new SearchableSelect(choices.map(item => item.label), tui, () => theme,
|
|
356
|
+
index => finish(choices[index]?.value), () => finish(undefined))
|
|
357
|
+
return new DialogPanel(`Approve ${tool}?`, list, () => theme, 'Enter choose · Esc deny', details)
|
|
358
|
+
}, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
|
|
359
|
+
return { decision: choice === 'once' || choice === 'global' ? 'approved' : 'denied', alwaysAllow: choice === 'global' }
|
|
360
|
+
} finally {
|
|
361
|
+
if (abortListener) signal.removeEventListener('abort', abortListener)
|
|
362
|
+
if (viewport && viewport === this.viewport && scrollTop !== undefined && wasFollowingEnd !== undefined) {
|
|
363
|
+
if (wasFollowingEnd) viewport.scrollToEnd()
|
|
364
|
+
else viewport.scrollTo(scrollTop, { disableFollow: true })
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/** Steps through the levels this model supports and stops at either end, unlike the wrapping cycle. */
|
|
369
|
+
private stepThinking(direction: 1 | -1): void {
|
|
370
|
+
const levels = this.session.getAvailableThinkingLevels()
|
|
371
|
+
const next = levels[Math.min(levels.length - 1, Math.max(0, levels.indexOf(this.session.thinkingLevel) + direction))]
|
|
372
|
+
if (!this.session.supportsThinking() || next === undefined || next === this.session.thinkingLevel) return
|
|
373
|
+
this.session.setThinkingLevel(next)
|
|
374
|
+
}
|
|
375
|
+
private exitScrollback(): string[] | undefined {
|
|
376
|
+
if (this.preferences.mode !== 'fullscreen' || this.preferences.exitOutput !== 'transcript' || !this.runtime?.snapshot().hasUserMessages) return undefined
|
|
377
|
+
return scrollbackLines(this.chat, this.terminal.columns)
|
|
378
|
+
}
|
|
379
|
+
private titleLines(width: number): string[] {
|
|
380
|
+
if (!this.ui) return []
|
|
381
|
+
const title = this.runtime?.snapshot().name
|
|
382
|
+
const active = this.startedAt !== null
|
|
383
|
+
const working = this.phase ?? (active
|
|
384
|
+
? this.ui.state.workingVisible ? this.ui.state.workingMessage ?? this.activeVerb ?? 'Working…' : ''
|
|
385
|
+
: this.completionStatus ?? '')
|
|
386
|
+
const titleText = title ? this.theme.italic(this.theme.fg('dim', singleLine(title))) : ''
|
|
387
|
+
const indicator = this.ui.state.workingIndicator
|
|
388
|
+
const frames = indicator?.frames ?? getWorkingSpinnerFrames(process.env.TERM)
|
|
389
|
+
const frame = active && frames.length
|
|
390
|
+
? spinnerFrameAt(Date.now() - this.startedAt!, frames, indicator?.intervalMs ?? SPINNER_INTERVAL_MS)
|
|
391
|
+
: ''
|
|
392
|
+
const defaultWorking = active && !this.phase && this.ui.state.workingVisible && !this.ui.state.workingMessage && working === this.activeVerb
|
|
393
|
+
const workingColor = defaultWorking ? 'warning' : 'muted'
|
|
394
|
+
const workText = working ? this.theme.fg(workingColor, (frame ? frame + ' ' : '') + singleLine(working)) : ''
|
|
395
|
+
return titleText || workText ? [truncateToWidth(workText, width, '') + ' '.repeat(Math.max(0, width - visibleWidth(workText) - visibleWidth(titleText))) + titleText] : []
|
|
396
|
+
}
|
|
397
|
+
private queuedMessages(width: number): string[] {
|
|
398
|
+
if (!this.ui) return []
|
|
399
|
+
const state = this.runtime?.snapshot()
|
|
400
|
+
return state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width)) : []
|
|
401
|
+
}
|
|
402
|
+
private statusLines(width: number): string[] {
|
|
403
|
+
return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
|
|
404
|
+
}
|
|
230
405
|
private syncDraft(text: string): void {
|
|
231
406
|
this.draftSync = this.draftSync.then(() => controlCall('terminal.draft', { dirty: text.length > 0 })).then(() => {}).catch(error => this.notify(String(error), 'error'))
|
|
232
407
|
}
|
|
233
|
-
private band(width: number
|
|
408
|
+
private band(width: number): string {
|
|
234
409
|
const state = this.runtime?.snapshot()
|
|
410
|
+
const usage = this.runtime?.session.getSessionStats().tokens
|
|
235
411
|
return renderStatusBand({ cwd: state?.cwd ?? process.cwd(), model: state?.model,
|
|
236
412
|
thinkingLevel: state?.thinkingLevel ?? 'off', branch: this.branch, account: state?.account?.label ?? 'account unknown',
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
running: this.startedAt !== null, elapsedMs: this.startedAt === null ? 0 : Date.now() - this.startedAt, hiddenAbove: above, hiddenBelow: below
|
|
413
|
+
inputTokens: (usage?.input ?? 0) + (usage?.cacheRead ?? 0), outputTokens: usage?.output ?? 0,
|
|
414
|
+
tokensPerSecond: this.lastTokensPerSecond
|
|
240
415
|
}, width, this.theme)
|
|
241
416
|
}
|
|
242
417
|
private submit(text: string, followUp = false): void {
|
|
@@ -245,14 +420,14 @@ export class TerminalApplication implements Component {
|
|
|
245
420
|
const command = text.trimStart().startsWith('/')
|
|
246
421
|
if (command) this.commandBusy = true
|
|
247
422
|
this.editor.addToHistory?.(text)
|
|
248
|
-
this.editor.setText(''); this.syncDraft(''); this.
|
|
423
|
+
this.editor.setText(''); this.syncDraft(''); this.scrollToLatest()
|
|
249
424
|
this.attempt(async () => {
|
|
250
425
|
try {
|
|
251
426
|
await this.draftSync
|
|
252
427
|
if (this.stopped) return
|
|
253
428
|
if (command && await this.commands.execute(text)) return
|
|
254
429
|
if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
|
|
255
|
-
await this.runtime!.submit(text, followUp)
|
|
430
|
+
await this.runtime!.submit(this.commands.expandSkill(text), followUp)
|
|
256
431
|
} finally { if (command) this.commandBusy = false }
|
|
257
432
|
})
|
|
258
433
|
}
|
|
@@ -272,7 +447,8 @@ export class TerminalApplication implements Component {
|
|
|
272
447
|
else if (this.editor.getText()) { this.editor.setText(''); this.syncDraft('') }
|
|
273
448
|
else void this.shutdown()
|
|
274
449
|
} else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
|
|
275
|
-
else if (is('app.thinking.cycle'))
|
|
450
|
+
else if (is('app.thinking.cycle')) this.session.cycleThinkingLevel()
|
|
451
|
+
else if (is('app.thinking.increase') || is('app.thinking.decrease')) this.stepThinking(is('app.thinking.increase') ? 1 : -1)
|
|
276
452
|
else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
|
|
277
453
|
else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
|
|
278
454
|
else if (is('app.model.select')) this.attempt(() => this.commands.execute('/model'))
|
|
@@ -284,10 +460,7 @@ export class TerminalApplication implements Component {
|
|
|
284
460
|
else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
|
|
285
461
|
else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
|
|
286
462
|
else if (is('app.suspend')) this.attempt(() => this.suspend())
|
|
287
|
-
else if (
|
|
288
|
-
else if (matchesKey(data, 'pageDown') || matchesKey(data, 'shift+pageDown')) this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(1, this.terminal.rows - 4))
|
|
289
|
-
else if (matchesKey(data, 'ctrl+home')) this.scrollOffset = Math.max(0, this.totalLines - 1)
|
|
290
|
-
else if (matchesKey(data, 'ctrl+end')) this.scrollOffset = 0
|
|
463
|
+
else if (data === '?' && this.editor.getText() === '') this.notify(this.hotkeys())
|
|
291
464
|
else return
|
|
292
465
|
this.change(); return { consume: true }
|
|
293
466
|
}
|
|
@@ -298,9 +471,34 @@ export class TerminalApplication implements Component {
|
|
|
298
471
|
return true
|
|
299
472
|
}
|
|
300
473
|
private accept(event: AgentSessionEvent): void {
|
|
474
|
+
if (event.type === 'turn_start') this.requestStartedAt = performance.now()
|
|
475
|
+
if (event.type === 'message_end' && event.message.role === 'assistant') {
|
|
476
|
+
const outputTokens = event.message.usage?.output
|
|
477
|
+
const startedAt = this.requestStartedAt
|
|
478
|
+
if (startedAt !== null && typeof outputTokens === 'number') {
|
|
479
|
+
const duration = performance.now() - startedAt
|
|
480
|
+
if (duration > 0) this.lastTokensPerSecond = outputTokens * 1000 / duration
|
|
481
|
+
}
|
|
482
|
+
this.requestStartedAt = null
|
|
483
|
+
}
|
|
301
484
|
this.transcript.accept(event)
|
|
302
|
-
if (event.type === 'agent_start') {
|
|
303
|
-
|
|
485
|
+
if (event.type === 'agent_start') {
|
|
486
|
+
this.startedAt = Date.now()
|
|
487
|
+
this.activeVerb = chooseWorkingVerb()
|
|
488
|
+
this.activeTurnId = randomUUID()
|
|
489
|
+
this.completionStatus = undefined
|
|
490
|
+
this.phase = undefined
|
|
491
|
+
this.terminal.setProgress(this.session.settingsManager.getShowTerminalProgress())
|
|
492
|
+
}
|
|
493
|
+
if (event.type === 'agent_settled') {
|
|
494
|
+
if (this.startedAt !== null) this.completionStatus = createCompletionStatus(this.activeTurnId ?? randomUUID(), Date.now() - this.startedAt)
|
|
495
|
+
this.startedAt = null
|
|
496
|
+
this.activeVerb = undefined
|
|
497
|
+
this.activeTurnId = undefined
|
|
498
|
+
this.requestStartedAt = null
|
|
499
|
+
this.phase = undefined
|
|
500
|
+
this.terminal.setProgress(false)
|
|
501
|
+
}
|
|
304
502
|
if (event.type === 'compaction_start') this.phase = 'Compacting context…'
|
|
305
503
|
if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
|
|
306
504
|
if (event.type === 'auto_retry_start') this.phase = `Retry ${event.attempt}/${event.maxAttempts} in ${(event.delayMs / 1000).toFixed(1)}s: ${event.errorMessage}`
|
|
@@ -316,12 +514,21 @@ export class TerminalApplication implements Component {
|
|
|
316
514
|
const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
|
317
515
|
if (text) this.editor.addToHistory?.(text)
|
|
318
516
|
}
|
|
319
|
-
|
|
517
|
+
const commands = this.commands.completions()
|
|
518
|
+
if (this.editor instanceof HarnessEditor) {
|
|
519
|
+
this.editor.setValidCommands([
|
|
520
|
+
...commands.map(command => command.name),
|
|
521
|
+
...this.commands.skills().map(skill => 'skill:' + skill.name)
|
|
522
|
+
])
|
|
523
|
+
}
|
|
524
|
+
this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(commands, this.runtime.cwd), this.commands.skills)
|
|
320
525
|
this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
|
|
321
526
|
this.change()
|
|
322
527
|
}
|
|
323
528
|
private resetPresentation(): void {
|
|
324
529
|
this.pendingAbort?.abort(); this.dismissedDecisions.clear()
|
|
530
|
+
this.resetWorkingStatus()
|
|
531
|
+
this.requestStartedAt = null; this.lastTokensPerSecond = null
|
|
325
532
|
this.transcript.clearSession()
|
|
326
533
|
this.ui.reset()
|
|
327
534
|
this.release(this.header); this.release(this.footer)
|
|
@@ -339,46 +546,6 @@ export class TerminalApplication implements Component {
|
|
|
339
546
|
if (this.runtime) this.refresh()
|
|
340
547
|
}
|
|
341
548
|
|
|
342
|
-
render(width: number): string[] {
|
|
343
|
-
if (width < 1 || !this.ui) return []
|
|
344
|
-
this.mouseRegions = []
|
|
345
|
-
const welcome = this.header ?? new HarnessWelcome(this.theme, process.env.XINGULAR_HARNESS_NAME)
|
|
346
|
-
const history = ['', ...welcome.render(width), '', '']
|
|
347
|
-
const state = this.runtime?.snapshot()
|
|
348
|
-
this.transcript.setThinking(state?.hideThinking ?? false, this.ui.state.hiddenThinkingLabel ?? 'Thinking…')
|
|
349
|
-
const transcriptRows = this.transcript.render(Math.max(3, width))
|
|
350
|
-
this.mouseRegions.push({ component: this.transcript, start: history.length, height: transcriptRows.length })
|
|
351
|
-
history.push(...transcriptRows); this.totalLines = history.length
|
|
352
|
-
const title = state?.name
|
|
353
|
-
const working = this.phase ?? (this.startedAt !== null && this.ui.state.workingVisible ? this.ui.state.workingMessage ?? 'Working…' : '')
|
|
354
|
-
const titleText = title ? this.theme.italic(this.theme.fg('dim', singleLine(title))) : ''
|
|
355
|
-
const indicator = this.ui.state.workingIndicator
|
|
356
|
-
const frames = indicator?.frames
|
|
357
|
-
const frame = this.startedAt !== null && frames?.length ? frames[Math.floor((Date.now() - this.startedAt) / Math.max(1, indicator?.intervalMs ?? 80)) % frames.length] : ''
|
|
358
|
-
const workText = working ? (frame ? frame + ' ' : '') + this.theme.fg('muted', singleLine(working)) : ''
|
|
359
|
-
const composer = titleText || workText ? [truncateToWidth(workText, width, '') + ' '.repeat(Math.max(0, width - visibleWidth(workText) - visibleWidth(titleText))) + titleText] : []
|
|
360
|
-
const append = (component: Component) => { const rows = component.render(width); this.mouseRegions.push({ component, start: history.length + composer.length, height: rows.length }); composer.push(...rows) }
|
|
361
|
-
for (const widget of this.widgets.values()) if (widget.placement !== 'belowEditor') append(widget.component)
|
|
362
|
-
if (state) for (const text of [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)]) composer.push(...wrapTextWithAnsi(this.theme.fg('muted', text), width))
|
|
363
|
-
for (const { message, type } of this.notices) composer.push(...wrapTextWithAnsi(this.theme.fg(type === 'error' ? 'error' : type === 'warning' ? 'warning' : 'muted', message), width))
|
|
364
|
-
append(this.editor)
|
|
365
|
-
for (const widget of this.widgets.values()) if (widget.placement === 'belowEditor') append(widget.component)
|
|
366
|
-
if (this.footer) append(this.footer)
|
|
367
|
-
else if (this.statuses.size) composer.push(...wrapTextWithAnsi([...this.statuses.values()].join(' '), width))
|
|
368
|
-
const available = Math.max(1, this.terminal.rows - composer.length - 1)
|
|
369
|
-
const end = Math.max(1, history.length - this.scrollOffset)
|
|
370
|
-
const start = this.scrollOffset ? Math.max(0, end - available) : 0
|
|
371
|
-
for (const region of this.mouseRegions) region.start -= region.component === this.transcript ? start : this.scrollOffset ? history.length - (end - start + 1) : 0
|
|
372
|
-
const content = this.scrollOffset ? [...history.slice(start, end), this.theme.fg('dim', `↑ Scrollback · ${this.scrollOffset} rows from latest · PgDn to return`)] : history
|
|
373
|
-
return [...content, ...composer].map(line => truncateToWidth(line, width, ''))
|
|
374
|
-
}
|
|
375
|
-
invalidate(): void { this.header?.invalidate(); this.footer?.invalidate(); this.transcript?.invalidate(); this.editor?.invalidate(); for (const widget of this.widgets.values()) widget.component.invalidate() }
|
|
376
|
-
handleMouse(event: TuiMouseEvent) {
|
|
377
|
-
if (event.type === 'wheel') { this.scrollOffset = Math.max(0, Math.min(this.totalLines - 1, this.scrollOffset - (event.wheelDelta ?? 0))); this.change(); return { handled: true } }
|
|
378
|
-
const region = this.mouseRegions.find(region => event.y >= region.start && event.y < region.start + region.height)
|
|
379
|
-
return region?.component.handleMouse?.({ ...event, y: event.y - region.start, height: region.height })
|
|
380
|
-
}
|
|
381
|
-
|
|
382
549
|
private async decisions(): Promise<void> {
|
|
383
550
|
if (this.pendingPolling || !this.runtime || this.stopped) return
|
|
384
551
|
this.pendingPolling = true
|
|
@@ -398,8 +565,8 @@ export class TerminalApplication implements Component {
|
|
|
398
565
|
const abort = new AbortController(); this.pendingAbort = abort; this.pendingId = request.requestId
|
|
399
566
|
this.dismissedDecisions.add(request.requestId)
|
|
400
567
|
if (request.type === 'approval') {
|
|
401
|
-
const
|
|
402
|
-
if (!abort.signal.aborted) await this.runtime.approve(request.requestId,
|
|
568
|
+
const choice = await this.chooseApproval(request, abort.signal)
|
|
569
|
+
if (!abort.signal.aborted) await this.runtime.approve(request.requestId, choice.decision, choice.alwaysAllow)
|
|
403
570
|
} else {
|
|
404
571
|
const group = request.batchId
|
|
405
572
|
? requests.filter(item => item.type === 'question' && item.batchId === request.batchId).sort((a, b) => (a.batchIndex ?? 0) - (b.batchIndex ?? 0))
|
|
@@ -423,7 +590,7 @@ export class TerminalApplication implements Component {
|
|
|
423
590
|
finally { this.pendingBusy = false; this.pendingId = undefined; this.pendingAbort = undefined }
|
|
424
591
|
}
|
|
425
592
|
private hotkeys(): string {
|
|
426
|
-
return Object.entries(this.keybindings.getResolvedBindings()).filter(([name, keys]) => name.startsWith('app.') && (!Array.isArray(keys) || keys.length)).map(([name, keys]) => `${Array.isArray(keys) ? keys.join(', ') : keys}: ${name.slice(4).replaceAll('.', ' ')}`).join('\n') + '\nPgUp/PgDn: scroll transcript\nCtrl+Home/End: oldest/latest transcript'
|
|
593
|
+
return Object.entries(this.keybindings.getResolvedBindings()).filter(([name, keys]) => name.startsWith('app.') && (!Array.isArray(keys) || keys.length)).map(([name, keys]) => `${Array.isArray(keys) ? keys.join(', ') : keys}: ${name.slice(4).replaceAll('.', ' ')}`).join('\n') + (this.tui.mode === 'fullscreen' ? '\nPgUp/PgDn: scroll transcript\nCtrl+Home/End: oldest/latest transcript' : '')
|
|
427
594
|
}
|
|
428
595
|
private async pasteClipboard(): Promise<void> {
|
|
429
596
|
const adapter = await loadPiAdapter()
|
|
@@ -472,16 +639,19 @@ export class TerminalApplication implements Component {
|
|
|
472
639
|
protect(() => this.pendingAbort?.abort())
|
|
473
640
|
protect(() => this.ui?.dispose())
|
|
474
641
|
for (const cleanup of this.cleanup.splice(0)) protect(cleanup)
|
|
642
|
+
// Captured while the runtime and its components still exist; written only after the alternate screen is left.
|
|
643
|
+
const scrollback = this.exitScrollback()
|
|
475
644
|
try { await this.runtime?.dispose() }
|
|
476
645
|
catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
|
|
477
646
|
finally {
|
|
647
|
+
await this.terminal.drainInput(200, 30).catch(() => {})
|
|
648
|
+
protect(() => this.tui.stop())
|
|
649
|
+
if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
|
|
478
650
|
protect(() => this.transcript?.clearSession())
|
|
479
651
|
protect(() => this.header?.dispose?.())
|
|
480
652
|
protect(() => this.footer?.dispose?.())
|
|
481
653
|
for (const widget of this.widgets.values()) protect(() => widget.component.dispose?.())
|
|
482
654
|
protect(() => (this.editor as Mounted | undefined)?.dispose?.())
|
|
483
|
-
await this.terminal.drainInput(200, 30).catch(() => {})
|
|
484
|
-
protect(() => this.tui.stop())
|
|
485
655
|
for (const cleanup of this.finalCleanup.splice(0)) protect(cleanup)
|
|
486
656
|
this.finish()
|
|
487
657
|
}
|