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