From fa374e218fab57b743da68b38e295de010d0bc05 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 12:26:51 -0700 Subject: [PATCH 01/21] Add terminal context Storybook prototype --- docs/specs/layout.md | 8 + lib/src/stories/TerminalContext.stories.tsx | 242 ++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 lib/src/stories/TerminalContext.stories.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index b7d1b197..33409cf6 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -378,6 +378,14 @@ A store commit that empties the tree (last pane killed or minimized) triggers th 5. **Door keeps selection through the auto-spawn refill** ([Auto-spawn refill](#auto-spawn-refill)). Explicit user selection of a pane — a click, a drag, or an embed focusing itself — still moves selection off a door. 6. **Focus-neutral surface creation (`dor ensure` / `dor iframe` / `dor ab`)**: unlike `dor split`, these open in the background without moving focus off the caller (`docs/specs/dor-cli.md`, `docs/specs/dor-browser.md`). An add never re-parents the caller's subtree or steals activation, and the create does not call `selectPane` (`settleAddSelection` returns false for a focus-neutral, non-selection-replacing add). **The one exception**: `dor iframe` / `dor ab` replacing the pane the user is *currently selected on* moves selection to the replacement, else it would dangle on the removed leaf; any other pane, or a door selection, is left untouched. A throwaway that never reports OSC 633 integration is torn down with `killPaneImmediately`, whose live selection check leaves the caller's selection intact (a `--minimize` throwaway is already a door, disposed directly). +## Terminal context prototype + +**Must keep the Terminal context layout prototype isolated to Storybook.** It uses static terminal output and local presentation state; its controls open visual examples without spawning helpers or changing production menus. + +**Must keep the prototype's helper header on one line**, hiding its name at narrow widths. Notification details appear directly when present; the outer header has no terminal clipboard actions. + +Source of truth: `TerminalContextStory` in `lib/src/stories/TerminalContext.stories.tsx`. + ## Future **Scope: workspaces-rollout** — the remaining stages of the multi-Workspace feature. The model, container verbs, Window persistence (behind `dormouse.flags.workspaces`), and union projection are implemented but unwired ([Workspaces](#workspaces); persisted containers in `docs/specs/transport.md`, union projection in `docs/specs/alert.md`). This ledger is the single home for what remains; other specs link here rather than restating it. diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx new file mode 100644 index 00000000..b37ae394 --- /dev/null +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -0,0 +1,242 @@ +import { useState, type ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + ArrowCounterClockwiseIcon, ArrowLineUpIcon, ArrowSquareOutIcon, + BellIcon, BugBeetleIcon, CheckIcon, + CircleNotchIcon, CopyIcon, FrameCornersIcon, PauseIcon, TerminalIcon, + WarningIcon, XIcon, +} from '@phosphor-icons/react'; +import { OnOffSwitch, PANE_HEADER_HEIGHT_PX, POPUP_SURFACE_CLASS } from '../components/design'; +import { AgentRobotIcon } from '../components/wall/BrowserDisplayIcon'; + +// Presentation only: no PTYs, platform calls, persistence, or production menu +// wiring. Local state switches fixtures and opens visual detail treatments. +type Scenario = 'fresh' | 'noPorts' | 'running' | 'preserved' | 'editor' | 'differentDirectory' | 'multiplePorts' | 'notification' | 'autorunOff' | 'scanFailed'; +const SCENARIOS: { id: Scenario; label: string }[] = [ + { id: 'fresh', label: 'Common case' }, + { id: 'noPorts', label: 'No ports' }, + { id: 'running', label: 'Autorun running' }, + { id: 'preserved', label: 'User input' }, + { id: 'editor', label: 'Editor open' }, + { id: 'differentDirectory', label: 'Different directory' }, + { id: 'multiplePorts', label: 'Multiple ports' }, + { id: 'notification', label: 'Notification' }, + { id: 'autorunOff', label: 'Autorun off' }, + { id: 'scanFailed', label: 'Scan failed' }, +]; +const PARENT_DIR = '~/projects/dormouse'; +const HELPER_DIR = '~/projects/dormouse-fix'; +const noop = () => {}; + +function Action({ children, label, onClick }: { children: ReactNode; label: string; onClick?: () => void }) { + return ; +} + +function Prompt({ children, directory = PARENT_DIR }: { children?: ReactNode; directory?: string }) { + return
{directory} {children}
; +} + +function GitStatus() { + return <> + git status +
On branch new-right-click
+
Your branch is up to date with 'origin/new-right-click'.
+
+
Changes not staged for commit:
+
(use "git add <file>..." to update what will be committed)
+
modified: lib/src/components/Wall.tsx
+
modified: docs/specs/layout.md
+
+
no changes added to commit (use "git add" and/or "git commit -a")
+ ; +} + +function Cursor() { + return ; +} + +function TerminalOutput({ scenario }: { scenario: Scenario }) { + if (scenario === 'editor') return
+
GNU nano 8.3notes.mdModified
+
# Context menu ideas
+
+
Keep the helper big enough for actual work.
+
Make the directory mismatch impossible to miss.
+
+ ^G Help^O Write Out^W Where Is^K Cut + ^X Exit^R Read File^\ Replace^U Paste +
+
; + if (scenario === 'running') return <> + git status +
Refreshing index: 68% (816/1200)
+ ; + if (scenario === 'autorunOff') return ; + if (scenario === 'differentDirectory') return <> + cd ../dormouse-fix + git log -3 --oneline +
a8d21f0 Fix pane focus after splitting
+
56b302e Keep terminal titles stable
+
3c119a4 Update layout spec
+
+ + ; + return <> + +
+ {scenario === 'preserved' && <> + cat .node-version +
24.18.0
+ echo "check focus after split" +
check focus after split
+
+ } + {scenario === 'preserved' && 'git diff --'} + ; +} + +function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scenario: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth: number }) { + const [detail, setDetail] = useState(initialDetail); + const [port, setPort] = useState('5173'); + const preserved = ['preserved', 'editor', 'differentDirectory'].includes(scenario); + const mismatch = scenario === 'differentDirectory'; + const notification = scenario === 'notification'; + return
+
+ pnpm dev +
+
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE v8.0.14  ready in 182 ms\n\n  ➜  Local:   http://localhost:5173/\n  ➜  Network: use --host to expose\n\n12:04:31 [vite] (client) hmr update /src/App.tsx'}
+
+
+ Terminal context + surface:3 +
+ +
+
+ +
+
+ Title +
pnpm dev setDetail(detail === 'title' ? null : 'title')}>
+ Dir +
{PARENT_DIR}
+ Ports +
+ {scenario === 'noPorts' ? No listening ports + : scenario === 'scanFailed' ? Port scan failed · Reopen to try again + : <> + {scenario === 'multiplePorts' ? : <>localhost:5173vite} +
+ + + + +
+ {scenario === 'multiplePorts' && 3 ports} + } +
+ Alerts +
+ + {notification ? 'Tests complete' : 'Watch all pnpm commands'} + {!notification && } + + TODO +
+
+ {notification &&
+
341 passed, 0 failed
pnpm test · OSC 777 · 12:04:38
+
} +
+ +
+ {/* The name yields before status/actions; use helper width, not viewport width. */} +
+ Helper terminal +
+ {preserved ? <>Autorun paused to preserve your session + : <> + {scenario === 'running' ? : scenario === 'autorunOff' ? : } + {scenario === 'autorunOff' ? 'Autorun off' : scenario === 'running' ? <>Running git status automatically : <>Automatically ran git status} + + } +
+
Promote
+
+ {mismatch &&
+ +
Helper directory differs from parent
Helper{HELPER_DIR}Parent{PARENT_DIR}
+
} +
+
+ + {detail &&
setDetail(null)}> +
event.stopPropagation()}> +
{detail === 'title' ? 'Why this title?' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal?'} setDetail(null)}>
+ {detail === 'title' ? <> +
Current title: pnpm dev
+
+ User overrideNot set + OSC 2pnpm devUsed + OSC 0zshIgnored + Commandpnpm devFallback + OSC 777Tests completeDiagnostic +
+
OSC 2 is the latest eligible title from the running command.
+ : detail === 'modify' ? <> + +

Global default. Applies to new and reset helpers. Leave empty to turn autorun off.

+
setDetail(null)}>Cancel setDetail(null)}>Save default
+ : <> +

Discard this helper session and rerun git status in {PARENT_DIR}?

+

{scenario === 'editor' ? 'nano is still running. Unsaved edits will be lost.' : 'Its scrollback and any unfinished input will be lost.'}

