@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.
Files changed (135) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +36 -13
  3. package/dist/cli.js +10728 -8033
  4. package/dist/terminal-pi.js +365 -24
  5. package/docs/README.md +3 -0
  6. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  7. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
  8. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  9. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  10. package/docs/terminal-tui.md +109 -13
  11. package/package.json +5 -4
  12. package/src/accounts/accounts.test.ts +87 -1
  13. package/src/accounts/accounts.ts +120 -18
  14. package/src/core/bus.test.ts +22 -0
  15. package/src/core/bus.ts +17 -2
  16. package/src/harness.ts +12 -2
  17. package/src/hub/hub.test.ts +49 -1
  18. package/src/hub/hub.ts +122 -6
  19. package/src/hub/schema.ts +13 -1
  20. package/src/index.ts +7 -2
  21. package/src/mcp/connections.ts +39 -12
  22. package/src/mcp/service.test.ts +49 -3
  23. package/src/mcp/service.ts +14 -3
  24. package/src/mcp/types.ts +10 -0
  25. package/src/memory/curation.test.ts +44 -1
  26. package/src/memory/dispatch.ts +5 -0
  27. package/src/memory/maintenance.test.ts +17 -1
  28. package/src/memory/maintenance.ts +169 -8
  29. package/src/memory/recall.ts +2 -1
  30. package/src/memory/trajectory.test.ts +178 -0
  31. package/src/memory/trajectory.ts +220 -0
  32. package/src/memory/wiki.ts +2 -1
  33. package/src/memory/wikiIndex.ts +3 -2
  34. package/src/plugins/builtin.ts +4 -2
  35. package/src/plugins/catalogue.test.ts +1 -1
  36. package/src/server/methods.ts +8 -0
  37. package/src/server/server.test.ts +20 -1
  38. package/src/sessions/approvals.test.ts +49 -0
  39. package/src/sessions/approvals.ts +26 -4
  40. package/src/sessions/conversation.test.ts +56 -0
  41. package/src/sessions/lineageJoin.ts +1 -1
  42. package/src/sessions/planMode.test.ts +40 -0
  43. package/src/sessions/planMode.ts +57 -0
  44. package/src/sessions/registry.ts +38 -7
  45. package/src/sessions/runStore.ts +14 -0
  46. package/src/sessions/runner.ts +9 -3
  47. package/src/sessions/sessions.test.ts +69 -7
  48. package/src/sessions/sessions.ts +47 -39
  49. package/src/sessions/subagentService.test.ts +159 -0
  50. package/src/sessions/subagentService.ts +236 -0
  51. package/src/sessions/subagents.ts +97 -0
  52. package/src/terminal/controller.ts +73 -10
  53. package/src/terminal/piExtension.ts +42 -6
  54. package/src/terminal/socket.test.ts +23 -0
  55. package/src/terminal/socket.ts +42 -18
  56. package/src/terminal/standalone.ts +12 -0
  57. package/src/terminal/terminal.test.ts +150 -15
  58. package/src/terminal/terminals.ts +113 -5
  59. package/src/terminal/trajectory.test.ts +66 -0
  60. package/src/terminal/trajectory.ts +44 -0
  61. package/src/terminal/tui/application.test.ts +206 -9
  62. package/src/terminal/tui/application.ts +251 -81
  63. package/src/terminal/tui/commands.test.ts +70 -9
  64. package/src/terminal/tui/commands.ts +99 -7
  65. package/src/terminal/tui/editor.test.ts +56 -27
  66. package/src/terminal/tui/editor.ts +64 -19
  67. package/src/terminal/tui/keybindings.test.ts +7 -0
  68. package/src/terminal/tui/keybindings.ts +9 -1
  69. package/src/terminal/tui/planMode.test.ts +10 -0
  70. package/src/terminal/tui/planMode.ts +2 -0
  71. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  72. package/src/terminal/tui/runtime.test.ts +10 -0
  73. package/src/terminal/tui/runtime.ts +24 -9
  74. package/src/terminal/tui/screen.test.ts +143 -0
  75. package/src/terminal/tui/screen.ts +121 -0
  76. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  77. package/src/terminal/tui/skillInvocation.ts +51 -0
  78. package/src/terminal/tui/status.test.ts +24 -46
  79. package/src/terminal/tui/status.ts +42 -69
  80. package/src/terminal/tui/theme.ts +18 -7
  81. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  82. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  83. package/src/terminal/tui/transcript.test.ts +14 -0
  84. package/src/terminal/tui/transcript.ts +11 -0
  85. package/src/terminal/tui/ui.ts +2 -2
  86. package/src/terminal/tui/welcome.ts +4 -4
  87. package/src/terminal/tui/workingStatus.test.ts +44 -0
  88. package/src/terminal/tui/workingStatus.ts +238 -0
  89. package/src/tools/definitions.ts +42 -1
  90. package/src/tools/maintenance.ts +6 -1
  91. package/src/tools/spawnSubagent.ts +40 -85
  92. package/src/tools/types.ts +4 -0
  93. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  95. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  100. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  108. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  116. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  118. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  119. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  120. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  121. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  122. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  124. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  125. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  127. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  128. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  129. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  130. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  131. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  132. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  133. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  134. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  135. 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
- if (prepared.session) {
161
- const path = resolve(cwd, prepared.session)
162
- assertTerminalHistoryPath(path, sessionDirectory)
163
- assertTerminalSessionScope(path, cwd)
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: prepared.session ? SessionManager.open(resolve(cwd, prepared.session), sessionDirectory) : SessionManager.create(cwd, sessionDirectory),
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
+ }