@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
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type AgentSession, type AgentSessionEventListener, type AgentSessionRuntime, type AgentSessionServices,
|
|
5
5
|
type CreateAgentSessionRuntimeFactory, type ExtensionUIContext
|
|
6
6
|
} from '@earendil-works/pi-coding-agent'
|
|
7
|
-
import { closeSync, openSync, readSync, realpathSync } from 'node:fs'
|
|
7
|
+
import { closeSync, existsSync, openSync, readSync, realpathSync } from 'node:fs'
|
|
8
8
|
import { isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
9
9
|
|
|
10
10
|
import terminalExtension from '../piExtension'
|
|
@@ -84,11 +84,19 @@ export interface TerminalSnapshot {
|
|
|
84
84
|
readonly queue: Readonly<{ steering: readonly string[]; followUp: readonly string[] }>
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
export interface TerminalDecision extends TerminalQuestion { type: 'approval' | 'question'; target?: string; action?: string }
|
|
87
|
+
export interface TerminalDecision extends TerminalQuestion { type: 'approval' | 'question'; target?: string; action?: string; rememberable?: boolean }
|
|
88
|
+
|
|
89
|
+
export interface TerminalPermissionState {
|
|
90
|
+
readonly full: boolean
|
|
91
|
+
readonly globalTools: readonly string[]
|
|
92
|
+
}
|
|
88
93
|
|
|
89
94
|
export interface TerminalRuntime {
|
|
90
95
|
pending(): Promise<TerminalDecision[]>
|
|
91
|
-
approve(requestId: string, decision: 'approved' | 'denied'): Promise<void>
|
|
96
|
+
approve(requestId: string, decision: 'approved' | 'denied', alwaysAllowTool?: boolean): Promise<void>
|
|
97
|
+
permissions(): Promise<TerminalPermissionState>
|
|
98
|
+
setFullPermission(enabled: boolean): Promise<void>
|
|
99
|
+
setGlobalTool(tool: string, allowed: boolean): Promise<void>
|
|
92
100
|
answer(requestId: string, answer: QuestionAnswer): Promise<void>
|
|
93
101
|
answerBatch(batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): Promise<void>
|
|
94
102
|
snapshot(): TerminalSnapshot
|
|
@@ -157,10 +165,11 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
|
|
|
157
165
|
if (!agentDirValue || !isAbsolute(agentDirValue)) throw new Error('The Harness terminal requires its isolated account directory.')
|
|
158
166
|
const agentDir = realpathSync(agentDirValue)
|
|
159
167
|
const sessionDirectory = realpathSync(resolve(cwd, prepared.sessionDir))
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
// Pi writes a session file only after its first message, so a restart before that has nothing to resume.
|
|
169
|
+
const resume = prepared.session && existsSync(resolve(cwd, prepared.session)) ? resolve(cwd, prepared.session) : null
|
|
170
|
+
if (resume) {
|
|
171
|
+
assertTerminalHistoryPath(resume, sessionDirectory)
|
|
172
|
+
assertTerminalSessionScope(resume, cwd)
|
|
164
173
|
}
|
|
165
174
|
|
|
166
175
|
// Claim before loading any plugin code and retain it in the trusted process across reloads.
|
|
@@ -233,7 +242,7 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
|
|
|
233
242
|
}
|
|
234
243
|
const native = await createAgentSessionRuntime(factory, {
|
|
235
244
|
cwd, agentDir,
|
|
236
|
-
sessionManager:
|
|
245
|
+
sessionManager: resume ? SessionManager.open(resume, sessionDirectory) : SessionManager.create(cwd, sessionDirectory),
|
|
237
246
|
sessionStartEvent: { type: 'session_start', reason: 'startup' }
|
|
238
247
|
})
|
|
239
248
|
|
|
@@ -268,7 +277,13 @@ export async function createTerminalRuntime(args: string[], host: TerminalRuntim
|
|
|
268
277
|
if (disposed) return []
|
|
269
278
|
return (await controlCall<{ requests: TerminalDecision[] }>('terminal.pending')).requests
|
|
270
279
|
},
|
|
271
|
-
approve: (requestId, decision) => decide('terminal.approve', { requestId, decision }),
|
|
280
|
+
approve: (requestId, decision, alwaysAllowTool = false) => decide('terminal.approve', { requestId, decision, alwaysAllow: alwaysAllowTool }),
|
|
281
|
+
async permissions() {
|
|
282
|
+
if (disposed) return { full: false, globalTools: [] }
|
|
283
|
+
return controlCall<TerminalPermissionState>('terminal.permissions.get')
|
|
284
|
+
},
|
|
285
|
+
setFullPermission: async enabled => { await decide('terminal.permissions.setFull', { enabled }) },
|
|
286
|
+
setGlobalTool: async (tool, allowed) => { await decide('terminal.permissions.setTool', { tool, allowed }) },
|
|
272
287
|
answer: (requestId, answer) => decide('terminal.answer', { requestId, answer }),
|
|
273
288
|
answerBatch: (batchId, answers) => decide('terminal.answers', { batchId, answers }),
|
|
274
289
|
snapshot() {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { type Theme } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import { Container, TuiAltScreen, TuiMainScreen, VStack, stripTerminalSequences, type Component, type Terminal } from '@earendil-works/pi-tui'
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
|
|
8
|
+
import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences } from './screen'
|
|
9
|
+
import { createHarnessTheme } from './theme'
|
|
10
|
+
|
|
11
|
+
const directories: string[] = []
|
|
12
|
+
afterEach(async () => { for (const directory of directories.splice(0)) await rm(directory, { recursive: true, force: true }) })
|
|
13
|
+
|
|
14
|
+
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
15
|
+
const hooks: ScreenHooks = { theme: () => theme, latestKey: () => 'ctrl+end', openUrl: vi.fn(), pasteClipboard: vi.fn() }
|
|
16
|
+
const defaults = readScreenPreferences(undefined)
|
|
17
|
+
const prefs = (mode: ScreenPreferences['mode']): ScreenPreferences => ({ ...defaults, mode })
|
|
18
|
+
|
|
19
|
+
function fakeTerminal(columns = 40, rows = 12): Terminal {
|
|
20
|
+
return {
|
|
21
|
+
columns, rows, kittyProtocolActive: false, start: vi.fn(), stop: vi.fn(), drainInput: vi.fn(async () => {}), write: vi.fn(),
|
|
22
|
+
moveBy: vi.fn(), hideCursor: vi.fn(), showCursor: vi.fn(), clearLine: vi.fn(), clearFromCursor: vi.fn(), clearScreen: vi.fn(),
|
|
23
|
+
setTitle: vi.fn(), setProgress: vi.fn(), setProgramStatus: vi.fn()
|
|
24
|
+
} as unknown as Terminal
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A transcript whose rows the test changes, and a three-row dock standing in for the rule, prompt and footer. */
|
|
28
|
+
function panes(initial: string[]) {
|
|
29
|
+
let transcript = initial
|
|
30
|
+
const document = new Container()
|
|
31
|
+
document.addChild({ render: () => [...transcript], invalidate: () => {} })
|
|
32
|
+
const dock = new VStack([{ component: { render: () => ['─'.repeat(40), '❯ prompt', '? for shortcuts'], invalidate: () => {} }, shrink: 1, minSize: 0 }])
|
|
33
|
+
return { document, dock, set(lines: string[]) { transcript = lines } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function rowOf(lines: string[]): Component {
|
|
37
|
+
return { render: () => [...lines], invalidate: () => {} }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function screenRows(tui: TuiAltScreen): string[] {
|
|
41
|
+
return tui.getScreenLines().map(line => stripTerminalSequences(line).trimEnd())
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('terminal screen preferences', () => {
|
|
45
|
+
it('follows Pi fullscreen defaults when no account folder is prepared', () => {
|
|
46
|
+
expect(defaults).toEqual({ mode: 'fullscreen', exitOutput: 'transcript', scrollbar: 'auto', wheelScrollLines: 'auto', copyOnSelect: true })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('reads the regular screen from the account settings', async () => {
|
|
50
|
+
const directory = await mkdtemp(join(tmpdir(), 'harness-screen-'))
|
|
51
|
+
directories.push(directory)
|
|
52
|
+
await writeFile(join(directory, 'settings.json'), JSON.stringify({ tuiMode: 'regular' }))
|
|
53
|
+
expect(readScreenPreferences(directory).mode).toBe('regular')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('terminal screen layout', () => {
|
|
58
|
+
it('keeps the dock on the last rows while the transcript is short', () => {
|
|
59
|
+
const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
|
|
60
|
+
const { document, dock } = panes(['Welcome'])
|
|
61
|
+
mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
|
|
62
|
+
tui.start()
|
|
63
|
+
tui.renderNow(true)
|
|
64
|
+
const rows = screenRows(tui)
|
|
65
|
+
expect(rows).toHaveLength(12)
|
|
66
|
+
expect(rows[0]).toBe('Welcome')
|
|
67
|
+
expect(rows.slice(-3)).toEqual(['─'.repeat(40), '❯ prompt', '? for shortcuts'])
|
|
68
|
+
tui.stop()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('does not move the dock as the transcript grows', () => {
|
|
72
|
+
const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
|
|
73
|
+
const { document, dock, set } = panes(['Welcome'])
|
|
74
|
+
mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
|
|
75
|
+
tui.start()
|
|
76
|
+
tui.renderNow(true)
|
|
77
|
+
set(Array.from({ length: 30 }, (_, index) => `message ${index}`))
|
|
78
|
+
tui.renderNow(true)
|
|
79
|
+
const rows = screenRows(tui)
|
|
80
|
+
expect(rows).toHaveLength(12)
|
|
81
|
+
expect(rows.slice(-3)).toEqual(['─'.repeat(40), '❯ prompt', '? for shortcuts'])
|
|
82
|
+
expect(rows[8]).toBe('message 29')
|
|
83
|
+
tui.stop()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('scrolls the transcript above a dock that stays where it is', () => {
|
|
87
|
+
const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
|
|
88
|
+
const { document, dock, set } = panes(Array.from({ length: 30 }, (_, index) => `message ${index}`))
|
|
89
|
+
mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
|
|
90
|
+
tui.start()
|
|
91
|
+
tui.renderNow(true)
|
|
92
|
+
expect(tui.isFollowingOutput).toBe(true)
|
|
93
|
+
tui.scrollBy(-10)
|
|
94
|
+
tui.renderNow(true)
|
|
95
|
+
expect(tui.isFollowingOutput).toBe(false)
|
|
96
|
+
const rows = screenRows(tui)
|
|
97
|
+
expect(rows.slice(-3)).toEqual(['─'.repeat(40), '❯ prompt', '? for shortcuts'])
|
|
98
|
+
expect(rows.some(row => row === 'message 29')).toBe(false)
|
|
99
|
+
set(['message 0'])
|
|
100
|
+
tui.stop()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('shows the first rows of a long queue while the prompt and status stay whole', () => {
|
|
104
|
+
const tui = createTerminalScreen(fakeTerminal(40, 12), prefs('fullscreen'), hooks) as TuiAltScreen
|
|
105
|
+
const queued = Array.from({ length: 20 }, (_, index) => `queued ${index}`)
|
|
106
|
+
const dock = createDock({
|
|
107
|
+
title: rowOf(['title']), above: rowOf([]), queue: rowOf(queued), below: rowOf([]), footer: rowOf(['status']),
|
|
108
|
+
editor: rowOf(['─'.repeat(40), '❯ prompt', '─'.repeat(40), '? for shortcuts'])
|
|
109
|
+
})
|
|
110
|
+
const document = new Container()
|
|
111
|
+
document.addChild(rowOf(['Welcome']))
|
|
112
|
+
mountTerminalLayout(tui, { document, dock }, prefs('fullscreen'), hooks)
|
|
113
|
+
tui.start()
|
|
114
|
+
tui.renderNow(true)
|
|
115
|
+
const rows = screenRows(tui)
|
|
116
|
+
expect(rows).toHaveLength(12)
|
|
117
|
+
expect(rows.slice(-5)).toEqual(['─'.repeat(40), '❯ prompt', '─'.repeat(40), '? for shortcuts', 'status'])
|
|
118
|
+
expect(rows).toContain('queued 0')
|
|
119
|
+
expect(rows).not.toContain('queued 19')
|
|
120
|
+
tui.stop()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('flows the regular screen in order without a viewport', () => {
|
|
124
|
+
const tui = createTerminalScreen(fakeTerminal(), prefs('regular'), hooks)
|
|
125
|
+
expect(tui).toBeInstanceOf(TuiMainScreen)
|
|
126
|
+
const { document, dock } = panes(['Welcome'])
|
|
127
|
+
expect(mountTerminalLayout(tui, { document, dock }, prefs('regular'), hooks)).toBeUndefined()
|
|
128
|
+
expect(tui.children).toEqual([document, dock])
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('terminal scrollback on exit', () => {
|
|
133
|
+
it('truncates the conversation to the terminal width', () => {
|
|
134
|
+
const component: Component = { render: () => ['x'.repeat(30)], invalidate: () => {} }
|
|
135
|
+
expect(scrollbackLines(component, 10).map(line => stripTerminalSequences(line))).toEqual(['x'.repeat(10)])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('writes each line with its styles reset and CRLF endings', () => {
|
|
139
|
+
const terminal = fakeTerminal()
|
|
140
|
+
writeScrollback(terminal, ['first', 'second'])
|
|
141
|
+
expect(terminal.write).toHaveBeenCalledWith('first\x1b[0m\r\nsecond\x1b[0m\r\n')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Screen mode and layout. Pi's fullscreen settings still choose the mode; Harness supplies its own composition.
|
|
2
|
+
import { SettingsManager, copyToClipboard, type FullscreenExitOutput, type Theme, type TuiMode } from '@earendil-works/pi-coding-agent'
|
|
3
|
+
import { ScrollView, TuiAltScreen, TuiMainScreen, VStack, isViewportTUI, truncateToWidth, type Component, type Terminal, type TuiAltScreenOptions } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
export interface DockSlots {
|
|
6
|
+
readonly title: Component
|
|
7
|
+
readonly above: Component
|
|
8
|
+
readonly queue: Component
|
|
9
|
+
readonly editor: Component
|
|
10
|
+
readonly below: Component
|
|
11
|
+
readonly footer: Component
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type TerminalScreen = TuiMainScreen | TuiAltScreen
|
|
15
|
+
|
|
16
|
+
export interface ScreenPreferences {
|
|
17
|
+
readonly mode: TuiMode
|
|
18
|
+
readonly exitOutput: FullscreenExitOutput
|
|
19
|
+
readonly scrollbar: ReturnType<SettingsManager['getFullscreenScrollbar']>
|
|
20
|
+
readonly wheelScrollLines: ReturnType<SettingsManager['getFullscreenWheelScrollLines']>
|
|
21
|
+
readonly copyOnSelect: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ScreenHooks {
|
|
25
|
+
theme(): Theme
|
|
26
|
+
latestKey(): string | undefined
|
|
27
|
+
openUrl(url: string): void
|
|
28
|
+
pasteClipboard(): void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TerminalPanes {
|
|
32
|
+
readonly document: Component
|
|
33
|
+
readonly dock: Component
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readScreenPreferences(agentDir: string | undefined): ScreenPreferences {
|
|
37
|
+
// Without an account folder there is no settings file to read, so Pi's defaults apply.
|
|
38
|
+
const settings = agentDir ? SettingsManager.create(process.cwd(), agentDir) : SettingsManager.inMemory()
|
|
39
|
+
return {
|
|
40
|
+
mode: settings.getTuiMode(),
|
|
41
|
+
exitOutput: settings.getFullscreenExitOutput(),
|
|
42
|
+
scrollbar: settings.getFullscreenScrollbar(),
|
|
43
|
+
wheelScrollLines: settings.getFullscreenWheelScrollLines(),
|
|
44
|
+
copyOnSelect: settings.getFullscreenCopyOnSelect()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createTerminalScreen(terminal: Terminal, preferences: ScreenPreferences, hooks: ScreenHooks): TerminalScreen {
|
|
49
|
+
if (preferences.mode === 'regular') return new TuiMainScreen(terminal)
|
|
50
|
+
const { theme } = hooks
|
|
51
|
+
const options: TuiAltScreenOptions = {
|
|
52
|
+
wheelScrollLines: preferences.wheelScrollLines,
|
|
53
|
+
copyOnSelect: preferences.copyOnSelect,
|
|
54
|
+
searchMatchStyle: text => theme().underline(theme().bg('searchMatchBg', theme().fg('searchMatchText', text))),
|
|
55
|
+
searchCurrentMatchStyle: text => theme().bold(theme().inverse(theme().bg('searchMatchBg', theme().fg('searchMatchText', text)))),
|
|
56
|
+
searchNavigationButtonStyle: (text, hovered) => hovered ? theme().underline(text) : text,
|
|
57
|
+
scrollToEndIndicator: () => {
|
|
58
|
+
const key = hooks.latestKey()
|
|
59
|
+
return theme().bg('selectedBg', theme().fg('text', ` ↓ Jump to latest message${key ? ` · ${keyLabel(key)}` : ''} `))
|
|
60
|
+
},
|
|
61
|
+
openUrl: hooks.openUrl,
|
|
62
|
+
onRightClickPaste: hooks.pasteClipboard,
|
|
63
|
+
copySelection: copySelectionText
|
|
64
|
+
}
|
|
65
|
+
return new TuiAltScreen(terminal, undefined, undefined, options)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The prompt and status keep their full height; when the screen is short, queued messages and widgets show their first rows and scroll for the rest. */
|
|
69
|
+
export function createDock(slots: DockSlots): VStack {
|
|
70
|
+
const region = (component: Component) => new ScrollView(component)
|
|
71
|
+
return new VStack([
|
|
72
|
+
{ component: slots.title, shrink: 0 },
|
|
73
|
+
{ component: region(slots.above), shrink: 1, minSize: 0 },
|
|
74
|
+
{ component: region(slots.queue), shrink: 1, minSize: 0 },
|
|
75
|
+
{ component: slots.editor, shrink: 0 },
|
|
76
|
+
{ component: region(slots.below), shrink: 1, minSize: 0 },
|
|
77
|
+
{ component: slots.footer, shrink: 0 }
|
|
78
|
+
])
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Fullscreen keeps the dock on the last rows and scrolls only the transcript above it; regular mode flows in order. */
|
|
82
|
+
export function mountTerminalLayout(tui: TerminalScreen, panes: TerminalPanes, preferences: ScreenPreferences, hooks: ScreenHooks): ScrollView | undefined {
|
|
83
|
+
if (!isViewportTUI(tui)) {
|
|
84
|
+
tui.addChild(panes.document)
|
|
85
|
+
tui.addChild(panes.dock)
|
|
86
|
+
return undefined
|
|
87
|
+
}
|
|
88
|
+
const transcript = new ScrollView(panes.document, {
|
|
89
|
+
follow: 'end', primary: true, overscroll: 'chain', scrollbar: preferences.scrollbar,
|
|
90
|
+
scrollbarTrackStyle: text => hooks.theme().fg('scrollbarTrack', text),
|
|
91
|
+
scrollbarThumbStyle: text => hooks.theme().fg('scrollbarThumb', text)
|
|
92
|
+
})
|
|
93
|
+
tui.setLayoutRoot(new VStack([
|
|
94
|
+
{ component: transcript, basis: 0, grow: 1, shrink: 1, minSize: 1 },
|
|
95
|
+
{ component: panes.dock, basis: 'auto', grow: 0, shrink: 1, minSize: 1 }
|
|
96
|
+
]))
|
|
97
|
+
return transcript
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Rendered before the alternate screen is left, because the components are disposed afterwards. */
|
|
101
|
+
export function scrollbackLines(component: Component, width: number): string[] {
|
|
102
|
+
return component.render(width).map(line => truncateToWidth(line, width, ''))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Leaving fullscreen discards the alternate screen, so the conversation is copied into normal scrollback. */
|
|
106
|
+
export function writeScrollback(terminal: Terminal, lines: string[]): void {
|
|
107
|
+
terminal.write(lines.map(line => `${line}\x1b[0m`).join('\r\n') + '\r\n')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function keyLabel(key: string): string {
|
|
111
|
+
return key.replace(/(^|\+)([a-z])/g, (_match, separator: string, letter: string) => separator + letter.toUpperCase())
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function copySelectionText(text: string): Promise<boolean | string> {
|
|
115
|
+
try {
|
|
116
|
+
await copyToClipboard(text)
|
|
117
|
+
return true
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return error instanceof Error ? error.message : String(error)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { AutocompleteProvider } from '@earendil-works/pi-tui'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { expandSkillInvocation, skillCompletions, type SkillEntry } from './skillInvocation'
|
|
5
|
+
|
|
6
|
+
const skills: SkillEntry[] = [
|
|
7
|
+
{ name: 'review', description: 'Review a change' },
|
|
8
|
+
{ name: 'refactor', description: 'Refactor a module' },
|
|
9
|
+
{ name: 'deploy', description: 'Ship a build' }
|
|
10
|
+
]
|
|
11
|
+
const names = new Set(skills.map((skill) => skill.name))
|
|
12
|
+
const signal = new AbortController().signal
|
|
13
|
+
|
|
14
|
+
const base: AutocompleteProvider = {
|
|
15
|
+
getSuggestions: async () => ({ items: [{ value: 'accounts', label: 'accounts' }], prefix: '/a' }),
|
|
16
|
+
applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('invoking a skill with $name', () => {
|
|
20
|
+
it('rewrites a leading $name of a loaded skill into the form Pi expands', () => {
|
|
21
|
+
expect(expandSkillInvocation('$review the module', names)).toBe('/skill:review the module')
|
|
22
|
+
expect(expandSkillInvocation(' $deploy', names)).toBe('/skill:deploy')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('leaves text alone when the name is not a loaded skill, or the $ is not a skill', () => {
|
|
26
|
+
expect(expandSkillInvocation('$unknown the module', names)).toBe('$unknown the module')
|
|
27
|
+
expect(expandSkillInvocation('It costs $5 today', names)).toBe('It costs $5 today')
|
|
28
|
+
expect(expandSkillInvocation('$5 budget', names)).toBe('$5 budget')
|
|
29
|
+
expect(expandSkillInvocation('/review module', names)).toBe('/review module')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('the $ completions', () => {
|
|
34
|
+
it('offers the skills that start with what has been typed after a word-start $', async () => {
|
|
35
|
+
const provider = skillCompletions(base, () => skills)
|
|
36
|
+
const suggestions = await provider.getSuggestions(['look at $rev'], 0, 12, { signal })
|
|
37
|
+
expect(suggestions).toEqual({
|
|
38
|
+
items: [{ value: 'review', label: '$review', description: 'Review a change' }],
|
|
39
|
+
prefix: '$rev'
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('offers every skill at a bare $, and nothing when no skill matches', async () => {
|
|
44
|
+
const provider = skillCompletions(base, () => skills)
|
|
45
|
+
expect((await provider.getSuggestions(['$'], 0, 1, { signal }))?.items.map((item) => item.value)).toEqual(['review', 'refactor', 'deploy'])
|
|
46
|
+
expect(await provider.getSuggestions(['cost $5'], 0, 7, { signal })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('gives a line that starts with a skill invocation no file completions in its arguments', async () => {
|
|
50
|
+
const files: AutocompleteProvider = { getSuggestions: async () => ({ items: [{ value: 'src/', label: 'src/' }], prefix: 'sr' }), applyCompletion: (lines) => ({ lines, cursorLine: 0, cursorCol: 0 }) }
|
|
51
|
+
const provider = skillCompletions(files, () => skills)
|
|
52
|
+
expect(await provider.getSuggestions(['$review '], 0, 8, { signal })).toBeNull()
|
|
53
|
+
expect(await provider.getSuggestions(['$review check the sr'], 0, 18, { signal })).toBeNull()
|
|
54
|
+
expect((await provider.getSuggestions(['check the sr'], 0, 12, { signal }))?.items).toEqual([{ value: 'src/', label: 'src/' }])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('hands every other completion to the base provider', async () => {
|
|
58
|
+
const provider = skillCompletions(base, () => skills)
|
|
59
|
+
expect(await provider.getSuggestions(['/a'], 0, 2, { signal })).toEqual({ items: [{ value: 'accounts', label: 'accounts' }], prefix: '/a' })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('inserts the chosen skill as $name and a space in place of the typed prefix', () => {
|
|
63
|
+
const provider = skillCompletions(base, () => skills)
|
|
64
|
+
const completed = provider.applyCompletion(['look at $re and more'], 0, 11, { value: 'review', label: '$review' }, '$re')
|
|
65
|
+
expect(completed).toEqual({ lines: ['look at $review and more'], cursorLine: 0, cursorCol: 16 })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('opens the menu when a $ is typed', () => {
|
|
69
|
+
expect(skillCompletions(base, () => skills).triggerCharacters).toContain('$')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// A skill is invoked as `$name` in the terminal. Pi expands only `/skill:name`, so the terminal
|
|
2
|
+
// rewrites the leading `$name` before the prompt is sent, and offers the loaded skills at a `$`.
|
|
3
|
+
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from '@earendil-works/pi-tui'
|
|
4
|
+
|
|
5
|
+
export interface SkillEntry {
|
|
6
|
+
name: string
|
|
7
|
+
description: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Rewrites a leading `$name` of a loaded skill into `/skill:name`. Anything else passes through, so `$5` stays text. */
|
|
11
|
+
export function expandSkillInvocation(text: string, skills: ReadonlySet<string>): string {
|
|
12
|
+
const trimmed = text.trimStart()
|
|
13
|
+
const match = /^\$(\S+)(?=\s|$)/.exec(trimmed)
|
|
14
|
+
if (!match || !skills.has(match[1]!)) return text
|
|
15
|
+
return `/skill:${match[1]}${trimmed.slice(match[0].length)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A `$` that starts a word, and the partial name after it, at the cursor. */
|
|
19
|
+
const SKILL_TOKEN = /(?:^|\s)\$([^\s$]*)$/
|
|
20
|
+
/** A line whose first word is a skill invocation. */
|
|
21
|
+
const SKILL_LINE = /^\s*\$\S/
|
|
22
|
+
|
|
23
|
+
/** Offers the loaded skills at a `$` that starts a word. Every other completion is `base`'s. */
|
|
24
|
+
export function skillCompletions(base: AutocompleteProvider, skills: () => readonly SkillEntry[]): AutocompleteProvider {
|
|
25
|
+
return {
|
|
26
|
+
triggerCharacters: [...(base.triggerCharacters ?? []), '$'],
|
|
27
|
+
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
|
|
28
|
+
const line = lines[cursorLine] ?? ''
|
|
29
|
+
const token = SKILL_TOKEN.exec(line.slice(0, cursorCol))
|
|
30
|
+
if (token) {
|
|
31
|
+
const partial = token[1]!
|
|
32
|
+
const items: AutocompleteItem[] = skills()
|
|
33
|
+
.filter((skill) => skill.name.startsWith(partial))
|
|
34
|
+
.map((skill) => ({ value: skill.name, label: `$${skill.name}`, description: skill.description }))
|
|
35
|
+
return items.length ? { items, prefix: `$${partial}` } : null
|
|
36
|
+
}
|
|
37
|
+
// The arguments of a line that starts with a skill invocation are free text, not file paths.
|
|
38
|
+
if (SKILL_LINE.test(line)) return null
|
|
39
|
+
return base.getSuggestions(lines, cursorLine, cursorCol, options)
|
|
40
|
+
},
|
|
41
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
42
|
+
if (!prefix.startsWith('$')) return base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
|
43
|
+
const line = lines[cursorLine] ?? ''
|
|
44
|
+
const start = cursorCol - prefix.length
|
|
45
|
+
const inserted = `$${item.value} `
|
|
46
|
+
const newLines = [...lines]
|
|
47
|
+
newLines[cursorLine] = `${line.slice(0, start)}${inserted}${line.slice(cursorCol)}`
|
|
48
|
+
return { lines: newLines, cursorLine, cursorCol: start + inserted.length }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -2,69 +2,47 @@ import { initTheme, Theme } from '@earendil-works/pi-coding-agent'
|
|
|
2
2
|
import { stripTerminalSequences, visibleWidth } from '@earendil-works/pi-tui'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { displayProjectName, renderStatusBand, type BandState } from './status'
|
|
6
6
|
import { createHarnessTheme } from './theme'
|
|
7
7
|
|
|
8
8
|
initTheme('dark', false)
|
|
9
9
|
const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
|
|
10
10
|
const state: BandState = {
|
|
11
11
|
cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
|
|
12
|
-
thinkingLevel: 'high', branch: 'main',
|
|
13
|
-
|
|
12
|
+
thinkingLevel: 'high', branch: 'main', account: 'Tanny', inputTokens: 1300, outputTokens: 50,
|
|
13
|
+
tokensPerSecond: 30
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
describe('
|
|
17
|
-
it('renders the
|
|
16
|
+
describe('composer status band', () => {
|
|
17
|
+
it('renders the four text groups in order with distinct colors', () => {
|
|
18
18
|
const line = renderStatusBand(state, 120, theme)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
expect(visibleWidth(line)).toBe(
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
expect(stripTerminalSequences(line)).toBe('project | main | Tanny · Sonnet 4.6 · high | 30 tok/s in 1,300 out 50')
|
|
20
|
+
for (const color of ['38;2;232;236;244', '38;2;0;255;136', '38;2;0;180;255', '38;2;212;192;144']) {
|
|
21
|
+
expect(line).toContain(color)
|
|
22
|
+
}
|
|
23
|
+
expect(visibleWidth(line)).toBe(stripTerminalSequences(line).length)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('uses the last path component and a readable root fallback', () => {
|
|
27
|
+
expect(displayProjectName('/work/a/long-project/')).toBe('long-project')
|
|
28
|
+
expect(displayProjectName('/')).toBe('/')
|
|
26
29
|
})
|
|
27
30
|
|
|
28
|
-
it('
|
|
31
|
+
it('shows text fallbacks when branch, account, model, and rate are unavailable', () => {
|
|
32
|
+
const line = renderStatusBand({ ...state, branch: null, account: undefined, model: undefined, tokensPerSecond: null }, 120, theme)
|
|
33
|
+
expect(stripTerminalSequences(line)).toBe('project | — | account unknown · no-model · high | — tok/s in 1,300 out 50')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('stays within narrow terminal widths and sanitizes metadata', () => {
|
|
29
37
|
for (let width = 1; width <= 180; width++) {
|
|
30
38
|
const line = renderStatusBand({ ...state,
|
|
31
|
-
cwd: '/work/日本語/long-project-name
|
|
39
|
+
cwd: '/work/日本語/long-project-name',
|
|
32
40
|
branch: '\x1b]2;malicious title\x07main\nother',
|
|
33
41
|
model: { ...state.model!, name: '非常に長い名前'.repeat(20) }
|
|
34
42
|
}, width, theme)
|
|
35
43
|
expect(visibleWidth(line), `width ${width}`).toBeLessThanOrEqual(width)
|
|
36
|
-
expect(line).not.toContain('malicious title')
|
|
37
|
-
expect(line).not.toContain('\n')
|
|
38
|
-
}
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('never invents model, quota, usage, or git data while startup context is unknown', () => {
|
|
42
|
-
const plain = stripTerminalSequences(renderStatusBand({ ...state,
|
|
43
|
-
model: undefined, branch: null, contextWindow: 0, contextPercent: null, costUsd: 0
|
|
44
|
-
}, 80, theme))
|
|
45
|
-
expect(plain).toContain('⬢ no-model')
|
|
46
|
-
expect(plain).not.toMatch(/0%|200K|main|\$|quota|plan|collab/i)
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('clamps gauge values and omits an unknown percentage while retaining the window', () => {
|
|
50
|
-
for (const percent of [null, -20, 0, 50, 100, 120, NaN]) {
|
|
51
|
-
for (const width of [0, 1, 5, 16, 60]) {
|
|
52
|
-
const gauge = contextGauge(width, percent, 200_000, theme)
|
|
53
|
-
expect(visibleWidth(gauge)).toBe(width)
|
|
54
|
-
expect(stripTerminalSequences(gauge)).not.toContain('NaN')
|
|
55
|
-
}
|
|
44
|
+
expect(stripTerminalSequences(line)).not.toContain('malicious title')
|
|
45
|
+
expect(stripTerminalSequences(line)).not.toContain('\n')
|
|
56
46
|
}
|
|
57
|
-
expect(stripTerminalSequences(contextGauge(20, null, 200_000, theme))).not.toContain('%')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('keeps sibling directories outside home and shows existing subscription and overflow states', () => {
|
|
61
|
-
expect(displayPath('/home/test/project', '/home/test')).toBe('~/project')
|
|
62
|
-
expect(displayPath('/home/test-other/project', '/home/test')).toBe('/home/test-other/project')
|
|
63
|
-
const line = stripTerminalSequences(renderStatusBand({ ...state,
|
|
64
|
-
subscription: true, costUsd: 0, running: true, elapsedMs: 65_000, hiddenAbove: 4
|
|
65
|
-
}, 160, theme))
|
|
66
|
-
expect(line).toContain('1m 5s')
|
|
67
|
-
expect(line).toContain('$0.00 (sub)')
|
|
68
|
-
expect(line).toContain('↑ 4 more')
|
|
69
47
|
})
|
|
70
48
|
})
|