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