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