@xingular/harness 0.1.6 → 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 (131) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/NOTICE.md +38 -0
  3. package/README.md +139 -28
  4. package/dist/cli.js +9598 -2201
  5. package/dist/terminal-pi.js +808 -16
  6. package/docs/README.md +15 -0
  7. package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
  8. package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
  9. package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
  10. package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
  11. package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
  12. package/docs/terminal-tui.md +344 -0
  13. package/package.json +11 -4
  14. package/src/accounts/accounts.test.ts +197 -1
  15. package/src/accounts/accounts.ts +266 -28
  16. package/src/cli.ts +7 -3
  17. package/src/core/bus.test.ts +22 -0
  18. package/src/core/bus.ts +17 -2
  19. package/src/harness.ts +27 -4
  20. package/src/hub/catalog.json +123 -0
  21. package/src/hub/cli.ts +59 -0
  22. package/src/hub/hub.test.ts +205 -0
  23. package/src/hub/hub.ts +256 -0
  24. package/src/hub/schema.ts +81 -0
  25. package/src/index.ts +12 -2
  26. package/src/mcp/connections.ts +39 -12
  27. package/src/mcp/service.test.ts +49 -3
  28. package/src/mcp/service.ts +14 -3
  29. package/src/mcp/types.ts +10 -0
  30. package/src/memory/api.ts +16 -2
  31. package/src/memory/blocks.ts +3 -1
  32. package/src/memory/caller.ts +13 -1
  33. package/src/memory/curation.test.ts +206 -0
  34. package/src/memory/curation.ts +64 -0
  35. package/src/memory/dispatch.ts +5 -0
  36. package/src/memory/maintenance.test.ts +17 -1
  37. package/src/memory/maintenance.ts +367 -18
  38. package/src/memory/operations.ts +12 -0
  39. package/src/memory/proposals.ts +16 -8
  40. package/src/memory/recall.test.ts +11 -0
  41. package/src/memory/recall.ts +11 -3
  42. package/src/memory/trajectory.test.ts +178 -0
  43. package/src/memory/trajectory.ts +220 -0
  44. package/src/memory/wiki.ts +2 -1
  45. package/src/memory/wikiIndex.ts +3 -2
  46. package/src/plugins/builtin.ts +4 -2
  47. package/src/plugins/catalogue.test.ts +1 -1
  48. package/src/server/methods.ts +43 -2
  49. package/src/server/server.test.ts +20 -1
  50. package/src/sessions/approvals.test.ts +49 -0
  51. package/src/sessions/approvals.ts +26 -4
  52. package/src/sessions/contracts.ts +29 -3
  53. package/src/sessions/conversation.test.ts +83 -0
  54. package/src/sessions/lineageJoin.ts +1 -1
  55. package/src/sessions/policy.ts +4 -0
  56. package/src/sessions/questionBatches.test.ts +110 -0
  57. package/src/sessions/registry.ts +145 -27
  58. package/src/sessions/runStore.ts +14 -0
  59. package/src/sessions/runner.ts +45 -4
  60. package/src/sessions/sessions.test.ts +137 -4
  61. package/src/sessions/sessions.ts +51 -15
  62. package/src/sessions/subagentService.test.ts +159 -0
  63. package/src/sessions/subagentService.ts +236 -0
  64. package/src/sessions/subagents.ts +97 -0
  65. package/src/skills/registry.ts +16 -10
  66. package/src/terminal/accountsMenu.test.ts +225 -0
  67. package/src/terminal/accountsMenu.ts +382 -0
  68. package/src/terminal/control.test.ts +23 -0
  69. package/src/terminal/control.ts +23 -0
  70. package/src/terminal/controller.ts +134 -7
  71. package/src/terminal/hubMenu.ts +24 -0
  72. package/src/terminal/launch.test.ts +5 -14
  73. package/src/terminal/launch.ts +2 -42
  74. package/src/terminal/piExtension.lifecycle.test.ts +55 -0
  75. package/src/terminal/piExtension.ts +91 -6
  76. package/src/terminal/socket.test.ts +23 -0
  77. package/src/terminal/socket.ts +55 -20
  78. package/src/terminal/standalone.test.ts +37 -0
  79. package/src/terminal/standalone.ts +28 -7
  80. package/src/terminal/terminal.test.ts +220 -16
  81. package/src/terminal/terminals.ts +234 -16
  82. package/src/terminal/trajectory.test.ts +66 -0
  83. package/src/terminal/trajectory.ts +44 -0
  84. package/src/terminal/tui/application.test.ts +290 -0
  85. package/src/terminal/tui/application.ts +653 -0
  86. package/src/terminal/tui/commands.test.ts +278 -0
  87. package/src/terminal/tui/commands.ts +443 -0
  88. package/src/terminal/tui/dialogs.ts +301 -0
  89. package/src/terminal/tui/editor.test.ts +113 -0
  90. package/src/terminal/tui/editor.ts +109 -0
  91. package/src/terminal/tui/keybindings.test.ts +30 -0
  92. package/src/terminal/tui/keybindings.ts +59 -0
  93. package/src/terminal/tui/piAdapter.ts +62 -0
  94. package/src/terminal/tui/planMode.test.ts +10 -0
  95. package/src/terminal/tui/planMode.ts +12 -0
  96. package/src/terminal/tui/presentation.test.ts +79 -0
  97. package/src/terminal/tui/questions.test.ts +41 -0
  98. package/src/terminal/tui/questions.ts +47 -0
  99. package/src/terminal/tui/runtime.socket.test.ts +178 -0
  100. package/src/terminal/tui/runtime.test.ts +225 -0
  101. package/src/terminal/tui/runtime.ts +403 -0
  102. package/src/terminal/tui/screen.test.ts +143 -0
  103. package/src/terminal/tui/screen.ts +121 -0
  104. package/src/terminal/tui/sessionUi.ts +24 -0
  105. package/src/terminal/tui/settings.test.ts +150 -0
  106. package/src/terminal/tui/settings.ts +188 -0
  107. package/src/terminal/tui/skillInvocation.test.ts +71 -0
  108. package/src/terminal/tui/skillInvocation.ts +51 -0
  109. package/src/terminal/tui/status.test.ts +48 -0
  110. package/src/terminal/tui/status.ts +82 -0
  111. package/src/terminal/tui/theme.ts +185 -0
  112. package/src/terminal/tui/tools.test.ts +145 -0
  113. package/src/terminal/tui/tools.ts +142 -0
  114. package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
  115. package/src/terminal/tui/trajectoryPanel.ts +226 -0
  116. package/src/terminal/tui/transcript.test.ts +39 -0
  117. package/src/terminal/tui/transcript.ts +135 -0
  118. package/src/terminal/tui/ui.test.ts +313 -0
  119. package/src/terminal/tui/ui.ts +166 -0
  120. package/src/terminal/tui/welcome.ts +102 -0
  121. package/src/terminal/tui/workingStatus.test.ts +44 -0
  122. package/src/terminal/tui/workingStatus.ts +238 -0
  123. package/src/tools/askUser.test.ts +69 -0
  124. package/src/tools/askUser.ts +31 -17
  125. package/src/tools/definitions.ts +42 -1
  126. package/src/tools/maintenance.ts +29 -0
  127. package/src/tools/memoryTools.ts +1 -1
  128. package/src/tools/registryWrite.test.ts +101 -0
  129. package/src/tools/registryWrite.ts +182 -0
  130. package/src/tools/spawnSubagent.ts +47 -0
  131. package/src/tools/types.ts +2 -0
