Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/renderer/src/features/command-keybindings/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,17 @@ export function buildDefaultKeybindings(): CommandBindingDefault[] {
{ commandId: 'toggle-reader-mode', bindings: ['Alt+R'], context: 'global' },
{ commandId: 'tldr-preview', bindings: ['Cmd+L'], context: 'global' },
{ commandId: 'goal-preview', bindings: ['Cmd+G'], context: 'global' },
{ commandId: 'goal-loop-preview', bindings: ['Cmd+Shift+Y'], context: 'global' },
// Not Cmd+Shift+Y (#1007): macOS reserves ⇧⌘Y for the system "New
// Sticky Note" service, which check:keybindings cannot see. ⌘⇧G sits
// beside the Goal peek (⌘G), and the unified stage (#992) freed it when
// Global Dispatch was retired.
//
// ⌘⇧G is ALSO Monaco's Find Previous, exactly as ⌘G beside it is Monaco's
// Find Next (#1045 review reproduced the collision). Both are approved
// overlaps: the router yields the chord whenever editor chrome owns the
// target, so only one owner is ever live. reservations.ts records
// Monaco's claim so the checker stops offering the chord as free.
{ commandId: 'goal-loop-preview', bindings: ['Cmd+Shift+G'], context: 'global' },
{ commandId: 'toggle-spotlight', bindings: ['Alt+S'], context: 'global' },
// ⌥F, leaving ⌥⇧F open for Auto-follow ALL Visible Agents — the same
// soft/heavy pairing, and the command that OWNS the effective state when
Expand Down
10 changes: 10 additions & 0 deletions src/renderer/src/features/command-keybindings/reservations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,15 @@ export const RESERVED_INTERACTIONS: readonly ReservedInteraction[] = [
},
{ bindings: ['Cmd+L'], context: 'editor', owner: 'Editor Select Line' },
{ bindings: ['Cmd+G'], context: 'editor', owner: 'Editor Find Next' },
// Monaco's Find Previous, verified in
// node_modules/monaco-editor/esm/vs/editor/contrib/find/browser/findController.js
// (PreviousMatchFindAction, mac: CtrlCmd|Shift|KeyG) rather than from memory.
// Missing until #1007 moved goal-loop-preview onto this chord: the app-side
// checker passed because nothing in this table claimed it, and review found
// the router was latching the goal-loop overlay on top of Monaco's own
// Find Previous. This is the second time the "incomplete list" failure in
// the header has happened, and both times a reservation was the fix.
{ bindings: ['Cmd+Shift+G'], context: 'editor', owner: 'Editor Find Previous' },
]

