diff --git a/docs/design/workspace.md b/docs/design/workspace.md new file mode 100644 index 00000000..b8a3e31c --- /dev/null +++ b/docs/design/workspace.md @@ -0,0 +1,144 @@ +# Workspace: sidebar, detail panel and regional focus + +Program: [#330](https://github.com/raiseCatError/notMyShell/issues/330). This document covers the shared foundation +(`src/workspace/`) that the Workspace Sidebar (#331), the contextual detail panel (#332) and the semantic keymap +(#335) build on, and the narrow contract by which the frontend (`src/app/TerminalApp.ts`) adopts them. + +Status: **foundation modules and tests only.** Nothing here is wired into the running app yet; `TerminalApp.ts` is +not modified by this slice (see [Integration contract](#integration-contract)). + +## Boundary + +NMSh is a terminal-first control and presentation surface over a persistent real shell. Navigation never transfers +ownership: tmux owns sessions, windows, panes, their PTYs, layout and detach; Git owns worktrees; GitHub owns PRs and +issues; a provider owns its agent loop. The sidebar lists what those owners report and proposes an activation the +frontend performs through a supported path. It never creates PTYs for listed objects, edits code or renders a browser. + +## Regions + +```text +┌ sidebar ─┬─ center (transcript, composer) ─────────────┬─ detail panel ┐ +│ where to │ the current work: unchanged in behavior, │ what is │ +│ go │ sized as the PTY │ selected │ +└──────────┴──────────────────────────────────────────────┴───────────────┘ + Status Strip (full width, at its configured edge) +``` + +The regions span the rows of the NMSh pane except the Status Strip row, which stays full width. Rows inside the +center keep using ScreenPlan; `planWorkspaceColumns` is its column counterpart. + +### Column plan (`layout.ts`) + +| Mode | Meaning | PTY | +| --- | --- | --- | +| `docked` | own columns, one divider cell from the center | center width | +| `compact` | a narrower docked sidebar (markers, short labels) | center width | +| `drawer` | drawn over the center while open **and focused** | **unchanged**: opening/closing never resizes the shell | +| `hidden` | closed, or suppressed by fullscreen/raw passthrough or an idle screen | full width | + +Constants (cells): center minimum 60, sidebar docked 28 / compact 18, panel 36–72 (default about 38% of the width), +drawer minimum 24 (narrower terminals give a drawer the whole width). A docked region never squeezes the center below +60; it becomes a drawer instead. Sidebar first (docked, then compact), then the panel; when both are open and do not +fit, the sidebar narrows to compact before the panel becomes a drawer. The plan is a pure function of its input, so +resizing back restores the earlier layout. `columnAt` gives pointer hits (drawers win where they overlap); +`frameColumn` maps a center-relative column to the frame. + +**Drawers follow the keyboard.** Below about 80 columns a drawer covers the center, composer included, and at 40 +columns both regions would be full-width drawers on top of each other. With `focus` (`regionOf(owner)`) in the input, +an open drawer is drawn only while it has the keyboard; otherwise it is `tucked` (open, not drawn, mode `hidden`). +So at most one drawer shows, and nothing covers the region typing goes to. Tucking is not a resize and keeps the +region open: focusing it again, or widening the terminal until it docks, shows it again. `applyFocusAction(state, +action, columns)` focuses a region opened where it can only be a drawer (an unfocused drawer would show nothing), +and toggling a tucked drawer brings it forward instead of closing what cannot be seen; docked regions still open +without taking focus and toggle closed. + +These thresholds are a starting point chosen so an 80-column shell stays usable; they still need physical +measurement in Ghostty and Terminal.app (see QA). + +## Regional focus (`focus.ts`, `keymap.ts`) + +Owners extend the Managed Target `InputOwner` set from #329 with `LEFT_SIDEBAR` and `RIGHT_PANEL`; there is no second +dispatcher. Exactly one owner holds the keyboard. + +- `routeWorkspaceKey(key, context)` returns a workspace action or `undefined` ("not mine; handle as before"). +- Protected owners are never intercepted: `PASSTHROUGH`, `APPROVAL`, `CHOICE`, `SEARCH`, `AGENT_MESSAGE`, `PANEL`, + `TRANSCRIPT`. +- In `COMPOSER` only explicit `composerEmpty` bindings apply, only with an empty draft, and never to text or paste. + **The defaults bind nothing in the composer**: arrows, Tab and Shift+Tab already mean something there. The regions are + reached with `/sidebar` and `/panel` (and `/sidebar focus`, `/panel focus`); #335 lets a person opt into keys. +- Region defaults: sidebar ↑ ↓ Home End, → expand, ← collapse, Enter open, `/` filter, Tab next region, Esc composer; + panel ↑ ↓ PgUp PgDn, `q` close, Tab, Esc. +- `applyFocusAction` handles focus/visibility actions. Closing a focused region returns focus to the composer; + opening a docked region does not steal focus (a drawer opens focused, see the column plan); Tab cycles composer → + sidebar → panel, skipping closed regions. +- `resumeFocus` restores the previous owner after passthrough or an idle screen if its region is still open. +- Help rows come from the bindings in effect (`bindingHelp`), so help cannot drift from behavior. + +Focus is shown without color: the focused region's edge and title are marked (`▸`/`>`), the selected row has a marker, +and the active resource has its own marker; Safe glyphs and NO_COLOR keep all three distinct. + +## Resources (`resources.ts`) + +A `WorkspaceResource` has a stable id (selection follows it across refreshes), a kind, sanitized bounded text, and an +explicit support level: + +- `activatable`: carries an `ActivationIntent` (tmux session/window/pane target, change directory, NMSh session, + managed target, or open a dedicated manager). An `activatable` claim without an intent is downgraded to + `unsupported` with a reason, so nothing is drawn as actionable without a path. +- `informational`: shown, not activatable, with a reason. +- `unsupported`: says why. + +Selected, focused, active, attention (`input`, `approval`, `failed`, `finished`) and stale are separate. An unknown +observation time is stale, never fresh. Trees are bounded: 512 resources, four levels, 80-cell labels; external text +has whole escape sequences removed (NMSh's `stripTerminalControls`, so no `]0;title` debris), then controls, format +characters and newlines. + +Selection never activates. Moving the selection may ask for a **cached, read-only** preview in the detail panel; only +Enter/→ (or the same semantic action from the pointer) activates. + +## Slices + +Each slice has its own design document, delivered with its PR, so the slices do not edit the same file: + +| Slice | Issue | Document | +| --- | --- | --- | +| Workspace Sidebar, tmux navigation | #331, #333 | [workspace-sidebar.md](workspace-sidebar.md) | +| Detail panel | #332 | [workspace-panel.md](workspace-panel.md) | +| Keymap | #335 | [workspace-keymap.md](workspace-keymap.md) | + +## Integration contract + +Integration into `TerminalApp.ts` is deliberately a separate, later step: that file is under active input-routing +stabilization on the workflow track (#359), and this slice does not touch it. When the stabilization checkpoint is +published, the adoption is: + +1. **State.** The app holds one `WorkspaceFocusState` and the sidebar/panel controllers (later slices). Persisted: + only `sidebarOpen`/`panelOpen` preferences and the panel width. +2. **Geometry.** Each frame computes `planWorkspaceColumns({columns, sidebarOpen, panelOpen, suppressed, + focus: regionOf(owner)})` beside ScreenPlan, and focus actions pass the width: `applyFocusAction(state, action, + columns)`. Transcript and composer render into `center` (center-relative columns); `ptyColumns` replaces the + terminal width when sizing the PTY. A change in `ptyColumns` is the only workspace event that resizes the shell. +3. **Keys.** Before the existing owner-specific handling, call `routeWorkspaceKey`. `undefined` means continue + unchanged. A focus action goes to `applyFocusAction`; a region action goes to that region's controller. +4. **Passthrough and idle.** Set `suppressed: true` while fullscreen/raw passthrough or an idle screen owns the pane; + on return, `resumeFocus(previous, current)`. +5. **Pointer.** `columnAt` decides the region; within a region, the region's own hit-testing yields the same semantic + actions as keys. +6. **Commands.** `/sidebar [focus]` and `/panel [focus]` dispatch `sidebar.toggle`/`sidebar.focus` and + `panel.toggle`/`panel.focus`. +7. **Chrome stays out of content.** Sidebar and panel rows are frontend chrome: never written to the transcript, + journals, snapshots or plain-text `/copy`. + +Conflict-prone paths for the integration branch: `src/app/TerminalApp.ts` (key routing, render composition, PTY +sizing), `src/commands/slashCommands.ts` (two commands) and `src/help/helpContent.ts`. + +## QA still required + +Automated tests cover geometry, focus routing, protection of owners and drafts, resource bounds and sanitization. +Physical checks remain separate and are not claimed: + +- Ghostty and Terminal.app at 200, 120, 100, 89, 80, 70 and 40 columns: docked/compact/drawer transitions, divider + placement, no horizontal scroll, and that opening a drawer does not reflow the shell. +- Safe glyphs and `NO_COLOR=1`: focus, selection and active markers distinguishable. +- Inside tmux: the sidebar's keys are not consumed by the tmux prefix table; passthrough (vim, less) hides the regions + and focus returns afterwards. diff --git a/src/workspace/focus.ts b/src/workspace/focus.ts new file mode 100644 index 00000000..7351eb1f --- /dev/null +++ b/src/workspace/focus.ts @@ -0,0 +1,116 @@ +import type {InputOwner} from '../agents/input/controller.js'; +import type {Key} from '../terminal/keys.js'; +import {DEFAULT_BINDINGS, resolveBinding, type Binding, type WorkspaceActionId} from './keymap.js'; +import {planWorkspaceColumns, type FocusRegion} from './layout.js'; + +/** + * Regional keyboard ownership for the workspace (#330 regional focus + * contract). It extends the Managed Target owners (#329 `InputOwner`) with + * the two workspace regions instead of adding a second dispatcher: exactly one + * owner holds the keyboard, and the frontend asks `routeWorkspaceKey` first + * only while that owner is a workspace region or the shell composer. + * + * Protected owners are never intercepted: raw/fullscreen passthrough, an + * approval or choice, search, and an agent message draft keep every key. In + * the composer only explicit `composerEmpty` bindings apply, and only with an + * empty draft, so an active shell draft always wins Left/Right/number keys. + */ + +export type WorkspaceOwner = InputOwner | 'LEFT_SIDEBAR' | 'RIGHT_PANEL'; + +const PROTECTED: ReadonlySet = new Set(['PASSTHROUGH', 'APPROVAL', 'CHOICE', 'SEARCH', 'AGENT_MESSAGE', 'PANEL', 'TRANSCRIPT']); + +export interface WorkspaceKeyContext { + owner: WorkspaceOwner; + /** The shell composer's draft is empty (no text, no selection). */ + composerEmpty: boolean; + sidebarOpen: boolean; + panelOpen: boolean; + bindings?: readonly Binding[]; +} + +/** The workspace action a key triggers now, or undefined: the key is not the workspace's and goes where it went before. */ +export function routeWorkspaceKey(key: Key, context: WorkspaceKeyContext): WorkspaceActionId | undefined { + const bindings = context.bindings ?? DEFAULT_BINDINGS; + if (PROTECTED.has(context.owner)) return undefined; + if (context.owner === 'LEFT_SIDEBAR') return context.sidebarOpen ? resolveBinding(bindings, 'sidebar', key) : undefined; + if (context.owner === 'RIGHT_PANEL') return context.panelOpen ? resolveBinding(bindings, 'panel', key) : undefined; + // COMPOSER: only opt-in bindings, only with an empty draft, and never plain text or paste (typing always reaches the shell). + if (!context.composerEmpty || key.kind === 'text' || key.kind === 'paste') return undefined; + return resolveBinding(bindings, 'composerEmpty', key); +} + +export interface WorkspaceFocusState { + owner: WorkspaceOwner; + sidebarOpen: boolean; + panelOpen: boolean; +} + +/** The column-plan region an owner types into: a workspace region, or the center for every other owner. */ +export function regionOf(owner: WorkspaceOwner): FocusRegion { + return owner === 'LEFT_SIDEBAR' ? 'sidebar' : owner === 'RIGHT_PANEL' ? 'panel' : 'center'; +} + +/** + * Focus transitions for the focus/visibility actions. Region-internal actions (moving, scrolling, activating) belong to + * the sidebar and panel controllers and leave focus unchanged. Closing a focused region returns focus to the + * composer, so the keyboard never belongs to something that is not drawn. + * + * Opening a docked region does not take focus. With `columns`, opening a region that can only be a drawer at that + * width focuses it: an unfocused drawer is not drawn (the column plan tucks it away), so opening it without focus + * would show nothing. Toggling a tucked drawer brings it forward rather than closing it. + */ +export function applyFocusAction(state: WorkspaceFocusState, action: WorkspaceActionId, columns?: number): WorkspaceFocusState { + if (columns !== undefined && (action === 'sidebar.toggle' || action === 'panel.toggle')) { + // Toggling a tucked drawer (open, not drawn) brings it forward; closing what cannot be seen would look like nothing. + const current = planWorkspaceColumns({columns, sidebarOpen: state.sidebarOpen, panelOpen: state.panelOpen}); + if (action === 'sidebar.toggle' && state.sidebarOpen && state.owner !== 'LEFT_SIDEBAR' && current.sidebar.mode === 'drawer') return {...state, owner: 'LEFT_SIDEBAR'}; + if (action === 'panel.toggle' && state.panelOpen && state.owner !== 'RIGHT_PANEL' && current.panel.mode === 'drawer') return {...state, owner: 'RIGHT_PANEL'}; + } + const next = focusTransition(state, action); + if (columns === undefined) return next; + const plan = planWorkspaceColumns({columns, sidebarOpen: next.sidebarOpen, panelOpen: next.panelOpen}); + if (!state.sidebarOpen && next.sidebarOpen && plan.sidebar.mode === 'drawer') return {...next, owner: 'LEFT_SIDEBAR'}; + if (!state.panelOpen && next.panelOpen && plan.panel.mode === 'drawer') return {...next, owner: 'RIGHT_PANEL'}; + return next; +} + +function focusTransition(state: WorkspaceFocusState, action: WorkspaceActionId): WorkspaceFocusState { + switch (action) { + case 'sidebar.toggle': { + const sidebarOpen = !state.sidebarOpen; + return {...state, sidebarOpen, owner: !sidebarOpen && state.owner === 'LEFT_SIDEBAR' ? 'COMPOSER' : state.owner}; + } + case 'panel.toggle': { + const panelOpen = !state.panelOpen; + return {...state, panelOpen, owner: !panelOpen && state.owner === 'RIGHT_PANEL' ? 'COMPOSER' : state.owner}; + } + case 'panel.close': return {...state, panelOpen: false, owner: state.owner === 'RIGHT_PANEL' ? 'COMPOSER' : state.owner}; + case 'sidebar.focus': return {...state, sidebarOpen: true, owner: 'LEFT_SIDEBAR'}; + case 'panel.focus': return {...state, panelOpen: true, owner: 'RIGHT_PANEL'}; + case 'focus.return': return {...state, owner: 'COMPOSER'}; + case 'focus.cycle': { + // Composer → sidebar → panel → composer, skipping closed regions. + const order: WorkspaceOwner[] = ['COMPOSER', ...(state.sidebarOpen ? ['LEFT_SIDEBAR' as const] : []), ...(state.panelOpen ? ['RIGHT_PANEL' as const] : [])]; + const index = order.indexOf(state.owner); + return {...state, owner: order[(index + 1) % order.length] ?? 'COMPOSER'}; + } + default: return state; + } +} + +/** Whether an action changes focus or visibility (handled by applyFocusAction) rather than acting inside a region. */ +export function isFocusAction(action: WorkspaceActionId): boolean { + return ['sidebar.toggle', 'panel.toggle', 'panel.close', 'sidebar.focus', 'panel.focus', 'focus.return', 'focus.cycle'].includes(action); +} + +/** + * Fullscreen/raw passthrough and idle screens own the whole pane: the frontend switches the owner to PASSTHROUGH (or + * its idle owner) and keeps the previous state. On return the previous owner comes back if its region is still open; + * otherwise the composer. Selection, expansion and scroll positions live in the region controllers and are untouched. + */ +export function resumeFocus(previous: WorkspaceFocusState, current: Pick): WorkspaceFocusState { + const owner = previous.owner === 'LEFT_SIDEBAR' && !current.sidebarOpen ? 'COMPOSER' + : previous.owner === 'RIGHT_PANEL' && !current.panelOpen ? 'COMPOSER' : previous.owner; + return {...current, owner}; +} diff --git a/src/workspace/keymap.ts b/src/workspace/keymap.ts new file mode 100644 index 00000000..19d6082f --- /dev/null +++ b/src/workspace/keymap.ts @@ -0,0 +1,94 @@ +import type {Key} from '../terminal/keys.js'; + +/** + * Key → semantic workspace action, per context (#335 builds remapping on this + * table). Controllers receive actions, never key sequences, and every action + * also has a slash-command path (`/sidebar`, `/panel`), so a key the terminal + * host or tmux consumes before NMSh sees it never makes an action unreachable. + * + * Contexts are the workspace owners plus `composerEmpty`: a binding there + * applies only while the shell composer owns the keyboard with an empty draft. + * The defaults bind nothing in the composer, because every key it receives + * (arrows, Tab, Shift+Tab) already means something to the draft and the + * transcript; a person can opt in through the keymap. + */ + +export type WorkspaceActionId = + | 'sidebar.toggle' | 'sidebar.focus' | 'panel.toggle' | 'panel.focus' | 'focus.return' | 'focus.cycle' + | 'sidebar.up' | 'sidebar.down' | 'sidebar.first' | 'sidebar.last' | 'sidebar.expand' | 'sidebar.collapse' | 'sidebar.activate' | 'sidebar.search' + | 'panel.up' | 'panel.down' | 'panel.pageUp' | 'panel.pageDown' | 'panel.close'; + +export type KeymapContext = 'composerEmpty' | 'sidebar' | 'panel'; + +/** A key as the input parser reports it: its kind, and for text the exact character. */ +export interface KeyChord { + kind: Key['kind']; + text?: string; +} + +export interface Binding { + action: WorkspaceActionId; + context: KeymapContext; + key: KeyChord; +} + +export const WORKSPACE_ACTIONS: Readonly> = { + 'sidebar.toggle': {label: 'show or hide the sidebar', command: '/sidebar'}, + 'sidebar.focus': {label: 'focus the sidebar', command: '/sidebar focus'}, + 'panel.toggle': {label: 'show or hide the detail panel', command: '/panel'}, + 'panel.focus': {label: 'focus the detail panel', command: '/panel focus'}, + 'focus.return': {label: 'back to the composer'}, + 'focus.cycle': {label: 'next region'}, + 'sidebar.up': {label: 'previous'}, + 'sidebar.down': {label: 'next'}, + 'sidebar.first': {label: 'first'}, + 'sidebar.last': {label: 'last'}, + 'sidebar.expand': {label: 'expand'}, + 'sidebar.collapse': {label: 'collapse'}, + 'sidebar.activate': {label: 'open'}, + 'sidebar.search': {label: 'filter'}, + 'panel.up': {label: 'scroll up'}, + 'panel.down': {label: 'scroll down'}, + 'panel.pageUp': {label: 'page up'}, + 'panel.pageDown': {label: 'page down'}, + 'panel.close': {label: 'close panel'}, +}; + +const bind = (context: KeymapContext, action: WorkspaceActionId, kind: Key['kind'], text?: string): Binding => + ({action, context, key: text === undefined ? {kind} : {kind, text}}); + +export const DEFAULT_BINDINGS: readonly Binding[] = [ + bind('sidebar', 'sidebar.up', 'up'), bind('sidebar', 'sidebar.down', 'down'), + bind('sidebar', 'sidebar.first', 'bufferHome'), bind('sidebar', 'sidebar.last', 'bufferEnd'), + bind('sidebar', 'sidebar.expand', 'right'), bind('sidebar', 'sidebar.collapse', 'left'), + bind('sidebar', 'sidebar.activate', 'enter'), bind('sidebar', 'sidebar.search', 'text', '/'), + bind('sidebar', 'focus.return', 'escape'), bind('sidebar', 'focus.cycle', 'complete'), + bind('panel', 'panel.up', 'up'), bind('panel', 'panel.down', 'down'), + bind('panel', 'panel.pageUp', 'pageUp'), bind('panel', 'panel.pageDown', 'pageDown'), + bind('panel', 'focus.return', 'escape'), bind('panel', 'focus.cycle', 'complete'), bind('panel', 'panel.close', 'text', 'q'), +]; + +export function chordMatches(chord: KeyChord, key: Key): boolean { + if (chord.kind !== key.kind) return false; + return chord.text === undefined || (key.kind === 'text' && key.value === chord.text); +} + +/** The action a key triggers in a context, or undefined when the key is not the workspace's to handle. */ +export function resolveBinding(bindings: readonly Binding[], context: KeymapContext, key: Key): WorkspaceActionId | undefined { + return bindings.find(binding => binding.context === context && chordMatches(binding.key, key))?.action; +} + +/** How a chord is named in help (`↑`, `Enter`, `/`, `Shift+Tab`); derived, so help cannot drift from the table. */ +export function chordLabel(chord: KeyChord): string { + const names: Partial> = { + up: '↑', down: '↓', left: '←', right: '→', enter: 'Enter', escape: 'Esc', complete: 'Tab', focusPrevious: 'Shift+Tab', + pageUp: 'PgUp', pageDown: 'PgDn', bufferHome: 'Home', bufferEnd: 'End', toggleDetails: 'Ctrl+O', palette: 'Ctrl+Shift+P', + find: 'Ctrl+F', backspace: 'Backspace', lineHome: 'Ctrl+A', lineEnd: 'Ctrl+E', newline: 'Ctrl+J', + }; + return chord.kind === 'text' ? chord.text ?? 'text' : names[chord.kind] ?? chord.kind; +} + +/** Help rows for a context, from the bindings in effect: `[key, label]`. */ +export function bindingHelp(bindings: readonly Binding[], context: KeymapContext): Array<[string, string]> { + return bindings.filter(binding => binding.context === context).map(binding => [chordLabel(binding.key), WORKSPACE_ACTIONS[binding.action].label]); +} diff --git a/src/workspace/layout.ts b/src/workspace/layout.ts new file mode 100644 index 00000000..9d48f303 --- /dev/null +++ b/src/workspace/layout.ts @@ -0,0 +1,152 @@ +/** + * Horizontal workspace geometry (#330): where the left Workspace Sidebar, the + * center (transcript and composer) and the right detail panel sit in one + * frame, and how many columns the shell's PTY gets. It is the column + * counterpart of ScreenPlan (rows): render, hit-testing, cursor placement and + * PTY sizing consume the same plan, so they cannot disagree. + * + * Modes, measured in terminal cells: + * + * docked the region takes its own columns; the center (and the PTY) is narrower + * compact a narrower docked sidebar (markers and short labels) + * drawer the region is drawn over the center while it is open and focused; the + * center and the PTY keep their width, so opening or closing it never + * resizes the shell. An open drawer without the keyboard is tucked away + * (not drawn), so it never covers the composer someone is typing in + * hidden closed, or suppressed (fullscreen/raw passthrough, idle screens own the pane) + * + * The center never drops below CENTER_MIN_COLUMNS because of a docked region: + * a region that cannot dock becomes a drawer instead. Coordinates are + * zero-based columns; one divider cell separates a docked region from the center. + */ + +export type DockMode = 'hidden' | 'docked' | 'compact' | 'drawer'; + +/** A shell narrower than this stops being useful for ordinary output; docked regions never squeeze it below. */ +export const CENTER_MIN_COLUMNS = 60; +export const SIDEBAR_DOCKED_COLUMNS = 28; +export const SIDEBAR_COMPACT_COLUMNS = 18; +export const PANEL_MIN_COLUMNS = 36; +export const PANEL_MAX_COLUMNS = 72; +/** A drawer narrower than this would not show a useful row; below it the drawer takes the whole width. */ +export const DRAWER_MIN_COLUMNS = 24; +const DIVIDER = 1; + +export interface ColumnRegion { + /** Zero-based first column. */ + x: number; + width: number; +} + +export interface WorkspaceColumnsInput { + columns: number; + sidebarOpen: boolean; + panelOpen: boolean; + /** A preferred panel width (cells), clamped to the panel's bounds; defaults to about 38% of the width. */ + panelWidth?: number; + /** Fullscreen/raw passthrough or an idle screen owns the whole pane: no workspace chrome. */ + suppressed?: boolean; + /** + * Which region has the keyboard (`regionOf(owner)`). Given, an open drawer is drawn only while it is focused, so at + * most one drawer shows and it never hides the region typing goes to. Omitted, every open drawer is drawn. + */ + focus?: FocusRegion; +} + +export type FocusRegion = 'sidebar' | 'panel' | 'center'; + +export interface WorkspaceRegion { + mode: DockMode; + /** Absent while hidden. A drawer's region overlaps the center. */ + region?: ColumnRegion; + /** Open, but a drawer without the keyboard: not drawn until it is focused again (it stays open). */ + tucked?: boolean; +} + +export interface WorkspaceColumns { + columns: number; + sidebar: WorkspaceRegion; + panel: WorkspaceRegion; + /** Transcript, composer and everything else the frontend draws in the middle. */ + center: ColumnRegion; + /** Columns the shell's PTY is sized to: the center's width (drawers never change it). */ + ptyColumns: number; + /** Zero-based columns holding a vertical divider between a docked region and the center. */ + dividers: readonly number[]; +} + +const clamp = (value: number, low: number, high: number) => Math.max(low, Math.min(high, value)); + +function panelWidth(input: WorkspaceColumnsInput): number { + return clamp(Math.round(input.panelWidth ?? input.columns * 0.38), PANEL_MIN_COLUMNS, PANEL_MAX_COLUMNS); +} + +function drawerWidth(columns: number, preferred: number): number { + return columns < DRAWER_MIN_COLUMNS * 2 ? columns : Math.max(DRAWER_MIN_COLUMNS, Math.min(preferred, columns - 4)); +} + +/** + * The frame's column plan. Docking is decided sidebar first (docked, then compact), then the panel; when both do not + * fit beside a useful center the sidebar narrows to compact before the panel becomes a drawer. Deterministic for a + * given input, so a resize back to an earlier width restores the earlier layout. + */ +export function planWorkspaceColumns(input: WorkspaceColumnsInput): WorkspaceColumns { + const columns = Math.max(1, Math.floor(input.columns)); + const full: ColumnRegion = {x: 0, width: columns}; + if (input.suppressed || (!input.sidebarOpen && !input.panelOpen)) { + return {columns, sidebar: {mode: 'hidden'}, panel: {mode: 'hidden'}, center: full, ptyColumns: columns, dividers: []}; + } + const panel = input.panelOpen ? panelWidth(input) : 0; + const fits = (side: number, right: number) => columns - (side ? side + DIVIDER : 0) - (right ? right + DIVIDER : 0) >= CENTER_MIN_COLUMNS; + let sideMode: DockMode = 'hidden', side = 0; + if (input.sidebarOpen) { + if (fits(SIDEBAR_DOCKED_COLUMNS, 0)) { sideMode = 'docked'; side = SIDEBAR_DOCKED_COLUMNS; } + else if (fits(SIDEBAR_COMPACT_COLUMNS, 0)) { sideMode = 'compact'; side = SIDEBAR_COMPACT_COLUMNS; } + else sideMode = 'drawer'; + } + let panelMode: DockMode = 'hidden'; + if (input.panelOpen) { + if (fits(sideMode === 'drawer' ? 0 : side, panel)) panelMode = 'docked'; + else if (sideMode === 'docked' && fits(SIDEBAR_COMPACT_COLUMNS, panel)) { sideMode = 'compact'; side = SIDEBAR_COMPACT_COLUMNS; panelMode = 'docked'; } + else panelMode = 'drawer'; + } + const leftDocked = sideMode === 'docked' || sideMode === 'compact'; + const rightDocked = panelMode === 'docked'; + const centerX = leftDocked ? side + DIVIDER : 0; + const centerWidth = columns - centerX - (rightDocked ? panel + DIVIDER : 0); + const center: ColumnRegion = {x: centerX, width: centerWidth}; + const dividers: number[] = []; + if (leftDocked) dividers.push(side); + if (rightDocked) dividers.push(centerX + centerWidth); + const sidebar: WorkspaceRegion = sideMode === 'hidden' ? {mode: 'hidden'} + : leftDocked ? {mode: sideMode, region: {x: 0, width: side}} + : {mode: 'drawer', region: {x: 0, width: drawerWidth(columns, SIDEBAR_DOCKED_COLUMNS)}}; + const right: WorkspaceRegion = panelMode === 'hidden' ? {mode: 'hidden'} + : rightDocked ? {mode: 'docked', region: {x: columns - panel, width: panel}} + : (() => { const width = drawerWidth(columns, panel); return {mode: 'drawer' as const, region: {x: columns - width, width}}; })(); + const tuck = (region: WorkspaceRegion, name: FocusRegion): WorkspaceRegion => + input.focus !== undefined && region.mode === 'drawer' && input.focus !== name ? {mode: 'hidden', tucked: true} : region; + return {columns, sidebar: tuck(sidebar, 'sidebar'), panel: tuck(right, 'panel'), center, ptyColumns: centerWidth, dividers}; +} + +export type ColumnHit = 'sidebar' | 'panel' | 'center' | 'divider'; + +/** + * What a pointer at a zero-based column lands on. Drawers are on top of the center, so they win where they overlap; + * the pointer then invokes the same semantic actions as the keyboard. + */ +export function columnAt(plan: WorkspaceColumns, column: number): ColumnHit | undefined { + if (column < 0 || column >= plan.columns) return undefined; + const inside = (region: ColumnRegion | undefined) => region !== undefined && column >= region.x && column < region.x + region.width; + if (plan.sidebar.mode === 'drawer' && inside(plan.sidebar.region)) return 'sidebar'; + if (plan.panel.mode === 'drawer' && inside(plan.panel.region)) return 'panel'; + if (plan.dividers.includes(column)) return 'divider'; + if (inside(plan.sidebar.region)) return 'sidebar'; + if (inside(plan.panel.region)) return 'panel'; + return inside(plan.center) ? 'center' : undefined; +} + +/** A center-relative column (what the transcript/composer renderers use) to the frame's column. */ +export function frameColumn(plan: WorkspaceColumns, centerColumn: number): number { + return plan.center.x + Math.max(0, Math.min(plan.center.width - 1, centerColumn)); +} diff --git a/src/workspace/resources.ts b/src/workspace/resources.ts new file mode 100644 index 00000000..2844705a --- /dev/null +++ b/src/workspace/resources.ts @@ -0,0 +1,125 @@ +import {stripTerminalControls} from '../util/terminalControls.js'; + +/** + * Workspace resources (#330/#331): what the Workspace Sidebar lists and how it + * may be reached. A resource is a description of something another owner + * keeps (tmux owns sessions/windows/panes, Git owns worktrees, GitHub owns + * PRs, a provider owns its agent target); listing it never transfers that + * ownership. + * + * State that the sidebar keeps apart, so none of them implies another: + * + * selected the row the sidebar's cursor is on (browsing; may show a cached preview) + * focused whether the sidebar region owns the keyboard (WorkspaceFocus) + * active what the owner reports as current (the attached tmux client's pane, this NMSh session) + * attention something the owner reports needs the person (input wanted, approval, a failure) + * stale the listing is older than its freshness window, or the owner could not be asked + * + * Support is explicit: `activatable` resources carry an activation intent the + * caller can perform through a supported path; `informational` ones are shown + * but cannot be activated; `unsupported` ones say why. A resource without a + * truthful navigation path is never drawn as activatable. + */ + +export type ResourceKind = 'section' | 'nmshSession' | 'managedTarget' | 'project' | 'worktree' | 'tmuxSession' | 'tmuxWindow' | 'tmuxPane' | 'githubPr' | 'githubIssue'; +export type ResourceSupport = 'activatable' | 'informational' | 'unsupported'; +export type Attention = 'input' | 'approval' | 'failed' | 'finished'; + +/** What activating a resource asks its owner to do. The caller performs it; the sidebar only proposes it. */ +export type ActivationIntent = + | {kind: 'tmux'; scope: 'session' | 'window' | 'pane'; target: string; socket?: string} + | {kind: 'changeDirectory'; path: string} + | {kind: 'nmshSession'; id: string} + | {kind: 'managedTarget'; id: string} + | {kind: 'openManager'; command: '/worktrees' | '/github' | '/agents' | '/sessions' | '/tmux'; argument?: string}; + +export interface WorkspaceResource { + /** Stable identity across refreshes (`tmux:$1:@2:%3`, `worktree:/path`); selection follows it. */ + id: string; + kind: ResourceKind; + label: string; + /** A short secondary text (a branch, a command, a path). */ + detail?: string; + support: ResourceSupport; + /** Why an informational/unsupported resource cannot be activated; shown, never hidden. */ + reason?: string; + activation?: ActivationIntent; + active?: boolean; + attention?: Attention; + /** Epoch ms when the owner was last asked; undefined when unknown. */ + observedAt?: number; + children?: readonly WorkspaceResource[]; +} + +export const MAX_LABEL_CELLS = 80; +export const MAX_RESOURCES = 512; +export const MAX_DEPTH = 4; + +/** + * External names (session titles, branch names, pane commands) are hostile text. Whole escape sequences go first, so + * `evil ESC ] 0 ; PWNED BEL` shows as `evil`, not `evil ]0;PWNED`; remaining controls, format characters and newlines + * become spaces. + */ +export function resourceText(value: string, limit = MAX_LABEL_CELLS): string { + // Newlines and tabs separate words; stripping would join them, so they are spaces before sequences are removed. + const words = stripTerminalControls(value.slice(0, Math.max(limit, 1) * 8).replace(/[\t\n\r\v\f]/gu, ' '), Math.max(limit, 1) * 8); + const clean = words.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ').replace(/\s+/gu, ' ').trim(); + return clean.length > limit ? `${clean.slice(0, limit - 1)}…` : clean; +} + +/** + * A resource as the sidebar may show it: text sanitized and bounded, children bounded in count and depth, and an + * `activatable` claim downgraded to `unsupported` when no activation intent backs it. + */ +export function normalizeResource(resource: WorkspaceResource, depth = 0, budget = {remaining: MAX_RESOURCES}): WorkspaceResource | undefined { + if (budget.remaining <= 0) return undefined; + budget.remaining -= 1; + const support: ResourceSupport = resource.support === 'activatable' && !resource.activation ? 'unsupported' : resource.support; + const children = depth + 1 < MAX_DEPTH + ? (resource.children ?? []).flatMap(child => normalizeResource(child, depth + 1, budget) ?? []) + : []; + return { + ...resource, + id: resourceText(resource.id, 512), + label: resourceText(resource.label) || resource.kind, + ...(resource.detail !== undefined ? {detail: resourceText(resource.detail)} : {}), + support, + ...(support !== resource.support ? {reason: 'no supported way to open this here'} : resource.reason !== undefined ? {reason: resourceText(resource.reason, 160)} : {}), + ...(support === 'activatable' ? {} : {activation: undefined}), + ...(children.length ? {children} : {children: undefined}), + }; +} + +export interface ResourceRow { + resource: WorkspaceResource; + depth: number; + /** Whether the row has children and whether they are shown. */ + expandable: boolean; + expanded: boolean; +} + +/** Rows in display order: a child is visible only when every ancestor is expanded. */ +export function visibleRows(roots: readonly WorkspaceResource[], expanded: ReadonlySet): ResourceRow[] { + const rows: ResourceRow[] = []; + const walk = (resource: WorkspaceResource, depth: number) => { + const expandable = (resource.children?.length ?? 0) > 0; + const open = expandable && expanded.has(resource.id); + rows.push({resource, depth, expandable, expanded: open}); + if (open) for (const child of resource.children!) walk(child, depth + 1); + }; + for (const root of roots) walk(root, 0); + return rows; +} + +/** Every id in a tree, for reconciling selection and expansion after a refresh. */ +export function resourceIds(roots: readonly WorkspaceResource[]): Set { + const ids = new Set(); + const walk = (resource: WorkspaceResource) => { ids.add(resource.id); resource.children?.forEach(walk); }; + roots.forEach(walk); + return ids; +} + +/** Whether a listing observed at `observedAt` is past its freshness window (unknown observation time counts as stale). */ +export function isStale(resource: Pick, now: number, freshnessMs: number): boolean { + return resource.observedAt === undefined || now - resource.observedAt > freshnessMs; +} diff --git a/tests/workspaceFoundation.test.ts b/tests/workspaceFoundation.test.ts new file mode 100644 index 00000000..76670f32 --- /dev/null +++ b/tests/workspaceFoundation.test.ts @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {CENTER_MIN_COLUMNS, columnAt, frameColumn, planWorkspaceColumns, SIDEBAR_COMPACT_COLUMNS, SIDEBAR_DOCKED_COLUMNS} from '../src/workspace/layout.js'; +import {isStale, normalizeResource, resourceIds, resourceText, visibleRows, type WorkspaceResource} from '../src/workspace/resources.js'; +import {applyFocusAction, regionOf, resumeFocus, routeWorkspaceKey, type WorkspaceOwner} from '../src/workspace/focus.js'; +import {bindingHelp, DEFAULT_BINDINGS, resolveBinding, type Binding} from '../src/workspace/keymap.js'; +import type {Key} from '../src/terminal/keys.js'; + +test('column plan: docked, compact and drawer by measured width; the center never drops below its minimum because of a dock', () => { + const widths = [240, 160, 120, 100, 89, 88, 80, 79, 78, 77, 70, 60, 50, 40, 30, 12, 1]; + for (const columns of widths) { + for (const [sidebarOpen, panelOpen] of [[true, false], [false, true], [true, true]] as const) { + const plan = planWorkspaceColumns({columns, sidebarOpen, panelOpen}); + const docked = [plan.sidebar, plan.panel].filter(region => region.mode === 'docked' || region.mode === 'compact'); + if (docked.length) assert.ok(plan.center.width >= CENTER_MIN_COLUMNS, `${columns} ${sidebarOpen}/${panelOpen}: center ${plan.center.width}`); + assert.equal(plan.ptyColumns, plan.center.width, 'the PTY is the center'); + // Regions never leave the frame and docked regions never overlap the center. + for (const region of [plan.sidebar.region, plan.panel.region, plan.center].filter(Boolean)) { + assert.ok(region!.x >= 0 && region!.x + region!.width <= columns, `${columns}: ${JSON.stringify(region)}`); + } + if (plan.sidebar.mode === 'docked' || plan.sidebar.mode === 'compact') assert.ok(plan.sidebar.region!.x + plan.sidebar.region!.width < plan.center.x + 1); + if (plan.panel.mode === 'docked') assert.ok(plan.center.x + plan.center.width <= plan.panel.region!.x); + } + } + assert.equal(planWorkspaceColumns({columns: 120, sidebarOpen: true, panelOpen: false}).sidebar.mode, 'docked'); + assert.equal(planWorkspaceColumns({columns: SIDEBAR_DOCKED_COLUMNS + 1 + CENTER_MIN_COLUMNS, sidebarOpen: true, panelOpen: false}).sidebar.mode, 'docked'); + assert.equal(planWorkspaceColumns({columns: SIDEBAR_DOCKED_COLUMNS + CENTER_MIN_COLUMNS, sidebarOpen: true, panelOpen: false}).sidebar.mode, 'compact'); + assert.equal(planWorkspaceColumns({columns: SIDEBAR_COMPACT_COLUMNS + CENTER_MIN_COLUMNS, sidebarOpen: true, panelOpen: false}).sidebar.mode, 'drawer'); + // Both open at a medium width: the sidebar narrows to compact before the panel becomes a drawer. + const both = planWorkspaceColumns({columns: 130, sidebarOpen: true, panelOpen: true}); + assert.deepEqual([both.sidebar.mode, both.panel.mode], ['compact', 'docked']); + const tighter = planWorkspaceColumns({columns: 120, sidebarOpen: true, panelOpen: true}); + assert.deepEqual([tighter.sidebar.mode, tighter.panel.mode], ['docked', 'drawer'], 'when even a compact sidebar leaves no room, the panel is a drawer and the sidebar stays as it was'); + assert.deepEqual([planWorkspaceColumns({columns: 160, sidebarOpen: true, panelOpen: true}).sidebar.mode, planWorkspaceColumns({columns: 160, sidebarOpen: true, panelOpen: true}).panel.mode], ['docked', 'docked']); + assert.equal(planWorkspaceColumns({columns: 90, sidebarOpen: true, panelOpen: true}).panel.mode, 'drawer'); +}); + +test('drawers overlay the center without resizing the shell; suppression hides everything and gives the PTY the whole width', () => { + const closed = planWorkspaceColumns({columns: 70, sidebarOpen: false, panelOpen: false}); + const drawer = planWorkspaceColumns({columns: 70, sidebarOpen: true, panelOpen: false}); + assert.equal(drawer.sidebar.mode, 'drawer'); + assert.equal(drawer.ptyColumns, closed.ptyColumns, 'opening a drawer is not a resize'); + assert.equal(columnAt(drawer, 5), 'sidebar', 'the drawer is on top where it overlaps'); + assert.equal(columnAt(drawer, 60), 'center'); + const narrow = planWorkspaceColumns({columns: 40, sidebarOpen: false, panelOpen: true}); + assert.deepEqual(narrow.panel, {mode: 'drawer', region: {x: 0, width: 40}}, 'below twice the drawer minimum a drawer takes the whole width'); + const suppressed = planWorkspaceColumns({columns: 200, sidebarOpen: true, panelOpen: true, suppressed: true}); + assert.deepEqual([suppressed.sidebar.mode, suppressed.panel.mode, suppressed.ptyColumns], ['hidden', 'hidden', 200]); + const docked = planWorkspaceColumns({columns: 160, sidebarOpen: true, panelOpen: true}); + assert.equal(columnAt(docked, SIDEBAR_DOCKED_COLUMNS), 'divider'); + assert.equal(columnAt(docked, 0), 'sidebar'); + assert.equal(columnAt(docked, 159), 'panel'); + assert.equal(frameColumn(docked, 0), SIDEBAR_DOCKED_COLUMNS + 1); + assert.equal(columnAt(docked, 200), undefined); + // Deterministic: the same input gives the same plan, so resizing back restores the earlier layout. + assert.deepEqual(planWorkspaceColumns({columns: 160, sidebarOpen: true, panelOpen: true}), docked); +}); + +const tree = (): WorkspaceResource[] => [{ + id: 'tmux', kind: 'section', label: 'TMUX', support: 'informational', children: [{ + id: 'tmux:$1', kind: 'tmuxSession', label: 'qa', support: 'activatable', activation: {kind: 'tmux', scope: 'session', target: '$1'}, children: [ + {id: 'tmux:$1:@1', kind: 'tmuxWindow', label: '0 code', support: 'activatable', activation: {kind: 'tmux', scope: 'window', target: '@1'}}, + {id: 'tmux:$1:@2', kind: 'tmuxWindow', label: '1 servers', support: 'activatable', activation: {kind: 'tmux', scope: 'window', target: '@2'}}, + ]}], +}]; + +test('resources: hidden-row expansion, stable ids, freshness, and an activatable claim needs a real activation path', () => { + const roots = tree(); + assert.deepEqual(visibleRows(roots, new Set()).map(row => row.resource.id), ['tmux']); + const open = visibleRows(roots, new Set(['tmux', 'tmux:$1'])); + assert.deepEqual(open.map(row => [row.resource.id, row.depth]), [['tmux', 0], ['tmux:$1', 1], ['tmux:$1:@1', 2], ['tmux:$1:@2', 2]]); + assert.deepEqual(open.map(row => row.expandable), [true, true, false, false]); + assert.deepEqual([...resourceIds(roots)].sort(), ['tmux', 'tmux:$1', 'tmux:$1:@1', 'tmux:$1:@2']); + const fake = normalizeResource({id: 'x', kind: 'managedTarget', label: 'Codex', support: 'activatable'})!; + assert.equal(fake.support, 'unsupported'); + assert.match(fake.reason!, /no supported way/u); + assert.equal(isStale({observedAt: 1000}, 3000, 5000), false); + assert.equal(isStale({observedAt: 1000}, 9000, 5000), true); + assert.equal(isStale({}, 9000, 5000), true, 'unknown observation time is never fresh'); +}); + +test('resources: hostile names are inert, bounded in length, count and depth', () => { + const hostile = normalizeResource({id: 'evil', kind: 'tmuxSession', label: '\u001b]8;;https://x\u0007click\u001b[2J‮\nname' + 'x'.repeat(500), + detail: 'cmd\u0000\u0007', support: 'informational', reason: '\u001b[31mred'})!; + for (const text of [hostile.label, hostile.detail!, hostile.reason!]) assert.doesNotMatch(text, /[\u0000-\u001f\u007f-\u009f‮]/u); + assert.ok(hostile.label.length <= 80); + assert.equal(resourceText(' a \n\t b '), 'a b'); + // Whole sequences go, not just their ESC: no `]0;PWNED` or `[31m` debris that reads like part of the name. + assert.equal(resourceText('evil\u001b]0;PWNED\u0007 session'), 'evil session'); + assert.equal(resourceText('\u001b[31mred\u001b[0m window'), 'red window'); + assert.equal(resourceText('\u009b2Jclear\u009d8;;https://x\u009clink'), 'clearlink'); + let deep: WorkspaceResource = {id: 'leaf', kind: 'tmuxPane', label: 'leaf', support: 'informational'}; + for (let i = 0; i < 10; i++) deep = {id: `n${i}`, kind: 'section', label: `n${i}`, support: 'informational', children: [deep]}; + const normalized = normalizeResource(deep)!; + let depth = 0; + for (let node: WorkspaceResource | undefined = normalized; node; node = node.children?.[0]) depth += 1; + assert.equal(depth, 4, 'at most four levels'); + const wide = normalizeResource({id: 'root', kind: 'section', label: 'root', support: 'informational', + children: Array.from({length: 2000}, (_, i) => ({id: `c${i}`, kind: 'tmuxPane' as const, label: `c${i}`, support: 'informational' as const}))})!; + assert.equal(wide.children!.length, 511, 'at most 512 resources in all'); +}); + +const key = (kind: Key['kind'], value?: string): Key => (value === undefined ? {kind} : {kind, value}) as Key; + +test('focus: protected owners keep every key; the composer keeps typing and its draft; regions get their bindings', () => { + const base = {composerEmpty: true, sidebarOpen: true, panelOpen: true}; + for (const owner of ['PASSTHROUGH', 'APPROVAL', 'CHOICE', 'SEARCH', 'AGENT_MESSAGE', 'PANEL', 'TRANSCRIPT'] as WorkspaceOwner[]) { + for (const k of [key('up'), key('escape'), key('enter'), key('complete'), key('text', '/')]) assert.equal(routeWorkspaceKey(k, {...base, owner}), undefined, `${owner} ${k.kind}`); + } + for (const k of [key('left'), key('right'), key('up'), key('complete'), key('focusPrevious'), key('text', '1'), key('escape')]) { + assert.equal(routeWorkspaceKey(k, {...base, owner: 'COMPOSER'}), undefined, `default composer bindings take nothing (${k.kind})`); + } + // An opt-in composer binding applies only with an empty draft, and never to typing. + const optIn: Binding[] = [...DEFAULT_BINDINGS, {action: 'sidebar.focus', context: 'composerEmpty', key: {kind: 'left'}}, {action: 'panel.toggle', context: 'composerEmpty', key: {kind: 'text', text: 'p'}}]; + assert.equal(routeWorkspaceKey(key('left'), {...base, owner: 'COMPOSER', bindings: optIn}), 'sidebar.focus'); + assert.equal(routeWorkspaceKey(key('left'), {...base, owner: 'COMPOSER', composerEmpty: false, bindings: optIn}), undefined, 'an active draft wins'); + assert.equal(routeWorkspaceKey(key('text', 'p'), {...base, owner: 'COMPOSER', bindings: optIn}), undefined, 'typing always reaches the shell'); + assert.equal(routeWorkspaceKey(key('down'), {...base, owner: 'LEFT_SIDEBAR'}), 'sidebar.down'); + assert.equal(routeWorkspaceKey(key('enter'), {...base, owner: 'LEFT_SIDEBAR'}), 'sidebar.activate'); + assert.equal(routeWorkspaceKey(key('text', '/'), {...base, owner: 'LEFT_SIDEBAR'}), 'sidebar.search'); + assert.equal(routeWorkspaceKey(key('text', 'x'), {...base, owner: 'LEFT_SIDEBAR'}), undefined); + assert.equal(routeWorkspaceKey(key('down'), {...base, owner: 'LEFT_SIDEBAR', sidebarOpen: false}), undefined, 'a closed region owns nothing'); + assert.equal(routeWorkspaceKey(key('pageDown'), {...base, owner: 'RIGHT_PANEL'}), 'panel.pageDown'); +}); + +test('focus transitions: closing a focused region returns to the composer; cycling skips closed regions; passthrough return restores', () => { + let state = {owner: 'COMPOSER' as WorkspaceOwner, sidebarOpen: false, panelOpen: false}; + state = applyFocusAction(state, 'sidebar.focus'); + assert.deepEqual(state, {owner: 'LEFT_SIDEBAR', sidebarOpen: true, panelOpen: false}); + assert.equal(applyFocusAction(state, 'focus.cycle').owner, 'COMPOSER', 'the panel is closed, so the cycle skips it'); + state = applyFocusAction(state, 'panel.toggle'); + assert.equal(state.owner, 'LEFT_SIDEBAR', 'opening the panel does not steal focus'); + assert.equal(applyFocusAction(state, 'focus.cycle').owner, 'RIGHT_PANEL'); + assert.deepEqual(applyFocusAction(state, 'sidebar.toggle'), {owner: 'COMPOSER', sidebarOpen: false, panelOpen: true}); + const panel = applyFocusAction(state, 'panel.focus'); + assert.deepEqual(applyFocusAction(panel, 'panel.close'), {owner: 'COMPOSER', sidebarOpen: true, panelOpen: false}); + assert.equal(applyFocusAction(panel, 'focus.return').owner, 'COMPOSER'); + assert.equal(applyFocusAction(panel, 'sidebar.down').owner, 'RIGHT_PANEL', 'region actions leave focus alone'); + assert.equal(resumeFocus(panel, {sidebarOpen: true, panelOpen: true}).owner, 'RIGHT_PANEL'); + assert.equal(resumeFocus(panel, {sidebarOpen: true, panelOpen: false}).owner, 'COMPOSER'); +}); + +test('help derives from the bindings in effect', () => { + assert.deepEqual(bindingHelp(DEFAULT_BINDINGS, 'panel').map(([chord]) => chord), ['↑', '↓', 'PgUp', 'PgDn', 'Esc', 'Tab', 'q']); + assert.equal(resolveBinding(DEFAULT_BINDINGS, 'sidebar', key('escape')), 'focus.return'); + assert.deepEqual(bindingHelp(DEFAULT_BINDINGS, 'composerEmpty'), []); +}); + +test('drawers follow the keyboard: only a focused drawer is drawn, and opening one that can only be a drawer focuses it', () => { + // 40 columns: both regions can only be full-width drawers. + const both = {columns: 40, sidebarOpen: true, panelOpen: true}; + assert.deepEqual([planWorkspaceColumns(both).sidebar.mode, planWorkspaceColumns(both).panel.mode], ['drawer', 'drawer'], 'without focus every open drawer is drawn (as before)'); + const typing = planWorkspaceColumns({...both, focus: 'center'}); + assert.deepEqual([typing.sidebar, typing.panel], [{mode: 'hidden', tucked: true}, {mode: 'hidden', tucked: true}], 'nothing covers the composer being typed in'); + assert.equal(columnAt(typing, 5), 'center'); + const panel = planWorkspaceColumns({...both, focus: 'panel'}); + assert.deepEqual([panel.sidebar.mode, panel.panel.mode], ['hidden', 'drawer'], 'one drawer at a time: the focused one'); + assert.equal(columnAt(panel, 5), 'panel'); + assert.equal(panel.ptyColumns, 40, 'tucking is not a resize'); + // Docked regions are drawn whatever has focus. + const wide = planWorkspaceColumns({columns: 160, sidebarOpen: true, panelOpen: true, focus: 'center'}); + assert.deepEqual([wide.sidebar.mode, wide.panel.mode], ['docked', 'docked']); + + const composer = {owner: 'COMPOSER' as WorkspaceOwner, sidebarOpen: false, panelOpen: false}; + assert.equal(applyFocusAction(composer, 'sidebar.toggle', 160).owner, 'COMPOSER', 'a docked region opens without taking focus'); + assert.equal(applyFocusAction(composer, 'sidebar.toggle', 40).owner, 'LEFT_SIDEBAR', 'a drawer opens focused, or it would not be drawn'); + assert.equal(applyFocusAction({...composer, sidebarOpen: true}, 'panel.toggle', 120).owner, 'RIGHT_PANEL', 'beside a docked sidebar at 120 the panel is a drawer'); + assert.equal(applyFocusAction(composer, 'sidebar.toggle').owner, 'COMPOSER', 'without the width, focus is unchanged (as before)'); + // Leaving a drawer keeps it open (tucked); focusing it again shows it where it was. + const inDrawer = applyFocusAction(composer, 'sidebar.toggle', 40); + const back = applyFocusAction(inDrawer, 'focus.return', 40); + assert.deepEqual(back, {owner: 'COMPOSER', sidebarOpen: true, panelOpen: false}); + assert.equal(planWorkspaceColumns({columns: 40, sidebarOpen: back.sidebarOpen, panelOpen: back.panelOpen, focus: regionOf(back.owner)}).sidebar.tucked, true); + assert.deepEqual(applyFocusAction(back, 'sidebar.toggle', 40), {owner: 'LEFT_SIDEBAR', sidebarOpen: true, panelOpen: false}, '/sidebar on a tucked drawer shows it'); + assert.deepEqual(applyFocusAction(inDrawer, 'sidebar.toggle', 40), {owner: 'COMPOSER', sidebarOpen: false, panelOpen: false}, '/sidebar on the shown drawer closes it'); + assert.equal(applyFocusAction({owner: 'COMPOSER', sidebarOpen: true, panelOpen: false}, 'sidebar.toggle', 160).sidebarOpen, false, 'a docked region still toggles closed'); + assert.equal(regionOf('APPROVAL'), 'center'); +});