@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
@@ -4,7 +4,7 @@ import { createTerminalCommands, type TerminalCommandHost } from './commands'
4
4
 
5
5
  function setup() {
6
6
  const ui = {
7
- notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(),
7
+ notify: vi.fn(), select: vi.fn(), confirm: vi.fn(), input: vi.fn(), custom: vi.fn(async () => undefined),
8
8
  theme: { name: 'harness-titanium' }
9
9
  } as unknown as ExtensionUIContext
10
10
  const session = {
@@ -15,16 +15,28 @@ function setup() {
15
15
  },
16
16
  settingsManager: { getEnableSkillCommands: () => true },
17
17
  compact: vi.fn(), reload: vi.fn(), setSessionName: vi.fn(),
18
+ getActiveToolNames: () => ['read', 'bash'],
18
19
  getLastAssistantText: vi.fn(() => 'Actual assistant response'),
19
20
  getSessionStats: () => ({ sessionId: 'real-session', tokens: { input: 7 } }),
20
21
  exportToHtml: vi.fn(async (path: string) => path), exportToJsonl: vi.fn((path: string) => path)
21
22
  } as unknown as AgentSession
22
- const runtime = { newSession: vi.fn(), switchSession: vi.fn(), fork: vi.fn(), importFromJsonl: vi.fn(), reload: vi.fn(), navigateTree: vi.fn(), installationDeviceId: vi.fn(async () => '12345678-1234-4234-8234-123456789abc') }
23
+ const runtime = {
24
+ newSession: vi.fn(), switchSession: vi.fn(), fork: vi.fn(), importFromJsonl: vi.fn(), reload: vi.fn(), navigateTree: vi.fn(),
25
+ installationDeviceId: vi.fn(async () => '12345678-1234-4234-8234-123456789abc'),
26
+ permissions: vi.fn(async () => ({ full: false, globalTools: [] as string[] })),
27
+ setFullPermission: vi.fn(async () => {}), setGlobalTool: vi.fn(async () => {})
28
+ }
29
+ const trajectoryRun = { id: 'run-1', mode: 'code', goal: 'Review the change', startedAt: '2026-10-18T00:00:00.000Z', endedAt: '2026-10-18T00:00:01.000Z', status: 'completed' }
23
30
  const host: TerminalCommandHost = {
24
31
  session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
25
32
  cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
26
- refresh: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
27
- hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes'
33
+ refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
34
+ planMode: () => false, setPlanMode: vi.fn(),
35
+ hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
36
+ trajectory: {
37
+ list: vi.fn(async () => [trajectoryRun]),
38
+ read: vi.fn(async () => ({ run: trajectoryRun, events: [] }))
39
+ }
28
40
  }
29
41
  return { host, session, runtime, ui, commands: createTerminalCommands(host) }
30
42
  }
@@ -47,14 +59,21 @@ describe('owned terminal commands', () => {
47
59
  it('populates completion from builtins and loaded resources', () => {
48
60
  const { commands } = setup()
49
61
  const names = commands.completions().map(item => item.name)
50
- expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'skill:diagnose']))
62
+ expect(names).toEqual(expect.arrayContaining(['model', 'resume', 'accounts', 'review', 'effort', 'trajectory', 'permissions']))
63
+ expect(names).not.toContain('thinking')
64
+ expect(names).not.toContain('skill:diagnose')
51
65
  expect(new Set(names).size).toBe(names.length)
52
66
  })
53
67
 
54
- it('respects disabled skill commands', () => {
68
+ it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
55
69
  const { commands, session } = setup()
70
+ expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
71
+ expect(commands.expandSkill('$diagnose the trace')).toBe('/skill:diagnose the trace')
72
+ expect(commands.expandSkill('$unknown the trace')).toBe('$unknown the trace')
73
+ expect(commands.expandSkill('/skill:diagnose trace')).toBe('/skill:diagnose trace')
56
74
  vi.spyOn(session.settingsManager, 'getEnableSkillCommands').mockReturnValue(false)
57
- expect(commands.completions().map(item => item.name)).not.toContain('skill:diagnose')
75
+ expect(commands.skills()).toEqual([])
76
+ expect(commands.expandSkill('$diagnose the trace')).toBe('$diagnose the trace')
58
77
  })
59
78
 
