From a9d9596be5163a9708244292f63a350759608a0d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:25:37 +0530 Subject: [PATCH 1/4] Workspace foundation: column plan, regional focus, semantic bindings and resource model Shared groundwork for the sidebar (#331), detail panel (#332) and keymap (#335) under #330, as standalone modules; the running app is unchanged. - layout.ts: the column counterpart of ScreenPlan. Docked, compact, drawer and hidden modes by measured width; a docked region never squeezes the center below 60 cells (it becomes a drawer), drawers overlay the center without resizing the PTY, and passthrough/idle suppress both regions. - focus.ts: LEFT_SIDEBAR and RIGHT_PANEL extend #329's InputOwner rather than adding a dispatcher. Protected owners (passthrough, approval, choice, search, agent message, panels) are never intercepted; in the composer only opt-in bindings apply, only with an empty draft, never to typing. Closing a focused region returns focus to the composer; focus resumes after passthrough. - keymap.ts: key -> semantic action per context, with slash-command paths and help derived from the bindings. The defaults bind nothing in the composer. - resources.ts: stable ids; activatable/informational/unsupported with an activation intent required for the first; selected, focused, active, attention and stale kept apart; hostile text sanitized; trees bounded. docs/design/workspace.md records the integration contract for TerminalApp.ts, which waits for the workflow track's stabilization checkpoint (#359). --- docs/design/workspace.md | 122 +++++++++++++++++++++++++ src/workspace/focus.ts | 91 +++++++++++++++++++ src/workspace/keymap.ts | 94 ++++++++++++++++++++ src/workspace/layout.ts | 139 +++++++++++++++++++++++++++++ src/workspace/resources.ts | 117 ++++++++++++++++++++++++ tests/workspaceFoundation.test.ts | 143 ++++++++++++++++++++++++++++++ 6 files changed, 706 insertions(+) create mode 100644 docs/design/workspace.md create mode 100644 src/workspace/focus.ts create mode 100644 src/workspace/keymap.ts create mode 100644 src/workspace/layout.ts create mode 100644 src/workspace/resources.ts create mode 100644 tests/workspaceFoundation.test.ts diff --git a/docs/design/workspace.md b/docs/design/workspace.md new file mode 100644 index 00000000..f9aa4850 --- /dev/null +++ b/docs/design/workspace.md @@ -0,0 +1,122 @@ +# 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 | **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. + +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 region does not steal focus; 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 controls, format characters and newlines removed. + +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. + +## 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})` beside + ScreenPlan. 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..47fee1a4 --- /dev/null +++ b/src/workspace/focus.ts @@ -0,0 +1,91 @@ +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'; + +/** + * 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; +} + +/** + * 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. + */ +export function applyFocusAction(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..b5ba8be8 --- /dev/null +++ b/src/workspace/layout.ts @@ -0,0 +1,139 @@ +/** + * 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; the center and + * the PTY keep their width, so opening or closing it never resizes the shell + * 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; +} + +export interface WorkspaceRegion { + mode: DockMode; + /** Absent while hidden. A drawer's region overlaps the center. */ + region?: ColumnRegion; +} + +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}}; })(); + return {columns, sidebar, panel: right, 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..bb89717b --- /dev/null +++ b/src/workspace/resources.ts @@ -0,0 +1,117 @@ +/** + * 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: controls, format characters and newlines become spaces. */ +export function resourceText(value: string, limit = MAX_LABEL_CELLS): string { + const clean = value.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..f9581d7d --- /dev/null +++ b/tests/workspaceFoundation.test.ts @@ -0,0 +1,143 @@ +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, 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'); + 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'), []); +}); From dc25989d9186c0dcd9f1512d32f3763df4cac1c1 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:39:17 +0530 Subject: [PATCH 2/4] Docs: index of the workspace slices' design documents --- docs/design/workspace.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/design/workspace.md b/docs/design/workspace.md index f9aa4850..0b71916a 100644 --- a/docs/design/workspace.md +++ b/docs/design/workspace.md @@ -85,6 +85,16 @@ has controls, format characters and newlines removed. 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 From 1740f4e45afcb0024bc4a1494967121e4c90442b Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:42:16 +0530 Subject: [PATCH 3/4] Workspace foundation: whole escape sequences leave external names; drawers follow the keyboard - resourceText turned only the ESC of a sequence into a space, so a tmux session named with an OSC title showed as "evil ]0;PWNED session". It removes whole sequences first (NMSh's stripTerminalControls), then controls. - Below about 80 columns a drawer covered the composer while the composer had the keyboard, and at 40 columns both regions were full-width drawers on top of each other. Given the focused region, the plan draws an open drawer only while it is focused and tucks the others (still open, not drawn, no resize); opening a region that can only be a drawer focuses it. Without the new arguments, behavior is unchanged. --- docs/design/workspace.md | 21 ++++++++++++++----- src/workspace/focus.ts | 21 ++++++++++++++++++- src/workspace/layout.ts | 19 ++++++++++++++--- src/workspace/resources.ts | 12 +++++++++-- tests/workspaceFoundation.test.ts | 34 ++++++++++++++++++++++++++++++- 5 files changed, 95 insertions(+), 12 deletions(-) diff --git a/docs/design/workspace.md b/docs/design/workspace.md index 0b71916a..72274081 100644 --- a/docs/design/workspace.md +++ b/docs/design/workspace.md @@ -33,7 +33,7 @@ center keep using ScreenPlan; `planWorkspaceColumns` is its column counterpart. | --- | --- | --- | | `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 | **unchanged**: opening/closing never resizes the shell | +| `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), @@ -43,6 +43,14 @@ fit, the sidebar narrows to compact before the panel becomes a drawer. The plan 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); +docked regions still open without taking focus. + 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). @@ -60,7 +68,8 @@ dispatcher. Exactly one owner holds the keyboard. - 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 region does not steal focus; Tab cycles composer → sidebar → panel, skipping closed regions. + 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. @@ -80,7 +89,8 @@ explicit support level: 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 controls, format characters and newlines removed. +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. @@ -103,8 +113,9 @@ 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})` beside - ScreenPlan. Transcript and composer render into `center` (center-relative columns); `ptyColumns` replaces the +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. diff --git a/src/workspace/focus.ts b/src/workspace/focus.ts index 47fee1a4..6ef8b920 100644 --- a/src/workspace/focus.ts +++ b/src/workspace/focus.ts @@ -1,6 +1,7 @@ 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 @@ -45,12 +46,30 @@ export interface WorkspaceFocusState { 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. */ -export function applyFocusAction(state: WorkspaceFocusState, action: WorkspaceActionId): WorkspaceFocusState { +export function applyFocusAction(state: WorkspaceFocusState, action: WorkspaceActionId, columns?: number): WorkspaceFocusState { + 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; diff --git a/src/workspace/layout.ts b/src/workspace/layout.ts index b5ba8be8..9d48f303 100644 --- a/src/workspace/layout.ts +++ b/src/workspace/layout.ts @@ -9,8 +9,10 @@ * * 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; the center and - * the PTY keep their width, so opening or closing it never resizes the shell + * 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: @@ -44,12 +46,21 @@ export interface WorkspaceColumnsInput { 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 { @@ -113,7 +124,9 @@ export function planWorkspaceColumns(input: WorkspaceColumnsInput): WorkspaceCol 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}}; })(); - return {columns, sidebar, panel: right, center, ptyColumns: centerWidth, dividers}; + 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'; diff --git a/src/workspace/resources.ts b/src/workspace/resources.ts index bb89717b..2844705a 100644 --- a/src/workspace/resources.ts +++ b/src/workspace/resources.ts @@ -1,3 +1,5 @@ +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 @@ -53,9 +55,15 @@ 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: controls, format characters and newlines become spaces. */ +/** + * 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 { - const clean = value.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, ' ').replace(/\s+/gu, ' ').trim(); + // 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; } diff --git a/tests/workspaceFoundation.test.ts b/tests/workspaceFoundation.test.ts index f9581d7d..7020ae3a 100644 --- a/tests/workspaceFoundation.test.ts +++ b/tests/workspaceFoundation.test.ts @@ -2,7 +2,7 @@ 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, resumeFocus, routeWorkspaceKey, type WorkspaceOwner} from '../src/workspace/focus.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'; @@ -85,6 +85,10 @@ test('resources: hostile names are inert, bounded in length, count and depth', ( 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)!; @@ -141,3 +145,31 @@ test('help derives from the bindings in effect', () => { 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.equal(regionOf('APPROVAL'), 'center'); +}); From 111af0daf15bce06ea10645b56eab50ca239ccab Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:49:19 +0530 Subject: [PATCH 4/4] Workspace focus: toggling a tucked drawer brings it forward instead of closing what cannot be seen --- docs/design/workspace.md | 5 +++-- src/workspace/focus.ts | 8 +++++++- tests/workspaceFoundation.test.ts | 3 +++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/design/workspace.md b/docs/design/workspace.md index 72274081..b8a3e31c 100644 --- a/docs/design/workspace.md +++ b/docs/design/workspace.md @@ -48,8 +48,9 @@ columns both regions would be full-width drawers on top of each other. With `foc 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); -docked regions still open without taking focus. +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). diff --git a/src/workspace/focus.ts b/src/workspace/focus.ts index 6ef8b920..7351eb1f 100644 --- a/src/workspace/focus.ts +++ b/src/workspace/focus.ts @@ -58,9 +58,15 @@ export function regionOf(owner: WorkspaceOwner): FocusRegion { * * 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. + * 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}); diff --git a/tests/workspaceFoundation.test.ts b/tests/workspaceFoundation.test.ts index 7020ae3a..76670f32 100644 --- a/tests/workspaceFoundation.test.ts +++ b/tests/workspaceFoundation.test.ts @@ -171,5 +171,8 @@ test('drawers follow the keyboard: only a focused drawer is drawn, and opening o 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'); });