@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
@@ -1,44 +1,134 @@
1
- import { CustomEditor, type ExtensionUIContext, type Theme } from '@earendil-works/pi-coding-agent'
1
+ import { CustomEditor, type AgentSessionEvent, type ExtensionUIContext, type Theme } from '@earendil-works/pi-coding-agent'
2
+ import { stripTerminalSequences } from '@earendil-works/pi-tui'
2
3
  import { describe, expect, it, vi } from 'vitest'
3
4
 
4
5
  vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
5
6
  import { controlCall } from '../control'
6
7
  import { TerminalApplication } from './application'
8
+ import type { TerminalDecision } from './runtime'
7
9
  import { terminalKeybindings } from './keybindings'
8
- import { createHarnessTheme } from './theme'
10
+ import { autocompleteRuleColor, createHarnessTheme } from './theme'
11
+ import { TerminalTranscript } from './transcript'
9
12
 
10
13
  function setup() {
11
14
  const application = new TerminalApplication([])
12
15
  const app = application as unknown as {
13
- ui: ExtensionUIContext & { wrapAutocomplete<T>(value: T): T; dispose(): void }
16
+ theme: Theme
17
+ ui: ExtensionUIContext & {
18
+ wrapAutocomplete<T>(value: T): T
19
+ dispose(): void
20
+ state: { workingVisible: boolean; workingMessage?: string; workingIndicator?: { frames?: string[]; intervalMs?: number } }
21
+ }
14
22
  keybindings: ReturnType<typeof terminalKeybindings>
15
23
  editor: CustomEditor
16
- runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; session: unknown }
24
+ runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; session: object; snapshot(): { hasUserMessages: boolean; cwd?: string; model?: { name: string; reasoning: boolean; contextWindow: number }; thinkingLevel?: string; account?: { label: string } | null; context?: { percent: number | null; window: number }; costUsd?: number; subscription?: boolean } }
17
25
  commands: { execute: ReturnType<typeof vi.fn> }
26
+ masthead: { render(width: number): string[] }
27
+ header?: { render(width: number): string[]; invalidate(): void }
28
+ branch: string | null
29
+ transcript: TerminalTranscript
18
30
  initialized: boolean
19
31
  commandBusy: boolean
20
32
  mountEditor(factory?: Parameters<ExtensionUIContext['setEditorComponent']>[0]): void
21
33
  dispatchInput(data: string): { consume?: boolean } | undefined
22
34
  submit(text: string): void
35
+ accept(event: AgentSessionEvent): void
36
+ band(width: number): string
37
+ titleLines(width: number): string[]
38
+ resetWorkingStatus(): void
39
+ phase?: string
23
40
  }
24
41
  vi.spyOn(application.tui, 'requestRender').mockImplementation(() => {})
25
42
  vi.spyOn(application.tui, 'stop').mockImplementation(() => {})
26
43
  vi.spyOn(application.terminal, 'drainInput').mockResolvedValue()
27
- const ui = { theme: createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme), wrapAutocomplete: <T>(value: T) => value,
44
+ vi.spyOn(application.terminal, 'setProgress').mockImplementation(() => {})
45
+ const ui = { theme: createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme), state: { workingVisible: true }, wrapAutocomplete: <T>(value: T) => value,
28
46
  setToolsExpanded: vi.fn(), dispose: vi.fn() } as unknown as typeof app.ui
29
47
  app.ui = ui
48
+ app.transcript = new TerminalTranscript(application.tui, () => ui.theme, () => undefined)
30
49
  app.keybindings = terminalKeybindings('/unused/isolated-account')
31
50
  app.initialized = true
32
51
  const context = {}
33
- app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), session: {
52
+ app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), snapshot: () => ({
53
+ hasUserMessages: false, cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
54
+ thinkingLevel: 'high', account: { label: 'Tanny' }, context: { percent: null, window: 0 }, costUsd: 0, subscription: false
55
+ }), session: {
34
56
  extensionRunner: { getShortcuts: () => new Map(), createContext: () => context },
35
- state: { messages: [] }, settingsManager: {}
57
+ state: { messages: [] }, settingsManager: { getShowTerminalProgress: () => false },
58
+ getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } })
36
59
  } }
