@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +29 -10
  3. package/dist/cli.js +5179 -2976
  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 +68 -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 +100 -10
  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/index.ts +7 -2
  18. package/src/mcp/connections.ts +39 -12
  19. package/src/mcp/service.test.ts +49 -3
  20. package/src/mcp/service.ts +14 -3
  21. package/src/mcp/types.ts +10 -0
  22. package/src/memory/curation.test.ts +44 -1
  23. package/src/memory/dispatch.ts +5 -0
  24. package/src/memory/maintenance.test.ts +17 -1
  25. package/src/memory/maintenance.ts +169 -8
  26. package/src/memory/recall.ts +2 -1
  27. package/src/memory/trajectory.test.ts +178 -0
  28. package/src/memory/trajectory.ts +220 -0
  29. package/src/memory/wiki.ts +2 -1
  30. package/src/memory/wikiIndex.ts +3 -2
  31. package/src/plugins/builtin.ts +4 -2
  32. package/src/plugins/catalogue.test.ts +1 -1
  33. package/src/server/methods.ts +6 -0
  34. package/src/server/server.test.ts +20 -1
  35. package/src/sessions/approvals.test.ts +49 -0
  36. package/src/sessions/approvals.ts +26 -4
  37. package/src/sessions/conversation.test.ts +56 -0
  38. package/src/sessions/lineageJoin.ts +1 -1
  39. package/src/sessions/registry.ts +38 -7
  40. package/src/sessions/runStore.ts +14 -0
  41. package/src/sessions/runner.ts +9 -3
  42. package/src/sessions/sessions.test.ts +46 -7
  43. package/src/sessions/sessions.ts +31 -39
  44. package/src/sessions/subagentService.test.ts +159 -0
  45. package/src/sessions/subagentService.ts +236 -0
  46. package/src/sessions/subagents.ts +97 -0
  47. package/src/terminal/controller.ts +73 -10
  48. package/src/terminal/piExtension.ts +42 -6
  49. package/src/terminal/socket.test.ts +23 -0
  50. package/src/terminal/socket.ts +42 -18
  51. package/src/terminal/standalone.ts +12 -0
  52. package/src/terminal/terminal.test.ts +150 -15
  53. package/src/terminal/terminals.ts +113 -5
  54. package/src/terminal/trajectory.test.ts +66 -0
  55. package/src/terminal/trajectory.ts +44 -0
  56. package/src/terminal/tui/application.test.ts +184 -8
  57. package/src/terminal/tui/application.ts +243 -81
  58. package/src/terminal/tui/commands.test.ts +69 -9
  59. package/src/terminal/tui/commands.ts +89 -7
  60. package/src/terminal/tui/editor.test.ts +56 -27
  61. package/src/terminal/tui/editor.ts +49 -19
  62. package/src/terminal/tui/keybindings.test.ts +8 -0
  63. package/src/terminal/tui/keybindings.ts +10 -1
  64. package/src/terminal/tui/planMode.test.ts +10 -0
  65. package/src/terminal/tui/planMode.ts +12 -0
  66. package/src/terminal/tui/runtime.socket.test.ts +77 -3
  67. package/src/terminal/tui/runtime.test.ts +10 -0
  68. package/src/terminal/tui/runtime.ts +24 -9
  69. package/src/terminal/tui/screen.test.ts +143 -0
  70. package/src/terminal/tui/screen.ts +121 -0
  71. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  72. package/src/terminal/tui/skillInvocation.ts +51 -0
  73. package/src/terminal/tui/status.test.ts +24 -46
  74. package/src/terminal/tui/status.ts +42 -69
  75. package/src/terminal/tui/theme.ts +18 -7
  76. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  77. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  78. package/src/terminal/tui/transcript.test.ts +14 -0
  79. package/src/terminal/tui/transcript.ts +11 -0
  80. package/src/terminal/tui/ui.ts +2 -2
  81. package/src/terminal/tui/welcome.ts +4 -4
  82. package/src/terminal/tui/workingStatus.test.ts +44 -0
  83. package/src/terminal/tui/workingStatus.ts +238 -0
  84. package/src/tools/definitions.ts +42 -1
  85. package/src/tools/maintenance.ts +6 -1
  86. package/src/tools/spawnSubagent.ts +40 -85
  87. package/src/tools/types.ts +2 -0
  88. package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
  89. package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
  90. package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
  91. package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
  92. package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
  93. package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
  94. package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
  95. package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
  96. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
  97. package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
  98. package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
  99. package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
  100. package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
  101. package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
  102. package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
  103. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
  104. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
  105. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
  106. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
  107. package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
  108. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
  109. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
  110. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
  111. package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
  112. package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
  113. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
  114. package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
  115. package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
  116. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
  117. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
  118. package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
  119. package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
  120. package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
  121. package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
  122. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
  123. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
  124. package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
  125. package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
  126. package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
  127. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
  128. package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
  129. package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
  130. 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
- 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
+ }
@@ -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 { contextGauge, displayPath, renderStatusBand, type BandState } from './status'
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', contextPercent: 25, contextWindow: 200_000,
13
- costUsd: 0.15, subscription: false, running: false, elapsedMs: 0
12
+ thinkingLevel: 'high', branch: 'main', account: 'Tanny', inputTokens: 1300, outputTokens: 50,
13
+ tokensPerSecond: 30
14
14
  }
15
15
 
16
- describe('Oh My Pi status band', () => {
17
- it('renders the actual model, effort, branch, cost, and embedded context gauge', () => {
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
- const plain = stripTerminalSequences(line)
20
- expect(plain).toContain('π > ◒ Sonnet 4.6 > 📁 /work/project > ⑂ main > $0.15 ▶')
21
- expect(plain).toContain('25%')
22
- expect(plain).toMatch(/200K─$/)
23
- expect(visibleWidth(line)).toBe(120)
24
- expect(line).toContain('48;2;15;18;22')
25
- expect(line).toContain('38;2;0;180;255')
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('stays within every narrow and wide terminal with wide, colored, and untrusted metadata', () => {
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/with-many-parts',
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
  })