@xingular/harness 0.1.7 → 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 +14 -0
- package/README.md +29 -10
- package/dist/cli.js +5179 -2976
- 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 +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 +100 -10
- 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/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 +6 -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/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +46 -7
- package/src/sessions/sessions.ts +31 -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 +184 -8
- package/src/terminal/tui/application.ts +243 -81
- package/src/terminal/tui/commands.test.ts +69 -9
- package/src/terminal/tui/commands.ts +89 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +49 -19
- package/src/terminal/tui/keybindings.test.ts +8 -0
- package/src/terminal/tui/keybindings.ts +10 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -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 +2 -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,11 @@ export class TerminalApplication implements Component {
|
|
|
128
184
|
session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
|
|
129
185
|
cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
|
|
130
186
|
refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
|
|
131
|
-
copyText: copyToClipboard, openExternal
|
|
132
|
-
|
|
133
|
-
|
|
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 }) })
|
|
134
192
|
},
|
|
135
193
|
suspend: () => this.suspend(), hotkeys: () => this.hotkeys(),
|
|
136
194
|
changelog: async () => {
|
|
@@ -143,7 +201,7 @@ export class TerminalApplication implements Component {
|
|
|
143
201
|
})
|
|
144
202
|
this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
|
|
145
203
|
this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
|
|
146
|
-
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.
|
|
204
|
+
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.planMode = false; this.normalTools = undefined; this.refresh(true) }))
|
|
147
205
|
this.initialized = true
|
|
148
206
|
this.phase = undefined
|
|
149
207
|
this.refresh(true)
|
|
@@ -170,9 +228,10 @@ export class TerminalApplication implements Component {
|
|
|
170
228
|
this.release(this.widgets.get(key)?.component)
|
|
171
229
|
if (!content) this.widgets.delete(key)
|
|
172
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()
|
|
173
232
|
},
|
|
174
233
|
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) },
|
|
234
|
+
setFooter: factory => { this.footerFactory = factory; this.release(this.footer); this.footer = factory?.(this.tui, this.theme, this.footerData); this.syncDock() },
|
|
176
235
|
getAutocompleteProvider: () => this.baseAutocomplete!,
|
|
177
236
|
setAutocompleteProvider: provider => this.editor.setAutocompleteProvider?.(provider),
|
|
178
237
|
themeChanged: next => { setPiTheme(next); this.remountPresentation() },
|
|
@@ -203,14 +262,13 @@ export class TerminalApplication implements Component {
|
|
|
203
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)
|
|
204
263
|
} }
|
|
205
264
|
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('/')
|
|
265
|
+
band: width => this.band(width), theme: () => this.theme,
|
|
266
|
+
placeholder: () => this.runtime?.snapshot().hasUserMessages || this.startedAt !== null ? undefined : this.suggestion
|
|
209
267
|
})
|
|
210
268
|
this.editor.setText(draft)
|
|
211
269
|
this.editor.onSubmit = text => this.submit(text)
|
|
212
270
|
const onChange = this.editor.onChange
|
|
213
|
-
this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.
|
|
271
|
+
this.editor.onChange = text => { onChange?.(text); this.syncDraft(text); this.change() }
|
|
214
272
|
if (factory && this.editor instanceof CustomEditor) {
|
|
215
273
|
// A trusted editor may consume keys in its own mode before delegating to CustomEditor.
|
|
216
274
|
// Wire host actions into that delegation instead of taking the keys away in a listener.
|
|
@@ -225,18 +283,133 @@ export class TerminalApplication implements Component {
|
|
|
225
283
|
editor.onExtensionShortcut ??= data => this.extensionShortcut(data)
|
|
226
284
|
}
|
|
227
285
|
if (this.baseAutocomplete) this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
|
|
286
|
+
this.syncDock()
|
|
228
287
|
this.tui.setFocus(this.editor)
|
|
229
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
|
+
}
|
|
230
403
|
private syncDraft(text: string): void {
|
|
231
404
|
this.draftSync = this.draftSync.then(() => controlCall('terminal.draft', { dirty: text.length > 0 })).then(() => {}).catch(error => this.notify(String(error), 'error'))
|
|
232
405
|
}
|
|
233
|
-
private band(width: number
|
|
406
|
+
private band(width: number): string {
|
|
234
407
|
const state = this.runtime?.snapshot()
|
|
408
|
+
const usage = this.runtime?.session.getSessionStats().tokens
|
|
235
409
|
return renderStatusBand({ cwd: state?.cwd ?? process.cwd(), model: state?.model,
|
|
236
410
|
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
|
|
411
|
+
inputTokens: (usage?.input ?? 0) + (usage?.cacheRead ?? 0), outputTokens: usage?.output ?? 0,
|
|
412
|
+
tokensPerSecond: this.lastTokensPerSecond
|
|
240
413
|
}, width, this.theme)
|
|
241
414
|
}
|
|
242
415
|
private submit(text: string, followUp = false): void {
|
|
@@ -245,14 +418,14 @@ export class TerminalApplication implements Component {
|
|
|
245
418
|
const command = text.trimStart().startsWith('/')
|
|
246
419
|
if (command) this.commandBusy = true
|
|
247
420
|
this.editor.addToHistory?.(text)
|
|
248
|
-
this.editor.setText(''); this.syncDraft(''); this.
|
|
421
|
+
this.editor.setText(''); this.syncDraft(''); this.scrollToLatest()
|
|
249
422
|
this.attempt(async () => {
|
|
250
423
|
try {
|
|
251
424
|
await this.draftSync
|
|
252
425
|
if (this.stopped) return
|
|
253
426
|
if (command && await this.commands.execute(text)) return
|
|
254
427
|
if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
|
|
255
|
-
await this.runtime!.submit(text, followUp)
|
|
428
|
+
await this.runtime!.submit(this.commands.expandSkill(text), followUp)
|
|
256
429
|
} finally { if (command) this.commandBusy = false }
|
|
257
430
|
})
|
|
258
431
|
}
|
|
@@ -272,7 +445,9 @@ export class TerminalApplication implements Component {
|
|
|
272
445
|
else if (this.editor.getText()) { this.editor.setText(''); this.syncDraft('') }
|
|
273
446
|
else void this.shutdown()
|
|
274
447
|
} else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
|
|
275
|
-
else if (is('app.thinking.cycle'))
|
|
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)
|
|
276
451
|
else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
|
|
277
452
|
else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
|
|
278
453
|
else if (is('app.model.select')) this.attempt(() => this.commands.execute('/model'))
|
|
@@ -284,10 +459,7 @@ export class TerminalApplication implements Component {
|
|
|
284
459
|
else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
|
|
285
460
|
else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
|
|
286
461
|
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
|
|
462
|
+
else if (data === '?' && this.editor.getText() === '') this.notify(this.hotkeys())
|
|
291
463
|
else return
|
|
292
464
|
this.change(); return { consume: true }
|
|
293
465
|
}
|
|
@@ -298,9 +470,34 @@ export class TerminalApplication implements Component {
|
|
|
298
470
|
return true
|
|
299
471
|
}
|
|
300
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
|
+
}
|
|
301
483
|
this.transcript.accept(event)
|
|
302
|
-
if (event.type === 'agent_start') {
|
|
303
|
-
|
|
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
|
+
}
|
|
304
501
|
if (event.type === 'compaction_start') this.phase = 'Compacting context…'
|
|
305
502
|
if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
|
|
306
503
|
if (event.type === 'auto_retry_start') this.phase = `Retry ${event.attempt}/${event.maxAttempts} in ${(event.delayMs / 1000).toFixed(1)}s: ${event.errorMessage}`
|
|
@@ -316,12 +513,14 @@ export class TerminalApplication implements Component {
|
|
|
316
513
|
const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
|
317
514
|
if (text) this.editor.addToHistory?.(text)
|
|
318
515
|
}
|
|
319
|
-
this.baseAutocomplete = new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd)
|
|
516
|
+
this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(this.commands.completions(), this.runtime.cwd), this.commands.skills)
|
|
320
517
|
this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
|
|
321
518
|
this.change()
|
|
322
519
|
}
|
|
323
520
|
private resetPresentation(): void {
|
|
324
521
|
this.pendingAbort?.abort(); this.dismissedDecisions.clear()
|
|
522
|
+
this.resetWorkingStatus()
|
|
523
|
+
this.requestStartedAt = null; this.lastTokensPerSecond = null
|
|
325
524
|
this.transcript.clearSession()
|
|
326
525
|
this.ui.reset()
|
|
327
526
|
this.release(this.header); this.release(this.footer)
|
|
@@ -339,46 +538,6 @@ export class TerminalApplication implements Component {
|
|
|
339
538
|
if (this.runtime) this.refresh()
|
|
340
539
|
}
|
|
341
540
|
|
|
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
541
|
private async decisions(): Promise<void> {
|
|
383
542
|
if (this.pendingPolling || !this.runtime || this.stopped) return
|
|
384
543
|
this.pendingPolling = true
|
|
@@ -398,8 +557,8 @@ export class TerminalApplication implements Component {
|
|
|
398
557
|
const abort = new AbortController(); this.pendingAbort = abort; this.pendingId = request.requestId
|
|
399
558
|
this.dismissedDecisions.add(request.requestId)
|
|
400
559
|
if (request.type === 'approval') {
|
|
401
|
-
const
|
|
402
|
-
if (!abort.signal.aborted) await this.runtime.approve(request.requestId,
|
|
560
|
+
const choice = await this.chooseApproval(request, abort.signal)
|
|
561
|
+
if (!abort.signal.aborted) await this.runtime.approve(request.requestId, choice.decision, choice.alwaysAllow)
|
|
403
562
|
} else {
|
|
404
563
|
const group = request.batchId
|
|
405
564
|
? requests.filter(item => item.type === 'question' && item.batchId === request.batchId).sort((a, b) => (a.batchIndex ?? 0) - (b.batchIndex ?? 0))
|
|
@@ -423,7 +582,7 @@ export class TerminalApplication implements Component {
|
|
|
423
582
|
finally { this.pendingBusy = false; this.pendingId = undefined; this.pendingAbort = undefined }
|
|
424
583
|
}
|
|
425
584
|
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'
|
|
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' : '')
|
|
427
586
|
}
|
|
428
587
|
private async pasteClipboard(): Promise<void> {
|
|
429
588
|
const adapter = await loadPiAdapter()
|
|
@@ -472,16 +631,19 @@ export class TerminalApplication implements Component {
|
|
|
472
631
|
protect(() => this.pendingAbort?.abort())
|
|
473
632
|
protect(() => this.ui?.dispose())
|
|
474
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()
|
|
475
636
|
try { await this.runtime?.dispose() }
|
|
476
637
|
catch (error) { this.failure ??= error instanceof Error ? error : new Error(String(error)) }
|
|
477
638
|
finally {
|
|
639
|
+
await this.terminal.drainInput(200, 30).catch(() => {})
|
|
640
|
+
protect(() => this.tui.stop())
|
|
641
|
+
if (scrollback) protect(() => writeScrollback(this.terminal, scrollback))
|
|
478
642
|
protect(() => this.transcript?.clearSession())
|
|
479
643
|
protect(() => this.header?.dispose?.())
|
|
480
644
|
protect(() => this.footer?.dispose?.())
|
|
481
645
|
for (const widget of this.widgets.values()) protect(() => widget.component.dispose?.())
|
|
482
646
|
protect(() => (this.editor as Mounted | undefined)?.dispose?.())
|
|
483
|
-
await this.terminal.drainInput(200, 30).catch(() => {})
|
|
484
|
-
protect(() => this.tui.stop())
|
|
485
647
|
for (const cleanup of this.finalCleanup.splice(0)) protect(cleanup)
|
|
486
648
|
this.finish()
|
|
487
649
|
}
|
|
@@ -4,7 +4,7 @@ import { createTerminalCommands, type TerminalCommandHost } from './commands'
|
|
|
4
4
|
|
|
5
5
|
function setup() {
|
|
6
6
|
const ui = {
|
|
7
|
-
notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(),
|
|
7
|
+
notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(), custom: vi.fn(async () => undefined),
|
|
8
8
|
theme: { name: 'harness-titanium' }
|
|
9
9
|
} as unknown as ExtensionUIContext
|
|
10
10
|
const session = {
|
|
@@ -15,16 +15,27 @@ function setup() {
|
|
|
15
15
|
},
|
|
16
16
|
settingsManager: { getEnableSkillCommands: () => true },
|
|
17
17
|
compact: vi.fn(), reload: vi.fn(), setSessionName: vi.fn(),
|
|
18
|
+
getActiveToolNames: () => ['read', 'bash'],
|
|
18
19
|
getLastAssistantText: vi.fn(() => 'Actual assistant response'),
|
|
19
20
|
getSessionStats: () => ({ sessionId: 'real-session', tokens: { input: 7 } }),
|
|
20
21
|
exportToHtml: vi.fn(async (path: string) => path), exportToJsonl: vi.fn((path: string) => path)
|
|
21
22
|
} as unknown as AgentSession
|
|
22
|
-
const runtime = {
|
|
23
|
+
const runtime = {
|
|
24
|
+
newSession: vi.fn(), switchSession: vi.fn(), fork: vi.fn(), importFromJsonl: vi.fn(), reload: vi.fn(), navigateTree: vi.fn(),
|
|
25
|
+
installationDeviceId: vi.fn(async () => '12345678-1234-4234-8234-123456789abc'),
|
|
26
|
+
permissions: vi.fn(async () => ({ full: false, globalTools: [] as string[] })),
|
|
27
|
+
setFullPermission: vi.fn(async () => {}), setGlobalTool: vi.fn(async () => {})
|
|
28
|
+
}
|
|
29
|
+
const trajectoryRun = { id: 'run-1', mode: 'code', goal: 'Review the change', startedAt: '2026-10-18T00:00:00.000Z', endedAt: '2026-10-18T00:00:01.000Z', status: 'completed' }
|
|
23
30
|
const host: TerminalCommandHost = {
|
|
24
31
|
session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
|
|
25
32
|
cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
|
|
26
|
-
refresh: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
|
|
27
|
-
hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes'
|
|
33
|
+
refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
|
|
34
|
+
hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
|
|
35
|
+
trajectory: {
|
|
36
|
+
list: vi.fn(async () => [trajectoryRun]),
|
|
37
|
+
read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
|
|
38
|
+
}
|
|
28
39
|
}
|
|
29
40
|
return { host, session, runtime, ui, commands: createTerminalCommands(host) }
|
|
30
41
|
}
|
|
@@ -47,14 +58,21 @@ describe('owned terminal commands', () => {
|
|
|
47
58
|
it('populates completion from builtins and loaded resources', () => {
|
|
48
59
|
const { commands } = setup()
|
|
49
60
|
const names = commands.completions().map(item => item.name)
|
|
50
|
-
expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', '
|
|
61
|
+
expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
|
|
62
|
+
expect(names).not.toContain('thinking')
|
|
63
|
+
expect(names).not.toContain('skill:diagnose')
|
|
51
64
|
expect(new Set(names).size).toBe(names.length)
|
|
52
65
|
})
|
|
53
66
|
|
|
54
|
-
it('
|
|
67
|
+
it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
|
|
55
68
|
const { commands, session } = setup()
|
|
69
|
+
expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
|
|
70
|
+
expect(commands.expandSkill('$diagnose the trace')).toBe('/skill:diagnose the trace')
|
|
71
|
+
expect(commands.expandSkill('$unknown the trace')).toBe('$unknown the trace')
|
|
72
|
+
expect(commands.expandSkill('/skill:diagnose trace')).toBe('/skill:diagnose trace')
|
|
56
73
|
vi.spyOn(session.settingsManager, 'getEnableSkillCommands').mockReturnValue(false)
|
|
57
|
-
expect(commands.
|
|
74
|
+
expect(commands.skills()).toEqual([])
|
|
75
|
+
expect(commands.expandSkill('$diagnose the trace')).toBe('$diagnose the trace')
|
|
58
76
|
})
|
|
59
77
|
|
|
60
78
|
it('uses the scope checked runtime for explicit resume and import paths', async () => {
|
|
@@ -129,6 +147,48 @@ describe('owned terminal commands', () => {
|
|
|
129
147
|
expect(ui.notify).toHaveBeenCalledWith('Harness release notes', 'info')
|
|
130
148
|
})
|
|
131
149
|
|
|
150
|
+
it('opens the latest or requested trajectory run in the interactive overlay', async () => {
|
|
151
|
+
const { commands, host, ui } = setup()
|
|
152
|
+
await commands.execute('/trajectory')
|
|
153
|
+
expect(host.trajectory.list).toHaveBeenCalledOnce()
|
|
154
|
+
expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
|
|
155
|
+
expect(ui.custom).toHaveBeenCalledOnce()
|
|
156
|
+
expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
|
|
157
|
+
|
|
158
|
+
await commands.execute('/trajectory run-1')
|
|
159
|
+
expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
|
|
163
|
+
const { commands, host, runtime, ui } = setup()
|
|
164
|
+
vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
|
|
165
|
+
.mockImplementationOnce(async () => 'done' as never)
|
|
166
|
+
await commands.execute('/permissions')
|
|
167
|
+
expect(runtime.permissions).toHaveBeenCalled()
|
|
168
|
+
expect(runtime.setGlobalTool).toHaveBeenCalledWith('bash', true)
|
|
169
|
+
|
|
170
|
+
vi.mocked(ui.custom).mockImplementationOnce(async () => 'full' as never)
|
|
171
|
+
.mockImplementationOnce(async () => 'done' as never)
|
|
172
|
+
vi.mocked(ui.confirm).mockResolvedValueOnce(true)
|
|
173
|
+
await commands.execute('/permissions')
|
|
174
|
+
expect(ui.confirm).toHaveBeenCalledWith('Enable Full Permission?', expect.stringContaining('Hard denials'))
|
|
175
|
+
expect(runtime.setFullPermission).toHaveBeenCalledWith(true)
|
|
176
|
+
expect(host.refresh).toHaveBeenCalled()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('reports empty history and refuses run IDs outside the current native session', async () => {
|
|
180
|
+
const { commands, host, ui } = setup()
|
|
181
|
+
vi.mocked(host.trajectory.list).mockResolvedValue([])
|
|
182
|
+
await commands.execute('/trajectory')
|
|
183
|
+
expect(ui.notify).toHaveBeenCalledWith('No runs recorded for this native session yet', 'info')
|
|
184
|
+
expect(ui.custom).not.toHaveBeenCalled()
|
|
185
|
+
|
|
186
|
+
vi.mocked(host.trajectory.list).mockResolvedValue([{ id: 'run-1', mode: 'code', goal: null, startedAt: '2026-10-18T00:00:00.000Z', endedAt: null, status: 'running' }])
|
|
187
|
+
await commands.execute('/trajectory other-run')
|
|
188
|
+
expect(host.trajectory.read).not.toHaveBeenCalled()
|
|
189
|
+
expect(ui.notify).toHaveBeenLastCalledWith('Run not found in this native session', 'error')
|
|
190
|
+
})
|
|
191
|
+
|
|
132
192
|
it('exits through the application lifecycle', async () => {
|
|
133
193
|
const { commands, host } = setup()
|
|
134
194
|
await commands.execute('/quit')
|
|
@@ -137,9 +197,9 @@ describe('owned terminal commands', () => {
|
|
|
137
197
|
it('validates thinking levels before mutating the session', async () => {
|
|
138
198
|
const { commands, session, ui } = setup()
|
|
139
199
|
Object.assign(session, { getAvailableThinkingLevels: () => ['off', 'high'], setThinkingLevel: vi.fn() })
|
|
140
|
-
await commands.execute('/
|
|
200
|
+
await commands.execute('/effort high')
|
|
141
201
|
expect(session.setThinkingLevel).toHaveBeenCalledWith('high', { persist: true })
|
|
142
|
-
await commands.execute('/
|
|
202
|
+
await commands.execute('/effort fabricated')
|
|
143
203
|
expect(session.setThinkingLevel).toHaveBeenCalledTimes(1)
|
|
144
204
|
expect(ui.notify).toHaveBeenCalledWith('Available thinking levels: off, high', 'error')
|
|
145
205
|
})
|