Files
orca/src/shared/window-shortcut-policy.test.ts
T
NeilandOrca 62e02a3b4c feat(new-workspace): unified source picker + non-submitted URL drafts for agents (#1426)
* Fix new workspace composer focus restore

* Unify new workspace source selection

* WIP: selected source pill in smart workspace name field

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): truncate source pill so it doesn't expand the dialog

Co-authored-by: Orca <help@stably.ai>

* feat(new-workspace): add open-in-browser button to source pill, fix vertical alignment

Co-authored-by: Orca <help@stably.ai>

* refactor(new-workspace): drop redundant kind suffix, distinct PR/issue icons, tooltips on pill actions

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): type linked URL into agent input without auto-submit

Co-authored-by: Orca <help@stably.ai>

* fix(new-workspace): use bracketed-paste for draft URL injection so it actually appears in the agent input

Co-authored-by: Orca <help@stably.ai>

* feat(agents): per-agent draft injection strategy (codex slow paste, pi/opencode type-chars)

Co-authored-by: Orca <help@stably.ai>

* fix(agents): smarter TUI-ready heuristic + bracketed paste for codex/pi/opencode

Replaces per-agent strategy guesswork with a measured readiness check:
title-idle / non-shell-foreground stable for 1.5s / 2.5s minimum floor.
Verified against codex, pi, opencode, claude in a node-pty + xterm-headless
test rig — bracketed paste lands in the input buffer for all four.

Co-authored-by: Orca <help@stably.ai>

* refactor(agents): drop unused per-agent draft strategy abstraction

The TUI-ready heuristic in agent-paste-draft.ts works for every tested
agent (claude/codex/pi/opencode), so the AgentDraftInjectionStrategy
field, type-chars + bracketed-paste-slow code paths, and per-agent
overrides are dead. Keep the `agent` arg on pasteDraftWhenAgentReady
for future per-agent escape hatches without touching every call site.

Co-authored-by: Orca <help@stably.ai>

* feat(agents): skip draft URL injection for copilot + cursor-agent

Both TUIs open with a 'Do you trust this folder?' menu on first launch
that consumes keystrokes as menu input — pasting a URL there either
selects an arbitrary option or quits the session. Mark them with
skipDraftUrlInjection so the workspace still opens cleanly; the user
types/pastes the URL themselves once past the trust menu.

Co-authored-by: Orca <help@stably.ai>

* feat(agents): native --prefill for claude, trust pre-write for cursor/copilot

Replaces the empirical TUI-ready waits with two deterministic mechanisms:

1) `claude --prefill <text>` flag — Claude launches with the URL already in
   its input box, no submit. Eliminates the readiness/paste race entirely
   for the most common agent.
2) DECSET 2004 (`\x1b[?2004h`) detection on the PTY data stream for every
   other agent. That escape is the protocol-level "input layer ready,
   accepting bracketed paste" handshake — emitted by claude/codex/pi/
   opencode/gemini/cursor-agent/copilot the moment the input box mounts.
   We tap it via a sidecar subscription on pty-dispatcher (no interference
   with the primary xterm handler) and paste as soon as it lands. The
   8s budget is now an upper bound, not a target.

Cursor-agent and Copilot's "Do you trust this folder?" menus are bypassed
by writing the same trust artifacts the CLIs themselves write after the
user accepts:
- Cursor: `~/.cursor/projects/<slug>/.workspace-trusted` (slug = abs path
  with leading `/` stripped, remaining `/` → `-`).
- Copilot: append cwd to `trustedFolders` in `~/.copilot/config.json`
  (the same array the bundled `addTrustedFolder` writes).

Verified against the cursor-agent CLI bundle (versions/2026.04.17-787b533/
index.js: `_=".workspace-trusted"`) and the @github/copilot 1.0.32 bundle
(`isFolderTrusted` / `addTrustedFolder` both read/write `trustedFolders`).
Both check via realpath() before string-comparing, so the trust preset
canonicalizes too.

