@xingular/harness 0.1.7 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +8 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
@@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
7
7
|
import { createHarness, type Harness } from '../../harness'
|
|
8
8
|
import { memorySecretStore } from '../../accounts/secrets'
|
|
9
9
|
import { connectionFromEnv, socketCall, terminalCall } from '../client'
|
|
10
|
+
import type { TerminalPrepareInput } from '../terminals'
|
|
10
11
|
import { controlCall } from '../control'
|
|
12
|
+
import { readAllRuns } from '../../sessions/runIndex'
|
|
11
13
|
import { createTerminalRuntime, type TerminalRuntime } from './runtime'
|
|
12
14
|
|
|
13
15
|
const opened: Array<{ harness: Harness; runtime?: TerminalRuntime; root: string }> = []
|
|
@@ -21,7 +23,7 @@ afterEach(async () => {
|
|
|
21
23
|
vi.unstubAllEnvs()
|
|
22
24
|
})
|
|
23
25
|
|
|
24
|
-
async function setup() {
|
|
26
|
+
async function setup(input: Partial<TerminalPrepareInput> = {}) {
|
|
25
27
|
const root = mkdtempSync(join(tmpdir(), 'harness-sdk-socket-'))
|
|
26
28
|
const cwd = join(root, 'project')
|
|
27
29
|
mkdirSync(cwd)
|
|
@@ -30,7 +32,7 @@ async function setup() {
|
|
|
30
32
|
opened.push(entry)
|
|
31
33
|
await harness.ready
|
|
32
34
|
const account = harness.accounts.create('Offline test', 'apiKey').accounts[0]
|
|
33
|
-
const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history') })
|
|
35
|
+
const launch = await harness.terminals.prepare({ sessionId: 'offline-sdk', cwd, accountId: account.id, sessionFolder: join(root, 'history'), ...input })
|
|
34
36
|
for (const [name, value] of Object.entries(launch.env)) vi.stubEnv(name, value === null ? undefined : value)
|
|
35
37
|
vi.spyOn(process, 'cwd').mockReturnValue(cwd)
|
|
36
38
|
const ui = { select: vi.fn(async () => undefined), confirm: vi.fn(async () => false), notify: vi.fn(), getEditorText: () => '' } as unknown as ExtensionUIContext
|
|
@@ -40,6 +42,78 @@ async function setup() {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
describe('a native SDK runtime on the actual Harness terminal socket', () => {
|
|
45
|
+
it('reads and changes shared terminal permissions only through the claimed private control key', async () => {
|
|
46
|
+
const { runtime } = await setup()
|
|
47
|
+
await expect(socketCall(connectionFromEnv(), 'terminal.permissions.get')).rejects.toThrow('Only a person')
|
|
48
|
+
expect(await runtime.permissions()).toEqual({ full: false, globalTools: [] })
|
|
49
|
+
await runtime.setGlobalTool('bash', true)
|
|
50
|
+
await runtime.setFullPermission(true)
|
|
51
|
+
expect(await runtime.permissions()).toEqual({ full: true, globalTools: ['bash'] })
|
|
52
|
+
await runtime.setGlobalTool('bash', false)
|
|
53
|
+
await runtime.setFullPermission(false)
|
|
54
|
+
expect(await runtime.permissions()).toEqual({ full: false, globalTools: [] })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('runs asynchronous children through the protected bridge and joins their results before settling', async () => {
|
|
58
|
+
const { harness, runtime } = await setup()
|
|
59
|
+
const ai = await import('@earendil-works/pi-ai')
|
|
60
|
+
const faux = ai.fauxProvider()
|
|
61
|
+
runtime.services.modelRuntime.registerNativeProvider(faux.provider)
|
|
62
|
+
harness.accounts.runtime = async () => runtime.services.modelRuntime
|
|
63
|
+
await runtime.session.setModel(faux.getModel())
|
|
64
|
+
const route = (context: { messages: Array<{ role: string; content: unknown }> }) => {
|
|
65
|
+
const users = context.messages.filter(message => message.role === 'user').map(message => typeof message.content === 'string' ? message.content : (message.content as Array<{ text?: string }>).map(part => part.text ?? '').join(''))
|
|
66
|
+
if (users.includes('terminal-native-child')) return ai.fauxAssistantMessage('TERMINAL_CHILD_EVIDENCE')
|
|
67
|
+
if (JSON.stringify(context.messages).includes('TERMINAL_CHILD_EVIDENCE')) return ai.fauxAssistantMessage('Final with TERMINAL_CHILD_EVIDENCE')
|
|
68
|
+
if (!context.messages.some(message => message.role === 'toolResult')) return ai.fauxAssistantMessage([ai.fauxToolCall('spawn_subagent', { brief: 'terminal-native-child' })], { stopReason: 'toolUse' })
|
|
69
|
+
return ai.fauxAssistantMessage('Draft before joining')
|
|
70
|
+
}
|
|
71
|
+
faux.setResponses(Array(8).fill(route) as never)
|
|
72
|
+
await expect(socketCall(connectionFromEnv(), 'terminal.subagents.tools')).rejects.toThrow('Only a person')
|
|
73
|
+
await runtime.session.prompt('Terminal parent')
|
|
74
|
+
const runs = readAllRuns(harness.env.root)
|
|
75
|
+
const parent = runs.find(run => run.sessionId === 'offline-sdk')!
|
|
76
|
+
const child = runs.find(run => run.relation === 'subagent')!
|
|
77
|
+
expect(parent.status).toBe('completed')
|
|
78
|
+
expect(child).toMatchObject({ parentRunId: parent.id, accountId: parent.accountId, status: 'completed' })
|
|
79
|
+
expect(harness.runs.activeRunIds()).toEqual([])
|
|
80
|
+
const entries = readFileSync(runtime.session.sessionFile!, 'utf8').trim().split('\n').map(line => JSON.parse(line))
|
|
81
|
+
const receipts = entries.filter(entry => entry.customType === 'xingular-subagent-results')
|
|
82
|
+
expect(receipts).toHaveLength(1)
|
|
83
|
+
expect(receipts[0].content).toContain('TERMINAL_CHILD_EVIDENCE')
|
|
84
|
+
expect(harness.terminals.state('offline-sdk').active).toBe(false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('routes a child approval to its original run through the terminal decision controls', async () => {
|
|
88
|
+
const { harness, runtime } = await setup()
|
|
89
|
+
await terminalCall('begin', { text: 'Parent fixture' })
|
|
90
|
+
await controlCall('terminal.subagents.register', { tools: ['read', 'bash', 'spawn_subagent', 'wait_subagents'], history: { owners: [], receipts: [] } })
|
|
91
|
+
const parentId = harness.runs.activeForSession('offline-sdk')!.runId
|
|
92
|
+
const { loadRun, saveRun } = await import('../../sessions/runStore')
|
|
93
|
+
const parent = loadRun(harness.env.root, parentId)
|
|
94
|
+
saveRun(harness.env.root, { ...parent, id: 'approval-child', sessionId: 'opaque-child', parentRunId: parentId, relation: 'subagent', status: 'queued', endedAt: null })
|
|
95
|
+
const active = harness.runs.start('opaque-child', 'approval-child')
|
|
96
|
+
const answer = active.requestApproval({ target: 'bash', action: 'fixture', rememberable: false })
|
|
97
|
+
let pending: Awaited<ReturnType<typeof runtime.pending>> = []
|
|
98
|
+
await vi.waitFor(async () => { pending = await runtime.pending(); expect(pending).toHaveLength(1) })
|
|
99
|
+
await runtime.approve(pending[0].requestId, 'denied')
|
|
100
|
+
await expect(answer).resolves.toBe('denied')
|
|
101
|
+
expect(await runtime.pending()).toEqual([])
|
|
102
|
+
active.finish('interrupted')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('offers native tools without connected apps and preserves a read-only child ceiling', async () => {
|
|
106
|
+
const { harness, runtime } = await setup({ connections: false, readOnly: true })
|
|
107
|
+
expect(runtime.session.getToolDefinition('spawn_subagent')).toBeDefined()
|
|
108
|
+
await terminalCall('begin', { text: 'Read-only parent' })
|
|
109
|
+
await controlCall('terminal.subagents.register', { tools: ['read', 'write', 'bash', 'spawn_subagent', 'wait_subagents'], history: { owners: [], receipts: [] } })
|
|
110
|
+
const parentRunId = harness.runs.activeForSession('offline-sdk')!.runId
|
|
111
|
+
await expect(harness.subagents.spawn({ parentRunId, brief: 'Cannot widen', tools: ['write'] })).rejects.toThrow('cannot have tools')
|
|
112
|
+
const { loadRun } = await import('../../capabilities/runSnapshot')
|
|
113
|
+
expect(loadRun(harness.env.root, parentRunId)?.tools).toEqual(['read', 'spawn_subagent', 'wait_subagents'])
|
|
114
|
+
await runtime.interrupt()
|
|
115
|
+
})
|
|
116
|
+
|
|
43
117
|
it('keeps the same account, command control and readiness after new/reload with no provider request', async () => {
|
|
44
118
|
const { harness, runtime, ui } = await setup()
|
|
45
119
|
expect(harness.terminals.state('offline-sdk')).toEqual({ ready: true, dirty: false, active: false })
|
|
@@ -187,6 +187,16 @@ describe('the persistent terminal SDK adapter', () => {
|
|
|
187
187
|
expect(runtime.session.sessionFile).toBe(join(realpathSync(history), 'external.jsonl'))
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
+
it('starts a new conversation when the recorded history file was never written', async () => {
|
|
191
|
+
// Pi writes a session file only after its first message, so a restart before that has nothing to resume.
|
|
192
|
+
const f = fixture()
|
|
193
|
+
const unwritten = join(f.history, 'not-written-yet.jsonl')
|
|
194
|
+
const runtime = await createTerminalRuntime([...f.args, '--session', unwritten], f.host)
|
|
195
|
+
runtimes.push(runtime)
|
|
196
|
+
expect(runtime.session.sessionFile?.startsWith(realpathSync(f.history))).toBe(true)
|
|
197
|
+
expect(runtime.session.sessionFile).not.toBe(unwritten)
|
|
198
|
+
})
|
|
199
|
+
|
|
190
200
|
it('requires the prepared isolated account and refuses malformed session headers', async () => {
|
|
191
201
|
const f = fixture()
|
|
192
202
|
vi.stubEnv('PI_CODING_AGENT_DIR', '')
|
|
@@ -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
|
+
}
|