@@ -0,0 +1,66 @@
1
+ import { mkdtempSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, expect, it } from 'vitest'
5
+
6
+ import { closeIndex } from '../core/index'
7
+ import type { RunEvent, RunRecord } from '../sessions/contracts'
8
+ import { appendEvent, saveRun } from '../sessions/runStore'
9
+ import { listTerminalTrajectoryRuns, readTerminalTrajectory } from './trajectory'
10
+
11
+ const roots: string[] = []
12
+ afterEach(() => {
13
+ for (const root of roots.splice(0)) closeIndex(root)
14
+ })
15
+
16
+ function setup() {
17
+ const root = mkdtempSync(join(tmpdir(), 'harness-terminal-trajectory-'))
18
+ roots.push(root)
19
+ const run = (id: string, sessionId: string, nativeSessionId: string | null): RunRecord => ({
20
+ id, sessionId, nativeSessionId, mode: 'code', goal: id,
21
+ resumedFromRunId: null, parentRunId: null, relation: null, terminalReason: null,
22
+ projectId: null, harness: 'pi', sessionType: 'terminal', workingDirectory: '/project',
23
+ worktreeId: null, attachmentCount: 0, startedAt: `2026-10-18T00:00:0${id.slice(-1)}Z`,
24
+ endedAt: null, status: 'running', exitCode: null, handoffId: null,
25
+ accountId: 'account-1', agentId: null, trigger: null
26
+ })
27
+ return { root, run }
28
+ }
29
+
30
+ const event = (runId: string, sessionId: string, sequence: number): RunEvent => ({
31
+ id: `${runId}-event-${sequence}`, runId, sessionId, sequence,
32
+ timestamp: `2026-10-18T00:00:0${sequence}Z`, type: 'started', payload: {}
33
+ })
34
+
35
+ it('lists newest runs only for the current terminal session and native conversation', () => {
36
+ const { root, run } = setup()
37
+ for (const item of [
38
+ run('run-1', 'terminal-1', '/history/one.jsonl'),
39
+ run('run-2', 'terminal-1', '/history/two.jsonl'),
40
+ run('run-3', 'terminal-2', '/history/one.jsonl')
41
+ ]) saveRun(root, item)
42
+
43
+ expect(listTerminalTrajectoryRuns(root, 'terminal-1', '/history/one.jsonl').map(item => item.id)).toEqual(['run-1'])
44
+ expect(listTerminalTrajectoryRuns(root, 'terminal-1', null)).toEqual([])
45
+ })
46
+
47
+ it('reads a selected run and only returns events after the requested sequence', () => {
48
+ const { root, run } = setup()
49
+ saveRun(root, run('run-1', 'terminal-1', '/history/current.jsonl'))
50
+ appendEvent(root, event('run-1', 'terminal-1', 0))
51
+ appendEvent(root, event('run-1', 'terminal-1', 1))
52
+
53
+ expect(readTerminalTrajectory(root, 'terminal-1', '/history/current.jsonl', 'run-1', 0)).toMatchObject({
54
+ run: { id: 'run-1', goal: 'run-1' },
55
+ events: [{ sequence: 1 }]
56
+ })
57
+ })
58
+
59
+ it('does not reveal runs from a different terminal or native conversation', () => {
60
+ const { root, run } = setup()
61
+ saveRun(root, run('run-1', 'terminal-1', '/history/one.jsonl'))
62
+
63
+ expect(() => readTerminalTrajectory(root, 'terminal-2', '/history/one.jsonl', 'run-1')).toThrow('Run was not found')
64
+ expect(() => readTerminalTrajectory(root, 'terminal-1', '/history/two.jsonl', 'run-1')).toThrow('Run was not found')
65
+ expect(() => readTerminalTrajectory(root, 'terminal-1', '/history/one.jsonl', 'run-1', -1)).toThrow('Run was not found')
66
+ })
@@ -0,0 +1,44 @@
1
+ import { notFound } from '../core/errors'
2
+ import type { RunEvent, RunRecord } from '../sessions/contracts'
3
+ import { findRun, listRuns, readEvents } from '../sessions/runStore'
4
+
5
+ export type TerminalTrajectoryRun = Pick<RunRecord, 'id' | 'mode' | 'goal' | 'startedAt' | 'endedAt' | 'status'>
6
+
7
+ export interface TerminalTrajectorySnapshot {
8
+ run: TerminalTrajectoryRun
9
+ events: RunEvent[]
10
+ }
11
+
12
+ function summary(run: RunRecord): TerminalTrajectoryRun {
13
+ return {
14
+ id: run.id,
15
+ mode: run.mode,
16
+ goal: run.goal,
17
+ startedAt: run.startedAt,
18
+ endedAt: run.endedAt,
19
+ status: run.status
20
+ }
21
+ }
22
+
23
+ /** The native conversation file is the terminal's boundary for this read-only view. */
24
+ export function listTerminalTrajectoryRuns(root: string, sessionId: string, nativeSessionId: string | null): TerminalTrajectoryRun[] {
25
+ if (!nativeSessionId) return []
26
+ return listRuns(root, { sessionId })
27
+ .filter(run => run.nativeSessionId === nativeSessionId)
28
+ .map(summary)
29
+ }
30
+
31
+ /** Read a run only when it belongs to both this terminal and its current native conversation. */
32
+ export function readTerminalTrajectory(
33
+ root: string,
34
+ sessionId: string,
35
+ nativeSessionId: string | null,
36
+ runId: string,
37
+ afterSequence?: number
38
+ ): TerminalTrajectorySnapshot {
39
+ if (!nativeSessionId || !Number.isSafeInteger(afterSequence ?? 0) || (afterSequence ?? 0) < 0) throw notFound('Run')
40
+ const run = findRun(root, runId)
41
+ if (!run || run.sessionId !== sessionId || run.nativeSessionId !== nativeSessionId) throw notFound('Run')
42
+ const events = readEvents(root, runId).filter(event => afterSequence === undefined || event.sequence > afterSequence)
43
+ return { run: summary(run), events }
44
+ }
@@ -0,0 +1,290 @@
1
+ import { CustomEditor, type AgentSessionEvent, type ExtensionUIContext, type Theme } from '@earendil-works/pi-coding-agent'
2
+ import { stripTerminalSequences } from '@earendil-works/pi-tui'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
6
+ import { controlCall } from '../control'
7
+ import { TerminalApplication } from './application'
8
+ import type { TerminalDecision } from './runtime'
9
+ import { terminalKeybindings } from './keybindings'
10
+ import { autocompleteRuleColor, createHarnessTheme } from './theme'
11
+ import { TerminalTranscript } from './transcript'
12
+
13
+ function setup() {
14
+ const application = new TerminalApplication([])
15
+ const app = application as unknown as {
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
+ }
22
+ keybindings: ReturnType<typeof terminalKeybindings>
23
+ editor: CustomEditor
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 } }
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
30
+ initialized: boolean
31
+ commandBusy: boolean
32
+ mountEditor(factory?: Parameters<ExtensionUIContext['setEditorComponent']>[0]): void
33
+ dispatchInput(data: string): { consume?: boolean } | undefined
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
40
+ }
41
+ vi.spyOn(application.tui, 'requestRender').mockImplementation(() => {})
42
+ vi.spyOn(application.tui, 'stop').mockImplementation(() => {})
43
+ vi.spyOn(application.terminal, 'drainInput').mockResolvedValue()
44
+ vi.spyOn(application.terminal, 'setProgress').mockImplementation(() => {})
45
+ const ui = { theme: createHarnessTheme({ getColorMode: () => 'truecolor' } as Theme), state: { workingVisible: true }, wrapAutocomplete: <T>(value: T) => value,
46
+ setToolsExpanded: vi.fn(), dispose: vi.fn() } as unknown as typeof app.ui
47
+ app.ui = ui
48
+ app.transcript = new TerminalTranscript(application.tui, () => ui.theme, () => undefined)
49
+ app.keybindings = terminalKeybindings('/unused/isolated-account')
50
+ app.initialized = true
51
+ const context = {}
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: {
56
+ extensionRunner: { getShortcuts: () => new Map(), createContext: () => context },
57
+ state: { messages: [] }, settingsManager: { getShowTerminalProgress: () => false },
58
+ getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } })
59
+ } }
60
+ return { application, app, ui }
61
+ }
62
+
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
+
132
+ it('lets trusted custom editor modes consume Escape before delegating host actions', async () => {
133
+ const { application, app } = setup()
134
+ let normalMode = false
135
+ class ModalEditor extends CustomEditor {
136
+ override handleInput(data: string) {
137
+ if (data === '\x1b') { normalMode = true; return }
138
+ super.handleInput(data)
139
+ }
140
+ }
141
+ app.mountEditor((tui, theme, keys) => new ModalEditor(tui, theme, keys))
142
+ expect(app.dispatchInput('\x1b')).toBeUndefined()
143
+ app.editor.handleInput('\x1b')
144
+ expect(normalMode).toBe(true)
145
+ expect(app.runtime.interrupt).not.toHaveBeenCalled()
146
+ expect(app.dispatchInput('\x0f')).toBeUndefined()
147
+ app.editor.handleInput('\x0f')
148
+ expect(app.ui.setToolsExpanded).toHaveBeenCalledWith(true)
149
+ await application.shutdown()
150
+ })
151
+
152
+ it('preserves custom editor change callbacks and reports actual draft contents', async () => {
153
+ const { application, app } = setup()
154
+ const changed = vi.fn()
155
+ app.mountEditor((tui, theme, keys) => { const editor = new CustomEditor(tui, theme, keys); editor.onChange = changed; return editor })
156
+ app.dispatchInput('draft')
157
+ app.editor.handleInput('draft')
158
+ await vi.waitFor(() => expect(controlCall).toHaveBeenCalledWith('terminal.draft', { dirty: true }))
159
+ expect(changed).toHaveBeenCalledWith('draft')
160
+ expect(app.editor.getText()).toBe('draft')
161
+ await application.shutdown()
162
+ })
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
+
222
+ it('routes leading-space commands and serializes command dialogs', async () => {
223
+ const { application, app } = setup()
224
+ app.mountEditor()
225
+ let resolve!: (value: boolean) => void
226
+ app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })) }
227
+ app.submit(' /settings')
228
+ await vi.waitFor(() => expect(app.commands.execute).toHaveBeenCalledWith(' /settings'))
229
+ app.submit('/model')
230
+ await Promise.resolve(); await Promise.resolve()
231
+ expect(app.commands.execute).toHaveBeenCalledTimes(1)
232
+ resolve(true)
233
+ await vi.waitFor(() => expect(app.commandBusy).toBe(false))
234
+ await application.shutdown()
235
+ })
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
+
265
+ it('restores terminal modes and finishes even when runtime and plugin cleanup fail', async () => {
266
+ const { application, app } = setup()
267
+ const header = { dispose: vi.fn(() => { throw new Error('header cleanup failed') }) }
268
+ const footer = { dispose: vi.fn() }
269
+ Object.assign(app, { header, footer })
270
+ app.ui.dispose = vi.fn(() => { throw new Error('dialog cleanup failed') })
271
+ app.runtime.dispose.mockRejectedValue(new Error('runtime cleanup failed'))
272
+ await expect(application.shutdown()).resolves.toBeUndefined()
273
+ expect(header.dispose).toHaveBeenCalledOnce()
274
+ expect(footer.dispose).toHaveBeenCalledOnce()
275
+ expect(app.runtime.dispose).toHaveBeenCalledOnce()
276
+ expect(application.terminal.drainInput).toHaveBeenCalledOnce()
277
+ expect(application.tui.stop).toHaveBeenCalledOnce()
278
+ })
279
+
280
+ it('has one shutdown that disposes the runtime and restores the terminal', async () => {
281
+ const { application, app } = setup()
282
+ const first = application.shutdown()
283
+ expect(application.shutdown()).toBe(first)
284
+ await first
285
+ expect(app.ui.dispose).toHaveBeenCalledOnce()
286
+ expect(app.runtime.dispose).toHaveBeenCalledOnce()
287
+ expect(application.terminal.drainInput).toHaveBeenCalledOnce()
288
+ expect(application.tui.stop).toHaveBeenCalledOnce()
289
+ })
290
+ })