37
- application.tui.addChild(application)
38
60
  return { application, app, ui }
39
61
  }
40
62
 
41
63
  describe('the owned terminal application', () => {
64
+ it('shows a stable animated verb, then keeps completion status until the next turn', async () => {
65
+ const { application, app } = setup()
66
+ let now = 1000
67
+ const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now)
68
+ const random = vi.spyOn(Math, 'random').mockReturnValue(0)
69
+
70
+ app.accept({ type: 'agent_start' } as AgentSessionEvent)
71
+ const initialStatus = app.titleLines(100)[0] ?? ''
72
+ expect(initialStatus).toContain(app.theme.fg('warning', '· Accomplishing'))
73
+ const orangeRgb = autocompleteRuleColor(app.theme).slice(1).match(/.{2}/g)?.map(value => Number.parseInt(value, 16)) ?? []
74
+ expect(initialStatus).toContain(`38;2;${orangeRgb.join(';')}`)
75
+ expect(stripTerminalSequences(initialStatus).trim()).toContain('· Accomplishing')
76
+ now = 1080
77
+ const nextFrameStatus = app.titleLines(100)[0] ?? ''
78
+ expect(nextFrameStatus).toContain(app.theme.fg('warning', '✢ Accomplishing'))
79
+ expect(stripTerminalSequences(nextFrameStatus).trim()).toContain('✢ Accomplishing')
80
+
81
+ now = 2450
82
+ app.accept({ type: 'agent_settled' } as AgentSessionEvent)
83
+ const completed = stripTerminalSequences(app.titleLines(100)[0] ?? '').trim()
84
+ expect(completed).toMatch(/^✻ (Baked|Brewed|Churned|Cogitated|Cooked|Crunched|Sautéed|Worked) for 1s$/)
85
+ expect(stripTerminalSequences(app.titleLines(100)[0] ?? '').trim()).toBe(completed)
86
+
87
+ now = 3000
88
+ app.accept({ type: 'agent_start' } as AgentSessionEvent)
89
+ expect(stripTerminalSequences(app.titleLines(100)[0] ?? '').trim()).toContain('· Accomplishing')
90
+ app.resetWorkingStatus()
91
+ expect(app.titleLines(100)).toEqual([])
92
+
93
+ random.mockRestore()
94
+ dateNow.mockRestore()
95
+ await application.shutdown()
96
+ })
97
+
98
+ it('keeps extension working overrides and phase labels ahead of the default verb', async () => {
99
+ const { application, app, ui } = setup()
100
+ ui.state.workingMessage = 'Inspecting'
101
+ ui.state.workingIndicator = { frames: ['●'], intervalMs: 500 }
102
+
103
+ app.accept({ type: 'agent_start' } as AgentSessionEvent)
104
+ expect(stripTerminalSequences(app.titleLines(100)[0] ?? '').trim()).toContain('● Inspecting')
105
+ app.accept({ type: 'compaction_start' } as AgentSessionEvent)
106
+ expect(stripTerminalSequences(app.titleLines(100)[0] ?? '').trim()).toContain('● Compacting context…')
107
+ ui.state.workingVisible = false
108
+ expect(stripTerminalSequences(app.titleLines(100)[0] ?? '').trim()).toContain('● Compacting context…')
109
+ app.phase = undefined
110
+ expect(app.titleLines(100)).toEqual([])
111
+
112
+ await application.shutdown()
113
+ })
114
+
115
+ it('shows current-conversation input plus cache-read totals and the last completed response rate', async () => {
116
+ const { application, app } = setup()
117
+ app.branch = 'main'
118
+ Object.assign(app.runtime.session, { getSessionStats: () => ({ tokens: { input: 1200, output: 540, cacheRead: 300, cacheWrite: 100 } }) })
119
+ const now = vi.spyOn(performance, 'now').mockReturnValueOnce(1000).mockReturnValueOnce(3000).mockReturnValueOnce(4000).mockReturnValueOnce(6000)
120
+ app.accept({ type: 'turn_start' } as AgentSessionEvent)
121
+ expect(stripTerminalSequences(app.band(160))).toContain('— tok/s in 1,500 out 540')
122
+ app.accept({ type: 'message_end', message: { role: 'assistant', content: [], usage: { output: 60 } } } as unknown as AgentSessionEvent)
123
+ expect(stripTerminalSequences(app.band(160))).toBe('project | main | Tanny · Sonnet 4.6 · high | 30 tok/s in 1,500 out 540')
124
+ app.accept({ type: 'turn_start' } as AgentSessionEvent)
125
+ expect(stripTerminalSequences(app.band(160))).toContain('30 tok/s in 1,500 out 540')
126
+ app.accept({ type: 'message_end', message: { role: 'assistant', content: [], usage: { output: 40 } } } as unknown as AgentSessionEvent)
127
+ expect(stripTerminalSequences(app.band(160))).toContain('20 tok/s in 1,500 out 540')
128
+ now.mockRestore()
129
+ await application.shutdown()
130
+ })
131
+
42
132
  it('lets trusted custom editor modes consume Escape before delegating host actions', async () => {
43
133
  const { application, app } = setup()
44
134
  let normalMode = false
@@ -71,6 +161,64 @@ describe('the owned terminal application', () => {
71
161
  await application.shutdown()
72
162
  })
73
163
 
164
+ it('opens the keyboard list on ? over an empty prompt, and types ? into a draft', async () => {
165
+ const { application, app } = setup()
166
+ app.mountEditor()
167
+ expect(app.dispatchInput('?')).toEqual({ consume: true })
168
+ expect(app.editor.getText()).toBe('')
169
+ expect(app.transcript.render(120).join('\n')).toContain('PgUp/PgDn: scroll transcript')
170
+ app.editor.setText('draft')
171
+ expect(app.dispatchInput('?')).toBeUndefined()
172
+ await application.shutdown()
173
+ })
174
+
175
+ it('offers Allow once, a tool-wide grant only when eligible, and Deny in the approval prompt', async () => {
176
+ const { application, app } = setup()
177
+ let shown = ''
178
+ let chooseGlobal = true
179
+ const ui = app.ui as unknown as { custom: (factory: (...args: unknown[]) => unknown) => Promise<string | undefined> }
180
+ ui.custom = factory => new Promise(resolve => {
181
+ const panel = factory(application.tui, app.ui.theme, app.keybindings, (value: unknown) => resolve(typeof value === 'string' ? value : undefined)) as {
182
+ handleInput(data: string): void
183
+ render(width: number): string[]
184
+ }
185
+ shown = stripTerminalSequences(panel.render(100).join('\n'))
186
+ if (chooseGlobal) panel.handleInput('\x1b[B')
187
+ panel.handleInput('\r')
188
+ })
189
+ const choose = (application as unknown as { chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: string; alwaysAllow: boolean }> }).chooseApproval.bind(application)
190
+ const remembered = await choose({ type: 'approval', requestId: 'r1', target: 'bash', rememberable: true, action: '{"command":"git status"}' } as TerminalDecision, new AbortController().signal)
191
+ expect(shown).toContain('Always allow bash everywhere')
192
+ expect(shown).toContain('Allow once')
193
+ expect(shown).toContain('Deny')
194
+ expect(remembered).toEqual({ decision: 'approved', alwaysAllow: true })
195
+
196
+ chooseGlobal = false
197
+ const oneTime = await choose({ type: 'approval', requestId: 'r2', target: 'bash', rememberable: false, action: '{}' } as TerminalDecision, new AbortController().signal)
198
+ expect(shown).not.toContain('Always allow bash everywhere')
199
+ expect(oneTime).toEqual({ decision: 'approved', alwaysAllow: false })
200
+ await application.shutdown()
201
+ })
202
+
203
+ it('hides the product lockup once the chat has a user message', async () => {
204
+ const { application, app } = setup()
205
+ expect(app.masthead.render(80).join('\n')).toContain('Xingular Harness')
206
+ app.runtime.snapshot = () => ({ hasUserMessages: true })
207
+ expect(app.masthead.render(80)).toEqual([])
208
+ app.runtime.snapshot = () => ({ hasUserMessages: false })
209
+ expect(app.masthead.render(80).join('\n')).toContain('Xingular Harness')
210
+ await application.shutdown()
211
+ })
212
+
213
+ it('keeps an extension header after the chat starts', async () => {
214
+ const { application, app } = setup()
215
+ vi.spyOn(application.terminal, 'write').mockImplementation(() => {})
216
+ app.header = { render: () => ['custom header'], invalidate: () => {} }
217
+ app.runtime.snapshot = () => ({ hasUserMessages: true })
218
+ expect(app.masthead.render(80)).toContain('custom header')
219
+ await application.shutdown()
220
+ })
221
+
74
222
  it('routes leading-space commands and serializes command dialogs', async () => {
75
223
  const { application, app } = setup()
76
224
  app.mountEditor()
@@ -86,6 +234,34 @@ describe('the owned terminal application', () => {
86
234
  await application.shutdown()
87
235
  })
88
236
 
237
+ it('toggles plan mode on Shift+Tab, narrowing to read-only tools and leaving thinking alone', async () => {
238
+ const { application, app } = setup()
239
+ const session = app.runtime.session as { getActiveToolNames(): string[]; setActiveToolsByName(names: string[]): void; cycleThinkingLevel(): void }
240
+ const setActiveToolsByName = vi.fn()
241
+ const cycleThinkingLevel = vi.fn()
242
+ Object.assign(session, { getActiveToolNames: () => ['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'], setActiveToolsByName, cycleThinkingLevel })
243
+ expect(app.dispatchInput('\x1b[Z')).toEqual({ consume: true })
244
+ expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'grep'])
245
+ expect(app.dispatchInput('\x1b[Z')).toEqual({ consume: true })
246
+ expect(setActiveToolsByName).toHaveBeenLastCalledWith(['read', 'bash', 'edit', 'write', 'grep', 'spawn_subagent'])
247
+ expect(cycleThinkingLevel).not.toHaveBeenCalled()
248
+ await application.shutdown()
249
+ })
250
+
251
+ it('steps thinking effort with shift arrows and stops at the ends of the model levels', async () => {
252
+ const { application, app } = setup()
253
+ const session = app.runtime.session as { thinkingLevel: string; getAvailableThinkingLevels(): string[]; supportsThinking(): boolean; setThinkingLevel(level: string): void }
254
+ const setThinkingLevel = vi.fn((level: string) => { session.thinkingLevel = level })
255
+ Object.assign(session, { thinkingLevel: 'low', getAvailableThinkingLevels: () => ['off', 'minimal', 'low', 'medium', 'high'], supportsThinking: () => true, setThinkingLevel })
256
+ expect(app.dispatchInput('\x1b[1;2A')).toEqual({ consume: true })
257
+ expect(app.dispatchInput('\x1b[1;2A')).toEqual({ consume: true })
258
+ expect(app.dispatchInput('\x1b[1;2A')).toEqual({ consume: true })
259
+ expect(setThinkingLevel.mock.calls).toEqual([['medium'], ['high']])
260
+ expect(app.dispatchInput('\x1b[1;2B')).toEqual({ consume: true })
261
+ expect(setThinkingLevel).toHaveBeenLastCalledWith('medium')
262
+ await application.shutdown()
263
+ })
264
+
89
265
  it('restores terminal modes and finishes even when runtime and plugin cleanup fail', async () => {
90
266
  const { application, app } = setup()
91
267
  const header = { dispose: vi.fn(() => { throw new Error('header cleanup failed') }) }