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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions docs/design/workspace.md
Original file line number Diff line number Diff line change
@@ -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.
116 changes: 116 additions & 0 deletions src/workspace/focus.ts
Original file line number Diff line number Diff line change
@@ -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<WorkspaceOwner> = 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, 'sidebarOpen' | 'panelOpen'>): WorkspaceFocusState {
const owner = previous.owner === 'LEFT_SIDEBAR' && !current.sidebarOpen ? 'COMPOSER'
: previous.owner === 'RIGHT_PANEL' && !current.panelOpen ? 'COMPOSER' : previous.owner;
return {...current, owner};
}
Loading
Loading