@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.
- package/CHANGELOG.md +25 -0
- package/NOTICE.md +38 -0
- package/README.md +139 -28
- package/dist/cli.js +9598 -2201
- package/dist/terminal-pi.js +808 -16
- package/docs/README.md +15 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +68 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +344 -0
- package/package.json +11 -4
- package/src/accounts/accounts.test.ts +197 -1
- package/src/accounts/accounts.ts +266 -28
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +27 -4
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +12 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +206 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +367 -18
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +11 -3
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +43 -2
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +83 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +145 -27
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +45 -4
- package/src/sessions/sessions.test.ts +137 -4
- package/src/sessions/sessions.ts +51 -15
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +134 -7
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +91 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +55 -20
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +28 -7
- package/src/terminal/terminal.test.ts +220 -16
- package/src/terminal/terminals.ts +234 -16
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +290 -0
- package/src/terminal/tui/application.ts +653 -0
- package/src/terminal/tui/commands.test.ts +278 -0
- package/src/terminal/tui/commands.ts +443 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +113 -0
- package/src/terminal/tui/editor.ts +109 -0
- package/src/terminal/tui/keybindings.test.ts +30 -0
- package/src/terminal/tui/keybindings.ts +59 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +12 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +178 -0
- package/src/terminal/tui/runtime.test.ts +225 -0
- package/src/terminal/tui/runtime.ts +403 -0
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +48 -0
- package/src/terminal/tui/status.ts +82 -0
- package/src/terminal/tui/theme.ts +185 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +39 -0
- package/src/terminal/tui/transcript.ts +135 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +29 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +47 -0
- 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
|
+
})
|