skipDraftUrlInjection is dropped — both agents now get the draft URL
paste once the trust menu is pre-resolved.

Tests: 24 passing across tui-agent-startup, agent-trust-presets,
pty-dispatcher routing.

Co-authored-by: Orca <help@stably.ai>

* fix(agents): wait for post-?2004h render burst to settle before paste

OpenCode emits DECSET 2004 at ~500ms during alt-screen setup, then runs
a 1.3s splash render with NO bytes on the PTY, then paints the actual
input box at ~1.85s. Pasting on the bare ?2004h signal lands during the
silent gap and the bytes are dropped.

The fix: take ?2004h as the necessary precondition, then wait for the
TUI's render burst to finish — defined as 1500ms of stream silence
after the most recent post-?2004h byte. This captures both the fast
TUIs (claude/pi/codex emit setup escapes in one burst then go quiet)
and the slow ones (opencode emits, sleeps for the splash, emits again,
then goes quiet).

Verified against opencode/claude/pi in a node-pty rig: paste lands on
the first try with the new strategy. The hard 8s timeout still caps
the wait when an agent fails to launch.

Co-authored-by: Orca <help@stably.ai>

* fix(agents): guard agentTrust IPC so stale preload doesn't crash launch

If the preload bundle is older than the renderer (a real situation in
electron-vite dev because preload changes only apply on full restart,
not HMR), `window.api.agentTrust` is undefined and the launch crashes
with "Cannot read properties of undefined (reading 'markTrusted')"
before the worktree even opens.

Guard the call sites in launch-work-item-direct and useComposerState
to skip the trust pre-write when the IPC isn't exposed, and wrap the
invoke in try/catch so an IPC error never blocks the launch — the user
just sees the trust menu and accepts it manually, same as before this
feature shipped.

Co-authored-by: Orca <help@stably.ai>

* feat(tasks): route 'Use' through the New Workspace dialog instead of yolo-create

The Use CTA on the Tasks page used to create+activate a worktree
synchronously, which surprised users — the worktree appeared in the
sidebar before they had a chance to confirm name / agent / setup. The
unified New Workspace dialog landed in this branch already supports
opening with a linked work item pre-filled (see openComposerForItem /
openComposerForLinearItem), so just route Use through it.

The launchWorkItemDirect helper stays exported for ProjectViewWrapper,
which has its own UX where the immediate-create flow is the right call.

Co-authored-by: Orca <help@stably.ai>

* test(agents): include `agent` field in autohand startup-plan assertion

