@xingular/harness 2.0.0 → 2.1.1
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 +10 -0
- package/README.md +7 -3
- package/dist/cli.js +8709 -8145
- package/dist/terminal-pi.js +79 -18
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +3 -1
- package/docs/terminal-tui.md +19 -13
- package/package.json +1 -1
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/server/methods.ts +2 -0
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/sessions.test.ts +23 -0
- package/src/sessions/sessions.ts +17 -1
- package/src/terminal/piExtension.lifecycle.test.ts +28 -1
- package/src/terminal/piExtension.ts +15 -1
- package/src/terminal/tui/application.test.ts +31 -14
- package/src/terminal/tui/application.ts +75 -21
- package/src/terminal/tui/commands.test.ts +13 -0
- package/src/terminal/tui/commands.ts +7 -0
- package/src/terminal/tui/editor.ts +16 -1
- package/src/terminal/tui/keybindings.test.ts +1 -2
- package/src/terminal/tui/keybindings.ts +1 -2
- package/src/terminal/tui/planMode.ts +2 -12
- package/src/terminal/tui/runtime.ts +24 -2
- package/src/tools/types.ts +2 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Plan mode: a run that may only look, ask and recall, and ends with a proposed plan. The tool list
|
|
2
|
+
// the model sees is unchanged, so switching the mode does not change the cached prompt prefix; the
|
|
3
|
+
// gate refuses everything else, and the instruction arrives as a context update.
|
|
4
|
+
import { deliveredSnapshot, type HostSection } from '../prompt/compile'
|
|
5
|
+
import { ASK_USER } from '../tools/askUser'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read-only inspection, questions and recall. Everything else is refused in plan mode: file
|
|
9
|
+
* changes, commands, memory writes, delegation and connected apps, since none of them can be
|
|
10
|
+
* judged read-only from their names. A provider marks its own read-only tools with `readOnly`.
|
|
11
|
+
*/
|
|
12
|
+
export const PLAN_READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
|
|
13
|
+
'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
|
|
14
|
+
'history_search', 'history_read', 'trajectory_search', 'trajectory_read',
|
|
15
|
+
'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load', 'mcp_list_tools'
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export const PLAN_SECTION_ID = 'plan'
|
|
19
|
+
/** Between the application's role and the memory blocks. */
|
|
20
|
+
export const PLAN_SECTION_ORDER = 25
|
|
21
|
+
export const PLAN_OPEN_TAG = '<proposed_plan>'
|
|
22
|
+
export const PLAN_CLOSE_TAG = '</proposed_plan>'
|
|
23
|
+
|
|
24
|
+
export const PLAN_INSTRUCTIONS = `Plan mode is on for this request. Investigate and design; do not carry out the work.
|
|
25
|
+
- Read, search and recall freely. When a decision belongs to the user, ask with ${ASK_USER} instead of guessing.
|
|
26
|
+
- Do not change files, run commands, write memory, delegate, or call connected apps. Such calls are refused while plan mode is on.
|
|
27
|
+
- When the plan is ready, end the reply with exactly one block in this form, after any other text:
|
|
28
|
+
${PLAN_OPEN_TAG}
|
|
29
|
+
# <Plan title>
|
|
30
|
+
<Markdown: the context, the steps with the files they change, and how to verify the result>
|
|
31
|
+
${PLAN_CLOSE_TAG}
|
|
32
|
+
- If you need the user's input before a plan is possible, ask instead of proposing one.`
|
|
33
|
+
|
|
34
|
+
/** The tools a plan-mode run may call: the read-only set and the provider tools marked read-only. */
|
|
35
|
+
export function planModeTools(normal: readonly string[], readOnly: Iterable<string> = []): string[] {
|
|
36
|
+
const allowed = new Set([...PLAN_READ_ONLY_TOOLS, ...readOnly])
|
|
37
|
+
return normal.filter((name) => allowed.has(name))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why a plan-mode run may not call this tool, or null when it may. */
|
|
41
|
+
export function planRefusal(tool: string, readOnly: ReadonlySet<string>): string | null {
|
|
42
|
+
if (PLAN_READ_ONLY_TOOLS.has(tool) || readOnly.has(tool)) return null
|
|
43
|
+
return `Plan mode is read-only: ${tool} is not available until the user leaves plan mode. Continue investigating, or finish with a ${PLAN_OPEN_TAG} block.`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The plan section for this request. While plan mode is on it carries the instructions; on the
|
|
48
|
+
* first request after it, the empty slot withdraws the earlier instructions from the conversation.
|
|
49
|
+
* Otherwise there is no section, so a conversation that never planned is delivered exactly as before.
|
|
50
|
+
*/
|
|
51
|
+
export function planSection(plan: boolean, historyFile: string | null): HostSection | null {
|
|
52
|
+
if (plan) return { id: PLAN_SECTION_ID, title: 'Plan mode', body: PLAN_INSTRUCTIONS, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
|
|
53
|
+
const delivered = historyFile ? deliveredSnapshot(historyFile) : null
|
|
54
|
+
return delivered?.some((entry) => entry.id === PLAN_SECTION_ID)
|
|
55
|
+
? { id: PLAN_SECTION_ID, title: 'Plan mode', body: null, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
|
|
56
|
+
: null
|
|
57
|
+
}
|
|
@@ -400,6 +400,29 @@ describe('session turns on Pi', () => {
|
|
|
400
400
|
expect(contexts).toEqual([policyContext])
|
|
401
401
|
})
|
|
402
402
|
|
|
403
|
+
it('refuses non-read-only calls in plan mode and delivers its instructions without changing the system prompt', async () => {
|
|
404
|
+
const seen: { system: string; update: string | null; reasons: (string | null)[] }[] = []
|
|
405
|
+
const { run, harness } = setup(async ({ gate, params }) => fakeSession(async () => {
|
|
406
|
+
const reasons = [await gate.check('bash', {}), await gate.check('write', {}), await gate.check('memory_append', {}), await gate.check('lookup', {}), await gate.check('change', {})]
|
|
407
|
+
seen.push({ system: params.system, update: params.update?.content ?? null, reasons })
|
|
408
|
+
}, gate))
|
|
409
|
+
harness.approvals.setAuto('chat', true)
|
|
410
|
+
harness.sessions.provide(() => [
|
|
411
|
+
{ name: 'lookup', label: 'Lookup', description: 'Reads', readOnly: true, parameters: (Type) => Type.Object({}), execute: async () => 'ok' },
|
|
412
|
+
{ name: 'change', label: 'Change', description: 'Writes', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }
|
|
413
|
+
])
|
|
414
|
+
await run('plan-run', { plan: true, tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
|
|
415
|
+
await tick()
|
|
416
|
+
await run('normal-run', { tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
|
|
417
|
+
await tick()
|
|
418
|
+
const [planned, normal] = seen
|
|
419
|
+
expect(planned.reasons.map((reason) => reason?.startsWith('Plan mode is read-only') ?? null)).toEqual([true, true, true, null, true])
|
|
420
|
+
expect(normal.reasons).toEqual([null, null, null, null, null])
|
|
421
|
+
expect(planned.system).toBe(normal.system)
|
|
422
|
+
expect(planned.update).toContain('<proposed_plan>')
|
|
423
|
+
expect(normal.update ?? '').not.toContain('Plan mode is on')
|
|
424
|
+
})
|
|
425
|
+
|
|
403
426
|
it('aborts the Pi session on interrupt', async () => {
|
|
404
427
|
let session: (PiSessionLike & { aborted: boolean }) | null = null
|
|
405
428
|
const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
|
package/src/sessions/sessions.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { ToolPolicy } from './policy'
|
|
|
34
34
|
import type { ActiveRun, RunRegistry } from './registry'
|
|
35
35
|
import { piSessionFactory, runTurn, summarize, type Effort, type PiSessionLike, type SessionFactory, type ToolGate, type TurnParams } from './runner'
|
|
36
36
|
import { loadRun, runDir, saveRun, updateRun } from './runStore'
|
|
37
|
+
import { planRefusal, planSection } from './planMode'
|
|
37
38
|
|
|
38
39
|
/** The fields of a run record the caller decides; the harness fills in the rest. */
|
|
39
40
|
export type RunFields = Pick<RunRecord, 'id' | 'sessionId'> & Partial<Pick<RunRecord, 'mode' | 'harness' | 'sessionType' | 'projectId' | 'agentId' | 'trigger' | 'goal' | 'attachmentCount' | 'resumedFromRunId' | 'parentRunId' | 'relation' | 'worktreeId' | 'handoffId'>>
|
|
@@ -73,6 +74,11 @@ export interface SessionRunInput {
|
|
|
73
74
|
subagents?: SubagentOptions | null
|
|
74
75
|
/** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
|
|
75
76
|
providers?: boolean
|
|
77
|
+
/**
|
|
78
|
+
* Plan mode: the run may only read, search, recall and ask, and is told to end with a
|
|
79
|
+
* `<proposed_plan>` block. Other calls are refused at the gate; the tool list is unchanged.
|
|
80
|
+
*/
|
|
81
|
+
plan?: boolean
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/** The connected-apps broker, as a session sees it. */
|
|
@@ -240,8 +246,10 @@ export class Sessions {
|
|
|
240
246
|
try {
|
|
241
247
|
if (!existsSync(input.sessionDir)) mkdirSync(input.sessionDir, { recursive: true })
|
|
242
248
|
const launch = input.launch ?? {}
|
|
249
|
+
const plan = planSection(input.plan === true, historyFile)
|
|
243
250
|
const effectiveLaunch = {
|
|
244
251
|
...launch,
|
|
252
|
+
sections: plan ? [...(launch.sections ?? []).filter((section) => section.id !== plan.id), plan] : launch.sections,
|
|
245
253
|
tools: capabilities.tools,
|
|
246
254
|
connectionTools: (launch.connectionTools ?? []).filter((name) => capabilities.tools.includes(name)),
|
|
247
255
|
toolGroups: launch.toolGroups?.map((group) => ({ ...group, tools: group.tools.filter((name) => capabilities.tools.includes(name)) })).filter((group) => group.tools.length > 0)
|
|
@@ -269,6 +277,7 @@ export class Sessions {
|
|
|
269
277
|
...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
|
|
270
278
|
]
|
|
271
279
|
if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
280
|
+
const readOnly = new Set(tools.filter((tool) => tool.readOnly).map((tool) => tool.name))
|
|
272
281
|
const params: TurnParams = {
|
|
273
282
|
runId,
|
|
274
283
|
restrictTools: input.run.relation === 'subagent',
|
|
@@ -294,7 +303,14 @@ export class Sessions {
|
|
|
294
303
|
allow,
|
|
295
304
|
gate: (() => {
|
|
296
305
|
const gate = this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name))))
|
|
297
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
...gate,
|
|
308
|
+
check: (name: string, args: unknown) => {
|
|
309
|
+
if (input.run.relation === 'subagent' && !capabilities.tools.includes(name)) return Promise.resolve('This tool is outside the child capability snapshot.')
|
|
310
|
+
const refusal = input.plan ? planRefusal(name, readOnly) : null
|
|
311
|
+
return refusal ? Promise.resolve(refusal) : gate.check(name, args)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
298
314
|
})(),
|
|
299
315
|
tools,
|
|
300
316
|
runtime: () => this.options.accounts.runtime(context),
|
|
@@ -11,7 +11,8 @@ vi.mock('./client', () => ({
|
|
|
11
11
|
terminalCall: transport.telemetry
|
|
12
12
|
}))
|
|
13
13
|
|
|
14
|
-
import terminalExtension from './piExtension'
|
|
14
|
+
import terminalExtension, { type TerminalPlanRunState } from './piExtension'
|
|
15
|
+
import { PLAN_INSTRUCTIONS } from '../sessions/planMode'
|
|
15
16
|
|
|
16
17
|
function extension() {
|
|
17
18
|
const handlers = new Map<string, (...args: unknown[]) => unknown>()
|
|
@@ -28,6 +29,32 @@ function extension() {
|
|
|
28
29
|
afterEach(() => vi.useRealTimers())
|
|
29
30
|
|
|
30
31
|
describe('terminal bridge lifecycle', () => {
|
|
32
|
+
it('adds plan instructions as a one-run context section and withdraws them on the next user request', async () => {
|
|
33
|
+
const state: TerminalPlanRunState = { active: true, allowedTools: new Set(['read']) }
|
|
34
|
+
const { api, ctx, handlers } = extension()
|
|
35
|
+
await terminalExtension(api, state)
|
|
36
|
+
const planned = { messageType: 'user', prompt: 'inspect this', systemPrompt: 'stable prefix', systemPromptOptions: { sections: { identity: 'identity' } } }
|
|
37
|
+
await handlers.get('before_agent_start')!(planned, ctx)
|
|
38
|
+
expect(planned.systemPromptOptions.sections).toEqual({ identity: 'identity', plan: PLAN_INSTRUCTIONS })
|
|
39
|
+
expect(planned.systemPrompt).toBe('stable prefix')
|
|
40
|
+
|
|
41
|
+
state.active = false
|
|
42
|
+
const ordinary = { messageType: 'user', prompt: 'now implement', systemPrompt: 'stable prefix', systemPromptOptions: { sections: { identity: 'identity', plan: PLAN_INSTRUCTIONS } } }
|
|
43
|
+
await handlers.get('before_agent_start')!(ordinary, ctx)
|
|
44
|
+
expect(ordinary.systemPromptOptions.sections).toEqual({ identity: 'identity' })
|
|
45
|
+
expect(ordinary.systemPrompt).toBe('stable prefix')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('blocks tools outside the active plan allowlist before consulting terminal policy', async () => {
|
|
49
|
+
const state: TerminalPlanRunState = { active: true, allowedTools: new Set(['read']) }
|
|
50
|
+
const { api, ctx, handlers } = extension()
|
|
51
|
+
await terminalExtension(api, state)
|
|
52
|
+
const policyCalls = transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy').length
|
|
53
|
+
const result = await handlers.get('tool_call')!({ toolName: 'bash', toolCallId: 'call-1', input: { command: 'touch unsafe' } }, ctx)
|
|
54
|
+
expect(result).toMatchObject({ block: true, reason: expect.stringContaining('Plan mode is read-only') })
|
|
55
|
+
expect(transport.socket.mock.calls.filter(([, call]) => call === 'terminal.policy')).toHaveLength(policyCalls)
|
|
56
|
+
})
|
|
57
|
+
|
|
31
58
|
it('keeps one private control claim while replacing native session pollers and restoring readiness', async () => {
|
|
32
59
|
vi.useFakeTimers()
|
|
33
60
|
const first = extension()
|
|
@@ -13,6 +13,7 @@ import { claimTerminalControl, controlCall } from './control'
|
|
|
13
13
|
import { SUBAGENT_OWNER, SUBAGENT_RECEIPT, SUBAGENT_RESULTS, subagentHistory, type SubagentReceipt } from '../sessions/subagents'
|
|
14
14
|
import { askUserTool } from '../tools/askUser'
|
|
15
15
|
import type { QuestionAnswer } from '../sessions/registry'
|
|
16
|
+
import { PLAN_INSTRUCTIONS, planRefusal } from '../sessions/planMode'
|
|
16
17
|
|
|
17
18
|
interface SocketTool {
|
|
18
19
|
name: string
|
|
@@ -21,6 +22,11 @@ interface SocketTool {
|
|
|
21
22
|
parameters: Record<string, unknown>
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
export interface TerminalPlanRunState {
|
|
26
|
+
active: boolean
|
|
27
|
+
allowedTools: ReadonlySet<string>
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
/** How much text a tool handed back to the model. */
|
|
25
31
|
function resultSize(result: unknown): number | null {
|
|
26
32
|
const content = (result as { content?: unknown } | null | undefined)?.content
|
|
@@ -35,7 +41,7 @@ async function withParentCancellation<T>(signal: AbortSignal | undefined, work:
|
|
|
35
41
|
try { return await work() } finally { signal?.removeEventListener('abort', stop) }
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
export default async function (pi: ExtensionAPI) {
|
|
44
|
+
export default async function (pi: ExtensionAPI, planRun?: TerminalPlanRunState) {
|
|
39
45
|
await terminalCall('startup', { phase: 'Pi extension loaded' })
|
|
40
46
|
let context: ExtensionContext | null = null
|
|
41
47
|
let nativeSubagents = false
|
|
@@ -167,6 +173,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
167
173
|
await controlCall('terminal.subagents.register', { tools: pi.getActiveTools(), model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null, effort: pi.getThinkingLevel(), history: subagentHistory(ctx.sessionManager.getBranch()) })
|
|
168
174
|
subagentParent = true
|
|
169
175
|
}
|
|
176
|
+
if (planRun && event.messageType === 'user') {
|
|
177
|
+
if (planRun.active) event.systemPromptOptions.sections.plan = PLAN_INSTRUCTIONS
|
|
178
|
+
else delete event.systemPromptOptions.sections.plan
|
|
179
|
+
}
|
|
170
180
|
})
|
|
171
181
|
const deliverChildren = async (event: any, ctx: ExtensionContext, settle: boolean) => {
|
|
172
182
|
if (!nativeSubagents || (settle && event.outcome !== 'completed') || ctx.signal?.aborted) return
|
|
@@ -224,6 +234,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
224
234
|
requestStartedAt = Date.now()
|
|
225
235
|
})
|
|
226
236
|
pi.on('tool_call', async (event) => {
|
|
237
|
+
if (planRun?.active && !planRun.allowedTools.has(event.toolName)) {
|
|
238
|
+
const reason = planRefusal(event.toolName, new Set()) ?? `Plan mode is read-only: ${event.toolName} is not available in this session.`
|
|
239
|
+
return { block: true, reason }
|
|
240
|
+
}
|
|
227
241
|
try {
|
|
228
242
|
const policy = await controlCall<{ allowed: boolean; reason?: string; requiresApproval?: boolean }>('terminal.policy', { tool: event.toolName, args: event.input })
|
|
229
243
|
if (policy.requiresApproval) {
|
|
@@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
5
5
|
vi.mock('../control', () => ({ controlCall: vi.fn(async () => ({})) }))
|
|
6
6
|
import { controlCall } from '../control'
|
|
7
7
|
import { TerminalApplication } from './application'
|
|
8
|
+
import { createTerminalCommands } from './commands'
|
|
8
9
|
import type { TerminalDecision } from './runtime'
|
|
9
10
|
import { terminalKeybindings } from './keybindings'
|
|
10
11
|
import { autocompleteRuleColor, createHarnessTheme } from './theme'
|
|
@@ -21,8 +22,8 @@ function setup() {
|
|
|
21
22
|
}
|
|
22
23
|
keybindings: ReturnType<typeof terminalKeybindings>
|
|
23
24
|
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
|
|
25
|
+
runtime: { interrupt: ReturnType<typeof vi.fn>; dispose: ReturnType<typeof vi.fn>; submit: ReturnType<typeof vi.fn>; submitPlan: 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>; expandSkill(text: string): string }
|
|
26
27
|
masthead: { render(width: number): string[] }
|
|
27
28
|
header?: { render(width: number): string[]; invalidate(): void }
|
|
28
29
|
branch: string | null
|
|
@@ -49,7 +50,7 @@ function setup() {
|
|
|
49
50
|
app.keybindings = terminalKeybindings('/unused/isolated-account')
|
|
50
51
|
app.initialized = true
|
|
51
52
|
const context = {}
|
|
52
|
-
app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), snapshot: () => ({
|
|
53
|
+
app.runtime = { interrupt: vi.fn(async () => {}), dispose: vi.fn(async () => {}), submit: vi.fn(async () => {}), submitPlan: vi.fn(async () => {}), snapshot: () => ({
|
|
53
54
|
hasUserMessages: false, cwd: '/work/project', model: { name: 'Claude Sonnet 4.6', reasoning: true, contextWindow: 200_000 },
|
|
54
55
|
thinkingLevel: 'high', account: { label: 'Tanny' }, context: { percent: null, window: 0 }, costUsd: 0, subscription: false
|
|
55
56
|
}), session: {
|
|
@@ -223,7 +224,7 @@ describe('the owned terminal application', () => {
|
|
|
223
224
|
const { application, app } = setup()
|
|
224
225
|
app.mountEditor()
|
|
225
226
|
let resolve!: (value: boolean) => void
|
|
226
|
-
app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })) }
|
|
227
|
+
app.commands = { execute: vi.fn(() => new Promise<boolean>(done => { resolve = done })), expandSkill: text => text }
|
|
227
228
|
app.submit(' /settings')
|
|
228
229
|
await vi.waitFor(() => expect(app.commands.execute).toHaveBeenCalledWith(' /settings'))
|
|
229
230
|
app.submit('/model')
|
|
@@ -234,17 +235,33 @@ describe('the owned terminal application', () => {
|
|
|
234
235
|
await application.shutdown()
|
|
235
236
|
})
|
|
236
237
|
|
|
237
|
-
it('
|
|
238
|
+
it('keeps a queued plan isolated and sends later messages after it settles', async () => {
|
|
238
239
|
const { application, app } = setup()
|
|
239
|
-
const session = app.runtime.session as {
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
240
|
+
const session = app.runtime.session as { isIdle: boolean }
|
|
241
|
+
Object.assign(session, { isIdle: false })
|
|
242
|
+
const sent: string[] = []
|
|
243
|
+
app.runtime.submitPlan = vi.fn(async brief => { sent.push(`plan:${brief}`) })
|
|
244
|
+
app.runtime.submit = vi.fn(async text => { sent.push(`prompt:${text}`) })
|
|
245
|
+
const enqueuePlan = (application as unknown as { enqueuePlan(brief: string): void }).enqueuePlan.bind(application)
|
|
246
|
+
app.mountEditor()
|
|
247
|
+
app.commands = {
|
|
248
|
+
execute: vi.fn(async text => {
|
|
249
|
+
if (text.trimStart().startsWith('/plan ')) { enqueuePlan(text.trimStart().slice('/plan '.length)); return true }
|
|
250
|
+
return false
|
|
251
|
+
}),
|
|
252
|
+
expandSkill: text => text
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
app.submit('/plan inspect the source')
|
|
256
|
+
await vi.waitFor(() => expect(app.commandBusy).toBe(false))
|
|
257
|
+
app.submit('then implement it')
|
|
258
|
+
await vi.waitFor(() => expect((application as unknown as { deferredInputs: unknown[] }).deferredInputs).toHaveLength(2))
|
|
259
|
+
expect(sent).toEqual([])
|
|
260
|
+
|
|
261
|
+
session.isIdle = true
|
|
262
|
+
app.accept({ type: 'agent_settled' } as AgentSessionEvent)
|
|
263
|
+
await vi.waitFor(() => expect(sent).toEqual(['plan:inspect the source', 'prompt:then implement it']))
|
|
264
|
+
expect(app.runtime.submitPlan).toHaveBeenCalledOnce()
|
|
248
265
|
await application.shutdown()
|
|
249
266
|
})
|
|
250
267
|
|
|
@@ -16,7 +16,6 @@ import { HARNESS_KEYBINDINGS, terminalKeybindings } from './keybindings'
|
|
|
16
16
|
import { loadPiAdapter, setPiTheme } from './piAdapter'
|
|
17
17
|
import { createTerminalRuntime, type TerminalDecision, type TerminalRuntime } from './runtime'
|
|
18
18
|
import { createDock, createTerminalScreen, mountTerminalLayout, readScreenPreferences, scrollbackLines, writeScrollback, type ScreenHooks, type ScreenPreferences, type TerminalScreen } from './screen'
|
|
19
|
-
import { planModeTools } from './planMode'
|
|
20
19
|
import { renderStatusBand, singleLine } from './status'
|
|
21
20
|
import { skillCompletions } from './skillInvocation'
|
|
22
21
|
import { createHarnessLightTheme, createHarnessTheme } from './theme'
|
|
@@ -30,6 +29,7 @@ import type { TerminalTrajectoryRun, TerminalTrajectorySnapshot } from '../traje
|
|
|
30
29
|
|
|
31
30
|
const exec = promisify(execFile)
|
|
32
31
|
type Mounted = Component & { dispose?(): void }
|
|
32
|
+
type DeferredTerminalInput = { kind: 'plan'; text: string } | { kind: 'prompt'; text: string; followUp: boolean }
|
|
33
33
|
type EditorFactory = Parameters<ExtensionUIContext['setEditorComponent']>[0]
|
|
34
34
|
type HeaderFactory = Parameters<ExtensionUIContext['setHeader']>[0]
|
|
35
35
|
type FooterFactory = Parameters<ExtensionUIContext['setFooter']>[0]
|
|
@@ -102,8 +102,11 @@ export class TerminalApplication {
|
|
|
102
102
|
private phase?: string
|
|
103
103
|
private viewport?: ScrollView
|
|
104
104
|
private toolsExpanded = false
|
|
105
|
-
private
|
|
106
|
-
private
|
|
105
|
+
private deferredInputs: DeferredTerminalInput[] = []
|
|
106
|
+
private planBarrierOpen = false
|
|
107
|
+
private planActive = false
|
|
108
|
+
private drainingDeferred = false
|
|
109
|
+
private activeDeferredKind?: DeferredTerminalInput['kind']
|
|
107
110
|
private stopped = false
|
|
108
111
|
private initialized = false
|
|
109
112
|
private closing?: Promise<void>
|
|
@@ -184,6 +187,7 @@ export class TerminalApplication {
|
|
|
184
187
|
session: () => this.session, runtime: () => this.runtime!, ui: this.ui,
|
|
185
188
|
cwd: () => this.runtime!.cwd, sessionDirectory: () => this.runtime!.sessionDirectory,
|
|
186
189
|
refresh: () => this.refresh(), shutdown: () => { void this.shutdown() }, setEditorText: text => { this.editor.setText(text); this.syncDraft(text) },
|
|
190
|
+
plan: brief => this.enqueuePlan(brief),
|
|
187
191
|
copyText: copyToClipboard, openExternal,
|
|
188
192
|
changed: this.change,
|
|
189
193
|
trajectory: {
|
|
@@ -201,7 +205,7 @@ export class TerminalApplication {
|
|
|
201
205
|
})
|
|
202
206
|
this.cleanup.push(this.runtime.subscribe(event => this.accept(event)))
|
|
203
207
|
this.cleanup.push(this.runtime.onBeforeSessionChange(() => this.resetPresentation()))
|
|
204
|
-
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.
|
|
208
|
+
this.cleanup.push(this.runtime.onSessionChange(() => { this.keybindings.reload(); this.resetWorkingStatus(); this.scrollToLatest(); this.refresh(true) }))
|
|
205
209
|
this.initialized = true
|
|
206
210
|
this.phase = undefined
|
|
207
211
|
this.refresh(true)
|
|
@@ -306,19 +310,46 @@ export class TerminalApplication {
|
|
|
306
310
|
this.completionStatus = undefined
|
|
307
311
|
this.phase = undefined
|
|
308
312
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (!this.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
this.normalTools = this.session.getActiveToolNames()
|
|
317
|
-
this.session.setActiveToolsByName(planModeTools(this.normalTools))
|
|
318
|
-
}
|
|
319
|
-
this.planMode = !this.planMode
|
|
320
|
-
this.notify(this.planMode ? 'Plan mode: only read-only tools are available. Shift+Tab returns to normal.' : 'Normal mode: all tools are available.')
|
|
313
|
+
private enqueuePlan(brief: string): void {
|
|
314
|
+
const text = brief.trim()
|
|
315
|
+
if (!text) { this.notify('Use /plan <plan brief>.', 'error'); return }
|
|
316
|
+
const waiting = !this.session.isIdle
|
|
317
|
+
this.planBarrierOpen = true
|
|
318
|
+
this.deferredInputs.push({ kind: 'plan', text })
|
|
319
|
+
this.notify(waiting ? 'Plan request queued to run by itself after the current response.' : 'Plan request queued as an isolated run.')
|
|
321
320
|
this.change()
|
|
321
|
+
this.drainDeferred()
|
|
322
|
+
}
|
|
323
|
+
private drainDeferred(): void {
|
|
324
|
+
if (this.drainingDeferred || !this.planBarrierOpen || !this.runtime || !this.session.isIdle || !this.deferredInputs.length) return
|
|
325
|
+
this.drainingDeferred = true
|
|
326
|
+
void (async () => {
|
|
327
|
+
try {
|
|
328
|
+
while (this.deferredInputs.length && !this.stopped && this.runtime && this.session.isIdle) {
|
|
329
|
+
const next = this.deferredInputs.shift()!
|
|
330
|
+
this.activeDeferredKind = next.kind
|
|
331
|
+
this.change()
|
|
332
|
+
try {
|
|
333
|
+
if (next.kind === 'plan') {
|
|
334
|
+
this.planActive = true
|
|
335
|
+
await this.runtime.submitPlan(next.text)
|
|
336
|
+
} else {
|
|
337
|
+
await this.runtime.submit(next.text, next.followUp)
|
|
338
|
+
}
|
|
339
|
+
} catch (error) {
|
|
340
|
+
this.notify(error instanceof Error ? error.message : String(error), 'error')
|
|
341
|
+
} finally {
|
|
342
|
+
if (next.kind === 'plan') this.planActive = false
|
|
343
|
+
this.activeDeferredKind = undefined
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} finally {
|
|
347
|
+
this.drainingDeferred = false
|
|
348
|
+
if (!this.deferredInputs.length && !this.planActive) this.planBarrierOpen = false
|
|
349
|
+
this.change()
|
|
350
|
+
if (this.deferredInputs.length && this.runtime?.session.isIdle) this.drainDeferred()
|
|
351
|
+
}
|
|
352
|
+
})()
|
|
322
353
|
}
|
|
323
354
|
private scrollToLatest(): void { this.viewport?.scrollToEnd() }
|
|
324
355
|
private async chooseApproval(request: TerminalDecision, signal: AbortSignal): Promise<{ decision: 'approved' | 'denied'; alwaysAllow: boolean }> {
|
|
@@ -395,7 +426,9 @@ export class TerminalApplication {
|
|
|
395
426
|
private queuedMessages(width: number): string[] {
|
|
396
427
|
if (!this.ui) return []
|
|
397
428
|
const state = this.runtime?.snapshot()
|
|
398
|
-
|
|
429
|
+
const native = state ? [...state.queue.steering.map(text => `Steering: ${text}`), ...state.queue.followUp.map(text => `Follow-up: ${text}`)] : []
|
|
430
|
+
const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `Plan: /plan ${input.text}` : `Queued: ${input.text}`)
|
|
431
|
+
return [...native, ...deferred].flatMap(text => wrapTextWithAnsi(this.theme.fg('muted', text), width))
|
|
399
432
|
}
|
|
400
433
|
private statusLines(width: number): string[] {
|
|
401
434
|
return this.statuses.size ? wrapTextWithAnsi([...this.statuses.values()].join(' '), width) : []
|
|
@@ -425,7 +458,13 @@ export class TerminalApplication {
|
|
|
425
458
|
if (this.stopped) return
|
|
426
459
|
if (command && await this.commands.execute(text)) return
|
|
427
460
|
if (text.trimStart().startsWith('!')) throw new Error('Use the bash tool so this action can be checked.')
|
|
428
|
-
|
|
461
|
+
const expanded = this.commands.expandSkill(text)
|
|
462
|
+
if (this.planBarrierOpen) {
|
|
463
|
+
this.deferredInputs.push({ kind: 'prompt', text: expanded, followUp })
|
|
464
|
+
this.change()
|
|
465
|
+
return
|
|
466
|
+
}
|
|
467
|
+
await this.runtime!.submit(expanded, followUp)
|
|
429
468
|
} finally { if (command) this.commandBusy = false }
|
|
430
469
|
})
|
|
431
470
|
}
|
|
@@ -446,7 +485,6 @@ export class TerminalApplication {
|
|
|
446
485
|
else void this.shutdown()
|
|
447
486
|
} else if (is('app.exit') && this.editor.getText() === '') void this.shutdown()
|
|
448
487
|
else if (is('app.thinking.cycle')) this.session.cycleThinkingLevel()
|
|
449
|
-
else if (is('app.plan.toggle')) this.togglePlanMode()
|
|
450
488
|
else if (is('app.thinking.increase') || is('app.thinking.decrease')) this.stepThinking(is('app.thinking.increase') ? 1 : -1)
|
|
451
489
|
else if (is('app.thinking.save')) this.session.setThinkingLevel(this.session.thinkingLevel, { persist: true })
|
|
452
490
|
else if (is('app.model.cycleForward') || is('app.model.cycleBackward')) this.attempt(() => this.session.cycleModel(is('app.model.cycleBackward') ? 'backward' : 'forward'))
|
|
@@ -454,7 +492,14 @@ export class TerminalApplication {
|
|
|
454
492
|
else if (is('app.tools.expand')) this.ui.setToolsExpanded(!this.toolsExpanded)
|
|
455
493
|
else if (is('app.thinking.toggle')) { const hidden = !this.session.settingsManager.getHideThinkingBlock(); this.session.settingsManager.setHideThinkingBlock(hidden); this.transcript.setThinking(hidden) }
|
|
456
494
|
else if (is('app.message.followUp')) this.submit(this.editor.getExpandedText?.() ?? this.editor.getText(), true)
|
|
457
|
-
else if (is('app.message.dequeue')) {
|
|
495
|
+
else if (is('app.message.dequeue')) {
|
|
496
|
+
const pending = this.session.clearQueue()
|
|
497
|
+
const deferred = this.deferredInputs.map(input => input.kind === 'plan' ? `/plan ${input.text}` : input.text)
|
|
498
|
+
this.deferredInputs = []
|
|
499
|
+
this.planBarrierOpen = this.planActive || this.activeDeferredKind === 'plan'
|
|
500
|
+
this.editor.setText([...pending.steering, ...pending.followUp, ...deferred, this.editor.getText()].filter(Boolean).join('\n'))
|
|
501
|
+
this.syncDraft(this.editor.getText())
|
|
502
|
+
}
|
|
458
503
|
else if (is('app.editor.external')) this.attempt(() => this.externalEditor())
|
|
459
504
|
else if (is('app.message.copy')) this.attempt(() => this.commands.execute('/copy'))
|
|
460
505
|
else if (is('app.clipboard.pasteImage')) this.attempt(() => this.pasteClipboard())
|
|
@@ -497,6 +542,7 @@ export class TerminalApplication {
|
|
|
497
542
|
this.requestStartedAt = null
|
|
498
543
|
this.phase = undefined
|
|
499
544
|
this.terminal.setProgress(false)
|
|
545
|
+
queueMicrotask(() => this.drainDeferred())
|
|
500
546
|
}
|
|
501
547
|
if (event.type === 'compaction_start') this.phase = 'Compacting context…'
|
|
502
548
|
if (event.type === 'compaction_end') { this.phase = undefined; this.transcript.bind(this.session); if (event.errorMessage) this.notify(event.errorMessage, 'error') }
|
|
@@ -513,12 +559,20 @@ export class TerminalApplication {
|
|
|
513
559
|
const text = typeof message.content === 'string' ? message.content : message.content.filter(part => part.type === 'text').map(part => part.text).join('\n')
|
|
514
560
|
if (text) this.editor.addToHistory?.(text)
|
|
515
561
|
}
|
|
516
|
-
|
|
562
|
+
const commands = this.commands.completions()
|
|
563
|
+
if (this.editor instanceof HarnessEditor) {
|
|
564
|
+
this.editor.setValidCommands([
|
|
565
|
+
...commands.map(command => command.name),
|
|
566
|
+
...this.commands.skills().map(skill => 'skill:' + skill.name)
|
|
567
|
+
])
|
|
568
|
+
}
|
|
569
|
+
this.baseAutocomplete = skillCompletions(new CombinedAutocompleteProvider(commands, this.runtime.cwd), this.commands.skills)
|
|
517
570
|
this.editor.setAutocompleteProvider?.(this.ui.wrapAutocomplete(this.baseAutocomplete))
|
|
518
571
|
this.change()
|
|
519
572
|
}
|
|
520
573
|
private resetPresentation(): void {
|
|
521
574
|
this.pendingAbort?.abort(); this.dismissedDecisions.clear()
|
|
575
|
+
this.deferredInputs = []; this.planBarrierOpen = false; this.planActive = false; this.activeDeferredKind = undefined
|
|
522
576
|
this.resetWorkingStatus()
|
|
523
577
|
this.requestStartedAt = null; this.lastTokensPerSecond = null
|
|
524
578
|
this.transcript.clearSession()
|
|
@@ -31,6 +31,7 @@ function setup() {
|
|
|
31
31
|
session: () => session, runtime: () => runtime as unknown as ReturnType<TerminalCommandHost['runtime']>, ui,
|
|
32
32
|
cwd: () => '/isolated/project', sessionDirectory: () => '/isolated/account/history',
|
|
33
33
|
refresh: vi.fn(), changed: vi.fn(), shutdown: vi.fn(), setEditorText: vi.fn(), copyText: vi.fn(async () => {}),
|
|
34
|
+
plan: vi.fn(),
|
|
34
35
|
hotkeys: () => 'Escape: interrupt', changelog: async () => 'Harness release notes',
|
|
35
36
|
trajectory: {
|
|
36
37
|
list: vi.fn(async () => [trajectoryRun]),
|
|
@@ -64,6 +65,18 @@ describe('owned terminal commands', () => {
|
|
|
64
65
|
expect(new Set(names).size).toBe(names.length)
|
|
65
66
|
})
|
|
66
67
|
|
|
68
|
+
it('runs one plan request from /plan <brief> and rejects toggle syntax', async () => {
|
|
69
|
+
const { commands, host, ui } = setup()
|
|
70
|
+
expect(await commands.execute('/plan inspect the current implementation')).toBe(true)
|
|
71
|
+
expect(host.plan).toHaveBeenCalledWith('inspect the current implementation')
|
|
72
|
+
expect(await commands.execute('/plan')).toBe(true)
|
|
73
|
+
expect(await commands.execute('/plan on')).toBe(true)
|
|
74
|
+
expect(await commands.execute('/plan toggle')).toBe(true)
|
|
75
|
+
expect(host.plan).toHaveBeenCalledOnce()
|
|
76
|
+
expect(ui.notify).toHaveBeenLastCalledWith('Use /plan <plan brief>.', 'error')
|
|
77
|
+
expect(commands.completions().find(item => item.name === 'plan')?.description).toContain('/plan <brief>')
|
|
78
|
+
})
|
|
79
|
+
|
|
67
80
|
it('invokes a loaded skill as $name, and leaves other text for the SDK', () => {
|
|
68
81
|
const { commands, session } = setup()
|
|
69
82
|
expect(commands.skills()).toEqual([{ name: 'diagnose', description: 'Diagnose a problem' }])
|
|
@@ -21,6 +21,7 @@ export interface TerminalCommandHost {
|
|
|
21
21
|
sessionDirectory(): string
|
|
22
22
|
refresh(): void
|
|
23
23
|
changed(): void
|
|
24
|
+
plan(brief: string): void
|
|
24
25
|
shutdown(): void
|
|
25
26
|
setEditorText(text: string): void
|
|
26
27
|
copyText(text: string): Promise<void>
|
|
@@ -39,6 +40,7 @@ export interface TerminalCommandCompletion { name: string; description?: string
|
|
|
39
40
|
|
|
40
41
|
const BUILTINS: TerminalCommandCompletion[] = [
|
|
41
42
|
{ name: 'help', description: 'Available terminal commands' },
|
|
43
|
+
{ name: 'plan', description: 'Run one read-only plan request: /plan <brief>' },
|
|
42
44
|
{ name: 'new', description: 'Start a new session' },
|
|
43
45
|
{ name: 'resume', description: 'Resume a session in this account and folder' },
|
|
44
46
|
{ name: 'tree', description: 'Navigate the session tree' },
|
|
@@ -286,6 +288,11 @@ export function createTerminalCommands(host: TerminalCommandHost) {
|
|
|
286
288
|
const session = host.session()
|
|
287
289
|
switch (name) {
|
|
288
290
|
case 'help': notify(completions().map(item => `/${item.name} ${item.description ?? ''}`).join('\n')); break
|
|
291
|
+
case 'plan': {
|
|
292
|
+
if (!argument || ['on', 'off', 'toggle'].includes(argument.toLowerCase())) throw new Error('Use /plan <plan brief>.')
|
|
293
|
+
host.plan(argument)
|
|
294
|
+
break
|
|
295
|
+
}
|
|
289
296
|
case 'quit': host.shutdown(); break
|
|
290
297
|
case 'new': await host.runtime().newSession(); break
|
|
291
298
|
case 'resume': {
|
|
@@ -15,6 +15,12 @@ export const COMPOSER_SUGGESTIONS = [
|
|
|
15
15
|
'write a test for the next change'
|
|
16
16
|
]
|
|
17
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
|
+
|
|
18
24
|
export interface ComposerView {
|
|
19
25
|
band(width: number, hiddenAbove: number, hiddenBelow: number): string
|
|
20
26
|
theme(): Theme
|
|
@@ -27,11 +33,17 @@ export class HarnessEditor extends CustomEditor {
|
|
|
27
33
|
private contentRows = 1
|
|
28
34
|
private completionRows = 0
|
|
29
35
|
private gutterWidth = 2
|
|
36
|
+
private validCommands = new Set<string>()
|
|
30
37
|
|
|
31
38
|
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, private readonly view: ComposerView) {
|
|
32
39
|
super(tui, theme, keybindings, { paddingX: 0 })
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
setValidCommands(commands: readonly string[]): void {
|
|
43
|
+
this.validCommands = new Set(commands)
|
|
44
|
+
this.tui.requestRender()
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
override setPaddingX(_padding: number): void {
|
|
36
48
|
super.setPaddingX(0)
|
|
37
49
|
}
|
|
@@ -58,10 +70,13 @@ export class HarnessEditor extends CustomEditor {
|
|
|
58
70
|
this.contentRows = content.length
|
|
59
71
|
const theme = this.view.theme()
|
|
60
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
|
|
61
75
|
const prompt = content.map((line, index) => {
|
|
62
76
|
const cue = theme.fg('dim', index === 0 ? '❯ '.slice(0, this.gutterWidth) : ' '.repeat(this.gutterWidth))
|
|
63
77
|
if (index === 0 && placeholder) return cue + this.placeholderRow(placeholder, innerWidth, theme)
|
|
64
|
-
|
|
78
|
+
const renderedLine = index === commandLine && command ? line.replace(command, theme.fg('mdLink', command)) : line
|
|
79
|
+
return cue + renderedLine
|
|
65
80
|
})
|
|
66
81
|
const completions = rows.slice(bottom + 1).map((line) => ' '.repeat(this.gutterWidth) + line)
|
|
67
82
|
this.completionRows = completions.length
|
|
@@ -4,11 +4,10 @@ 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
|
|
7
|
+
it('steps thinking effort on shift arrows', () => {
|
|
8
8
|
const manager = terminalKeybindings('/unused/isolated-account')
|
|
9
9
|
expect(manager.matches('\x1b[1;2A', 'app.thinking.increase')).toBe(true)
|
|
10
10
|
expect(manager.matches('\x1b[1;2B', 'app.thinking.decrease')).toBe(true)
|
|
11
|
-
expect(manager.matches('\x1b[Z', 'app.plan.toggle')).toBe(true)
|
|
12
11
|
expect(manager.matches('\x1b[Z', 'app.thinking.cycle')).toBe(false)
|
|
13
12
|
})
|
|
14
13
|
|
|
@@ -7,7 +7,6 @@ declare module '@earendil-works/pi-tui' {
|
|
|
7
7
|
interface Keybindings {
|
|
8
8
|
'app.thinking.increase': true
|
|
9
9
|
'app.thinking.decrease': true
|
|
10
|
-
'app.plan.toggle': true
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
12
|
|
|
@@ -15,7 +14,7 @@ declare module '@earendil-works/pi-tui' {
|
|
|
15
14
|
const actions: Record<string, string | string[]> = {
|
|
16
15
|
'app.interrupt': 'escape', 'app.clear': 'ctrl+c', 'app.exit': 'ctrl+d',
|
|
17
16
|
'app.suspend': process.platform === 'win32' ? [] : 'ctrl+z',
|
|
18
|
-
'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s',
|
|
17
|
+
'app.thinking.cycle': [], 'app.thinking.save': 'ctrl+s',
|
|
19
18
|
'app.thinking.increase': 'shift+up', 'app.thinking.decrease': 'shift+down',
|
|
20
19
|
'app.model.cycleForward': 'ctrl+p', 'app.model.cycleBackward': 'alt+p',
|
|
21
20
|
'app.model.select': 'ctrl+l', 'app.tools.expand': 'ctrl+o', 'app.thinking.toggle': 'ctrl+t',
|