60
79
  it('uses the scope checked runtime for explicit resume and import paths', async () => {
@@ -129,6 +148,48 @@ describe('owned terminal commands', () => {
129
148
  expect(ui.notify).toHaveBeenCalledWith('Harness release notes', 'info')
130
149
  })
131
150
 
151
+ it('opens the latest or requested trajectory run in the interactive overlay', async () => {
152
+ const { commands, host, ui } = setup()
153
+ await commands.execute('/trajectory')
154
+ expect(host.trajectory.list).toHaveBeenCalledOnce()
155
+ expect(host.trajectory.read).toHaveBeenCalledWith('run-1')
156
+ expect(ui.custom).toHaveBeenCalledOnce()
157
+ expect(ui.custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
158
+
159
+ await commands.execute('/trajectory run-1')
160
+ expect(host.trajectory.read).toHaveBeenLastCalledWith('run-1')
161
+ })
162
+
163
+ it('shows shared tool grants and confirms Full Permission before enabling it', async () => {
164
+ const { commands, host, runtime, ui } = setup()
165
+ vi.mocked(ui.custom).mockImplementationOnce(async () => 'tool:bash' as never)
166
+ .mockImplementationOnce(async () => 'done' as never)
167
+ await commands.execute('/permissions')
168
+ expect(runtime.permissions).toHaveBeenCalled()
169
+ expect(runtime.setGlobalTool).toHaveBeenCalledWith('bash', true)
170
+
171
+ vi.mocked(ui.custom).mockImplementationOnce(async () => 'full' as never)
172
+ .mockImplementationOnce(async () => 'done' as never)
173
+ vi.mocked(ui.confirm).mockResolvedValueOnce(true)
174
+ await commands.execute('/permissions')
175
+ expect(ui.confirm).toHaveBeenCalledWith('Enable Full Permission?', expect.stringContaining('Hard denials'))
176
+ expect(runtime.setFullPermission).toHaveBeenCalledWith(true)
177
+ expect(host.refresh).toHaveBeenCalled()
178
+ })
179
+
180
+ it('reports empty history and refuses run IDs outside the current native session', async () => {
181
+ const { commands, host, ui } = setup()
182
+ vi.mocked(host.trajectory.list).mockResolvedValue([])
183
+ await commands.execute('/trajectory')
184
+ expect(ui.notify).toHaveBeenCalledWith('No runs recorded for this native session yet', 'info')
185
+ expect(ui.custom).not.toHaveBeenCalled()
186
+
187
+ vi.mocked(host.trajectory.list).mockResolvedValue([{ id: 'run-1', mode: 'code', goal: null, startedAt: '2026-10-18T00:00:00.000Z', endedAt: null, status: 'running' }])
188
+ await commands.execute('/trajectory other-run')
189
+ expect(host.trajectory.read).not.toHaveBeenCalled()
190
+ expect(ui.notify).toHaveBeenLastCalledWith('Run not found in this native session', 'error')
191
+ })
192
+
132
193
  it('exits through the application lifecycle', async () => {
133
194
  const { commands, host } = setup()
134
195
  await commands.execute('/quit')
@@ -137,9 +198,9 @@ describe('owned terminal commands', () => {
137
198
  it('validates thinking levels before mutating the session', async () => {
138
199
  const { commands, session, ui } = setup()
139
200
  Object.assign(session, { getAvailableThinkingLevels: () => ['off', 'high'], setThinkingLevel: vi.fn() })
140
- await commands.execute('/thinking high')
201
+ await commands.execute('/effort high')
141
202
  expect(session.setThinkingLevel).toHaveBeenCalledWith('high', { persist: true })
142
- await commands.execute('/thinking fabricated')
203
+ await commands.execute('/effort fabricated')
143
204
  expect(session.setThinkingLevel).toHaveBeenCalledTimes(1)
144
205
  expect(ui.notify).toHaveBeenCalledWith('Available thinking levels: off, high', 'error')
145
206
  })
@@ -5,17 +5,24 @@ import { isAbsolute, join, relative, resolve } from 'node:path'
5
5
  import { promisify } from 'node:util'
6
6
  import { ModelSelectorComponent, TreeSelectorComponent, SessionManager, type AgentSession, type ExtensionUIContext } from '@earendil-works/pi-coding-agent'
7
7
  import { SettingsPanel, type TerminalSettingItem } from './settings'
8
+ import { DialogPanel, SearchableSelect } from './dialogs'
9
+ import { TrajectoryPanel } from './trajectoryPanel'
10
+ import { expandSkillInvocation, type SkillEntry } from './skillInvocation'
8
11
  import manifest from '../../../package.json'
9
12
  import type { TerminalRuntime } from './runtime'
13
+ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../trajectory'
10
14
 
11
15
  export interface TerminalCommandHost {
12
16
  session(): AgentSession
13
17
  /** Navigation is scope checked by the Harness runtime adapter. */
14
- runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId'>
18
+ runtime(): Pick<TerminalRuntime, 'newSession' | 'switchSession' | 'fork' | 'importFromJsonl' | 'reload' | 'navigateTree' | 'installationDeviceId' | 'permissions' | 'setFullPermission' | 'setGlobalTool'>
15
19
  ui: ExtensionUIContext
16
20
  cwd(): string
17
21
  sessionDirectory(): string
18
22
  refresh(): void
23
+ changed(): void
24
+ planMode(): boolean
25
+ setPlanMode(enabled: boolean): void
19
26
  shutdown(): void
20
27
  setEditorText(text: string): void
21
28
  copyText(text: string): Promise<void>
@@ -24,23 +31,30 @@ export interface TerminalCommandHost {
24
31
  suspend?(): Promise<void>
25
32
  hotkeys?(): string
26
33
  changelog?(): Promise<string>
34
+ trajectory: {
35
+ list(): Promise<TerminalTrajectoryRun[]>
36
+ read(runId: string, afterSequence?: number): Promise<TerminalTrajectorySnapshot>
37
+ }
27
38
  }
28
39
 
29
40
  export interface TerminalCommandCompletion { name: string; description?: string }
30
41
 
31
42
  const BUILTINS: TerminalCommandCompletion[] = [
32
43
  { name: 'help', description: 'Available terminal commands' },
44
+ { name: 'plan', description: 'Toggle read-only plan mode' },
33
45
  { name: 'new', description: 'Start a new session' },
34
46
  { name: 'resume', description: 'Resume a session in this account and folder' },
35
47
  { name: 'tree', description: 'Navigate the session tree' },
36
48
  { name: 'fork', description: 'Fork from a user message' },
37
49
  { name: 'model', description: 'Choose a model' },
38
- { name: 'thinking', description: 'Choose thinking effort' },
50
+ { name: 'effort', description: 'Choose thinking effort' },
39
51
  { name: 'clone', description: 'Clone the current branch to a new session' },
40
52
  { name: 'scoped-models', description: 'Choose models for cycling' },
41
53
  { name: 'settings', description: 'Session and terminal settings' },
54
+ { name: 'permissions', description: 'Review and change terminal approval permissions' },
42
55
  { name: 'name', description: 'Name the current session' },
43
56
  { name: 'session', description: 'Current session statistics' },
57
+ { name: 'trajectory', description: 'Inspect recorded runs for this native session' },
44
58
  { name: 'compact', description: 'Compact the current context' },
45
59
  { name: 'reload', description: 'Reload trusted resources and keyboard bindings' },
46
60
  { name: 'trust', description: 'Review plugin code and project trust' },
@@ -72,11 +86,16 @@ export function createTerminalCommands(host: TerminalCommandHost) {
72
86
  for (const prompt of session.resourceLoader.getPrompts().prompts) {
73
87
  if (!commands.has(prompt.name)) commands.set(prompt.name, { name: prompt.name, description: prompt.description })
74
88
  }
75
- if (session.settingsManager.getEnableSkillCommands()) {
76
- for (const skill of session.resourceLoader.getSkills().skills) commands.set(`skill:${skill.name}`, { name: `skill:${skill.name}`, description: skill.description })
77
- }
78
89
  return [...commands.values()]
79
90
  }
91
+ /** The skills this session may be asked to use, each invoked as `$name`. Off with the "Skill commands" setting. */
92
+ const skills = (): SkillEntry[] => {
93
+ const session = host.session()
94
+ if (!session.settingsManager.getEnableSkillCommands()) return []
95
+ return session.resourceLoader.getSkills().skills.map((skill) => ({ name: skill.name, description: skill.description }))
96
+ }
97
+ /** The text to send: a leading `$name` of a loaded skill becomes the `/skill:name` form Pi expands. */
98
+ const expandSkill = (text: string): string => expandSkillInvocation(text, new Set(skills().map((skill) => skill.name)))
80
99
 
81
100
  async function settings() {
82
101
  const session = host.session()
@@ -192,6 +211,68 @@ export function createTerminalCommands(host: TerminalCommandHost) {
192
211
  } finally { await rm(directory, { recursive: true, force: true }) }
193
212
  }
194
213
 
214
+ async function trajectory(runId: string) {
215
+ const runs = await host.trajectory.list()
216
+ if (!runs.length) { notify('No runs recorded for this native session yet'); return }
217
+ const initialIndex = runId ? runs.findIndex(run => run.id === runId) : 0
218
+ if (initialIndex < 0) throw new Error('Run not found in this native session')
219
+ const initial = await host.trajectory.read(runs[initialIndex]!.id)
220
+ await host.ui.custom<void>((tui, theme, _keys, done) => {
221
+ const content = new TrajectoryPanel({
222
+ tui, theme, runs, initialIndex, initial,
223
+ read: host.trajectory.read,
224
+ copy: host.copyText,
225
+ close: () => done(undefined),
226
+ changed: host.changed,
227
+ notify: (message, type) => host.ui.notify(message, type === 'error' || type === 'warning' ? type : 'info')
228
+ })
229
+ const dialog = new DialogPanel('Trajectory', content, () => theme, '←/→ runs · ↑/↓ events · PgUp/PgDn details · C copy · Esc close')
230
+ return Object.assign(dialog, { dispose: () => content.dispose() })
231
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
232
+ }
233
+
234
+ async function permissions() {
235
+ for (;;) {
236
+ const runtime = host.runtime()
237
+ const state = await runtime.permissions()
238
+ const activeTools = host.session().getActiveToolNames()
239
+ const granted = new Set(state.globalTools)
240
+ const toolNames = [...new Set([...activeTools, ...granted])].sort((a, b) => a.localeCompare(b))
241
+ const entries = [
242
+ { id: 'full', label: `Full Permission: ${state.full ? 'on' : 'off'}` },
243
+ ...toolNames.map(tool => ({ id: `tool:${tool}`, label: `${tool}: allow everywhere ${granted.has(tool) ? 'on' : 'off'}` })),
244
+ { id: 'done', label: 'Done' }
245
+ ]
246
+ const description = [
247
+ 'Permissions apply to future Pi terminal approvals using this local Harness state.',
248
+ 'A tool grant allows every call with that tool name, regardless of its arguments.',
249
+ 'Full Permission approves every eligible future request. Hard denials and disabled tools still apply; human-only requests stay manual unless the host explicitly opts them in.',
250
+ 'Existing pending requests are never approved by changing these settings.'
251
+ ].join('\n')
252
+ const selected = await host.ui.custom<string | undefined>((tui, theme, _keys, done) => {
253
+ const list = new SearchableSelect(entries.map(entry => entry.label), tui, () => theme,
254
+ index => done(entries[index]?.id), () => done(undefined))
255
+ return new DialogPanel('Permissions', list, () => theme, 'Enter change · Type to search · Esc close', description)
256
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '90%' } })
257
+ if (!selected || selected === 'done') return
258
+ if (selected === 'full') {
259
+ const desired = !state.full
260
+ const latest = await runtime.permissions()
261
+ if (latest.full !== desired) {
262
+ if (!desired || await host.ui.confirm('Enable Full Permission?', 'Automatically approve eligible future requests in every Pi terminal session sharing this local Harness state. Hard denials and disabled tools still apply; human-only requests stay manual unless the host explicitly opts them in.')) {
263
+ await runtime.setFullPermission(desired)
264
+ }
265
+ }
266
+ continue
267
+ }
268
+ const tool = selected.slice('tool:'.length)
269
+ if (!tool) continue
270
+ const desired = !granted.has(tool)
271
+ const latest = await runtime.permissions()
272
+ if (latest.globalTools.includes(tool) !== desired) await runtime.setGlobalTool(tool, desired)
273
+ }
274
+ }
275
+
195
276
  async function execute(text: string): Promise<boolean> {
196
277
  const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim())
197
278
  if (!match) return false
@@ -199,6 +280,8 @@ export function createTerminalCommands(host: TerminalCommandHost) {
199
280
  const argument = match[2]?.trim() ?? ''
200
281
  if (!BUILTINS.some(item => item.name === name)) {
201
282
  if (completions().some(item => item.name === name)) return false
283
+ // A skill is offered as $name; the typed /skill:name form still reaches the SDK, which expands it.
284
+ if (skills().some(skill => `skill:${skill.name}` === name)) return false
202
285
  host.ui.notify(`Unknown command: /${name}. Use /help to list commands.`, 'error')
203
286
  return true
204
287
  }
@@ -206,6 +289,13 @@ export function createTerminalCommands(host: TerminalCommandHost) {
206
289
  const session = host.session()
207
290
  switch (name) {
208
291
  case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
292
+ case 'plan': {
293
+ const current = host.planMode()
294
+ const next = !argument || argument === 'toggle' ? !current : argument === 'on' ? true : argument === 'off' ? false : null
295
+ if (next === null) throw new Error('Use /plan, /plan on, /plan off, or /plan toggle.')
296
+ host.setPlanMode(next)
297
+ break
298
+ }
209
299
  case 'quit': host.shutdown(); break
210
300
  case 'new': await host.runtime().newSession(); break
211
301
  case 'resume': {
@@ -256,7 +346,7 @@ export function createTerminalCommands(host: TerminalCommandHost) {
256
346
  }
257
347
  break
258
348
  }
259
- case 'thinking': {
349
+ case 'effort': {
260
350
  const levels = session.getAvailableThinkingLevels()
261
351
  const level = argument || await host.ui.select('Thinking effort', levels)
262
352
  if (!level) break
@@ -292,12 +382,14 @@ export function createTerminalCommands(host: TerminalCommandHost) {
292
382
  break
293
383
  }
294
384
  case 'settings': await settings(); break
385
+ case 'permissions': await permissions(); break
295
386
  case 'name': {
296
387
  const name = argument || await host.ui.input('Session name', session.sessionName)
297
388
  if (name !== undefined) session.setSessionName(name)
298
389
  break
299
390
  }
300
391
  case 'session': notify(JSON.stringify(session.getSessionStats(), null, 2)); break
392
+ case 'trajectory': await trajectory(argument); break
301
393
  case 'compact': await session.compact(argument || undefined); break
302
394
  case 'reload': await host.runtime().reload(); notify('Trusted resources reloaded'); break
303
395
  case 'trust': {
@@ -357,5 +449,5 @@ export function createTerminalCommands(host: TerminalCommandHost) {
357
449
  } catch (error) { host.ui.notify(error instanceof Error ? error.message : String(error), 'error') }
358
450
  return true
359
451
  }
360
- return { execute, completions }
452
+ return { execute, completions, skills, expandSkill }
361
453
  }
@@ -1,50 +1,65 @@
1
1
  import { getSelectListTheme, initTheme, type KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent'
2
- import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, stripTerminalSequences, type TUI, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
2
+ import { CURSOR_MARKER, KeybindingsManager as TuiKeybindingsManager, renderFakeCursor, stripTerminalSequences, type TUI, type TuiMouseEvent, TUI_KEYBINDINGS, visibleWidth } from '@earendil-works/pi-tui'
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
5
- import { HarnessEditor } from './editor'
5
+ import { COMPOSER_SUGGESTIONS, type ComposerView, HarnessEditor } from './editor'
6
6
  import { createHarnessTheme } from './theme'
7
7
 
8
8
  initTheme('dark', false)
9
9
  const theme = createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme)
10
- function setup() {
10
+ const bandText = (width: number) => '[band]'.padEnd(width)
11
+ function setup(view: Partial<ComposerView> = {}) {
11
12
  const editor = new HarnessEditor({ terminal: { rows: 30 }, requestRender() {} } as unknown as TUI,
12
13
  { borderColor: (text) => text, selectList: getSelectListTheme() }, new TuiKeybindingsManager(TUI_KEYBINDINGS) as KeybindingsManager, {
13
- band: (width, above, below) => `${above}:${below}`.padEnd(width, '─'),
14
- theme: () => theme, showHint: () => true, thinkingKey: '⇧⇥'
14
+ band: (width) => bandText(width),
15
+ theme: () => theme, placeholder: () => undefined, ...view
15
16
  })
16
17
  editor.focused = true
17
18
  return editor
18
19
  }
19
20
 
20
- describe('band composer over the native Pi editor', () => {
21
- it('uses exactly a band and a curved prompt row, preserving the real cursor beside the hint', () => {
21
+ describe('Claude-style composer frame over the native Pi editor', () => {
22
+ it('frames the prompt between two full-width rules, with the band below', () => {
22
23
  const editor = setup()
23
- const rows = editor.render(80)
24
- expect(rows).toHaveLength(2)
25
- expect(stripTerminalSequences(rows[1])).toMatch(/^╰─ .*⇧⇥ to change thinking effort$/)
24
+ const rows = editor.render(40)
25
+ expect(rows).toHaveLength(4)
26
+ expect(stripTerminalSequences(rows[0])).toBe('─'.repeat(40))
27
+ expect(rows[0]).toContain('38;2;136;136;136')
28
+ expect(stripTerminalSequences(rows[1])).toMatch(/^❯ /)
26
29
  expect(rows[1]).toContain(CURSOR_MARKER)
27
- expect(rows.every((row) => visibleWidth(row) <= 80)).toBe(true)
30
+ expect(stripTerminalSequences(rows[2])).toBe('─'.repeat(40))
31
+ expect(stripTerminalSequences(rows[3]).trimEnd()).toBe('[band]')
32
+ expect(rows.every((row) => visibleWidth(row) <= 40)).toBe(true)
28
33
  expect(editor.getText()).toBe('')
29
34
  })
30
35
 
31
- it('closes the fake cursor before the hint, so the rest of the row is not reverse video', () => {
32
- const editor = setup()
33
- // Mirrors pi-tui's resolveFakeCursors: the cursor opens reverse video and its end marker closes it.
34
- const resolved = editor.render(80)[1].replace(/\x1b_pi:fc\x07/g, '\x1b[7m').replace(/\x1b_pi:\/fc\x07/g, '\x1b[27m')
35
- const reverseOn = resolved.indexOf('\x1b[7m')
36
- const reverseOff = resolved.indexOf('\x1b[27m')
37
- expect(reverseOn).toBeGreaterThan(-1)
38
- expect(reverseOff).toBeGreaterThan(reverseOn)
39
- expect(reverseOff).toBeLessThan(resolved.indexOf('⇧⇥'))
36
+ it('shows the suggestion as a fake cursor on its first character, only while focused', () => {
37
+ const editor = setup({ placeholder: () => COMPOSER_SUGGESTIONS[0] })
38
+ const rows = editor.render(60)
39
+ expect(stripTerminalSequences(rows[1])).toContain('Try "fix the failing test"')
40
+ expect(rows[1]).toContain(CURSOR_MARKER + renderFakeCursor('T'))
41
+ editor.focused = false
42
+ const idle = editor.render(60)[1]
43
+ expect(idle).not.toContain(CURSOR_MARKER)
44
+ expect(stripTerminalSequences(idle)).toContain('Try "fix the failing test"')
45
+ })
46
+
47
+ it('drops the suggestion as soon as there is a draft', () => {
48
+ const editor = setup({ placeholder: () => 'fix the failing test' })
49
+ editor.handleInput('h')
50
+ const rows = editor.render(60)
51
+ expect(editor.getText()).toBe('h')
52
+ expect(stripTerminalSequences(rows[1])).toMatch(/^❯ h/)
53
+ expect(stripTerminalSequences(rows[1])).not.toContain('Try')
40
54
  })
41
55
 
42
- it('closes the cursor cell so the hint is not painted in reverse video', () => {
56
+ it('does not render a shortcuts or thinking hint row', () => {
43
57
  const editor = setup()
44
- // Why: pi-tui paints its fake cursor as reverse video when it resolves a frame.
45
- const prompt = editor.render(80)[1].replace(/\x1b_pi:fc\x07/g, '\x1b[7m').replace(/\x1b_pi:\/fc\x07/g, '\x1b[27m')
46
- expect(prompt.split('\x1b[7m')).toHaveLength(prompt.split('\x1b[27m').length)
47
- expect(prompt.indexOf('to change')).toBeGreaterThan(prompt.lastIndexOf('\x1b[27m'))
58
+ const rows = editor.render(80).map(stripTerminalSequences)
59
+ expect(rows.join('\n')).not.toContain('? for shortcuts')
60
+ expect(rows.join('\n')).not.toContain('to change thinking effort')
61
+ editor.handleInput('x')
62
+ expect(editor.render(80).map(stripTerminalSequences).join('\n')).not.toContain('to clear')
48
63
  })
49
64
 
50
65
  it('preserves editing, multiline wrapping, history, and cursor markers across resizes', () => {
@@ -67,7 +82,7 @@ describe('band composer over the native Pi editor', () => {
67
82
  expect(editor.getText()).toBe('previous prompt')
68
83
  })
69
84
 
70
- it('retains autocomplete rows and selection without mistaking them for the removed bottom border', async () => {
85
+ it('lists autocomplete above the composer with matching orange dividers and accepts a selection', async () => {
71
86
  const editor = setup()
72
87
  editor.setAutocompleteProvider({
73
88
  getSuggestions: async () => ({ items: [{ value: '/accounts', label: '/accounts' }, { value: '/plugins', label: '/plugins' }], prefix: '/' }),
@@ -76,9 +91,23 @@ describe('band composer over the native Pi editor', () => {
76
91
  editor.handleInput('/')
77
92
  await new Promise((resolve) => setTimeout(resolve, 30))
78
93
  const rows = editor.render(50)
79
- expect(rows.some((row) => stripTerminalSequences(row).includes('/accounts'))).toBe(true)
94
+ const band = rows.findIndex((row) => stripTerminalSequences(row).startsWith('[band]'))
95
+ const completion = rows.findIndex((row) => stripTerminalSequences(row).includes('/accounts'))
96
+ const composerDivider = rows.findIndex((row, index) => index > completion && stripTerminalSequences(row) === '─'.repeat(50))
97
+ expect(rows[0]).toContain('38;2;255;179;71')
98
+ expect(rows[composerDivider]).toContain('38;2;136;136;136')
99
+ expect(completion).toBeGreaterThan(0)
100
+ expect(composerDivider).toBeGreaterThan(completion)
101
+ expect(band).toBeGreaterThan(composerDivider)
80
102
  expect(rows.every((row) => visibleWidth(row) <= 50)).toBe(true)
81
103
  editor.handleInput('\t')
82
104
  expect(editor.getText()).toContain('/accounts')
83
105
  })
106
+
107
+ it('does not hand the band row to the editor as its own', () => {
108
+ const editor = setup()
109
+ editor.render(40)
110
+ const at = (y: number) => ({ x: 5, y, width: 40, height: 5 }) as TuiMouseEvent
111
+ expect(editor.handleMouse(at(3))).toBeUndefined()
112
+ })
84
113
  })
@@ -1,26 +1,49 @@
1
1
  // Harness owns composer geometry and actions; Pi provides the editing primitive.
2
2
  import { CustomEditor, type KeybindingsManager, type Theme } from '@earendil-works/pi-coding-agent'
3
- import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent, sliceByColumn, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
3
+ import { CURSOR_MARKER, type EditorTheme, type TUI, type TuiMouseEvent, parseColor, renderFakeCursor, truncateToWidth } from '@earendil-works/pi-tui'
4
+
5
+ import { autocompleteRuleColor, composerRuleColor } from './theme'
4
6
 
5
7
  const BOTTOM = '\u0000harness-composer-bottom'
6
8
 
9
+ // Generic on purpose: the placeholder names no file, project or product.
10
+ export const COMPOSER_SUGGESTIONS = [
11
+ 'fix the failing test',
12
+ 'explain how this project is laid out',
13
+ 'find where the configuration is loaded',
14
+ 'summarize what changed since the last commit',
15
+ 'write a test for the next change'
16
+ ]
17
+
18
+ function leadingSlashCommand(text: string, validCommands: ReadonlySet<string>): string | null {
19
+ const match = /^\s*\/([^\s/]+)(?=\s|$)/u.exec(text)
20
+ if (!match || !validCommands.has(match[1]!)) return null
21
+ return '/' + match[1]
22
+ }
23
+
7
24
  export interface ComposerView {
8
25
  band(width: number, hiddenAbove: number, hiddenBelow: number): string
9
26
  theme(): Theme
10
- showHint(): boolean
11
- thinkingKey: string
27
+ placeholder(): string | undefined
12
28
  }
13
29
 
14
30
  export class HarnessEditor extends CustomEditor {
15
31
  private hiddenAbove = 0
16
32
  private hiddenBelow = 0
17
33
  private contentRows = 1
18
- private gutterWidth = 3
34
+ private completionRows = 0
35
+ private gutterWidth = 2
36
+ private validCommands = new Set<string>()
19
37
 
20
38
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
21
39
  super(tui, theme, keybindings, { paddingX: 0 })
22
40
  }
23
41
 
42
+ setValidCommands(commands: readonly string[]): void {
43
+ this.validCommands = new Set(commands)
44
+ this.tui.requestRender()
45
+ }
46
+
24
47
  override setPaddingX(_padding: number): void {
25
48
  super.setPaddingX(0)
26
49
  }
@@ -37,7 +60,7 @@ export class HarnessEditor extends CustomEditor {
37
60
 
38
61
  override render(width: number): string[] {
39
62
  if (width < 1) return []
40
- this.gutterWidth = Math.min(3, Math.max(0, width - 1))
63
+ this.gutterWidth = Math.min(2, Math.max(0, width - 1))
41
64
  const innerWidth = Math.max(1, width - this.gutterWidth)
42
65
  // Pi 1.2 cannot lay out a two-cell grapheme in a one-cell text area.
43
66
  // Lay out safely, then clip the presentation without changing the draft.
@@ -46,33 +69,55 @@ export class HarnessEditor extends CustomEditor {
46
69
  const content = rows.slice(1, bottom)
47
70
  this.contentRows = content.length
48
71
  const theme = this.view.theme()
72
+ const placeholder = this.getText() === '' ? this.view.placeholder() : undefined
73
+ const command = leadingSlashCommand(this.getText(), this.validCommands)
74
+ const commandLine = command ? content.findIndex(line => line.includes(command)) : -1
49
75
  const prompt = content.map((line, index) => {
50
- const cue = index === 0 ? '╰─ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth)
51
- let text = line
52
- if (index === 0 && this.getText() === '' && this.view.showHint()) {
53
- const hint = theme.fg('accent', this.view.thinkingKey) + ' ' + theme.italic(theme.fg('dim', 'to change thinking effort'))
54
- // Why: a one-cell slice cuts off the fake cursor's closing marker, so pi-tui
55
- // paints the rest of the row in reverse video. Two cells keep the marker.
56
- const prefix = sliceByColumn(line, 0, 2)
57
- const gap = innerWidth - visibleWidth(prefix) - visibleWidth(hint)
58
- if (gap >= 1) text = prefix + ' '.repeat(gap) + hint
59
- }
60
- return theme.fg('borderMuted', cue) + text
76
+ const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
77
+ if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
78
+ const renderedLine = index === commandLine && command ? line.replace(command, theme.fg('mdLink', command)) : line
79
+ return cue + renderedLine
61
80
  })
62
81
  const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
63
- const result = [this.view.band(width, this.hiddenAbove, this.hiddenBelow), ...prompt, ...completions].map((line) => truncateToWidth(line, width, ''))
82
+ this.completionRows = completions.length
83
+ const completionDivider = completions.length > 0 ? [this.rule(width, theme, autocompleteRuleColor(theme))] : []
84
+ const result = [
85
+ ...completionDivider, ...completions,
86
+ this.rule(width, theme), ...prompt, this.rule(width, theme), this.view.band(width, this.hiddenAbove, this.hiddenBelow)
87
+ ].map((line) => truncateToWidth(line, width, ''))
64
88
  if (this.focused && !result.some((line) => line.includes(CURSOR_MARKER))) {
65
- const index = Math.max(1, Math.min(result.length - 1, prompt.length))
89
+ const composerOffset = completions.length > 0 ? completions.length + 1 : 0
90
+ const index = composerOffset + Math.max(1, Math.min(result.length - composerOffset - 1, prompt.length))
66
91
  result[index] = truncateToWidth(result[index], Math.max(0, width - 1), '') + CURSOR_MARKER + ' '
67
92
  }
68
93
  return result
69
94
  }
70
95
 
96
+ private rule(width: number, theme: Theme, color = composerRuleColor(theme)): string {
97
+ return theme.style('─'.repeat(width), { fg: parseColor(color) })
98
+ }
99
+
100
+ private placeholderRow(suggestion: string, innerWidth: number, theme: Theme): string {
101
+ const text = truncateToWidth(`Try "${suggestion}"`, innerWidth, '')
102
+ if (!this.focused) return theme.fg('dim', text)
103
+ // The first character is the fake cursor, so an empty prompt reads as the caret it will type into.
104
+ const [first = '', ...rest] = Array.from(text)
105
+ return CURSOR_MARKER + renderFakeCursor(first) + theme.fg('dim', rest.join(''))
106
+ }
107
+
71
108
  override handleMouse(event: TuiMouseEvent) {
109
+ // Pi sees its own rows; the suggestion divider and status band are presentation rows.
110
+ const composerOffset = this.completionRows > 0 ? this.completionRows + 1 : 0
111
+ const band = composerOffset + this.contentRows + 2
112
+ if (event.y === band) return undefined
113
+ if (this.completionRows > 0 && event.y === 0) return undefined
114
+ const y = this.completionRows > 0 && event.y <= this.completionRows
115
+ ? this.contentRows + 1 + event.y
116
+ : event.y - composerOffset
72
117
  return super.handleMouse({
73
118
  ...event,
74
119
  x: event.x - this.gutterWidth,
75
- y: event.y > this.contentRows ? event.y + 1 : event.y,
120
+ y,
76
121
  width: Math.max(1, event.width - this.gutterWidth)
77
122
  })
78
123
  }
@@ -4,6 +4,13 @@ import { join } from 'node:path'
4
4
  import { expect, it } from 'vitest'
5
5
  import { terminalKeybindings } from './keybindings'
6
6
 
7
+ it('steps thinking effort on shift arrows', () => {
8
+ const manager = terminalKeybindings('/unused/isolated-account')
9
+ expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
10
+ expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
11
+ expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
12
+ })
13
+
7
14
  it('reloads edited isolated keybindings on the existing public manager', () => {
8
15
  const root = mkdtempSync(join(tmpdir(), 'harness-keybindings-'))
9
16
  try {
@@ -3,11 +3,19 @@ import { readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { KeybindingsManager, TUI_KEYBINDINGS, type KeybindingDefinitions, type KeybindingsConfig } from '@earendil-works/pi-tui'
5
5
 
6
+ declare module '@earendil-works/pi-tui' {
7
+ interface Keybindings {
8
+ 'app.thinking.increase': true
9
+ 'app.thinking.decrease': true
10
+ }
11
+ }
12
+
6
13
  // Harness owns application actions. Editing and selection retain Pi's reusable bindings.
7
14
  const actions: Record<string, string | string[]> = {
8
15
  'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
9
16
  'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
10
- 'app.thinking.cycle': 'shift+tab', 'app.thinking.save': 'ctrl+s',
17
+ 'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s',
18
+ 'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
11
19
  'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
12
20
  'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
13
21
  'app.editor.external': 'ctrl+g', 'app.message.copy': 'ctrl+x',
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { planModeTools } from './planMode'
4
+
5
+ describe('plan mode tools', () => {
6
+ it('keeps read-only inspection, questions and recall, and withdraws changes, commands and delegation', () => {
7
+ const normal = ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'ask_user', 'ask_user_question', 'wiki_search', 'memory_append', 'spawn_subagent', 'app_tool', 'history_read']
8
+ expect(planModeTools(normal)).toEqual(['read', 'grep', 'find', 'ls', 'ask_user', 'ask_user_question', 'wiki_search', 'history_read'])
9
+ })
10
+ })
@@ -0,0 +1,2 @@
1
+ // The terminal's plan mode uses the session's read-only tool set.
2
+ export { planModeTools } from '../../sessions/planMode'