+
setDetail(null)}>Keep helper
+ } +
+
} +
+
; +} + +function TerminalContextStory({ initialScenario = 'fresh', initialDetail = null, paneWidth = 900 }: { initialScenario?: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth?: number }) { + const [scenario, setScenario] = useState(initialScenario); + return
+
+
Terminal context / layout prototypeStatic terminal · actions are previews
+
+ {SCENARIOS.map(item => )} +
+
+ +
; +} + +const meta = { + title: 'Prototypes/Terminal context', + component: TerminalContextStory, + parameters: { layout: 'fullscreen' }, + args: { initialScenario: 'fresh' }, +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const CommonCase: Story = {}; +export const NarrowHelperHeader: Story = { args: { initialScenario: 'preserved', paneWidth: 660 } }; +export const NoPorts: Story = { args: { initialScenario: 'noPorts' } }; +export const AutorunRunning: Story = { args: { initialScenario: 'running' } }; +export const PreservedSession: Story = { args: { initialScenario: 'preserved' } }; +export const EditorOpen: Story = { args: { initialScenario: 'editor' } }; +export const DifferentDirectory: Story = { args: { initialScenario: 'differentDirectory' } }; +export const MultiplePorts: Story = { args: { initialScenario: 'multiplePorts' } }; +export const Notification: Story = { args: { initialScenario: 'notification' } }; +export const AutorunOff: Story = { args: { initialScenario: 'autorunOff' } }; +export const PortScanFailed: Story = { args: { initialScenario: 'scanFailed' } }; +export const TitleSources: Story = { args: { initialDetail: 'title' } }; +export const ModifyAutorun: Story = { args: { initialDetail: 'modify' } }; +export const ResetConfirmation: Story = { args: { initialScenario: 'editor', initialDetail: 'reset' } }; From 2a953fa994c7ab962220b3d1091000e97993c6f5 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 12:28:04 -0700 Subject: [PATCH 02/21] Document agent workflow inside Dormouse --- AGENTS.md | 22 ++++++++++++++++++++++ scripts/spec-word-budgets.json | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 89ba575b..0482170a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,3 +124,25 @@ in [`lib/src/theme-colors.css`](lib/src/theme-colors.css), which a host can import on its own, and the type scale, fonts, and animation tokens in [`lib/src/theme.css`](lib/src/theme.css). When adding or removing a color token, update `theme-colors.css` and `design.tsx` together. + + +## Running inside Dormouse + +If the `DORMOUSE_SURFACE_ID` environment variable is set, this terminal is +hosted by [Dormouse](https://dormouse.sh) and the `dor` CLI is on your `PATH`. +**Run `dor skill` first and do what it says** — it teaches you to use the +terminal fully (visible panes, browser surfaces, sub-agents). Two rules are +mandatory whether or not you have read it: + +- **NEVER start a long-running process — a dev server, a `--watch`, any daemon + — as a background subprocess.** It would be invisible to the user and die with + your shell. ALWAYS run it with `dor ensure -- ` (e.g. + `dor ensure -- npm run dev`), which puts it in a visible pane that outlives + you and is reused instead of duplicated on re-runs. +- **NEVER use a built-in, native, or bundled browser tool to open, view, or + drive a web page.** ALWAYS use `dor ab` (agent-browser) — `dor ab open + `, `dor ab click @e3`, and so on — so the page renders in a Dormouse + pane the user can watch. + +If `DORMOUSE_SURFACE_ID` is not set, ignore this section — `dor` is not here. + diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 76e8b28c..b728b00c 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 2900, + "AGENTS.md": 3100, "SECURITY.md": 200, "SELF_HOST.md": 6000, "docs/specs/alert.md": 6550, From 66ded4c9b570ec3a96b9dc737f9e4b0fca332337 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 12:30:45 -0700 Subject: [PATCH 03/21] Remove redundant terminal context heading --- docs/specs/layout.md | 4 +++- lib/src/stories/TerminalContext.stories.tsx | 19 +++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 33409cf6..25b5545b 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -382,7 +382,9 @@ A store commit that empties the tree (last pane killed or minimized) triggers th **Must keep the Terminal context layout prototype isolated to Storybook.** It uses static terminal output and local presentation state; its controls open visual examples without spawning helpers or changing production menus. -**Must keep the prototype's helper header on one line**, hiding its name at narrow widths. Notification details appear directly when present; the outer header has no terminal clipboard actions. +**Must put the prototype's Surface ref and close button at the right of the Title row**, without a separate heading row or terminal clipboard actions. + +**Must keep the prototype's helper header on one line**, hiding its name at narrow widths. Notification details appear directly when present. Source of truth: `TerminalContextStory` in `lib/src/stories/TerminalContext.stories.tsx`. diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index b37ae394..f583f794 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -110,18 +110,17 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena
{'~/projects/dormouse ❯ pnpm dev\n\n  VITE v8.0.14  ready in 182 ms\n\n  ➜  Local:   http://localhost:5173/\n  ➜  Network: use --host to expose\n\n12:04:31 [vite] (client) hmr update /src/App.tsx'}
-
- Terminal context - surface:3 -
- -
-
-
Title -
pnpm dev setDetail(detail === 'title' ? null : 'title')}>
+
+ pnpm dev + setDetail(detail === 'title' ? null : 'title')}> +
+ surface:3 + +
+
Dir
{PARENT_DIR}
Ports @@ -177,7 +176,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena
{detail &&
setDetail(null)}> -
event.stopPropagation()}> +
event.stopPropagation()}>
{detail === 'title' ? 'Why this title?' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal?'} setDetail(null)}>
{detail === 'title' ? <>
Current title: pnpm dev
From 00f886d8d6aab761c9b171f4c9b011c86c35d3a3 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 12:43:22 -0700 Subject: [PATCH 04/21] Label context actions and soften their accent color --- docs/specs/layout.md | 4 ++- lib/src/stories/TerminalContext.stories.tsx | 37 ++++++++++----------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 25b5545b..4d8c4e3b 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -380,12 +380,14 @@ A store commit that empties the tree (last pane killed or minimized) triggers th ## Terminal context prototype -**Must keep the Terminal context layout prototype isolated to Storybook.** It uses static terminal output and local presentation state; its controls open visual examples without spawning helpers or changing production menus. +**Must isolate the Terminal context prototype to Storybook.** Static presentation never spawns helpers or changes production menus. **Must put the prototype's Surface ref and close button at the right of the Title row**, without a separate heading row or terminal clipboard actions. **Must keep the prototype's helper header on one line**, hiding its name at narrow widths. Notification details appear directly when present. +**Must label Title, Dir, and all four port actions in subdued link color; put counts beside dropdowns.** + Source of truth: `TerminalContextStory` in `lib/src/stories/TerminalContext.stories.tsx`. ## Future diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index f583f794..c45a278b 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -27,10 +27,11 @@ const SCENARIOS: { id: Scenario; label: string }[] = [ const PARENT_DIR = '~/projects/dormouse'; const HELPER_DIR = '~/projects/dormouse-fix'; const noop = () => {}; +const ACTION_COLOR_CLASS = 'text-[color:color-mix(in_srgb,var(--color-link)_35%,var(--color-muted))] hover:text-link focus-visible:text-link'; -function Action({ children, label, onClick }: { children: ReactNode; label: string; onClick?: () => void }) { +function Action({ children, label, onClick, muted = false }: { children: ReactNode; label: string; onClick?: () => void; muted?: boolean }) { return ; } @@ -115,34 +116,32 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena Title
pnpm dev - setDetail(detail === 'title' ? null : 'title')}> + setDetail(detail === 'title' ? null : 'title')}>Explain
- surface:3 - + surface:3 +
Dir -
{PARENT_DIR}
+
{PARENT_DIR}Open in FinderCopy path
Ports -
+
{scenario === 'noPorts' ? No listening ports : scenario === 'scanFailed' ? Port scan failed · Reopen to try again : <> - {scenario === 'multiplePorts' ? setPort(event.target.value)} className="h-6 w-52 rounded border border-input-border bg-input-bg px-1 text-foreground"> - : <>localhost:5173vite} -
- - - - + 3 ports
: <>localhost:5173vite} +
+ System browser + Iframe + Agent browser + Popout
- {scenario === 'multiplePorts' && 3 ports} }
Alerts
- {notification ? 'Tests complete' : 'Watch all pnpm commands'} {!notification && } @@ -159,11 +158,11 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena
Helper terminal
- {preserved ? <>Autorun paused to preserve your session + {preserved ? <>Autorun paused to preserve your session : <> {scenario === 'running' ? : scenario === 'autorunOff' ? : } {scenario === 'autorunOff' ? 'Autorun off' : scenario === 'running' ? <>Running git status automatically : <>Automatically ran git status} - + }
Promote
@@ -177,7 +176,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena {detail &&
setDetail(null)}>
event.stopPropagation()}> -
{detail === 'title' ? 'Why this title?' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal?'} setDetail(null)}>
+
{detail === 'title' ? 'Why this title?' : detail === 'modify' ? 'Default helper autorun command' : 'Reset helper terminal?'} setDetail(null)}>
{detail === 'title' ? <>
Current title: pnpm dev
From dcff0895033f83fa4c6105dd5dcb5b23398e023f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 13:04:19 -0700 Subject: [PATCH 05/21] Adopt compact on-off switches across Dormouse --- DESIGN.md | 1 + docs/specs/layout.md | 4 +-- lib/src/components/design.tsx | 30 ++++++++++++------- lib/src/stories/OnOffSwitch.stories.tsx | 32 +++++++++++++++++++++ lib/src/stories/TerminalContext.stories.tsx | 16 +++++------ 5 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 lib/src/stories/OnOffSwitch.stories.tsx diff --git a/DESIGN.md b/DESIGN.md index a2b02c14..cde3b003 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -234,6 +234,7 @@ The system uses **raised surfaces**, not "cards." There are no nested cards. The - Used by `ThemePicker`. Style: `bg-input-bg`, `border border-input-border`, `rounded`, `font-mono`, `text-sm`. - **Focus:** native browser focus outline; this is acceptable because the entire input lives inside a raised surface that already has `shadow-2xl` and a border. - **Form fields inside a dialog** use the underlined pair in `design.tsx` instead, so a form mixing them reads as one: `NumericInput` for a number (filtered at the keystroke, sized in `ch`) and `TextInput` for a string (full width, `type` passed through — `type="password"` for a credential). The app has no checkbox anywhere: a boolean is an `OnOffSwitch`. +- **On/off switch:** a compact track with the thumb left when off and right when on, followed by only the current `On` / `Off` label. Off is neutral; on uses the host link accent. Its 60×24px button uses the subdued action tint and hover from `design.tsx`, with native keyboard and disabled-fieldset behavior. Nested settings text aligns through `UNDER_SWITCH_INDENT`. ### Navigation diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 4d8c4e3b..66fa4e24 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -382,11 +382,11 @@ A store commit that empties the tree (last pane killed or minimized) triggers th **Must isolate the Terminal context prototype to Storybook.** Static presentation never spawns helpers or changes production menus. -**Must put the prototype's Surface ref and close button at the right of the Title row**, without a separate heading row or terminal clipboard actions. +**Must place the Surface ref and close button at the Title row's right**, without a heading row or terminal clipboard actions. **Must keep the prototype's helper header on one line**, hiding its name at narrow widths. Notification details appear directly when present. -**Must label Title, Dir, and all four port actions in subdued link color; put counts beside dropdowns.** +**Must label Title, Dir, Modify, and four port actions in subdued link color; put counts beside dropdowns.** Switches use `OnOffSwitch` (`DESIGN.md` → Inputs). Source of truth: `TerminalContextStory` in `lib/src/stories/TerminalContext.stories.tsx`. diff --git a/lib/src/components/design.tsx b/lib/src/components/design.tsx index 0602b29e..ac46eee3 100644 --- a/lib/src/components/design.tsx +++ b/lib/src/components/design.tsx @@ -368,15 +368,19 @@ export const TextInput = forwardRef( /** * Left margin that lines content up under an `OnOffSwitch`'s label rather than - * its pill: the switch's `w-14` plus the usual `gap-3` between them. Lives here + * its control: the switch's `w-15` plus the usual `gap-3` between them. Lives here * so it moves with the switch's own geometry. */ -export const UNDER_SWITCH_INDENT = 'ml-[4.25rem]'; +export const UNDER_SWITCH_INDENT = 'ml-18'; + +/** Quiet action tint and interaction treatment, shared by switches and context actions. */ +export const SUBTLE_ACTION_COLOR_CLASS = 'text-[color:color-mix(in_srgb,var(--color-link)_35%,var(--color-muted))] enabled:hover:text-link enabled:focus-visible:text-link'; +export const SUBTLE_ACTION_INTERACTION_CLASS = 'enabled:hover:bg-current/10 focus-visible:outline focus-visible:outline-focus-ring'; /** - * The app's boolean control: a two-position pill reading "on | off". Rendered as - * a `role="switch"` button, so it is disabled natively by a surrounding - * `
`. + * The app's boolean control: compact track (off left, on right) and one state + * label. Keep its width fixed across states and in sync with UNDER_SWITCH_INDENT. + * Native button behavior handles Space/Enter and surrounding disabled fieldsets. */ export function OnOffSwitch({ on, @@ -397,15 +401,19 @@ export function OnOffSwitch({ aria-checked={on} aria-label={`${label} ${on ? 'on' : 'off'}`} onClick={() => (on ? onDisable() : onEnable())} - className="relative inline-flex h-5 w-14 items-center rounded-full border border-border bg-app-bg text-sm font-medium" + className={clsx( + 'inline-flex h-6 w-15 shrink-0 items-center gap-1.5 rounded px-1 font-mono text-sm font-normal disabled:cursor-not-allowed disabled:opacity-45', + SUBTLE_ACTION_COLOR_CLASS, + SUBTLE_ACTION_INTERACTION_CLASS, + )} > - on - off + className={clsx('relative h-3.5 w-6 shrink-0 rounded-full', on ? 'bg-link/25' : 'bg-foreground/10')} + > + + + {on ? 'On' : 'Off'} ); } diff --git a/lib/src/stories/OnOffSwitch.stories.tsx b/lib/src/stories/OnOffSwitch.stories.tsx new file mode 100644 index 00000000..6bafd75f --- /dev/null +++ b/lib/src/stories/OnOffSwitch.stories.tsx @@ -0,0 +1,32 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { OnOffSwitch, UNDER_SWITCH_INDENT } from '../components/design'; + +function SwitchExample({ label, initialOn, disabled = false }: { label: string; initialOn: boolean; disabled?: boolean }) { + const [on, setOn] = useState(initialOn); + return
+
+ setOn(true)} onDisable={() => setOn(false)} label={label} /> + {label} +
+
{disabled ? 'Disabled by the containing fieldset.' : 'Click, Space, or Enter to toggle.'}
+
; +} + +function SwitchStates() { + return
+ + + + +
; +} + +const meta = { + title: 'Components/OnOffSwitch', + component: SwitchStates, + parameters: { layout: 'centered' }, +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const States: Story = {}; diff --git a/lib/src/stories/TerminalContext.stories.tsx b/lib/src/stories/TerminalContext.stories.tsx index c45a278b..f6f4ba00 100644 --- a/lib/src/stories/TerminalContext.stories.tsx +++ b/lib/src/stories/TerminalContext.stories.tsx @@ -4,9 +4,9 @@ import { ArrowCounterClockwiseIcon, ArrowLineUpIcon, ArrowSquareOutIcon, BellIcon, BugBeetleIcon, CheckIcon, CircleNotchIcon, CopyIcon, FrameCornersIcon, PauseIcon, TerminalIcon, - WarningIcon, XIcon, + SlidersHorizontalIcon, WarningIcon, XIcon, } from '@phosphor-icons/react'; -import { OnOffSwitch, PANE_HEADER_HEIGHT_PX, POPUP_SURFACE_CLASS } from '../components/design'; +import { OnOffSwitch, PANE_HEADER_HEIGHT_PX, POPUP_SURFACE_CLASS, SUBTLE_ACTION_COLOR_CLASS as ACTION_COLOR_CLASS, SUBTLE_ACTION_INTERACTION_CLASS as ACTION_INTERACTION_CLASS } from '../components/design'; import { AgentRobotIcon } from '../components/wall/BrowserDisplayIcon'; // Presentation only: no PTYs, platform calls, persistence, or production menu @@ -26,12 +26,10 @@ const SCENARIOS: { id: Scenario; label: string }[] = [ ]; const PARENT_DIR = '~/projects/dormouse'; const HELPER_DIR = '~/projects/dormouse-fix'; -const noop = () => {}; -const ACTION_COLOR_CLASS = 'text-[color:color-mix(in_srgb,var(--color-link)_35%,var(--color-muted))] hover:text-link focus-visible:text-link'; function Action({ children, label, onClick, muted = false }: { children: ReactNode; label: string; onClick?: () => void; muted?: boolean }) { return ; } @@ -102,6 +100,8 @@ function TerminalOutput({ scenario }: { scenario: Scenario }) { function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scenario: Scenario; initialDetail?: 'title' | 'modify' | 'reset' | null; paneWidth: number }) { const [detail, setDetail] = useState(initialDetail); const [port, setPort] = useState('5173'); + const [watching, setWatching] = useState(false); + const [todo, setTodo] = useState(scenario === 'notification'); const preserved = ['preserved', 'editor', 'differentDirectory'].includes(scenario); const mismatch = scenario === 'differentDirectory'; const notification = scenario === 'notification'; @@ -143,9 +143,9 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena Alerts
{notification ? 'Tests complete' : 'Watch all pnpm commands'} - {!notification && } + {!notification && setWatching(true)} onDisable={() => setWatching(false)} label="Watch all pnpm commands" />} - TODO + TODO setTodo(true)} onDisable={() => setTodo(false)} label="TODO" />
{notification &&
@@ -162,7 +162,7 @@ function ContextPrototype({ scenario, initialDetail = null, paneWidth }: { scena : <> {scenario === 'running' ? : scenario === 'autorunOff' ? : } {scenario === 'autorunOff' ? 'Autorun off' : scenario === 'running' ? <>Running git status automatically : <>Automatically ran git status} - + setDetail('modify')}>Modify }
Promote
From a272a9e6dd682165868e937ee62f1d3020edf180 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 13:31:17 -0700 Subject: [PATCH 06/21] Plan terminal context and helper terminal implementation --- AGENTS.md | 1 + docs/specs/terminal-context.md | 111 +++++++++++++++++++++++++++++++++ scripts/spec-word-budgets.json | 5 +- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 docs/specs/terminal-context.md diff --git a/AGENTS.md b/AGENTS.md index 0482170a..272e3d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ One implementation map per spec: an exhaustive `Files` / `Code Map` section or s - **`docs/specs/tiling-engine.md`** — **Lath**, the in-house headless tiling engine: pure split-tree core, never-re-parent LathHost adapter, wall store + engine, Lath-only persistence. - **`docs/specs/alert.md`** — The Activity layer: alert tracks, attention model, TODO lifecycle, notification protocols with their sanitization rules, the Workspace union projection. - **`docs/specs/terminal-state.md`** — Per-Session semantic state: CWD, prompt/command lifecycle, title candidates and header derivation, grouping keys. +- **`docs/specs/terminal-context.md`** — Unified terminal context and helper terminal (design-stage): lifecycle, promotion, host integration, and implementation questions. - **`docs/specs/terminal-escapes.md`** — Registry of every escape sequence parsed, answered, or ignored, each row pointing at its owning spec. Read before touching OSC/CSI parsing. - **`docs/specs/transport.md`** — Adapter-agnostic webview ↔ host protocol: PTY lifecycle and buffering, reconnection, message contracts, persisted-session types, the invariants every adapter honors. - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned selection, copy (Raw / Rewrapped), paste tiers, smart URL/path extension, the mouse-ownership state matrix. diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md new file mode 100644 index 00000000..91bdcd29 --- /dev/null +++ b/docs/specs/terminal-context.md @@ -0,0 +1,111 @@ +# Terminal context + +> Status: design — the production context and helper are not implemented. The +> existing visual prototype is owned by `docs/specs/layout.md` → Terminal context prototype. +> +> See `docs/specs/glossary.md` for Surface / Session / Pane vocabulary. +> This design owns helper lifecycle and context composition; layout, terminal +> semantics, alerts, transport, and browser behavior retain their existing owners. + +## Future + +**Scope: terminal-context** — implement after resolving the questions below: + +1. Settle ownership and lifecycle contracts, update the owning specs, and add host metadata. +2. Build helper session management, input ownership, reset, promotion, and recovery. +3. Replace production terminal menus and connect metadata, alerts, directory, and port actions. +4. Replace static stories with shared production presentation, verify both desktop hosts, and promote completed rules above the fold. + +### Context composition + +- **Must use one context for terminal-capable Surfaces**, reached from the header, header alert, terminal body, and existing keyboard context command. Browser-only Surfaces retain their applicable controls. +- **Must anchor below the source header at the terminal body's top-left**, reserving the prototype's two-rem right and bottom gaps where space permits. Keep the overlay within the available body; small-screen redesign is deferred. +- **Must retain the approved prototype's rows, labels, subdued action treatment, compact switches, and single-line helper header.** The helper name yields space before its status and actions. +- **Must show the source's stable Surface ref on the Title row**, with copy and close affordances and no separate context heading. +- **Must explain the displayed title using the same derivation as the header.** Include the winning candidate, user override, command fallback, and latest relevant OSC candidates; do not invent a historical OSC log. +- **Must keep title, directory, and alerts current while open.** Ports are the exception: take one scan per opening and ignore late results after close or source change. +- **Must render alert notifications directly and retain existing attention, Watch, and TODO semantics.** Existing alert actions that open terminal details open this context; actions that acknowledge or toggle retain their behavior. +- **Must retain terminal selection copy/paste affordances without adding a clipboard toolbar to this context.** Right-click ownership in reporting applications is Q6. +- **Must keep one context open per Wall**, with nested settings/title disclosures belonging to that context. Switching source hides the previous helper under its normal lifecycle. + +### Directory and ports + +- **Must copy the absolute directory and abbreviate only the current user's home prefix for display.** Use path boundaries, platform path syntax, and directory host identity; do not guess home from path segments. +- **Must open directories through a dedicated platform capability**, with local absolute-directory validation, argument-safe process invocation, and visible failure feedback. Do not widen the external-URL opener to accept arbitrary file URLs. Unsupported or remote directories need an explanation rather than an inert action. +- **Must launch a new helper with the configured shell and a supported local source directory.** It does not inherit the source shell's exports, virtual environment, or SSH connection. Q5 settles the unavailable/remote-directory interaction. +- **Must show a prominent directory mismatch warning with both Helper and Parent locations.** A preserved helper never silently follows a parent directory change; unknown location is distinct from a confirmed match. +- **Must distinguish scanning, no ports, and scan failure.** Deduplicate and order ports using existing port URL rules; show the address and process when known. +- **Must show four labeled actions for the selected port**: System browser, Iframe, Agent browser, and Popout. One port needs no selector; multiple ports put their count beside the selector before the actions. +- **Must preserve the source when opening a port.** The existing browser-launch path's replacement of untouched terminals is inappropriate here. Disable unavailable capabilities with a reason; report launch failures in context. Q7 settles reuse. + +### Helper lifecycle + +- **Must create at most one helper per source, lazily on first opening.** Opening the parent terminal itself creates no helper. A helper cannot recursively acquire a helper. +- **Must keep the global autorun setting separate from per-helper state.** Factory default is `git status`; an empty command disables autorun. Settings changes affect new/reset helpers, and the status reports the command that this helper actually used. +- **Must wait for positively established shell readiness before injecting autorun.** Cancel pending injection on user input, reset, disposal, or promotion; never inject on an elapsed-time guess. Missing integration shows a skipped/unsupported state. Treat each launch as a separate generation so stale callbacks cannot write into its replacement. +- **Must make user input permanently preserve that helper until Reset or Promote.** Typing, paste, accepted drops, program mouse input, and explicit external writes count; selection, copying, resize, and protocol replies do not. An idle prompt does not restore automatic behavior. +- **Must refresh an untouched helper on reopening only when safely idle.** Preserve running autorun, foreground applications, background jobs, and uncertain process state. Prompt completion alone does not prove no background work remains. +- **Must hide preserved helpers when the context closes without killing or suspending their processes.** Keep scrollback, partial input, working directory, and terminal modes. Dispose an untouched, completed helper only after establishing the same safe-idle condition used for refresh. +- **Must make Reset explicitly discard the old helper and restore automatic behavior in a fresh helper.** Confirm before discarding user work or running/uncertain processes, naming the running command when known. Leave the existing helper intact if reset is cancelled. +- **Must make Promote transfer the actual Session into a regular split beside the source**, retaining its PTY, xterm instance, scrollback, directory, input, and identity. Close the context and focus the promoted terminal. Commit ownership transfer only after placement succeeds; failure leaves the helper usable. A subsequent source context gets a fresh helper. +- **Must let an exited helper retain its visible output**, with Reset available and no repeated automatic restart loop. + +| State | Single-line status and action | +|---|---| +| Awaiting readiness | Waiting for shell…; Modify | +| Autorun executing | Running `command`…; Modify | +| Untouched, completed | `command` autoran; Modify | +| User input received | Skipping autorun to preserve user keystrokes; Reset | +| Autorun disabled | Autorun off; Modify | +| Readiness unavailable | Autorun skipped: shell readiness unavailable; Modify | +| Launch failed / exited | Concrete error / exit status; Reset | + +### Identity, focus, and host lifetime + +- **Must model the helper as an explicitly owned auxiliary terminal Surface**, with a stable Session id and source association, rather than a fabricated minimized Pane. Its source Pane contains both; only the primary Surface has a Lath leaf until promotion. Update glossary identity/containment language and registry parking rules before relying on this exception. This does not introduce tabs or the staged workspace rollout. +- **Must separate session management from React overlay lifetime.** Keep retained xterm DOM in a mounted parking container when hidden; attach the same element when revealed or promoted. Overlay cleanup alone must never call Session disposal. +- **Must carry helper ownership in host live-PTY metadata before reconnect reconciliation.** Otherwise current orphan recovery treats a helper as a normal Pane and may discard the saved layout. Validate ownership within the owning Workspace and reconcile parent/helper restoration together. +- **Must retain the executed-command snapshot and sticky preservation state across live reconnects.** If that state cannot be recovered, preserve the helper and disarm autorun rather than infer that it was untouched. Promotion removes auxiliary ownership in the host as well as the frontend. +- **Must preserve helpers across reconnections that retain their PTYs**, including webview recreation. Cold starts follow each host's existing recovery contract with a fresh lazy helper; do not add standalone disk session persistence or promise saved editor buffers after process death. Missing-parent recovery must retain live user work in a regular Pane rather than silently dispose it. +- **Must route keyboard and clipboard input to the actual focused terminal.** While helper xterm owns focus, Escape, Tab, arrows, and digits belong to its program; global selection handling must not intercept them for the parent. Escape from context controls closes the innermost disclosure, then context. Outside click and explicit close hide the context. Opening focus is Q1. +- **Must restore source focus on close unless the user selected another target.** Source minimize hides context and retains its helper; destructive source closure follows Q2. +- **Must account for helper processes in host shutdown checks and resource cleanup.** Hidden work cannot bypass existing quit protection. Alert and external-discovery behavior are Q3 and Q4. + +### Questions for product decisions + +These recommendations are provisional, not settled behavior. + +| ID | Decision | Recommendation | +|---|---|---| +| Q1 | Where does focus land when opening? | Focus the helper immediately; input during startup cancels pending autorun. | +| Q2 | Close a parent with preserved or running helper work? | Offer Keep helper (take the parent's slot), Close both, or Cancel. Safely untouched helpers need no extra confirmation. | +| Q3 | What happens when a hidden helper needs attention? | Reflect its attention on the parent with a helper indicator; activating it reveals the helper. Keep source and helper Watch/TODO state distinct. | +| Q4 | Is an unpromoted helper discoverable outside its context? | Let `dor` identify/address it, label it as the parent's helper in listings, and make focus reveal its context. Defer Pocket access until promotion; filter both directory discovery and direct attachment. | +| Q5 | Source directory is remote or unavailable? | Show the fallback local directory and require an explicit Start locally action before spawning or autorunning. Copy the source path remains available. | +| Q6 | Right-click while a terminal application owns mouse input? | Preserve the application's right-click; Shift-right-click opens context. Header right-click always opens context. | +| Q7 | Repeated port action creates or reuses a browser? | Reuse per source and port; iframe has its own Surface, Agent browser and Popout share one agent-browser session and change its display mode. System browser follows OS behavior. | + +**Proposed delivery scope:** VS Code and Standalone desktop, plus working fake-adapter Storybook/demo coverage. Pocket composition and remote helper creation are deferred; existing terminal-only remote protocol remains unchanged. + +### Implementation map + +This map identifies existing integration points, not implemented feature ownership. Add dedicated context presentation, helper-session controller, and global-settings modules alongside these files during implementation. + +| Area | Integration points and required work | +|---|---| +| Composition | `lib/src/components/Wall.tsx`; `lib/src/components/wall/TerminalPanel.tsx`; `lib/src/components/wall/TerminalPaneHeader.tsx`; `lib/src/components/wall/PaneHeaderContextMenu.tsx`; `lib/src/components/TodoAlertDialog.tsx`: central context state, entry points, alert integration, safe split adoption. Retire only superseded terminal paths. | +| Lifecycle/input | `lib/src/lib/terminal-lifecycle.ts`; `lib/src/lib/terminal-store.ts`; `lib/src/components/wall/use-wall-keyboard.ts`: observable helper state, cancellable readiness, input-origin tracking, DOM parking, focus routing. Audit every PTY write path. | +| Metadata/browser | `lib/src/lib/terminal-state.ts`; `lib/src/components/wall/port-url.ts`; `lib/src/components/wall/connect-port.ts`: shared title explanation, host-aware directories, scan snapshot, four launch modes, source-preserving placement. | +| Adapters/hosts | `lib/src/lib/platform/types.ts`; `lib/src/lib/platform/vscode-adapter.ts`; `lib/src/lib/platform/fake-adapter.ts`; `vscode-ext/src/pty-manager.ts`; `vscode-ext/src/message-types.ts`; `standalone/src/tauri-adapter.ts`; `standalone/sidecar/pty-core.js`; `standalone/src-tauri/src/lib.rs`: directory capability, home identity, helper ownership, safe-idle evidence, promotion metadata updates, ownership validation across bridges. | +| Settings/recovery | `lib/src/lib/alert-settings-host.ts` as the existing global synchronization pattern; `lib/src/lib/reconnect.ts`; `lib/src/lib/session-save.ts`; `lib/src/lib/session-types.ts`: separate autorun setting, atomic live ownership recovery, backward-compatible metadata defaults, existing cold-start policies. | +| External surfaces | `lib/src/components/wall/use-dor-control.ts`; `dor/src/commands/types.ts`; `lib/src/remote/burrow/directory-collect.ts`; `lib/src/remote/burrow/remote-api.ts`: helper addressing, focus/kill semantics, remote discovery and attachment guards according to Q4. Audit alert unions and shutdown counts alongside these consumers. | +| Stories | `lib/src/stories/TerminalContext.stories.tsx`: use production presentation with deterministic fake sessions and controllable metadata, process, and capability states. | + +### Spec changes and validation + +- **Must update each behavior's owning spec in the same implementation slice.** This spec owns lifecycle; glossary owns auxiliary identity/containment, layout owns placement/focus/promotion, mouse-and-clipboard owns right-click/input routing, terminal-state owns title/readiness/directory semantics, alert owns helper attention, transport owns live metadata/recovery, dor-cli owns addressing, dor-browser owns reuse, and host specs own native operations/settings. Security-local and security-remote own new trust-boundary guarantees; update audited checks only when those guarantees change. +- **Must replace the layout prototype-only rule when production integration ships**, keeping presentation ownership in layout and lifecycle here. Promote completed text out of this scope; leave only unbuilt design under Future. +- **Must test lifecycle transitions and races**: first open, safe refresh, user input before readiness, running/background work, unknown readiness, hide/reopen, reset cancellation, stale callbacks, exit, exact-session promotion, failed placement, parent closure, and live reconnect with helpers. Use controllable fake PTYs and readiness signals. +- **Must test boundary behavior**: native directory validation and errors, home/path identity, async source changes, each port mode and reuse, alert isolation, all user-input routes, helper CLI ownership, and denied remote direct attachment. Add host tests where bridge fields or recovery behavior change. +- **Must verify real shells in both desktop hosts**: initial autorun, typed partial input, preserved scrollback, unsaved `nano`, a background job, missing shell integration, clipboard and application mouse reporting, source-directory changes, promotion, and webview reload. View the implemented stories through `dor ab` in light and dark themes. +- **Must run spec lint, relevant focused tests, root tests, type/build checks, and the production build before completion.** Keep Storybook states for zero/one/multiple ports, notifications, all helper states, directory mismatch/unknown, failed capabilities, and nested disclosures. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index d6777e96..f1c35fa1 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 3100, + "AGENTS.md": 3150, "SECURITY.md": 200, "SELF_HOST.md": 6000, "docs/specs/alert.md": 6550, @@ -9,7 +9,7 @@ "docs/specs/dor-cli.md": 4800, "docs/specs/dor-tool.md": 2100, "docs/specs/glossary.md": 2850, - "docs/specs/layout.md": 7650, + "docs/specs/layout.md": 7700, "docs/specs/mobile-terminal-ui.md": 1950, "docs/specs/mouse-and-clipboard.md": 3600, "docs/specs/pocket-app.md": 4050, @@ -24,6 +24,7 @@ "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1000, "docs/specs/standalone.md": 4050, + "docs/specs/terminal-context.md": 2100, "docs/specs/terminal-escapes.md": 3750, "docs/specs/terminal-state.md": 2200, "docs/specs/theme.md": 2000, From 36ac54d61361d19f24b1c65237b0dfd4bcdfad7a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 5 Sep 2026 15:07:38 -0700 Subject: [PATCH 07/21] Implement unified terminal context and helper terminals --- AGENTS.md | 2 +- docs/specs/alert.md | 16 +- docs/specs/dor-browser.md | 49 +-- docs/specs/dor-cli.md | 6 + docs/specs/glossary.md | 6 +- docs/specs/layout.md | 32 +- docs/specs/mouse-and-clipboard.md | 9 + docs/specs/security-local.md | 6 + docs/specs/security-remote.md | 6 + docs/specs/shortcuts.md | 8 +- docs/specs/standalone.md | 6 + docs/specs/terminal-context.md | 160 +++----- docs/specs/terminal-state.md | 8 + docs/specs/transport.md | 10 + docs/specs/vscode.md | 6 + lib/src/components/TerminalPane.tsx | 2 +- lib/src/components/Wall.test.tsx | 24 +- lib/src/components/Wall.tsx | 151 +++++++- .../wall/PaneHeaderContextMenu.test.tsx | 360 ------------------ .../components/wall/PaneHeaderContextMenu.tsx | 247 ------------ .../components/wall/TerminalContext.test.tsx | 90 +++++ lib/src/components/wall/TerminalContext.tsx | 65 ++++ .../components/wall/TerminalContextView.tsx | 101 +++++ .../components/wall/TerminalPaneHeader.tsx | 49 +-- lib/src/components/wall/TerminalPanel.tsx | 13 +- .../wall/keyboard/handle-pane-shortcuts.ts | 2 + lib/src/components/wall/use-dor-control.ts | 7 +- lib/src/components/wall/use-wall-keyboard.ts | 7 + lib/src/components/wall/wall-context.tsx | 9 + lib/src/lib/helper-terminal.test.ts | 84 ++++ lib/src/lib/helper-terminal.ts | 125 ++++++ lib/src/lib/platform/dor-control-dispatch.ts | 5 + lib/src/lib/platform/fake-adapter.ts | 55 ++- lib/src/lib/platform/types.ts | 6 +- lib/src/lib/platform/vscode-adapter.ts | 9 +- lib/src/lib/reconnect.test.ts | 12 + lib/src/lib/reconnect.ts | 8 +- lib/src/lib/session-activity-store.ts | 1 + lib/src/lib/terminal-context-types.ts | 14 + lib/src/lib/terminal-lifecycle.ts | 30 +- lib/src/lib/terminal-state-store.ts | 6 +- lib/src/lib/terminal-state.test.ts | 13 + lib/src/lib/terminal-state.ts | 22 ++ lib/src/lib/terminal-store.ts | 5 + lib/src/remote/burrow/directory-collect.ts | 2 +- lib/src/remote/burrow/peer-surfaces.test.ts | 10 + lib/src/remote/burrow/peer-surfaces.ts | 3 +- lib/src/stories/TerminalContext.stories.tsx | 131 +------ lib/src/stories/Wall.stories.tsx | 12 + scripts/spec-word-budgets.json | 14 +- standalone/scripts/dev-agent-browser.mjs | 3 +- standalone/sidecar/helper-terminal.test.js | 63 +++ standalone/sidecar/main.js | 1 + standalone/sidecar/pty-core.js | 80 +++- standalone/sidecar/pty-core.test.js | 2 +- standalone/src-tauri/src/lib.rs | 7 + standalone/src/browser-sidecar-adapter.ts | 18 +- standalone/src/tauri-adapter.ts | 22 +- vscode-ext/src/message-router.ts | 19 +- vscode-ext/src/message-types.ts | 6 +- vscode-ext/src/pty-host.js | 3 +- vscode-ext/src/pty-manager.ts | 26 ++ 62 files changed, 1259 insertions(+), 1015 deletions(-) delete mode 100644 lib/src/components/wall/PaneHeaderContextMenu.test.tsx delete mode 100644 lib/src/components/wall/PaneHeaderContextMenu.tsx create mode 100644 lib/src/components/wall/TerminalContext.test.tsx create mode 100644 lib/src/components/wall/TerminalContext.tsx create mode 100644 lib/src/components/wall/TerminalContextView.tsx create mode 100644 lib/src/lib/helper-terminal.test.ts create mode 100644 lib/src/lib/helper-terminal.ts create mode 100644 lib/src/lib/terminal-context-types.ts create mode 100644 standalone/sidecar/helper-terminal.test.js diff --git a/AGENTS.md b/AGENTS.md index 272e3d5c..e33dbe1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ One implementation map per spec: an exhaustive `Files` / `Code Map` section or s - **`docs/specs/tiling-engine.md`** — **Lath**, the in-house headless tiling engine: pure split-tree core, never-re-parent LathHost adapter, wall store + engine, Lath-only persistence. - **`docs/specs/alert.md`** — The Activity layer: alert tracks, attention model, TODO lifecycle, notification protocols with their sanitization rules, the Workspace union projection. - **`docs/specs/terminal-state.md`** — Per-Session semantic state: CWD, prompt/command lifecycle, title candidates and header derivation, grouping keys. -- **`docs/specs/terminal-context.md`** — Unified terminal context and helper terminal (design-stage): lifecycle, promotion, host integration, and implementation questions. +- **`docs/specs/terminal-context.md`** — Unified terminal context and helper terminal: lifecycle, promotion, source closure, and global autorun settings. - **`docs/specs/terminal-escapes.md`** — Registry of every escape sequence parsed, answered, or ignored, each row pointing at its owning spec. Read before touching OSC/CSI parsing. - **`docs/specs/transport.md`** — Adapter-agnostic webview ↔ host protocol: PTY lifecycle and buffering, reconnection, message contracts, persisted-session types, the invariants every adapter honors. - **`docs/specs/mouse-and-clipboard.md`** — Terminal-owned selection, copy (Raw / Rewrapped), paste tiers, smart URL/path extension, the mouse-ownership state matrix. diff --git a/docs/specs/alert.md b/docs/specs/alert.md index b30ee56c..23469814 100644 --- a/docs/specs/alert.md +++ b/docs/specs/alert.md @@ -347,21 +347,23 @@ Where it surfaces is host-specific: ### Pane Header -The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and a dialog opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. +The header shows an alert bell, a fixed-text `TODO` pill when `todo === true`, a hover/focus notification preview when TODO has `notification`, and the terminal context opened by right-click or by some left-click actions. Placement, sizing, and width tiers belong to `docs/specs/layout.md`. Bell rotation follows public status; motion follows latch edges. **When a track latches, ring each mounted bell for four 800ms cycles, then hold 45° until the ring clears** (test: `runs a finite ringing burst and then holds the bell at 45 degrees` in `lib/src/components/bell-icon-class.test.ts`; rationale). **A newly mounted ringing bell may replay once without advancing `ringSeq`** (test: `replays the finite burst when a ringing presentation remounts` in `lib/src/components/AlertBell.test.tsx`; rationale). **A newly latched track replays the burst; further reports on that track only enrich its summons.** `AlertState.ringSeq` counts per-Session latches and is compared by `alertStatesEqual` (tests: `counts a second track ringing behind an already-latched one` and `does not count a track that is already ringing` in `lib/src/lib/alert-manager.test.ts`, `replaces the icon when the ring counter advances` in `lib/src/components/AlertBell.test.tsx`; rationale). **Remote Clients have no counter:** `DirectoryEntry.ringing` is an edgeless boolean, so Pocket rings on mount and holds. **The bell names the command it would act on** ("Alert on all `claude`"), not an abstract toggle — that is the scope of what a click changes. Bell interactions — one transition table, in `dismissOrToggleAlert`: -- Left-click `ALERT_RINGING`: dismiss, create TODO if needed, open dialog. -- Left-click after `attentionDismissedRing`: consume the flag and open dialog. +- Left-click `ALERT_RINGING`: dismiss, create TODO if needed, open context. +- Left-click after `attentionDismissedRing`: consume the flag and open context. - Otherwise, with a command running: toggle that command's WATCHING rule on or off. Turning it off drops the rule for every Session running it. -- Exception: from `OSC_NOTIF_BUSY` or `COMMAND_EXIT_ARMED` with no rule set, open the dialog instead. Those alarms need no rule, so a click must not create one by surprise, and must not clear the progress or the arm. -- With no command running: change nothing and open the dialog, which explains that alerts are per command. -- Pressing `a` on the selected Pane in command mode uses the same action. Right-click always opens the dialog. +- Exception: from `OSC_NOTIF_BUSY` or `COMMAND_EXIT_ARMED` with no rule set, open the context instead. Those alarms need no rule, so a click must not create one by surprise, and must not clear the progress or the arm. +- With no command running: change nothing and open the context, which explains that alerts are per command. +- Pressing `a` on the selected Pane in command mode uses the same action. Right-click always opens the context. - Pressing `t` toggles TODO. -The dialog carries the TODO switch, the WATCHING rule switch for the running command, notification detail, and the same `WatchedCommandList` the Settings dialog renders — load-bearing, not decoration, for the reason under Settings dialog. +**Must keep context alert controls scoped to the source**, with TODO, running-command WATCHING, and notification detail. Settings owns the global watched-command list. **Must suppress helper alerting until promotion**, including bell/notification protocols, watched commands, TODO, speech, push, and attention projections; semantic command/readiness state remains active. Promotion starts ordinary alert behavior without replaying suppressed events. + +Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `createOwnerPtyStream` in `vscode-ext/src/message-router.ts`; `TauriAdapter` in `standalone/src/tauri-adapter.ts`; `FakePtyAdapter` in `lib/src/lib/platform/fake-adapter.ts`. The TODO pill always displays `TODO`; remote notification text belongs in preview/detail surfaces, not inside the pill. Clicking the pill clears TODO, and on clear the pill briefly shows the success flourish before unmounting. diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index 568046f3..c6de99cf 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -144,46 +144,15 @@ Source of truth: `lib/src/components/wall/use-dev-server-ports.ts`, ## Pane Context Menu Connect -The terminal pane header's context menu (`docs/specs/layout.md` → Header context -menu) lists the ports a pane's process tree binds, using the **same** per-port -URL selection as `surface.resolveOpen` (`docs/specs/dor-cli.md` → Browser Open -Target Resolution). Activating a row — click, its `1`–`9` digit accelerator, or -`Enter` — reproduces `dor ab open ` against the **default** key/session, -reusing or creating that session's browser surface: the wall-side mirror of the -CLI flow, not the control plane. Host-gated on `agentBrowserCommand`; without it -the rows are inert labels. - -**Activation reveals its surface.** Unlike focus-neutral `dor ab`, a menu row is -the human asking to see and control that browser, so every arm of the lookup -below **must end by selecting the surface in passthrough mode**, reattaching it -first when minimized, exactly as clicking its Door chip does — including from -command mode with `>` (rationale). - -**Instant create.** The click is fire-and-forget: the menu closes at once and the -pane appears **before** `agent-browser open` runs (rationale). - -- The eager surface is placed synchronously and **must carry no `session`** — a - session-less `ab-screencast` pane is inert, so it cannot race the daemon boot - (rationale). It carries `key: 'default'` and the target `url`, and shows a - `Connecting to browser session…` placeholder rather than the idle - `run dor ab open ` line (rationale). -- `agent-browser open ` runs, then a best-effort `stream status`. -- **Must hand over `{session, wsPort, binaryPath}` in one params refresh** - (rationale). Failed or rejected `open` still hands over session and binary; - a rejected stream-status lookup omits only the port. Failures log into the - console after the menu closes. Pinned by `connect-port.test.ts`. - -The lookup reuses before it creates: (a) a surface bound to the default session, -else (b) a still-booting session-less `key: 'default'` pane, so a double-click -doesn't spawn two panes, else (c) a fresh session-less pane. Accepted edge: a -pane persisted mid-boot restores session-less and stays a `Connecting…` -placeholder — kill it, or connect again (arm (b) reuses it). - -Source of truth: `lib/src/components/wall/connect-port.ts` -(`connectPortToDefaultBrowser`, `ensureEagerSurface`), `lib/src/components/wall/use-dor-control.ts` -(`useDorControl`'s `connectPort` and `updateSurfaceParams`, shared with -`ensureAgentBrowserSurface`), `lib/src/components/Wall.tsx` (`revealSurface`), `lib/src/components/wall/port-url.ts` -(`listenerUrlsByPort`), `lib/src/components/wall/PaneHeaderContextMenu.tsx`. +**Must scan once per context opening**, using the shared per-port URL selection in `docs/specs/dor-cli.md` → Browser Open Target Resolution. Zero/one port uses an inline row; multiple ports use a selector. Failed scans are distinct from no listeners. + +**Must offer System browser, Iframe, Agent browser, and Popout for the selected port**, disabling unavailable host capabilities with a reason. Opening a browser from context always preserves the source terminal, including an untouched one. + +**Must reuse targets per source and port**: iframe has a separate Surface; agent screencast and popout share a Session and switch display modes. Reattach minimized targets and recreate closed ones. System browser follows the OS opener's behavior. + +**Must create agent-browser Surfaces eagerly without a session**, binding the returned session only after the host launch succeeds; failures are reported in context. A launch completing after its eager Surface has closed releases its browser session. Concurrent requests for the same target are serialized. + +Source of truth: `openContextPort` in `lib/src/components/Wall.tsx`; `listenerUrlsByPort` in `lib/src/components/wall/port-url.ts`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. ## Display Modal And Render Swaps diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index e693fcaf..42b2cd8e 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -534,6 +534,12 @@ Source of truth: `dor/src/commands/skill.ts`, `scripts/generate-dor-skill.mjs`, `dor/skill.md`, whose byte-identity with `dor skill` output is pinned by `dor/test/cli-output.test.mjs`. +## Helper exclusion + +**Must exclude unpromoted helpers from discovery and control**, including direct internal-id targets and helper-origin requests. Promotion assigns the ordinary public Surface ref without changing Session identity; subsequent CLI operations use ordinary Surface semantics. + +Source of truth: `buildDorSurfacesInternal` in `lib/src/components/Wall.tsx`; `dispatchDorControlRequest` in `lib/src/lib/platform/dor-control-dispatch.ts`. + ## Future - **Surface a dead control channel in the UI.** A lost bind leaves one diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index f28e0cc7..9f62aa7a 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -54,10 +54,12 @@ The containment hierarchy `dor` handles commit to (`docs/specs/dor-cli.md`): Window ⊃ Workspace ⊃ Pane ⊃ Surface (terminal = Session | browser) ``` -**Surface identity:** a Surface's id is its Lath leaf id. A terminal Surface's *is* its `SessionId`, stable (I1); browser replacement and relaunch have different identity effects (I10). +**Surface identity:** a primary Surface's id is its Lath leaf id; a helper receives its Lath leaf only on promotion. A terminal Surface's *is* its `SessionId`, stable (I1); browser replacement and relaunch have different identity effects (I10). ## Containers +**Must keep a helper as an auxiliary terminal Surface in its source's Pane**, with a stable Session id and explicit parent association. It has no independent Lath leaf, public ref, or alerting until promotion; `docs/specs/terminal-context.md` owns its lifetime. A shown helper is `Paned` within the source body; a closed context leaves it `Hidden` and DOM-parked (`Mounted`). + Workspace and Window are containers, not Session layers — they group Surfaces rather than describing one Surface's state (containment is I7). | Container | Holds | Owner | @@ -148,7 +150,7 @@ A **Session** is the tuple of its `SessionId` plus one state per layer (I1). | State | Meaning | |---|---| -| `Paned` | Rendered as a pane in the content area (a Lath leaf) | +| `Paned` | Rendered in the content area: a primary Lath leaf or its shown auxiliary helper | | `Zoomed` | Subset of `Paned` — the passthrough-focused pane is maximized; acquiring zoom gives focus, losing focus returns it to `Paned` | | `Doored` | Rendered as a door on the baseboard. DOM survival is a rendering decision, not part of this state: browser DOM retention follows **parking** and eviction (`docs/specs/tiling-engine.md` → "Parked leaves"); a terminal Surface unmounts its element (Registry: `Orphaned`) and remounts the same xterm on reattach — nothing replays | | `Hidden` | In neither pane nor door — webview closed or mid-transition; inactive-Workspace presentation is staged (`docs/specs/layout.md` → Future). Process and Activity unaffected. | diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 441da1a2..5f6d7b51 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -54,17 +54,23 @@ The label is the `DerivedHeader` from `deriveHeader(...)`; `docs/specs/terminal- #### Header context menu -**One menu per terminal pane, opened by right-click anywhere on the header or by `>` in command mode** — at the pointer, or under the header's left edge (`data-pane-header-for` plus a synthetic `contextmenu`, so both paths share one code path). Browser headers and Doors have no such menu, so `>` no-ops there. Only the alert bell owns its own right-click (`stopPropagation`, opening the alert dialog); every other region, the title span included, bubbles here. It is portaled to `document.body`, viewport-clamped, and dismissed by outside `pointerdown`, `Escape`, `resize`, or capture-phase `scroll` — **never by a scroll originating inside the menu**, since arrow-key focus moves auto-scroll the overflowing list. +**Must open the terminal context from terminal header, alert, body, and command-mode `>` entry points.** Browser-only Surfaces and Doors have no context. Application mouse ownership follows `docs/specs/mouse-and-clipboard.md` → Terminal context input. -Content, top to bottom: +**Must anchor at the terminal body's top-left below its header**, leaving two rem at right and bottom. Keep one context per Wall. Outside pointer press and explicit close dismiss it; resize follows its containing body. No separate context heading or clipboard toolbar is shown. -- **Header row** — display title, the pane's `surface:N` handle (`resolveSurfaceRef`, muted), close button. -- **Title-candidates table** — latest entry per `titleCandidates` channel (`docs/specs/terminal-state.md`): channel, text, timestamp; else a muted `No title candidates`. **Diagnostic only** — it changes no title priority rule. -- **Port rows** — the TCP ports the pane's process tree binds, scanned by `getOpenPorts` **once per open** (reopen to rescan): a spinner, then one `host:port` row per distinct port (digit chip first, process name muted beside it), else a muted `no listening ports` / `port scan failed`. +| Row | Content | +|---|---| +| Title | Derived display title, labeled Explain action, copyable source Surface ref and close at right | +| Dir | Home-abbreviated directory, native explorer action, absolute-path copy | +| Ports | One scan per opening; scanning/empty/failure states; one port inline, multiple ports in a dropdown with count beside it; four labeled actions | +| Alerts | Source Watch and TODO controls; notification details directly below | +| Helper | Remaining space; one-line status, Modify/Reset and Promote; hide its name below 48rem container width | + +**Must focus context controls on opening.** Explicit entry into helper xterm gives it terminal keys; Escape there belongs to its program. Escape from controls closes the innermost disclosure, then context. Terminal clipboard routing uses the focused helper rather than the selected source. Actions use subdued link color and shared compact `OnOffSwitch` controls. -**The menu owns the keyboard while open**: DOM focus on mount, the previously focused element restored when a dismissal leaves input ownership unchanged, and registration as dialog-keyboard-active so command-mode keys don't fire underneath. `1`–`9` activate the matching port row and **presses during the scan are dropped, never buffered**; `↑`/`↓` rove the rows (wrapping), `Enter`/`Space` activate the focused row, `Tab`/`Shift+Tab` cycle every focusable element, `Escape` closes. +**Must promote by adopting the helper Session into a new split beside the source**, preserving identity and focusing it. Helper lifetime and source closure are owned by `docs/specs/terminal-context.md`. -Activating a port row reproduces `dor ab open ` for that port and closes the menu at once (`docs/specs/dor-browser.md` → Pane Context Menu Connect): the browser surface becomes the selection in passthrough, reattaching first if minimized — **the one command-mode gesture that moves selection off the pane it targeted and exits command mode** — with loading/errors surfacing in the pane, not the menu. With no `agentBrowserCommand` the rows are inert labels with no digit chips. Source of truth: `lib/src/components/wall/PaneHeaderContextMenu.tsx`, `lib/src/components/wall/TerminalPaneHeader.tsx`, `lib/src/components/wall/keyboard/handle-pane-shortcuts.ts`. +Source of truth: `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `TerminalPaneHeader` in `lib/src/components/wall/TerminalPaneHeader.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`. ### Pane body @@ -384,18 +390,6 @@ A store commit that empties the tree (last pane killed or minimized) triggers th 5. **Door keeps selection through the auto-spawn refill** ([Auto-spawn refill](#auto-spawn-refill)). Explicit user selection of a pane — a click, a drag, or an embed focusing itself — still moves selection off a door. 6. **Focus-neutral surface creation (`dor ensure` / `dor iframe` / `dor ab`)**: unlike `dor split`, these open in the background without moving focus off the caller (`docs/specs/dor-cli.md`, `docs/specs/dor-browser.md`). An add never re-parents the caller's subtree or steals activation, and the create does not call `selectPane` (`settleAddSelection` returns false for a focus-neutral, non-selection-replacing add). **The one exception**: `dor iframe` / `dor ab` replacing the pane the user is *currently selected on* moves selection to the replacement, else it would dangle on the removed leaf; any other pane, or a door selection, is left untouched. A throwaway that never reports OSC 633 integration is torn down with `killPaneImmediately`, whose live selection check leaves the caller's selection intact (a `--minimize` throwaway is already a door, disposed directly). -## Terminal context prototype - -**Must isolate the Terminal context prototype to Storybook.** Static presentation never spawns helpers or changes production menus. - -**Must place the Surface ref and close button at the Title row's right**, without a heading row or terminal clipboard actions. - -**Must keep the prototype's helper header on one line**, hiding its name at narrow widths. Notification details appear directly when present. - -**Must label Title, Dir, Modify, and four port actions in subdued link color; put counts beside dropdowns.** Switches use `OnOffSwitch` (`DESIGN.md` → Inputs). - -Source of truth: `TerminalContextStory` in `lib/src/stories/TerminalContext.stories.tsx`. - ## Future **Scope: workspaces-rollout** — the remaining stages of the multi-Workspace feature. Current implementation: [Workspaces](#workspaces). Persisted containers are owned by `docs/specs/transport.md`; union projection by `docs/specs/alert.md`. This ledger is the single home for what remains; other specs link here rather than restating it. diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 198a8358..3d8e9127 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -287,6 +287,15 @@ Dormouse's own ``s — pane rename, the browser URL editor, dialog fields --- +## Terminal context input + +**Must give application-captured right-click to the terminal program**, retaining header right-click as the context entry point. Do not add a Shift-right-click override gesture. A helper never opens a recursive context. + +**Must route clipboard chords and selection operations to the focused helper**, while leaving its Escape, Tab, arrows, and digits with xterm. Copying and selection do not disarm autorun; terminal input, paste, drops, and application mouse reports do. + +Source of truth: `TerminalPanel` in `lib/src/components/wall/TerminalPanel.tsx`; `useWallKeyboard` in `lib/src/components/wall/use-wall-keyboard.ts`; `markSessionTouched` in `lib/src/lib/terminal-lifecycle.ts`. + + ## 9. Future Not implemented today; they may be added in response to user feedback. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 4ef2ddd6..4b5690a0 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -171,3 +171,9 @@ does. A gap, not an accepted risk. Source of truth: `SESSION_STATE_KEY` in `vscode-ext/src/session-state.ts`, `ensureToken` in `vscode-ext/src/peer-link.ts`, `default_log_path` in `standalone/src-tauri/src/lib.rs`. + +## Terminal context directory actions + +**Must validate context directory arguments as existing absolute directories and pass the canonical path as one process argument without shell interpretation.** Keep this capability separate from the external-URL allowlist. VS Code per-terminal context requests and helper ownership updates remain scoped to the owning router. + +Source of truth: `context` in `standalone/sidecar/pty-core.js`; `attachRouter` in `vscode-ext/src/message-router.ts`. Test: `standalone/sidecar/helper-terminal.test.js`. diff --git a/docs/specs/security-remote.md b/docs/specs/security-remote.md index d45555ba..a471af08 100644 --- a/docs/specs/security-remote.md +++ b/docs/specs/security-remote.md @@ -220,6 +220,12 @@ and nothing records connects, attaches, denials, or writes. A self-hoster cannot "did anyone connect to my laptop last night", which also means an ACL entry added by any of the paths above would be invisible after the fact. +## Auxiliary helpers + +**Must exclude unpromoted helpers from both remote directory discovery and direct attachment/resize resolution.** Promotion enables ordinary terminal access; hidden helper output and input are unavailable before that ownership change. + +Source of truth: `collectDirectorySnapshot` in `lib/src/remote/burrow/directory-collect.ts`; `driveOwnSurface` in `lib/src/remote/burrow/peer-surfaces.ts`. + ## Future ### Cloud-hosted mode diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index efa0eb1f..15a6c8c2 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -26,7 +26,7 @@ A focused cross-origin iframe surface swallows the gesture; the proxy shim detec | `,` | Rename | Inline rename of the selected terminal pane's title; consumed no-op on browser surfaces and doors. | | `a` | Toggle alert | Dismiss or toggle the bell alert. Terminal Surfaces only; doors excluded. | | `t` | Toggle todo | Toggle the TODO marker on the selected Surface, terminal or browser; doors excluded. | -| `>` | Header context menu | Terminal panes only; consumed no-op on browser panes, inert on doors. | +| `>` | Terminal context | Terminal panes only; consumed no-op on browser panes, inert on doors. | ## Navigation (command mode) @@ -67,11 +67,9 @@ Every key not claimed above forwards to the embedded page while a screencast pan |-----|--------|-------------| | `Esc` | Close / cancel | Dismiss a dialog or popover; cancel a rename or kill confirmation; abort an in-progress sash or pane drag. | | `Enter` | Confirm rename | Save the new name while renaming a pane; blur commits too. | -| `Tab` / `Shift+Tab` | Focus cycle | Cycle focus through an open popover or dialog (trapped, wrapping). | +| `Tab` / `Shift+Tab` | Focus cycle | Cycle popover/dialog controls. In terminal context, navigate controls into the helper; once helper xterm has focus, Tab belongs to its program. | | Prompted letter | Confirm kill | Type the letter shown to confirm; other keys reaching the prompt cancel (see layout's dispatch order). | | `a` / `t` (alert dialog open) | Toggle alert / todo | Same as command-mode `a` / `t`, for the dialog's Session. | -| `1`–`9` (header context menu open) | Connect port | Open the nth port row in a browser surface, select it, enter passthrough. Dropped, never buffered, unless the scan loaded a row for that digit and the host can open one. | -| `↑` / `↓` (header context menu open) | Move row focus | Rove focus across port rows, wrapping; `Enter`/`Space` activates the focused row. | ## VS Code host @@ -91,7 +89,7 @@ The standalone host contributes no chords; `docs/specs/standalone.md` owns its n - `lib/src/components/wall/keyboard/` — one module per dispatch branch: `handle-dual-tap.ts`, `handle-editable-clipboard.ts`, `handle-mouse-selection-keys.ts`, `handle-kill-confirm.ts`, `handle-pane-shortcuts.ts`, `handle-pane-navigation.ts`; platform modifiers in `chords.ts` - `lib/src/lib/vscode-keybindings.ts` — the workbench mirror allowlist - `lib/src/lib/terminal-mouse-router.ts` — live Alt tracking during a drag -- `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/PaneHeaderContextMenu.tsx`, `lib/src/components/TodoAlertDialog.tsx`, `lib/src/components/wall/InlineEditInput.tsx`, `lib/src/components/use-popover-focus-trap.ts` — the popover/dialog handlers +- `lib/src/components/SelectionPopup.tsx`, `lib/src/components/wall/TerminalContextView.tsx`, `lib/src/components/TodoAlertDialog.tsx`, `lib/src/components/wall/InlineEditInput.tsx`, `lib/src/components/use-popover-focus-trap.ts` — the popover/dialog handlers - `lib/src/components/wall/agent-browser-surface-controller.ts` — browser key forwarding and the edit-chord bridge ## Future diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 29178564..d66f29df 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -513,3 +513,9 @@ Source of truth: `standalone/package.json` (package scripts), - `pnpm dev:standalone:ab` runs the sidecar + webview in a normal browser via the browser-dev harness instead of the Tauri WebView (`docs/specs/transport.md`, Standalone browser-dev harness). + +## Terminal context host operations + +**Must forward native directory opening, process inspection, helper promotion, and global autorun settings to the PTY host**, preserving correlated errors. Directory opening validates an existing absolute directory, resolves it canonically, and invokes Finder/Explorer/the platform opener with one path argument and no shell. Process-inspection failure is unknown work, never proof of idle. Preference storage is owned by `docs/specs/terminal-context.md` → Global autorun setting; live ownership and replay by `docs/specs/transport.md` → Auxiliary helper metadata. + +Source of truth: `terminalContext` in `standalone/src/tauri-adapter.ts`; `pty_context` in `standalone/src-tauri/src/lib.rs`; `context` in `standalone/sidecar/pty-core.js`. diff --git a/docs/specs/terminal-context.md b/docs/specs/terminal-context.md index 91bdcd29..5dac28c7 100644 --- a/docs/specs/terminal-context.md +++ b/docs/specs/terminal-context.md @@ -1,111 +1,61 @@ # Terminal context -> Status: design — the production context and helper are not implemented. The -> existing visual prototype is owned by `docs/specs/layout.md` → Terminal context prototype. -> > See `docs/specs/glossary.md` for Surface / Session / Pane vocabulary. -> This design owns helper lifecycle and context composition; layout, terminal -> semantics, alerts, transport, and browser behavior retain their existing owners. +> This spec owns the helper terminal lifecycle and global autorun preference. +> Layout owns context composition and input focus; terminal-state owns shell +> semantics; alert owns suppression; transport owns live recovery. + +## Helper lifecycle + +- **Must create at most one helper per source, lazily on first context opening.** Concurrent openings share the same pending creation. Closing the source during startup cancels creation. Helpers cannot have helpers. +- **Must start with the configured shell in the source's local directory**, using the ordinary split fallback when unavailable. Shell exports and virtual environments are not inherited. SSH integration is outside this feature. +- **Must inject autorun only after integrated shell readiness**, accepting prompt-start and prompt-end/editing states with no current command. User input before injection cancels it. After eight seconds without readiness, show an unsupported state and never write a timeout fallback. +- **Must treat typing, paste, accepted drops, and application mouse input as user work**, disarming automatic refresh until Reset or Promote. Selection, copying, resize, and terminal protocol replies do not count. Returning to idle does not rearm autorun. +- **Must refresh an untouched, completed or autorun-disabled helper on reopening only after a host idle check.** Recheck ownership and the touched flag after that asynchronous check; foreground commands, background descendants, and failed/unknown inspection preserve the helper. +- **Must hide a retained helper without terminating its PTY**, parking its xterm element in the document. Revealing or promoting reuses the same element; cleanup from an older mount cannot detach a newer mount. +- **Must keep a preserved helper's directory independent of its source**, showing both locations prominently when they differ. Unknown directory state is not evidence of a match. +- **Must retain exited output**, offer Reset, and avoid automatic restart loops. + +| State | Status and action | +|---|---| +| Starting | Waiting for shell…; Modify | +| Autorun executing | Running the captured command; Modify | +| Untouched completion | Captured command autoran; Modify | +| User input | Skipping autorun to preserve user keystrokes; Reset | +| Empty default | Autorun off; Modify | +| No readiness | Autorun skipped: shell readiness unavailable; Modify | +| Exited | Helper exited; Reset | + +**Must make Reset an explicit discard**, confirming loss of scrollback, unfinished input, running programs, and unsaved edits. Cancellation changes nothing; confirmation disposes the old helper and launches a fresh one using the source's current directory and current global setting. Stale timers cannot write to the replacement. + +Source of truth: `openHelper` / `helperHasWork` / `disposeHelper` in `lib/src/lib/helper-terminal.ts`; `markSessionTouched` / `unmountElement` in `lib/src/lib/terminal-lifecycle.ts`; `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`. Tests: `lib/src/lib/helper-terminal.test.ts`. + +## Promotion and source closure + +**Must promote the actual Session into a regular split beside its source**, preserving the PTY, xterm, scrollback, directory, partial input, and identity. Cancel pending autorun, close context, assign the public Surface ref, and focus the promoted terminal. Failed placement restores auxiliary host ownership. The source's next opening creates a new helper. + +**Must close an idle helper with its source**, even when it has user input or scrollback. The idle shell itself is not running work. Existing source-work confirmation remains applicable. + +**Must block source closure while its helper has running work**, warn, and reveal the helper. The user stops the work there and retries closure; no force-close-both or automatic promotion is offered. Failed process inspection keeps both terminals and reports the error. CLI attempts to close such a source return failure. + +**Must include hidden helper work in shutdown checks.** The helper's host inspection also detects background descendants; unresolved inspection counts conservatively as work. Minimizing the source hides its context and retains the helper. + +Source of truth: `killPaneImmediately` / `contextActions` in `lib/src/components/Wall.tsx`; `countRunningSessions` in `lib/src/lib/terminal-state-store.ts`; `helperHasWork` in `lib/src/lib/helper-terminal.ts`. + +## Global autorun setting + +**Must default to `git status`; an empty command disables autorun.** An explicit Modify edit applies to new and reset helpers, never a retained one. Its status describes the command captured at creation, even when the global default changes. + +**Must accept only a single command line of at most 4096 characters**, excluding CR, LF, and NUL. The host persists only this preference in `~/.dormouse/helper-terminal.json`, using atomic replacement with private file permissions. All desktop renderers read that shared preference through the host; the fake adapter keeps a deterministic in-memory setting. + +Source of truth: `context` in `standalone/sidecar/pty-core.js`; `terminalContext` in `lib/src/lib/platform/fake-adapter.ts`; `TerminalContextRequest` in `lib/src/lib/terminal-context-types.ts`. + +## Presentation coverage + +**Must share the context presentation between the live menu and its state gallery.** + +Source of truth: `TerminalContextView` in `lib/src/components/wall/TerminalContextView.tsx`; `lib/src/stories/TerminalContext.stories.tsx` supplies sample output; `lib/src/stories/Wall.stories.tsx` exercises the live helper with the fake shell. ## Future -**Scope: terminal-context** — implement after resolving the questions below: - -1. Settle ownership and lifecycle contracts, update the owning specs, and add host metadata. -2. Build helper session management, input ownership, reset, promotion, and recovery. -3. Replace production terminal menus and connect metadata, alerts, directory, and port actions. -4. Replace static stories with shared production presentation, verify both desktop hosts, and promote completed rules above the fold. - -### Context composition - -- **Must use one context for terminal-capable Surfaces**, reached from the header, header alert, terminal body, and existing keyboard context command. Browser-only Surfaces retain their applicable controls. -- **Must anchor below the source header at the terminal body's top-left**, reserving the prototype's two-rem right and bottom gaps where space permits. Keep the overlay within the available body; small-screen redesign is deferred. -- **Must retain the approved prototype's rows, labels, subdued action treatment, compact switches, and single-line helper header.** The helper name yields space before its status and actions. -- **Must show the source's stable Surface ref on the Title row**, with copy and close affordances and no separate context heading. -- **Must explain the displayed title using the same derivation as the header.** Include the winning candidate, user override, command fallback, and latest relevant OSC candidates; do not invent a historical OSC log. -- **Must keep title, directory, and alerts current while open.** Ports are the exception: take one scan per opening and ignore late results after close or source change. -- **Must render alert notifications directly and retain existing attention, Watch, and TODO semantics.** Existing alert actions that open terminal details open this context; actions that acknowledge or toggle retain their behavior. -- **Must retain terminal selection copy/paste affordances without adding a clipboard toolbar to this context.** Right-click ownership in reporting applications is Q6. -- **Must keep one context open per Wall**, with nested settings/title disclosures belonging to that context. Switching source hides the previous helper under its normal lifecycle. - -### Directory and ports - -- **Must copy the absolute directory and abbreviate only the current user's home prefix for display.** Use path boundaries, platform path syntax, and directory host identity; do not guess home from path segments. -- **Must open directories through a dedicated platform capability**, with local absolute-directory validation, argument-safe process invocation, and visible failure feedback. Do not widen the external-URL opener to accept arbitrary file URLs. Unsupported or remote directories need an explanation rather than an inert action. -- **Must launch a new helper with the configured shell and a supported local source directory.** It does not inherit the source shell's exports, virtual environment, or SSH connection. Q5 settles the unavailable/remote-directory interaction. -- **Must show a prominent directory mismatch warning with both Helper and Parent locations.** A preserved helper never silently follows a parent directory change; unknown location is distinct from a confirmed match. -- **Must distinguish scanning, no ports, and scan failure.** Deduplicate and order ports using existing port URL rules; show the address and process when known. -- **Must show four labeled actions for the selected port**: System browser, Iframe, Agent browser, and Popout. One port needs no selector; multiple ports put their count beside the selector before the actions. -- **Must preserve the source when opening a port.** The existing browser-launch path's replacement of untouched terminals is inappropriate here. Disable unavailable capabilities with a reason; report launch failures in context. Q7 settles reuse. - -### Helper lifecycle - -- **Must create at most one helper per source, lazily on first opening.** Opening the parent terminal itself creates no helper. A helper cannot recursively acquire a helper. -- **Must keep the global autorun setting separate from per-helper state.** Factory default is `git status`; an empty command disables autorun. Settings changes affect new/reset helpers, and the status reports the command that this helper actually used. -- **Must wait for positively established shell readiness before injecting autorun.** Cancel pending injection on user input, reset, disposal, or promotion; never inject on an elapsed-time guess. Missing integration shows a skipped/unsupported state. Treat each launch as a separate generation so stale callbacks cannot write into its replacement. -- **Must make user input permanently preserve that helper until Reset or Promote.** Typing, paste, accepted drops, program mouse input, and explicit external writes count; selection, copying, resize, and protocol replies do not. An idle prompt does not restore automatic behavior. -- **Must refresh an untouched helper on reopening only when safely idle.** Preserve running autorun, foreground applications, background jobs, and uncertain process state. Prompt completion alone does not prove no background work remains. -- **Must hide preserved helpers when the context closes without killing or suspending their processes.** Keep scrollback, partial input, working directory, and terminal modes. Dispose an untouched, completed helper only after establishing the same safe-idle condition used for refresh. -- **Must make Reset explicitly discard the old helper and restore automatic behavior in a fresh helper.** Confirm before discarding user work or running/uncertain processes, naming the running command when known. Leave the existing helper intact if reset is cancelled. -- **Must make Promote transfer the actual Session into a regular split beside the source**, retaining its PTY, xterm instance, scrollback, directory, input, and identity. Close the context and focus the promoted terminal. Commit ownership transfer only after placement succeeds; failure leaves the helper usable. A subsequent source context gets a fresh helper. -- **Must let an exited helper retain its visible output**, with Reset available and no repeated automatic restart loop. - -| State | Single-line status and action | -|---|---| -| Awaiting readiness | Waiting for shell…; Modify | -| Autorun executing | Running `command`…; Modify | -| Untouched, completed | `command` autoran; Modify | -| User input received | Skipping autorun to preserve user keystrokes; Reset | -| Autorun disabled | Autorun off; Modify | -| Readiness unavailable | Autorun skipped: shell readiness unavailable; Modify | -| Launch failed / exited | Concrete error / exit status; Reset | - -### Identity, focus, and host lifetime - -- **Must model the helper as an explicitly owned auxiliary terminal Surface**, with a stable Session id and source association, rather than a fabricated minimized Pane. Its source Pane contains both; only the primary Surface has a Lath leaf until promotion. Update glossary identity/containment language and registry parking rules before relying on this exception. This does not introduce tabs or the staged workspace rollout. -- **Must separate session management from React overlay lifetime.** Keep retained xterm DOM in a mounted parking container when hidden; attach the same element when revealed or promoted. Overlay cleanup alone must never call Session disposal. -- **Must carry helper ownership in host live-PTY metadata before reconnect reconciliation.** Otherwise current orphan recovery treats a helper as a normal Pane and may discard the saved layout. Validate ownership within the owning Workspace and reconcile parent/helper restoration together. -- **Must retain the executed-command snapshot and sticky preservation state across live reconnects.** If that state cannot be recovered, preserve the helper and disarm autorun rather than infer that it was untouched. Promotion removes auxiliary ownership in the host as well as the frontend. -- **Must preserve helpers across reconnections that retain their PTYs**, including webview recreation. Cold starts follow each host's existing recovery contract with a fresh lazy helper; do not add standalone disk session persistence or promise saved editor buffers after process death. Missing-parent recovery must retain live user work in a regular Pane rather than silently dispose it. -- **Must route keyboard and clipboard input to the actual focused terminal.** While helper xterm owns focus, Escape, Tab, arrows, and digits belong to its program; global selection handling must not intercept them for the parent. Escape from context controls closes the innermost disclosure, then context. Outside click and explicit close hide the context. Opening focus is Q1. -- **Must restore source focus on close unless the user selected another target.** Source minimize hides context and retains its helper; destructive source closure follows Q2. -- **Must account for helper processes in host shutdown checks and resource cleanup.** Hidden work cannot bypass existing quit protection. Alert and external-discovery behavior are Q3 and Q4. - -### Questions for product decisions - -These recommendations are provisional, not settled behavior. - -| ID | Decision | Recommendation | -|---|---|---| -| Q1 | Where does focus land when opening? | Focus the helper immediately; input during startup cancels pending autorun. | -| Q2 | Close a parent with preserved or running helper work? | Offer Keep helper (take the parent's slot), Close both, or Cancel. Safely untouched helpers need no extra confirmation. | -| Q3 | What happens when a hidden helper needs attention? | Reflect its attention on the parent with a helper indicator; activating it reveals the helper. Keep source and helper Watch/TODO state distinct. | -| Q4 | Is an unpromoted helper discoverable outside its context? | Let `dor` identify/address it, label it as the parent's helper in listings, and make focus reveal its context. Defer Pocket access until promotion; filter both directory discovery and direct attachment. | -| Q5 | Source directory is remote or unavailable? | Show the fallback local directory and require an explicit Start locally action before spawning or autorunning. Copy the source path remains available. | -| Q6 | Right-click while a terminal application owns mouse input? | Preserve the application's right-click; Shift-right-click opens context. Header right-click always opens context. | -| Q7 | Repeated port action creates or reuses a browser? | Reuse per source and port; iframe has its own Surface, Agent browser and Popout share one agent-browser session and change its display mode. System browser follows OS behavior. | - -**Proposed delivery scope:** VS Code and Standalone desktop, plus working fake-adapter Storybook/demo coverage. Pocket composition and remote helper creation are deferred; existing terminal-only remote protocol remains unchanged. - -### Implementation map - -This map identifies existing integration points, not implemented feature ownership. Add dedicated context presentation, helper-session controller, and global-settings modules alongside these files during implementation. - -| Area | Integration points and required work | -|---|---| -| Composition | `lib/src/components/Wall.tsx`; `lib/src/components/wall/TerminalPanel.tsx`; `lib/src/components/wall/TerminalPaneHeader.tsx`; `lib/src/components/wall/PaneHeaderContextMenu.tsx`; `lib/src/components/TodoAlertDialog.tsx`: central context state, entry points, alert integration, safe split adoption. Retire only superseded terminal paths. | -| Lifecycle/input | `lib/src/lib/terminal-lifecycle.ts`; `lib/src/lib/terminal-store.ts`; `lib/src/components/wall/use-wall-keyboard.ts`: observable helper state, cancellable readiness, input-origin tracking, DOM parking, focus routing. Audit every PTY write path. | -| Metadata/browser | `lib/src/lib/terminal-state.ts`; `lib/src/components/wall/port-url.ts`; `lib/src/components/wall/connect-port.ts`: shared title explanation, host-aware directories, scan snapshot, four launch modes, source-preserving placement. | -| Adapters/hosts | `lib/src/lib/platform/types.ts`; `lib/src/lib/platform/vscode-adapter.ts`; `lib/src/lib/platform/fake-adapter.ts`; `vscode-ext/src/pty-manager.ts`; `vscode-ext/src/message-types.ts`; `standalone/src/tauri-adapter.ts`; `standalone/sidecar/pty-core.js`; `standalone/src-tauri/src/lib.rs`: directory capability, home identity, helper ownership, safe-idle evidence, promotion metadata updates, ownership validation across bridges. | -| Settings/recovery | `lib/src/lib/alert-settings-host.ts` as the existing global synchronization pattern; `lib/src/lib/reconnect.ts`; `lib/src/lib/session-save.ts`; `lib/src/lib/session-types.ts`: separate autorun setting, atomic live ownership recovery, backward-compatible metadata defaults, existing cold-start policies. | -| External surfaces | `lib/src/components/wall/use-dor-control.ts`; `dor/src/commands/types.ts`; `lib/src/remote/burrow/directory-collect.ts`; `lib/src/remote/burrow/remote-api.ts`: helper addressing, focus/kill semantics, remote discovery and attachment guards according to Q4. Audit alert unions and shutdown counts alongside these consumers. | -| Stories | `lib/src/stories/TerminalContext.stories.tsx`: use production presentation with deterministic fake sessions and controllable metadata, process, and capability states. | - -### Spec changes and validation - -- **Must update each behavior's owning spec in the same implementation slice.** This spec owns lifecycle; glossary owns auxiliary identity/containment, layout owns placement/focus/promotion, mouse-and-clipboard owns right-click/input routing, terminal-state owns title/readiness/directory semantics, alert owns helper attention, transport owns live metadata/recovery, dor-cli owns addressing, dor-browser owns reuse, and host specs own native operations/settings. Security-local and security-remote own new trust-boundary guarantees; update audited checks only when those guarantees change. -- **Must replace the layout prototype-only rule when production integration ships**, keeping presentation ownership in layout and lifecycle here. Promote completed text out of this scope; leave only unbuilt design under Future. -- **Must test lifecycle transitions and races**: first open, safe refresh, user input before readiness, running/background work, unknown readiness, hide/reopen, reset cancellation, stale callbacks, exit, exact-session promotion, failed placement, parent closure, and live reconnect with helpers. Use controllable fake PTYs and readiness signals. -- **Must test boundary behavior**: native directory validation and errors, home/path identity, async source changes, each port mode and reuse, alert isolation, all user-input routes, helper CLI ownership, and denied remote direct attachment. Add host tests where bridge fields or recovery behavior change. -- **Must verify real shells in both desktop hosts**: initial autorun, typed partial input, preserved scrollback, unsaved `nano`, a background job, missing shell integration, clipboard and application mouse reporting, source-directory changes, promotion, and webview reload. View the implemented stories through `dor ab` in light and dark themes. -- **Must run spec lint, relevant focused tests, root tests, type/build checks, and the production build before completion.** Keep Storybook states for zero/one/multiple ports, notifications, all helper states, directory mismatch/unknown, failed capabilities, and nested disclosures. +Pocket context composition, remote helper creation, and SSH integration are unbuilt. diff --git a/docs/specs/terminal-state.md b/docs/specs/terminal-state.md index 4b4e7ab5..0de8f65d 100644 --- a/docs/specs/terminal-state.md +++ b/docs/specs/terminal-state.md @@ -150,3 +150,11 @@ Source of truth: `deriveHeader` / `deriveSurfaceLabel` / `resolveDisplayPrimary` - **`prompt` and `editing` collapse into one `idle` bucket**; **`finished` stays distinct** so a recently-completed pane can be filtered separately though its header label carries the same `` prefix. `statusBucket` projects the 5 `ShellActivity.kind` values onto 4. Source of truth: `groupTerminalPanes` / `TerminalGroupingMode` / `cwdIdentity` / `statusBucket` in `lib/src/lib/terminal-state.ts`. + +## Terminal context diagnostics + +**Must derive title explanation from the header's winning-title functions**, including user override, eligible OSC candidate, notification title, and command fallback. Retain the last command's captured title when later shell OSCs replace live candidates; the diagnostic table is not an OSC history. + +**Must abbreviate home only at a complete path boundary**, retaining the absolute path for copying and native directory operations. Compare helper and source host identity as well as directory paths. + +Source of truth: `explainTerminalTitle` in `lib/src/lib/terminal-state.ts`; `abbreviatedDirectory` in `lib/src/lib/helper-terminal.ts`; `TerminalContext` in `lib/src/components/wall/TerminalContext.tsx`. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 2842bd57..f4545ac7 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -244,3 +244,13 @@ prompt** (rationale). - **Replay filtering does not re-fire alerts**, quiesce-detector events, or protocol notifications (`docs/specs/terminal-escapes.md` → "`pty:data` strip semantics"). Source of truth: `getScrollbackReceived` / `getScrollbackSince` in `vscode-ext/src/pty-manager.ts`; the replay filter in `lib/src/lib/terminal-report-filter.ts`. + +## Auxiliary helper metadata + +**Must carry helper parent identity and captured autorun command in live PTY metadata**, validating that the parent is owned and is not itself a helper. Promotion clears that association without restarting the PTY. Reconnect restores helper entries before reconciling the primary layout, excluding them from ordinary orphan-pane recovery. A missing parent recovers its helper as an ordinary Pane. Recovered helpers conservatively disable automatic refresh. + +**Must retain Standalone replay only in memory**, bounded to the latest 200,000 UTF-16 code units per live PTY and sent after its live listing. Closing a PTY releases its buffer. Cold starts retain the existing host policy; helper scrollback and editor buffers are never written to Session snapshots. + +**Must expose terminal context operations through correlated host requests**, reporting errors and timeouts. The VS Code router checks Workspace ownership for per-terminal operations and helper parent metadata. + +Source of truth: `TerminalContextRequest` in `lib/src/lib/terminal-context-types.ts`; `PtyInfo` in `lib/src/lib/platform/types.ts`; `resumeOrRestore` in `lib/src/lib/reconnect.ts`; `context` in `standalone/sidecar/pty-core.js`; `attachRouter` in `vscode-ext/src/message-router.ts`. diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 781eaf91..0f0c168c 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -411,6 +411,12 @@ debugger can attach to. `vscode-ext/vite.config.ts` sets `root: ../lib` and `outDir: ./media`, building the shared React frontend directly into the extension's media folder. +## Terminal context host operations + +**Must forward native directory opening, process inspection, helper promotion, and global autorun settings to the PTY host**, preserving correlated errors. Directory opening validates an existing absolute directory, resolves it canonically, and invokes Finder/Explorer/the platform opener with one path argument and no shell. Process-inspection failure is unknown work, never proof of idle. Preference storage is owned by `docs/specs/terminal-context.md` → Global autorun setting; live ownership and replay by `docs/specs/transport.md` → Auxiliary helper metadata. + +Source of truth: `terminalContext` in `lib/src/lib/platform/vscode-adapter.ts`; `terminalContext` in `vscode-ext/src/pty-manager.ts`; `context` in `standalone/sidecar/pty-core.js`. + ## Future ### Webview→host Surface-state channel diff --git a/lib/src/components/TerminalPane.tsx b/lib/src/components/TerminalPane.tsx index 30309e8e..e863cc4f 100644 --- a/lib/src/components/TerminalPane.tsx +++ b/lib/src/components/TerminalPane.tsx @@ -53,7 +53,7 @@ export function TerminalPane({ id, isFocused = true }: TerminalPaneProps) { // Cancel any pending trailing refit — no fit after unmount. throttledRefit.cancel(); // Unmount DOM element — registry entry and Session survive - unmountElement(id); + unmountElement(id, container); }; }, [id]); diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index c2ceb8e8..3bf9223f 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -521,15 +521,15 @@ describe('Wall on the Lath engine', () => { it('reuses and closes a parked browser that gains its session after minimization', async () => { const defaultSession = sessionForKey('default'); const untouchedSpy = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(false); - let resolveOpen!: (result: { exitCode: number; stdout: string; stderr: string }) => void; - const openResult = new Promise<{ exitCode: number; stdout: string; stderr: string }>((resolve) => { + let resolveOpen!: (result: { ok: boolean; session: string; wsPort: number }) => void; + const openResult = new Promise<{ ok: boolean; session: string; wsPort: number }>((resolve) => { resolveOpen = resolve; }); const agentBrowserCommand = vi.fn(async (_session: string, args: string[]) => { - if (args[0] === 'open') return openResult; return { exitCode: 0, stdout: '', stderr: '' }; }); (fake as PlatformAdapter).agentBrowserCommand = agentBrowserCommand; + (fake as PlatformAdapter).agentBrowserOpen = vi.fn(() => openResult); (fake as PlatformAdapter).agentBrowserStreamStatus = vi.fn(async () => ({ ok: true, wsPort: 4321 })); try { @@ -560,7 +560,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); const portRow = document.querySelector( - '[data-pane-context-menu-for="pane-a"] button[data-port-entry="5173"]', + '[data-terminal-context] button[aria-label="Open in agent-browser screencast"]', )!; await act(async () => { portRow.click(); }); await flush(); @@ -583,7 +583,7 @@ describe('Wall on the Lath engine', () => { // Boot completion writes `session` only to live parked metadata. The Door // record is intentionally still the session-less minimize-time snapshot. await act(async () => { - resolveOpen({ exitCode: 0, stdout: '', stderr: '' }); + resolveOpen({ ok: true, session: defaultSession, wsPort: 4321 }); await openResult; }); await flush(); @@ -1432,7 +1432,7 @@ describe('Wall on the Lath engine', () => { await flush(); expect(focusOf('pane-a')).toBe('true'); - const browserId = await dispatchAgentBrowser({ + await dispatchAgentBrowser({ session: defaultSession, surface: 'surface:1', }); @@ -1448,6 +1448,7 @@ describe('Wall on the Lath engine', () => { await flush(); (fake as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); + (fake as PlatformAdapter).agentBrowserOpen = vi.fn(async () => ({ ok: true, session: 'context-browser', wsPort: 4321 })); if (!fake.hasPty('pane-a')) fake.spawnPty('pane-a'); fake.setOpenPorts('pane-a', [{ protocol: 'tcp', @@ -1471,7 +1472,7 @@ describe('Wall on the Lath engine', () => { await flush(); const portRow = document.querySelector( - '[data-pane-context-menu-for="pane-a"] button[data-port-entry="5173"]', + '[data-terminal-context] button[aria-label="Open in agent-browser screencast"]', ); expect(portRow).not.toBeNull(); await act(async () => { @@ -1479,13 +1480,10 @@ describe('Wall on the Lath engine', () => { }); await flush(); - expect(onEvent).toHaveBeenCalledWith({ type: 'selectionChange', id: browserId, kind: 'pane' }); + expect(onEvent).toHaveBeenCalledWith({ type: 'selectionChange', id: expect.any(String), kind: 'pane' }); + expect(container.querySelector('[data-lath-leaf="pane-a"]')).not.toBeNull(); expect(onEvent).toHaveBeenCalledWith({ type: 'modeChange', mode: 'passthrough' }); - expect((fake as PlatformAdapter).agentBrowserCommand).toHaveBeenCalledWith( - defaultSession, - ['open', 'http://localhost:5173/'], - undefined, - ); + expect((fake as PlatformAdapter).agentBrowserOpen).toHaveBeenCalledWith('http://localhost:5173/', { headed: false }, undefined); } finally { untouchedSpy.mockRestore(); } diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index c8aa5d56..746bdc18 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1,3 +1,8 @@ +import { TerminalContextContext } from './wall/wall-context'; +import type { PortMode } from './wall/TerminalContextView'; +import type { PortUrlEntry } from './wall/port-url'; +import { disposeHelper, forgetHelper, getHelper, helperHasWork } from '../lib/helper-terminal'; +import { registry as terminalRegistry } from '../lib/terminal-store'; import { useRef, useState, useEffect, useCallback, useMemo, useSyncExternalStore, lazy, Suspense, type ReactNode } from 'react'; import { clsx } from 'clsx'; import { Baseboard } from './Baseboard'; @@ -262,6 +267,8 @@ export function Wall({ */ enableBurrow?: boolean; } = {}) { + const [terminalContext, setTerminalContext] = useState<{ id: string; warning?: string } | null>(null); + const pendingHelperCloses = useRef(new Set()); // The Lath engine handle — Dormouse's tiling engine. Constructed lazily exactly // once per Wall mount, so `createLathWallEngine` is not re-invoked each render // (docs/specs/tiling-engine.md). @@ -515,7 +522,34 @@ export function Wall({ }, 1500); }, []); - const killPaneImmediately = useCallback((id: string) => { + const killPaneImmediately = useCallback((id: string): void | boolean | Promise => { + const helper = getHelper(id); + if (helper) { + if (pendingHelperCloses.current.has(id)) return false; + pendingHelperCloses.current.add(id); + return helperHasWork(helper).then(busy => { + pendingHelperCloses.current.delete(id); + if (getHelper(id) !== helper) return; + if (busy) { + const door = doorsRef.current.find(item => item.id === id); + if (door) handleReattachRef.current(door); + setConfirmKill(null); + setTerminalContext({ id, warning: 'Helper has running work. Stop it in the helper, then close this terminal again.' }); + return false; + } else { + disposeHelper(id); + setTerminalContext(current => current?.id === id ? null : current); + return killPaneImmediately(id); + } + }).catch(error => { + pendingHelperCloses.current.delete(id); + const door = doorsRef.current.find(item => item.id === id); + if (door) handleReattachRef.current(door); + setConfirmKill(null); + setTerminalContext({ id, warning: `Could not inspect helper processes: ${String(error)}` }); + return false; + }); + } // A second kill for a pane already mid-fade is a no-op (idempotent) — it must // not re-fire the event, re-dispose, or schedule a second removal. if (lath.isDying(id)) return; @@ -629,6 +663,7 @@ export function Wall({ * "Parked leaves"). Terminals do not park: their state lives in the PTY and the * registry replays it, so the existing remove/restore path already loses nothing. */ const minimizePane = useCallback((id: string, opts?: { select?: boolean }) => { + setTerminalContext(current => current?.id === id ? null : current); const meta = lath.getMeta(id); if (!meta) return; // May auto-spawn if this was the last leaf. `doorLeaf` retains the leaf's meta in @@ -1048,6 +1083,7 @@ export function Wall({ reference, title, focusNeutral, + preserveSource, }: { minimized: boolean; params: Record; @@ -1056,6 +1092,7 @@ export function Wall({ // `dor iframe` / `dor ab` pass this to open the surface in the background // without moving focus off the caller, matching `dor ensure`. focusNeutral?: boolean; + preserveSource?: boolean; }): ParseResult<{ id: string; ref: string; @@ -1069,7 +1106,7 @@ export function Wall({ // Replace-in-place is reserved for a reference with no browser — a blank // untouched shell. Anything holding web content (a browser surface today, a // tool that has both later) must split beside it instead of being destroyed. - const replaceUntouchedShell = !hasBrowser(reference.kind) && isUntouched(reference.id); + const replaceUntouchedShell = !preserveSource && !getHelper(reference.id) && !hasBrowser(reference.kind) && isUntouched(reference.id); if (replaceUntouchedShell) { // Whether the user's current selection sits on the pane being replaced. @@ -1154,6 +1191,7 @@ export function Wall({ const shouldReplaceUntouched = detail.replaceUntouched === true && selectedPaneVisible && + !getHelper(selectedPaneId!) && isUntouched(selectedPaneId!); const shellName = detail.name?.trim() || 'terminal'; @@ -1168,7 +1206,7 @@ export function Wall({ return; } - if (detail.replaceUntouched === true && selectedDoor && isUntouched(selectedDoor.id)) { + if (detail.replaceUntouched === true && selectedDoor && !getHelper(selectedDoor.id) && isUntouched(selectedDoor.id)) { handleReattachRef.current(selectedDoor, { enterPassthrough: false, afterRestore: { @@ -1244,13 +1282,32 @@ export function Wall({ const wallActions: WallActions = useMemo(() => ({ onKill: (id: string) => { - exitTerminalMode(); - if (isUntouched(id)) { - killPaneImmediately(id); - return; - } - const char = randomKillChar(); - setConfirmKill({ id, char }); + const confirmSource = () => { + exitTerminalMode(); + const door = doorsRef.current.find(item => item.id === id); + if (door) { + handleReattachRef.current(door, { enterPassthrough: false, afterRestore: isUntouched(id) ? 'kill-immediately' : 'confirm-kill' }); + return; + } + if (isUntouched(id)) { void killPaneImmediately(id); return; } + setConfirmKill({ id, char: randomKillChar() }); + }; + const helper = getHelper(id); + if (!helper) { confirmSource(); return; } + void helperHasWork(helper).then(busy => { + if (getHelper(id) !== helper) return; + if (busy) { + const door = doorsRef.current.find(item => item.id === id); + if (door) handleReattachRef.current(door); + setConfirmKill(null); + setTerminalContext({ id, warning: 'Helper has running work. Stop it in the helper, then close this terminal again.' }); + } else confirmSource(); + }).catch(error => { + const door = doorsRef.current.find(item => item.id === id); + if (door) handleReattachRef.current(door); + setConfirmKill(null); + setTerminalContext({ id, warning: `Could not inspect helper processes: ${String(error)}` }); + }); }, onAlertButton: (id: string, displayedStatus: SessionStatus) => { return dismissOrToggleAlert(id, displayedStatus); @@ -1432,6 +1489,78 @@ export function Wall({ // The pane context menu's "connect a port" action: act like `dor ab open`. onConnectPort: connectPort, }), [addSplitPanel, minimizePane, enterTerminalMode, exitTerminalMode, killPaneImmediately, replaceSurface, buildDorSurfaces, createContentSurface, surfaceRefForId, connectPort, updateSurfaceParams, lath, nav]); + const contextPortLaunches = useRef(new Map>()); + const openContextPort = useCallback(async (id: string, entry: PortUrlEntry, mode: PortMode): Promise => { + const platform = getPlatform(); + if (mode === 'system') { platform.openExternal?.(entry.url); return; } + const key = `${id}:${entry.port}:${mode === 'iframe' ? 'iframe' : 'agent'}`; + const pending = contextPortLaunches.current.get(key); + if (pending) { await pending; return openContextPort(id, entry, mode); } + const operation = (async () => { + const reference = buildDorSurfaces().find(surface => surface.id === id); + if (!reference) throw new Error('The parent terminal is no longer available'); + const existing = buildDorSurfaceList().find(surface => { + const params = lath.getMeta(surface.id)?.params; + return params?.contextPortKey === key && !lath.isDying(surface.id); + }); + if (existing) { + revealSurface(existing.id); + if (mode !== 'iframe') { + const controller = getAgentBrowserScreenController(existing.id); + controller?.actions.setRenderMode?.(mode); + controller?.chromeActions.navigate(entry.url); + } else updateSurfaceParams(existing.id, { url: entry.url }); + return; + } + if (mode !== 'iframe' && !platform.agentBrowserOpen) throw new Error('Agent browser is unavailable'); + const created = createContentSurface({ minimized: false, reference, preserveSource: true, + params: { surfaceType: 'browser', renderMode: mode, url: entry.url, syncEngaged: true, contextPortKey: key }, title: hostPathDisplay(entry.url, true) }); + if (!created.ok) throw new Error(created.message); + enterTerminalMode(created.value.id); + if (mode === 'iframe') return; + const result = await platform.agentBrowserOpen!(entry.url, { headed: mode === 'ab-popout' }, lastAgentBrowserBinaryPathRef.current); + if (!result.ok || !result.session) { + killPaneImmediately(created.value.id); + throw new Error(result.error ?? 'Could not open agent browser'); + } + const binding = { session: result.session, wsPort: result.wsPort, binaryPath: result.binaryPath }; + if (!lath.getMeta(created.value.id) || lath.isDying(created.value.id)) { closeAgentBrowserSession({ renderMode: mode, ...binding }); return; } + updateSurfaceParams(created.value.id, binding); + })(); + contextPortLaunches.current.set(key, operation); + try { await operation; } finally { contextPortLaunches.current.delete(key); } + }, [buildDorSurfaces, buildDorSurfaceList, createContentSurface, enterTerminalMode, killPaneImmediately, lath, revealSurface, updateSurfaceParams]); + const contextActions = useMemo(() => ({ + id: terminalContext?.id ?? null, warning: terminalContext?.warning, + open: (id: string, warning?: string) => { if (terminalRegistry.get(id)?.helper) return; setTerminalContext({ id, warning }); }, + close: () => setTerminalContext(null), + promote: async (id: string) => { + const helper = getHelper(id); + if (!helper || !nav.hasPane(id)) throw new Error('Helper cannot be placed beside this terminal'); + // Cancel launch injection before asynchronous ownership transfer. + clearInterval(helper.timer); + helper.promoting = true; + helper.status = 'preserved'; + const entry = terminalRegistry.get(helper.id); + if (entry) entry.untouched = false; + try { await getPlatform().terminalContext?.({ op: 'promote', id: helper.id }); } + catch (error) { helper.promoting = false; throw error; } + const edge = lath.store.autoEdgeFor(id); + const placed = lath.store.addLeaf(helper.id, terminalLeafMeta(), { refId: id, edge }); + if (!placed.ok) { + await getPlatform().terminalContext?.({ op: 'promote', id: helper.id, restore: { parentId: id, command: helper.command } }); + helper.promoting = false; + throw new Error('Could not split the source pane'); + } + if (entry) { entry.helper = undefined; entry.untouched = false; } + forgetHelper(id); + surfaceRefForId(helper.id); + setTerminalContext(null); + enterTerminalMode(helper.id); + }, + openPort: openContextPort, + }), [terminalContext, lath, nav, surfaceRefForId, enterTerminalMode, openContextPort]); + const wallActionsRef = useRef(wallActions); wallActionsRef.current = wallActions; @@ -1543,6 +1672,7 @@ export function Wall({ + @@ -1614,6 +1744,7 @@ export function Wall({ + diff --git a/lib/src/components/wall/PaneHeaderContextMenu.test.tsx b/lib/src/components/wall/PaneHeaderContextMenu.test.tsx deleted file mode 100644 index b23b8428..00000000 --- a/lib/src/components/wall/PaneHeaderContextMenu.test.tsx +++ /dev/null @@ -1,360 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, StrictMode } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { PaneProps } from './pane-props'; -import { TerminalPaneHeader } from './TerminalPaneHeader'; -import { DialogKeyboardContext, WallActionsContext, type WallActions } from './wall-context'; -import { ensureResizeObserver, stubWallActions as stubActions } from './wall-test-utils'; -import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; -import { setPlatform } from '../../lib/platform'; -import type { OpenPort, PlatformAdapter } from '../../lib/platform/types'; -import { removeTerminalPaneState, resetTerminalPaneState } from '../../lib/terminal-registry'; -import type { TerminalTitle, TerminalTitleSource } from '../../lib/terminal-state'; - -globalThis.IS_REACT_ACT_ENVIRONMENT = true; - -function headerProps(id: string, title: string): PaneProps { - return { id, title, params: undefined }; -} - -function candidate(title: string, source: TerminalTitleSource, updatedAt: number): TerminalTitle { - return { title, source, updatedAt }; -} - -function loopbackPort(port: number, processName?: string): OpenPort { - return { protocol: 'tcp', family: 'IPv4', address: '127.0.0.1', port, pid: 100, processName }; -} - -/** Make the running host able to open a browser surface, so port rows are buttons. */ -function enableConnect(platform: FakePtyAdapter): void { - (platform as PlatformAdapter).agentBrowserCommand = vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })); -} - -let container: HTMLDivElement; -let root: Root; -let platform: FakePtyAdapter; -let keyboardActiveSpy: ReturnType; - -beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - platform = new FakePtyAdapter(); - setPlatform(platform); - ensureResizeObserver(); - keyboardActiveSpy = vi.fn(); -}); - -afterEach(() => { - act(() => root.unmount()); - container.remove(); - platform.reset(); - removeTerminalPaneState('term-1'); -}); - -function renderHeader(props: PaneProps, actions: WallActions) { - act(() => { - root.render( - - - - - - - , - ); - }); -} - -function fireContextMenu() { - const header = container.firstElementChild as HTMLElement; - header.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 12 })); -} - -function menuFor(id: string): HTMLElement | null { - return document.body.querySelector(`[data-pane-context-menu-for="${id}"]`); -} - -describe('PaneHeaderContextMenu — pane header right-click', () => { - it('opens on header contextmenu showing the surface ref from resolveSurfaceRef', async () => { - const resolveSurfaceRef = vi.fn(() => 'surface:3'); - renderHeader(headerProps('term-1', 'title'), stubActions({ resolveSurfaceRef })); - - await act(async () => { fireContextMenu(); }); - - const menu = menuFor('term-1'); - expect(menu).not.toBeNull(); - expect(menu?.textContent).toContain('surface:3'); - expect(resolveSurfaceRef).toHaveBeenCalledWith('term-1'); - }); - - it('shows a spinner while the scan is pending, then port entries after it resolves', async () => { - platform.spawnPty('term-1'); - let resolvePorts!: (ports: OpenPort[]) => void; - platform.getOpenPorts = () => new Promise((res) => { resolvePorts = res; }); - renderHeader(headerProps('term-1', 't'), stubActions()); - - act(() => { fireContextMenu(); }); - const menu = menuFor('term-1'); - expect(menu?.querySelector('.animate-spin')).not.toBeNull(); - expect(menu?.textContent).toContain('scanning ports'); - - await act(async () => { resolvePorts([loopbackPort(5173, 'node')]); }); - expect(menu?.querySelector('.animate-spin')).toBeNull(); - expect(menu?.textContent).toContain('localhost:5173'); - expect(menu?.textContent).toContain('node'); - }); - - it('fires the connect and closes the menu immediately (loading feedback lives in the pane)', async () => { - enableConnect(platform); - platform.spawnPty('term-1'); - platform.setOpenPorts('term-1', [loopbackPort(5173, 'node')]); - const onConnectPort = vi.fn(); - renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); - - await act(async () => { fireContextMenu(); }); - const button = menuFor('term-1')?.querySelector('button[data-port-entry="5173"]') as HTMLButtonElement; - expect(button).not.toBeNull(); - - await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - - expect(onConnectPort).toHaveBeenCalledWith('term-1', 'http://localhost:5173/'); - expect(menuFor('term-1')).toBeNull(); - }); - - it('shows an empty state when the scan finds no listening ports', async () => { - platform.spawnPty('term-1'); - renderHeader(headerProps('term-1', 't'), stubActions()); - - await act(async () => { fireContextMenu(); }); - expect(menuFor('term-1')?.textContent).toContain('no listening ports'); - }); - - it('renders port rows as inert labels (no buttons) when the host cannot connect', async () => { - platform.spawnPty('term-1'); - platform.setOpenPorts('term-1', [loopbackPort(5173, 'node')]); - renderHeader(headerProps('term-1', 't'), stubActions()); - - await act(async () => { fireContextMenu(); }); - const menu = menuFor('term-1'); - expect(menu?.querySelector('button[data-port-entry="5173"]')).toBeNull(); - expect(menu?.querySelector('[data-port-entry="5173"]')).not.toBeNull(); - expect(menu?.textContent).toContain('localhost:5173'); - }); - - it('dismisses on Escape and on an outside pointerdown', async () => { - renderHeader(headerProps('term-1', 't'), stubActions()); - - await act(async () => { fireContextMenu(); }); - expect(menuFor('term-1')).not.toBeNull(); - act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); }); - expect(menuFor('term-1')).toBeNull(); - - await act(async () => { fireContextMenu(); }); - expect(menuFor('term-1')).not.toBeNull(); - act(() => { window.dispatchEvent(new Event('pointerdown')); }); - expect(menuFor('term-1')).toBeNull(); - }); - - it('opens the one context menu when a title-span right-click bubbles up', async () => { - renderHeader(headerProps('term-1', 'title'), stubActions()); - - const titleSpan = container.querySelector('[data-pane-title-for="term-1"]') as HTMLElement; - expect(titleSpan).not.toBeNull(); - await act(async () => { - titleSpan.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 40, clientY: 12 })); - }); - - expect(menuFor('term-1')).not.toBeNull(); - }); - - it('renders the header row (display title + surface ref) and the title-candidates table inline', async () => { - const resolveSurfaceRef = vi.fn(() => 'surface:7'); - resetTerminalPaneState('term-1', { - title: candidate('Pinned API', 'user', 6_000), - titleCandidates: { - user: candidate('Pinned API', 'user', 6_000), - osc0: candidate('dev server', 'osc0', 1_000), - osc99: candidate('Codex waiting', 'osc99', 4_000), - }, - }); - renderHeader(headerProps('term-1', 't'), stubActions({ resolveSurfaceRef })); - - await act(async () => { fireContextMenu(); }); - - const menu = menuFor('term-1'); - expect(menu).not.toBeNull(); - // Header row: current display title + surface ref. - expect(menu?.textContent).toContain('Pinned API'); - expect(menu?.textContent).toContain('surface:7'); - // Candidates table: one row per channel (source label + candidate title). - expect(menu?.textContent).toContain('OSC 0'); - expect(menu?.textContent).toContain('dev server'); - expect(menu?.textContent).toContain('OSC 99'); - expect(menu?.textContent).toContain('Codex waiting'); - expect(menu?.textContent).not.toContain('No title candidates'); - }); - - it('shows the empty title-candidates line when the pane has no candidates', async () => { - renderHeader(headerProps('term-1', 't'), stubActions()); - - await act(async () => { fireContextMenu(); }); - expect(menuFor('term-1')?.textContent).toContain('No title candidates'); - }); - - it('closes the menu when the header close button is clicked', async () => { - renderHeader(headerProps('term-1', 't'), stubActions()); - - await act(async () => { fireContextMenu(); }); - const closeButton = menuFor('term-1')?.querySelector('button[aria-label="Close menu"]') as HTMLButtonElement; - expect(closeButton).not.toBeNull(); - - await act(async () => { closeButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(menuFor('term-1')).toBeNull(); - }); -}); - -const DEFAULT_KB_PORTS: OpenPort[] = [loopbackPort(3000, 'alpha'), loopbackPort(5173, 'beta')]; - -function keydown(key: string, init: KeyboardEventInit = {}): KeyboardEvent { - return new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); -} - -/** Enable connect, spawn a pty with `ports`, render, and open the menu. */ -async function openConnectableMenu( - onConnectPort: ReturnType = vi.fn(), - ports: OpenPort[] = DEFAULT_KB_PORTS, -): Promise> { - enableConnect(platform); - platform.spawnPty('term-1'); - platform.setOpenPorts('term-1', ports); - renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); - await act(async () => { fireContextMenu(); }); - return onConnectPort; -} - -describe('PaneHeaderContextMenu — keyboard access', () => { - it('takes DOM focus on the menu container when it opens', async () => { - await openConnectableMenu(); - expect(document.activeElement).toBe(menuFor('term-1')); - }); - - it('reports dialog-keyboard-active while open and inactive once closed', async () => { - await openConnectableMenu(); - expect(keyboardActiveSpy).toHaveBeenLastCalledWith(true); - - act(() => { window.dispatchEvent(keydown('Escape')); }); - expect(menuFor('term-1')).toBeNull(); - expect(keyboardActiveSpy).toHaveBeenLastCalledWith(false); - }); - - it('renders digit chips and connects the nth port row when its digit is pressed, then closes', async () => { - const onConnectPort = vi.fn(); - await openConnectableMenu(onConnectPort); - // First 9 connectable rows carry a digit accelerator chip. - expect(menuFor('term-1')?.textContent).toContain('[1]'); - expect(menuFor('term-1')?.textContent).toContain('[2]'); - - act(() => { window.dispatchEvent(keydown('2')); }); - // Ports sort ascending: 3000 → [1], 5173 → [2]. - expect(onConnectPort).toHaveBeenCalledWith('term-1', 'http://localhost:5173/'); - expect(menuFor('term-1')).toBeNull(); - }); - - it('drops a digit press while the port scan is still running', async () => { - enableConnect(platform); - platform.spawnPty('term-1'); - platform.getOpenPorts = () => new Promise(() => {}); // never resolves - const onConnectPort = vi.fn(); - renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); - - act(() => { fireContextMenu(); }); - expect(menuFor('term-1')?.textContent).toContain('scanning ports'); - - act(() => { window.dispatchEvent(keydown('1')); }); - expect(onConnectPort).not.toHaveBeenCalled(); - expect(menuFor('term-1')).not.toBeNull(); - }); - - it('ignores an out-of-range digit', async () => { - const onConnectPort = vi.fn(); - await openConnectableMenu(onConnectPort); - - act(() => { window.dispatchEvent(keydown('5')); }); - expect(onConnectPort).not.toHaveBeenCalled(); - expect(menuFor('term-1')).not.toBeNull(); - }); - - it('renders no digit chips and ignores digits on an inert host', async () => { - platform.spawnPty('term-1'); - platform.setOpenPorts('term-1', DEFAULT_KB_PORTS); - const onConnectPort = vi.fn(); - renderHeader(headerProps('term-1', 't'), stubActions({ onConnectPort })); - - await act(async () => { fireContextMenu(); }); - expect(menuFor('term-1')?.textContent).not.toContain('[1]'); - - act(() => { window.dispatchEvent(keydown('1')); }); - expect(onConnectPort).not.toHaveBeenCalled(); - expect(menuFor('term-1')).not.toBeNull(); - }); - - it('roves focus across port rows with the arrow keys, wrapping at the ends', async () => { - await openConnectableMenu(); - const menu = menuFor('term-1')!; - const rows = menu.querySelectorAll('button[role="menuitem"]'); - expect(rows.length).toBe(2); - - act(() => { window.dispatchEvent(keydown('ArrowDown')); }); - expect(document.activeElement).toBe(rows[0]); - act(() => { window.dispatchEvent(keydown('ArrowDown')); }); - expect(document.activeElement).toBe(rows[1]); - act(() => { window.dispatchEvent(keydown('ArrowDown')); }); - expect(document.activeElement).toBe(rows[0]); // wraps past the last - act(() => { window.dispatchEvent(keydown('ArrowUp')); }); - expect(document.activeElement).toBe(rows[1]); // wraps before the first - }); - - it('cycles focus through every focusable with Tab, including the close button', async () => { - await openConnectableMenu(); - const menu = menuFor('term-1')!; - const closeBtn = menu.querySelector('button[aria-label="Close menu"]')!; - const rows = Array.from(menu.querySelectorAll('button[role="menuitem"]')); - const order = [closeBtn, rows[0], rows[1]]; - - // From the container, Tab lands on the first focusable and wraps through all. - for (const expected of [...order, order[0]]) { - act(() => { window.dispatchEvent(keydown('Tab')); }); - expect(document.activeElement).toBe(expected); - } - }); - - it('restores focus to the previously focused element when it closes', async () => { - const outside = document.createElement('button'); - document.body.appendChild(outside); - outside.focus(); - expect(document.activeElement).toBe(outside); - - await openConnectableMenu(); - expect(document.activeElement).toBe(menuFor('term-1')); - - act(() => { window.dispatchEvent(keydown('Escape')); }); - expect(menuFor('term-1')).toBeNull(); - expect(document.activeElement).toBe(outside); - outside.remove(); - }); - - it('survives a scroll originating inside the menu but dismisses on an outside scroll', async () => { - await openConnectableMenu(); - const menu = menuFor('term-1')!; - - act(() => { menu.dispatchEvent(new Event('scroll', { bubbles: false })); }); - expect(menuFor('term-1')).not.toBeNull(); - - act(() => { window.dispatchEvent(new Event('scroll')); }); - expect(menuFor('term-1')).toBeNull(); - }); -}); diff --git a/lib/src/components/wall/PaneHeaderContextMenu.tsx b/lib/src/components/wall/PaneHeaderContextMenu.tsx deleted file mode 100644 index 30a119f9..00000000 --- a/lib/src/components/wall/PaneHeaderContextMenu.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'; -import { createPortal } from 'react-dom'; -import { CircleNotchIcon, XIcon } from '@phosphor-icons/react'; -import { POPUP_SURFACE_CLASS, Shortcut } from '../design'; -import { clampOverlayPosition } from '../../lib/ui-geometry'; -import { getPlatform } from '../../lib/platform'; -import type { OpenPort } from '../../lib/platform/types'; -import { titleSourceLabel, type TerminalTitle } from '../../lib/terminal-state'; -import { stepFocus } from '../focus-step'; -import { POPOVER_FOCUSABLE_SELECTOR } from '../use-popover-focus-trap'; -import { listenerUrlsByPort, type PortUrlEntry } from './port-url'; -import { useDismissOverlay } from './use-dismiss-overlay'; -import { WallActionsContext } from './wall-context'; - -type ScanState = - | { status: 'scanning' } - | { status: 'loaded'; entries: PortUrlEntry[] } - | { status: 'failed' }; - -// One recipe for the interactive port-entry rows. -const MENU_ROW_CLASS = 'flex w-full items-baseline gap-2 px-2.5 py-1 text-left hover:bg-foreground/10'; - -/** - * The right-click menu on a terminal pane header (`docs/specs/layout.md` → Pane - * header): a header row with the current display title, the pane's `surface:N` - * handle, and a close button; then the diagnostic title-candidates table (latest - * entry per channel); then the TCP ports the pane's process tree binds. Clicking a - * port fires `dor ab open ` via `WallActions.onConnectPort` - * (`docs/specs/dor-browser.md` → Pane Context Menu Connect) and closes the menu - * immediately — the new pane's own "Connecting…" placeholder is the loading - * feedback, and a failure is logged, not shown here. Port rows are only clickable - * when the host can run agent-browser; otherwise the list is an inert label (the - * host-gated-affordance convention). - * - * The menu owns the keyboard while open (`docs/specs/layout.md` → Pane header): - * it takes DOM focus on mount (restoring the prior focus when a dismissal leaves - * input ownership unchanged; a port activation focuses its browser instead), - * reports dialog-keyboard-active so command-mode keys don't fire underneath, - * and handles Tab cycling, `↑`/`↓` roving, and the `1`–`9` port accelerators in - * one keydown handler. Dismissal (Escape, outside press, resize, scroll) is owned - * solely by `useDismissOverlay`. - */ -export function PaneHeaderContextMenu({ - id, - anchor, - onClose, - onKeyboardActiveChange, - candidates, - currentTitle, -}: { - id: string; - anchor: { x: number; y: number }; - onClose: () => void; - onKeyboardActiveChange: (active: boolean) => void; - candidates: TerminalTitle[]; - currentTitle: string; -}) { - const actions = useContext(WallActionsContext); - const ref = useRef(null); - const [style, setStyle] = useState({ position: 'fixed', left: anchor.x, top: anchor.y }); - const [scan, setScan] = useState({ status: 'scanning' }); - - // Absent ⇒ opening a browser surface isn't supported here; port rows render as - // plain labels rather than buttons (the list stays informative). - const canConnect = !!getPlatform().agentBrowserCommand; - - // Scan once when the menu opens — no polling, no rescan while open (right-click - // again to rescan). The scan may reject on timeout (`OPEN_PORT_TIMEOUT_MS`). - useEffect(() => { - let cancelled = false; - getPlatform().getOpenPorts(id).then( - (ports: OpenPort[]) => { if (!cancelled) setScan({ status: 'loaded', entries: listenerUrlsByPort(ports) }); }, - () => { if (!cancelled) setScan({ status: 'failed' }); }, - ); - return () => { cancelled = true; }; - }, [id]); - - // Clamp inside the viewport once measured; re-run when the content height - // changes (scanning → loaded). - useLayoutEffect(() => { - const el = ref.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - setStyle(clampOverlayPosition({ left: anchor.x, top: anchor.y, width: rect.width, height: rect.height })); - }, [anchor.x, anchor.y, scan]); - - useDismissOverlay(onClose, ref); - - // Report dialog-keyboard-active so command-mode shortcuts stay dormant while - // the menu is open (matches TodoAlertDialog). - useEffect(() => { - onKeyboardActiveChange(true); - return () => onKeyboardActiveChange(false); - }, [onKeyboardActiveChange]); - - // Take DOM focus on the menu container (tabIndex=-1) so our keyboard handlers - // fire via `el.contains(document.activeElement)`; restore the prior focus on - // close. A port activation's passthrough transition subsequently focuses the - // destination browser, superseding this generic popover cleanup. - useEffect(() => { - const previouslyFocused = document.activeElement as HTMLElement | null; - ref.current?.focus({ preventScroll: true }); - return () => { - if (previouslyFocused?.isConnected) previouslyFocused.focus({ preventScroll: true }); - }; - }, []); - - // Fire-and-forget: the pane appears immediately and reports its own progress, so - // the menu closes at once rather than waiting on the daemon boot. - const connect = useCallback((entry: PortUrlEntry) => { - actions.onConnectPort(id, entry.url); - onClose(); - }, [actions, id, onClose]); - - // Tab cycling + arrow rove + digit accelerators, one handler. Capture phase, - // scoped to the menu, and active only while mounted. Enter/Space are left to - // the focused native button. - useEffect(() => { - const el = ref.current; - if (!el) return; - const handler = (e: KeyboardEvent) => { - if (!el.contains(document.activeElement)) return; - if (e.key === 'Tab') { - e.preventDefault(); - stepFocus(Array.from(el.querySelectorAll(POPOVER_FOCUSABLE_SELECTOR)), e.shiftKey ? -1 : 1); - return; - } - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - e.stopPropagation(); - stepFocus(Array.from(el.querySelectorAll('[role="menuitem"]')), e.key === 'ArrowDown' ? 1 : -1); - return; - } - // Digits connect the nth port row — but only when there is a loaded list to - // index into. Scanning/failed/out-of-range/inert-host presses are dropped, - // not buffered. - if (canConnect && scan.status === 'loaded' && /^[1-9]$/.test(e.key)) { - const entry = scan.entries[Number(e.key) - 1]; - if (entry) { - e.preventDefault(); - e.stopPropagation(); - connect(entry); - } - } - }; - window.addEventListener('keydown', handler, true); - return () => window.removeEventListener('keydown', handler, true); - }, [canConnect, scan, connect]); - - return createPortal( -
e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - onContextMenu={(e) => e.preventDefault()} - > -
- {currentTitle} - {actions.resolveSurfaceRef(id)} - -
-
- {candidates.length === 0 ? ( -
No title candidates
- ) : ( -
- {candidates.map((candidate) => ( -
- {titleSourceLabel(candidate.source)} - {candidate.title} - -
- ))} -
- )} -
-
- {scan.status === 'scanning' && ( -
- - scanning ports… -
- )} - {scan.status === 'failed' && ( -
port scan failed
- )} - {scan.status === 'loaded' && scan.entries.length === 0 && ( -
no listening ports
- )} - {scan.status === 'loaded' && scan.entries.map((entry, index) => { - const label = ( - <> - {entry.host}:{entry.port} - {entry.processName && {entry.processName}} - - ); - return canConnect ? ( - - ) : ( -
- {label} -
- ); - })} -
, - document.body, - ); -} - -function formatTitleCandidateTime(timestamp: number): string { - if (!Number.isFinite(timestamp)) return 'unknown'; - return new Date(timestamp).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function formatTitleCandidateDateTime(timestamp: number): string | undefined { - if (!Number.isFinite(timestamp)) return undefined; - return new Date(timestamp).toISOString(); -} diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx new file mode 100644 index 00000000..f08f4a8f --- /dev/null +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { TerminalContextView, type TerminalContextViewProps } from './TerminalContextView'; +import { TerminalPaneHeader } from './TerminalPaneHeader'; +import { TerminalPanel } from './TerminalPanel'; +import { TerminalContextContext } from './wall-context'; +import { ensureResizeObserver } from './wall-test-utils'; +import { setMouseReporting, removeMouseSelectionState } from '../../lib/mouse-selection'; +import { setPlatform } from '../../lib/platform'; +import { FakePtyAdapter } from '../../lib/platform/fake-adapter'; + +vi.mock('../TerminalPane', () => ({ TerminalPane: () =>