export type BindingOwnerRef = {
Expand Down Expand Up @@ -294,6 +303,7 @@ const APPROVED_OVERLAPS: ReadonlyArray<{
}> = [
{ binding: 'Cmd+L', owners: ['tldr-preview', 'Editor Select Line'], reason: 'The TLDR hold handler explicitly yields while editor chrome owns the input target; Monaco keeps its native Select Line command and TLDR operates only in the agent workspace.' },
{ binding: 'Cmd+G', owners: ['goal-preview', 'Editor Find Next'], reason: 'Goal shares the TLDR hold handler, which yields while editor chrome owns the input target; Monaco keeps its native Find Next and Goal operates only in the agent workspace.' },
{ binding: 'Cmd+Shift+G', owners: ['goal-loop-preview', 'Editor Find Previous'], reason: 'The router yields the chord while editor chrome owns the input target, the same rule Cmd+L and Cmd+G already follow; Monaco keeps its native Find Previous and the goal loop overlay opens only in the agent workspace.' },
{
binding: 'Cmd+W',
owners: ['close-pane', 'Editor-native close file and indentation', 'Native application menu'],
Expand Down
25 changes: 14 additions & 11 deletions src/renderer/src/features/command-palette/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ const BASELINE_COMMAND_IDS: readonly string[] = [
'enable-root-agent-code-management',
'enable-tldr-mcp',
'enable-goal-mcp',
'enable-goal-loop-mcp',
'enable-workflow-mcp',
'reload-agent',
'soft-reload-agent',
Expand Down Expand Up @@ -233,12 +234,12 @@ const NAVIGATION_COMMAND_GROUP: readonly string[] = [
const ids = (): string[] => builtInCommandCatalog.map(c => c.id)

describe('built-in command catalog — baseline characterization', () => {
it('contains exactly the 123 governed commands in registration order', () => {
it('contains exactly the 124 governed commands in registration order', () => {
// Order matters: this is the palette's empty-query browse order.
expect(ids()).toEqual([...BASELINE_COMMAND_IDS])
})

it('has exactly 123 commands', () => {
it('has exactly 124 commands', () => {
// Stated separately from the order assertion because this number is the
// thing that moves, and a bare count failure is a clearer signal than a
// 99-line array diff.
Expand All @@ -260,11 +261,12 @@ describe('built-in command catalog — baseline characterization', () => {
// −normalize×3 → 114 with stage 3a: −tiled-tabs, −bury/revive/kill-buried,
// −attach×2, −detach → 115 with Clear Lane (#992 stage 4) → 119 with the
// lane keyboard grammar (#992 stage 5) → 123 once main's Goal Loop preview
// and stop (#1001) and the two generated Grok splits (#844) merged in.
// and stop (#1001) and the two generated Grok splits (#844) merged in → 124
// with Goal Loop MCP (#1006).
// Each step of that arithmetic was a deliberate edit to this line, which is the entire point of pinning it. (The two test
// titles above had drifted to "115" while this line said 116; they now
// track it again.)
expect(builtInCommandCatalog).toHaveLength(123)
expect(builtInCommandCatalog).toHaveLength(124)
})

it('reports no structural defects', () => {
Expand Down Expand Up @@ -298,13 +300,14 @@ describe('generated per-provider split commands', () => {
})

it('accounts for the difference between literal and total command count', () => {
// 123 total - 6 generated = 117 literal `id:` fields across the command
// 124 total - 6 generated = 118 literal `id:` fields across the command
// modules. At the original baseline this read 102 - 4 = 98; it moved down by
// the five retirements, then back up by the nine additions, Grid Dispatch's
// six row commands, New Window, and the later additions recorded in the
// count test above (through the lane keyboard grammar, #992 stage 5, and
// Goal Loop, #1001). Grok (#844) grew only the GENERATED term, 4 → 6.
expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(117)
// Goal Loop, #1001, and Goal Loop MCP, #1006). Grok (#844) grew only the
// GENERATED term, 4 → 6.
expect(builtInCommandCatalog.length - nonDefaultProviders.length * 2).toBe(118)
})

it('emits both directions for every non-default provider', () => {
Expand Down Expand Up @@ -411,7 +414,7 @@ describe('governance targets', () => {
})

it('lands on the arithmetic the plan predicted', () => {
// 102 baseline - 21 retirements + 42 additions = 123, checked against the
// 102 baseline - 21 retirements + 43 additions = 124, checked against the
// real catalog rather than trusted as prose. (5 governance retirements +
// 16 unified-layout retirements, all recorded in RETIRED_COMMAND_IDS.)
//
Expand Down Expand Up @@ -441,9 +444,9 @@ describe('governance targets', () => {
// `agent-analytics.open` (#964), `goal-loop-preview` and `goal-loop-stop`
// (#1001), `grok-vertical` and `grok-horizontal` (#844, generated from
// AGENT_PROVIDER_KINDS), `clear-focused-lane` (#992 stage 4), and the four
// lane-grammar commands (#992 stage 5).
expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 42).toBe(102)
expect(builtInCommandCatalog).toHaveLength(123)
// lane-grammar commands (#992 stage 5), and `enable-goal-loop-mcp` (#1006).
expect(builtInCommandCatalog.length + RETIRED_COMMAND_IDS.length - 43).toBe(102)
expect(builtInCommandCatalog).toHaveLength(124)
})
})

Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/features/goal-loop/controlReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const controlReference = [{
title: 'Harness-owned goal loops',
purpose: 'Keep an agent working toward a goal across turn boundaries until it reports the goal complete, without the user re-prompting.',
ui: 'An always-on per-pane status strip while a loop exists, a latched Goal Loop overlay with pause, resume, raise-cap and stop controls (Close leaves it), Dismiss on an ended loop to clear its strip, and a Stop Goal Loop session command.',
prerequisites: 'Enable Goal Loop MCP for the agent that should run loops (off by default). The agent starts the loop itself with goal_loop_start when asked; users never write the continuation prompt.',
prerequisites: 'Enable Goal Loop MCP for the agent that should run loops (off by default; per agent with the Goal Loop MCP command, or for all agents in Settings). The agent starts the loop itself with goal_loop_start when asked; users never write the continuation prompt.',
workflow: [
'Enable Goal Loop MCP for the agent; it reloads with the loop tools.',
'Ask the agent to run a loop on an outcome; it calls goal_loop_start with the goal and its own continuation prompt.',
Expand All @@ -19,5 +19,5 @@ export const controlReference = [{
],
outcome: 'The pane strip reads e.g. “Goal loop · active · iteration 3/25 · <goal>” with Pause and Stop, and the latched overlay centers the full state with all controls.',
cautions: 'Loops consume model calls on every continuation; the budget pauses rather than kills, and Raise cap re-arms it knowingly. A paused(loop) after a restart or provider switch stays paused until resumed. complete(done) is the agent\'s own claim of completion, not a verified fact; the summary states what it believes finished.',
commandIds: ['goal-loop-preview', 'goal-loop-stop'],
commandIds: ['goal-loop-preview', 'goal-loop-stop', 'enable-goal-loop-mcp'],
}] satisfies FeatureReference[]
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,22 @@ function workspace(): Workspace {
}
function Harness({ model }: { model: Workspace }) {
useKeybinds(model)
// A composer-like focus target, as in the tldr router harness.
return <input aria-label="Composer" />
return (
<>
{/* A composer-like focus target, as in the tldr router harness. */}
<input aria-label="Composer" />
{/* Editor chrome, with the marker the router reads to decide that Monaco
owns the target. The real global editor stamps it the same way. */}
<div data-global-editor-input-owner data-global-editor-monaco>
<textarea aria-label="Editor" />
</div>
{/* A transcript code block: a real Monaco instance with NO global-editor
marker, which is what lib/code/CodeBlock.tsx mounts in the feed. */}
<div className="monaco-editor">
<textarea aria-label="Code block" />
</div>
</>
)
}
function keyDown(options: Record<string, unknown>) {
return fireEvent.keyDown(document.activeElement ?? document.body, options)
Expand Down Expand Up @@ -78,11 +92,11 @@ describe('goal loop overlay keyboard dismissal', () => {
keyDown({ key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('the Cmd+Shift+Y toggle chord dismisses the latched overlay', async () => {
it('the Cmd+Shift+G toggle chord dismisses the latched overlay', async () => {
toggleGoalLoop()
render(<><Harness model={workspace()} /><GoalLoopPane sessionId="a" /></>)
expect(await screen.findByRole('dialog')).toBeTruthy()
keyDown({ key: 'y', code: 'KeyY', metaKey: true, shiftKey: true })
keyDown({ key: 'g', code: 'KeyG', metaKey: true, shiftKey: true })
expect(screen.queryByRole('dialog')).toBeNull()
})
})
Expand Down Expand Up @@ -158,7 +172,7 @@ describe('goal loop command with no loop on the session (#1021)', () => {
// press re-ran the command and re-armed an invisible latch.
render(<Harness model={workspace()} />)
runGoalLoopCommand()
keyDown({ key: 'y', code: 'KeyY', metaKey: true, shiftKey: true })
keyDown({ key: 'g', code: 'KeyG', metaKey: true, shiftKey: true })
expect(useGoalLoopView.getState().latched).toBe(false)
expect(harness.appState.requestCommandInvocation).not.toHaveBeenCalled()
})
Expand All @@ -180,8 +194,8 @@ describe('goal loop command with no loop on the session (#1021)', () => {
})

it('a rebound goal-loop-preview chord still dismisses the overlay', async () => {
// #1007 plans to move the default chord off Cmd+Shift+Y. Before this fix
// the dismissal was a hardcoded Meta+Shift+KeyY check, so any rebind would
// #1007 moved the default chord off Cmd+Shift+Y. Before #1021's fix the
// dismissal was a hardcoded Meta+Shift+KeyY check, so any rebind would
// have left Escape as the only keyboard exit.
harness.appState = { ...harness.appState, settings: { agentViewMode: 'agent', commandKeybindingOverrides: { 'goal-loop-preview': ['Cmd+Ctrl+J'] } } }
render(<><Harness model={workspace()} /><GoalLoopPane sessionId="a" /></>)
Expand All @@ -190,4 +204,54 @@ describe('goal loop command with no loop on the session (#1021)', () => {
keyDown({ key: 'j', code: 'KeyJ', metaKey: true, ctrlKey: true })
expect(screen.queryByRole('dialog')).toBeNull()
})

it('opens on the new Cmd+Shift+G chord (#1007)', () => {
render(<Harness model={workspace()} />)
keyDown({ key: 'g', code: 'KeyG', metaKey: true, shiftKey: true })
expect(harness.appState.requestCommandInvocation).toHaveBeenCalledWith('goal-loop-preview', 'keybinding')
})

it('yields Cmd+Shift+G to the editor, which owns it as Find Previous (#1045 review)', () => {
// Reproduced by review: the router latched the overlay on top of Monaco's
// own Find Previous, and the latch gate then swallowed every keystroke
// until Escape. Monaco's dispatcher does not check defaultPrevented, so
// both ran. The router must not consume the chord here at all.
render(<Harness model={workspace()} />)
const editor = screen.getByLabelText('Editor')
editor.focus()
expect(fireEvent.keyDown(editor, { key: 'g', code: 'KeyG', metaKey: true, shiftKey: true })).toBe(true)
expect(harness.appState.requestCommandInvocation).not.toHaveBeenCalled()
expect(useGoalLoopView.getState().latched).toBe(false)
})

it('yields Cmd+Shift+G to a transcript code block too (#1045 Codex review)', () => {
// Code blocks in the ordinary feed are Monaco editors without the global
// editor's marker, and Monaco binds Find Previous on read-only editors.
// A guard that knew only the global editor let the overlay latch over a
// code block and swallow every key after it.
render(<Harness model={workspace()} />)
const code = screen.getByLabelText('Code block')
code.focus()
expect(fireEvent.keyDown(code, { key: 'g', code: 'KeyG', metaKey: true, shiftKey: true })).toBe(true)
expect(harness.appState.requestCommandInvocation).not.toHaveBeenCalled()
expect(useGoalLoopView.getState().latched).toBe(false)
})

it('a stale latch does not cost the editor a Find Previous either (#1045 review)', () => {
// The stale-latch branch runs before the routed path. It must still drop
// the latch, but it may not consume the chord while the editor owns it.
render(<Harness model={workspace()} />)
runGoalLoopCommand()
expect(useGoalLoopView.getState().latched).toBe(true)
const editor = screen.getByLabelText('Editor')
editor.focus()
expect(fireEvent.keyDown(editor, { key: 'g', code: 'KeyG', metaKey: true, shiftKey: true })).toBe(true)
expect(useGoalLoopView.getState().latched).toBe(false)
})

it('the old Cmd+Shift+Y no longer opens the goal loop (#1007: macOS New Sticky Note)', () => {
render(<Harness model={workspace()} />)
keyDown({ key: 'y', code: 'KeyY', metaKey: true, shiftKey: true })
expect(harness.appState.requestCommandInvocation).not.toHaveBeenCalledWith('goal-loop-preview', expect.anything())
})
})
2 changes: 1 addition & 1 deletion src/renderer/src/features/settings/lib/settingsRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -747,7 +747,7 @@ export function getSettingsRegistry(
category: 'agents',
title: 'Goal Loop MCP',
description:
'Let agents run harness-owned goal loops that keep re-prompting until the goal is complete, with a control strip and Cmd+Shift+Y overlay. Off by default. Applies to new agents and existing agents on their next reload. Per-agent overrides take precedence; Use Global MCP Settings clears them.',
'Let agents run harness-owned goal loops that keep re-prompting until the goal is complete, with a control strip and an overlay on the Goal Loop chord (Cmd+Shift+G by default; it yields to the editor, which owns that chord as Find Previous). Off by default. Applies to new agents and existing agents on their next reload. Per-agent overrides take precedence; Use Global MCP Settings clears them.',
keywords: ['mcp', 'goal', 'loop', 'persistence', 'autonomous', 'default', 'reload', 'existing agents'],
metadata: { scope: 'app', apply: 'new-session', storage: 'settings' },
control: {
Expand Down
50 changes: 50 additions & 0 deletions src/renderer/src/features/workspace/commands/sessionCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,56 @@ export const sessionCommands: CommandDef[] = [
})
},
},
{
// #1006: the per-agent toggle every sibling domain has (TLDR, Goal).
// goal_loop already had the Settings row and the per-session override
// plumbing, but no command, so "turn the loop on for just this agent"
// meant a trip through Settings and a reload by hand.
id: 'enable-goal-loop-mcp',
category: 'session',
surface: 'session',
title: 'Goal Loop MCP',
description: '**What it does:** Reloads the focused agent with goal-loop tools on or off.\n\n**Use when:** You want this agent to be able to run a harness-owned goal loop that keeps re-prompting it until the goal is done.\n\n**Notes:** The agent starts a loop itself when you ask it to (goal_loop_start). Every continuation is a model call, and the loop pauses at its budget. Turning the tools off ends a running loop, because the agent would no longer be able to report that it is done. The Goal Loop command shows and controls a running loop.',
keywords: ['goal', 'loop', 'autonomous', 'persistence', 'keep going', 'mcp'],
when: ({ workspace }) => {
return targetSupportsBuiltInMcpDomain(workspace, 'goal_loop')
},
getState: ctx => builtInMcpDomainState(ctx, 'goal_loop'),
run: async ({ workspace, ui }) => {
const sessionId = commandTargetSessionId(workspace)
if (!sessionId) return
const meta = workspace.state.sessions[sessionId]
const kind = meta?.kind ?? DEFAULT_PROVIDER
// Provider policy is repeated at the mutation boundary: visibility is
// advisory and the command stays reachable from keybindings and control.
if (
!isAgentProviderKind(kind) ||
!providerSupportsBuiltInMcpDomain(kind, 'goal_loop') ||
!meta
) return

ui.closePalette()
const enable = !meta.builtInMcpDomains?.includes('goal_loop')
// Turning the tools off ENDS a running loop (#1045 review). The loop is
// harness-owned, so it survives the reload on its own, but the reloaded
// agent no longer has goal_loop_complete: it cannot say it is done, and
// every continuation it is sent runs to the cap. Ending it here is the
// honest reading of "this agent does not do goal loops any more", and it
// happens BEFORE the reload so it applies to the session id the loop is
// filed under rather than the replacement's.
if (!enable) {
// try/catch, not .catch(): a preload without the channel throws
// synchronously. Either way the reload is what the user asked for and
// must still happen; a loop left running is visible and stoppable from
// the Goal Loop command.
try { await window.api.controlGoalLoop({ sessionId, action: 'stop' }) } catch { /* reload anyway */ }
}
await reloadSessionWithBuiltInMcpChoice(workspace, sessionId, 'goal_loop', enable, {
reloaded: enable ? 'Reloaded with Goal Loop MCP' : 'Reloaded without Goal Loop MCP',
failed: 'Goal Loop MCP reload failed',
})
},
},
{
id: 'enable-workflow-mcp',
category: 'session',
Expand Down
Loading
Loading