Merging main brought in the Autohand Code agent test (PR #1382), which
predated this branch's addition of `agent` to AgentStartupPlan.
Aligning the assertion fixes the lone CI test failure on this PR.

Co-authored-by: Orca <help@stably.ai>

* refactor(agents): drop unused expectedProcess arg + snapshot sidecar set

Two minor follow-ups from self-review:

1. `pasteDraftWhenAgentReady` no longer reads `expectedProcess` — readiness
   is gated on DECSET 2004 alone now, not on PTY foreground process. Drop
   it from the signature and from the two callers (launch-work-item-direct,
   new-workspace).

2. The pty-dispatcher's sidecar fan-out iterates the live Set, which is
   safe against deleting the current element but not against a watcher
   that synchronously subscribes a sibling. Snapshot via Array.from
   before the loop. Cheap (Set is tiny) and removes the latent footgun.

No behavior change.

Co-authored-by: Orca <help@stably.ai>

* test(e2e): match the unified smart-name input's new placeholder

The CreateFromTab refactor in this branch replaced the separate "Workspace
name" Input with a single SmartWorkspaceNameField whose default-mode
placeholder is "Type a name, #1234, branch, GitHub or Linear URL". The
worktree-create e2e test was still anchoring on the old "Workspace name"
text and could not find the input.

Update the placeholder regex to match the new copy. Free-form text typed
into smart mode is treated as a workspace name by submitQuick — same
contract the test used before.

Verified locally: targeted e2e passes in 2.2s.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-05-05 01:07:52 -07:00

373 lines
12 KiB
TypeScript

/* eslint-disable max-lines -- Why: this file covers every branch of the
shortcut policy (letter chords, zoom variants, alt/shift gating, history
navigation, new-workspace tab routing). Splitting across files would
fragment the test of a single pure function. */
import { describe, expect, it } from 'vitest'
import {
isWindowShortcutModifierChord,
resolveWindowShortcutAction,
type WindowShortcutAction,
type WindowShortcutInput
} from './window-shortcut-policy'
describe('resolveWindowShortcutAction', () => {
it('keeps ctrl/cmd+r and readline control chords out of the main-process allowlist', () => {
const macCases: WindowShortcutInput[] = [
{ code: 'KeyR', key: 'r', meta: true, control: false, alt: false, shift: false },
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: false },
{ code: 'KeyU', key: 'u', meta: false, control: true, alt: false, shift: false },
{ code: 'KeyE', key: 'e', meta: false, control: true, alt: false, shift: false }
]
for (const input of macCases) {
expect(resolveWindowShortcutAction(input, 'darwin')).toBeNull()
}
const nonMacCases: WindowShortcutInput[] = [
{ code: 'KeyR', key: 'r', meta: false, control: true, alt: false, shift: false },
{ code: 'KeyU', key: 'u', meta: false, control: true, alt: false, shift: false },
{ code: 'KeyE', key: 'e', meta: false, control: true, alt: false, shift: false },
{ code: 'KeyJ', key: 'j', meta: false, control: true, alt: false, shift: false }
]
for (const input of nonMacCases) {
expect(resolveWindowShortcutAction(input, 'linux')).toBeNull()
}
})
it('resolves the explicit window shortcut allowlist on macOS', () => {
expect(
resolveWindowShortcutAction(
{ code: 'KeyJ', key: 'j', meta: true, control: false, alt: false, shift: false },
'darwin'
)
).toEqual({ type: 'toggleWorktreePalette' })
expect(
resolveWindowShortcutAction(
{ code: 'KeyP', key: 'p', meta: true, control: false, alt: false, shift: false },
'darwin'
)
).toEqual({ type: 'openQuickOpen' })
expect(
resolveWindowShortcutAction(
{ code: 'Digit3', key: '3', meta: true, control: false, alt: false, shift: false },
'darwin'
)
).toEqual({ type: 'jumpToWorktreeIndex', index: 2 })
})
it('requires shift for the non-mac worktree palette shortcut', () => {
expect(
resolveWindowShortcutAction(
{ code: 'KeyJ', key: 'j', meta: false, control: true, alt: false, shift: false },
'win32'
)
).toBeNull()
expect(
resolveWindowShortcutAction(
{ code: 'KeyJ', key: 'j', meta: false, control: true, alt: false, shift: true },
'win32'
)
).toEqual({ type: 'toggleWorktreePalette' })
})
it('accepts all supported zoom key variants', () => {
const zoomInCases: WindowShortcutInput[] = [
{ key: '=', meta: true, control: false, alt: false, shift: false },
{ key: '+', meta: true, control: false, alt: false, shift: true },
{ code: 'NumpadAdd', key: '', meta: true, control: false, alt: false, shift: false }
]
for (const input of zoomInCases) {
expect(resolveWindowShortcutAction(input, 'darwin')).toEqual({
type: 'zoom',
direction: 'in'
})
}
const zoomOutCases: WindowShortcutInput[] = [
{ key: '-', meta: false, control: true, alt: false, shift: false },
{ key: '_', meta: false, control: true, alt: false, shift: true },
{ key: 'Minus', meta: false, control: true, alt: false, shift: false },
{ code: 'NumpadSubtract', key: '', meta: false, control: true, alt: false, shift: false }
]
for (const input of zoomOutCases) {
expect(resolveWindowShortcutAction(input, 'linux')).toEqual({
type: 'zoom',
direction: 'out'
})
}
expect(
resolveWindowShortcutAction(
{ key: '0', meta: false, control: true, alt: false, shift: false },
'linux'
)
).toEqual({ type: 'zoom', direction: 'reset' })
})
it('resolves the worktree-history chord despite carrying Alt', () => {
expect(
resolveWindowShortcutAction(
{
code: 'ArrowLeft',
key: 'ArrowLeft',
meta: true,
control: false,
alt: true,
shift: false
},
'darwin'
)
).toEqual({ type: 'worktreeHistoryNavigate', direction: 'back' })
expect(
resolveWindowShortcutAction(
{
code: 'ArrowRight',
key: 'ArrowRight',
meta: true,
control: false,
alt: true,
shift: false
},
'darwin'
)
).toEqual({ type: 'worktreeHistoryNavigate', direction: 'forward' })
expect(
resolveWindowShortcutAction(
{
code: 'ArrowLeft',
key: 'ArrowLeft',
meta: false,
control: true,
alt: true,
shift: false
},
'linux'
)
).toEqual({ type: 'worktreeHistoryNavigate', direction: 'back' })
})
it('rejects the history chord when Shift is also held', () => {
expect(
resolveWindowShortcutAction(
{
code: 'ArrowLeft',
key: 'ArrowLeft',
meta: true,
control: false,
alt: true,
shift: true
},
'darwin'
)
).toBeNull()
})
it('leaves Alt+Arrow without a primary modifier untouched (word-nav territory)', () => {
expect(
resolveWindowShortcutAction(
{
code: 'ArrowLeft',
key: 'ArrowLeft',
meta: false,
control: false,
alt: true,
shift: false
},
'darwin'
)
).toBeNull()
})
it('ignores Cmd/Ctrl+Alt combined with ArrowUp or ArrowDown', () => {
// Why: the history predicate explicitly narrows to ArrowLeft/ArrowRight.
// Cmd+Alt+Up / Cmd+Alt+Down must fall through to null so the event
// reaches the renderer/PTTY (e.g. shells / readline).
expect(
resolveWindowShortcutAction(
{
code: 'ArrowUp',
key: 'ArrowUp',
meta: true,
control: false,
alt: true,
shift: false
},
'darwin'
)
).toBeNull()
expect(
resolveWindowShortcutAction(
{
code: 'ArrowDown',
key: 'ArrowDown',
meta: false,
control: true,
alt: true,
shift: false
},
'linux'
)
).toBeNull()
})
it('rejects the history chord when the opposite primary modifier is also held', () => {
// Why: Cmd+Ctrl+Alt+Arrow on macOS collides with Mission Control space
// switching; Ctrl+Meta+Alt+Arrow on Linux collides with GNOME workspace
// switching. The app must not intercept either.
expect(
resolveWindowShortcutAction(
{
code: 'ArrowLeft',
key: 'ArrowLeft',
meta: true,
control: true,
alt: true,
shift: false
},
'darwin'
)
).toBeNull()
expect(
resolveWindowShortcutAction(
{
code: 'ArrowRight',
key: 'ArrowRight',
meta: true,
control: true,
alt: true,
shift: false
},
'linux'
)
).toBeNull()
})
it('still returns null for other Cmd/Ctrl+Alt combos (not an allowlist escape)', () => {
// Why: regression guard — the history early-return must not swallow
// unrelated primary+alt chords in a way that changes their old null
// result. A future addition that intentionally consumes e.g. Cmd+Alt+KeyT
// must add a new branch explicitly.
expect(
resolveWindowShortcutAction(
{
code: 'KeyB',
key: 'b',
meta: true,
control: false,
alt: true,
shift: false
},
'darwin'
)
).toBeNull()
})
it('routes Cmd/Ctrl+Shift+N to the unified new-workspace composer', () => {
// Why: keep the former Create-from shortcut accepted so muscle memory
// still opens the composer; source switching now lives in the smart name field.
expect(
resolveWindowShortcutAction(
{ code: 'KeyN', key: 'n', meta: true, control: false, alt: false, shift: true },
'darwin'
)
).toEqual({ type: 'openNewWorkspace' })
expect(
resolveWindowShortcutAction(
{ code: 'KeyN', key: 'n', meta: false, control: true, alt: false, shift: true },
'linux'
)
).toEqual({ type: 'openNewWorkspace' })
// Alt must still be rejected — the allowlist is alt-free for Cmd/Ctrl+N
// so future chords like Cmd+Alt+Shift+N remain available.
expect(
resolveWindowShortcutAction(
{ code: 'KeyN', key: 'n', meta: true, control: false, alt: true, shift: true },
'darwin'
)
).toBeNull()
})
it('resolves letter shortcuts by layout-aware key, with code as fallback', () => {
// Why: non-QWERTY layouts (Dvorak, Colemak, AZERTY, …) move letters to
// other physical keys. Matching only on `input.code` (always QWERTY)
// breaks the shortcut for those users. Prefer `input.key` when it is a
// letter; fall back to `input.code` only when `key` is empty or a
// non-letter marker (dead keys, IME edge cases).
// Dvorak layout: the letters the user presses sit on different codes
// ('b'→KeyN, 'l'→KeyP, 'p'→KeyR, 'n'→KeyL, 'j'→KeyC). All must resolve
// to the layout-matched shortcut.
const dvorak: [WindowShortcutInput, WindowShortcutAction][] = [
[
{ code: 'KeyN', key: 'b', meta: true, alt: false, shift: false },
{ type: 'toggleLeftSidebar' }
],
[
{ code: 'KeyP', key: 'l', meta: true, alt: false, shift: false },
{ type: 'toggleRightSidebar' }
],
[{ code: 'KeyR', key: 'p', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }],
[
{ code: 'KeyL', key: 'n', meta: true, alt: false, shift: false },
{ type: 'openNewWorkspace' }
],
[
{ code: 'KeyC', key: 'j', meta: true, alt: false, shift: false },
{ type: 'toggleWorktreePalette' }
]
]
for (const [input, expected] of dvorak) {
expect(resolveWindowShortcutAction(input, 'darwin')).toEqual(expected)
}
// Inverse guard: physical QWERTY-B on Dvorak types 'x' — that is the
// platform Cut shortcut, not the sidebar. The layout-aware match must
// reject it.
expect(
resolveWindowShortcutAction(
{ code: 'KeyB', key: 'x', meta: true, alt: false, shift: false },
'darwin'
)
).toBeNull()
// Fallback: drivers/IME states that leave `key` empty or non-letter
// (dead keys, modifier names) must still reach the shortcut on QWERTY.
const fallbacks: [WindowShortcutInput, WindowShortcutAction][] = [
[
{ code: 'KeyB', key: '', meta: true, alt: false, shift: false },
{ type: 'toggleLeftSidebar' }
],
[
{ code: 'KeyN', key: 'Dead', meta: true, alt: false, shift: false },
{ type: 'openNewWorkspace' }
],
[{ code: 'KeyP', meta: true, alt: false, shift: false }, { type: 'openQuickOpen' }]
]
for (const [input, expected] of fallbacks) {
expect(resolveWindowShortcutAction(input, 'darwin')).toEqual(expected)
}
})
it('exposes the shared platform modifier gate used by browser guests', () => {
expect(
isWindowShortcutModifierChord({ meta: true, control: false, alt: false }, 'darwin')
).toBe(true)
expect(isWindowShortcutModifierChord({ meta: false, control: true, alt: false }, 'linux')).toBe(
true
)
expect(isWindowShortcutModifierChord({ meta: false, control: true, alt: true }, 'linux')).toBe(
false
)
})
})