From 6c8060141022f994fba2a55a7645637bf936ff8d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:30:51 +0530 Subject: [PATCH 1/4] Workspace Sidebar: keyboard-first resource list with tmux, worktree and manager sections #331, with the tmux navigation part of #333; stacked on the workspace foundation. Standalone controller and renderer: the running app is unchanged until the integration step. - SidebarController: selection by id across refreshes (a vanished one moves to the row in its place), tree expansion kept across refreshes, parent on Left, a `/` filter that shows matches with their ancestors. Moving only proposes a preview; activation returns an intent and only for an activatable resource; anything else says why. - renderSidebar: exactly rows x width; focus, selection, active, attention, exited and stale each have their own mark in Unicode and Safe glyphs, so NO_COLOR loses nothing; hostile names are inert. - tmux.ts: one bounded `list-panes -a` on the server NMSh runs in ($TMUX), fields separated by U+001F and every row validated; session -> window -> pane with NMSh's own pane marked active; navigation with tmux's own select-pane/select-window/switch-client on ids, never names, as argv. - sources.ts: worktrees (open = the /worktrees cd intent; missing or bare paths listed with a reason) and links to /github, /agents, /sessions. Tests include a real tmux server on a private socket: listing, selecting a window with the navigation argv, and a missing server reported as an error. --- docs/design/workspace.md | 39 ++++++ src/workspace/sidebar.ts | 211 ++++++++++++++++++++++++++++++++ src/workspace/sources.ts | 42 +++++++ src/workspace/tmux.ts | 160 ++++++++++++++++++++++++ tests/workspaceSidebar.test.ts | 215 +++++++++++++++++++++++++++++++++ 5 files changed, 667 insertions(+) create mode 100644 src/workspace/sidebar.ts create mode 100644 src/workspace/sources.ts create mode 100644 src/workspace/tmux.ts create mode 100644 tests/workspaceSidebar.test.ts diff --git a/docs/design/workspace.md b/docs/design/workspace.md index f9aa4850..f3ac49c2 100644 --- a/docs/design/workspace.md +++ b/docs/design/workspace.md @@ -85,6 +85,45 @@ 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. +## Workspace Sidebar (`sidebar.ts`, `sources.ts`, `tmux.ts`) + +`SidebarController` keeps browsing state only: selection by id, expansion, a filter and scroll. Its `dispatch(action)` +returns an outcome the frontend acts on: + +| Outcome | When | +| --- | --- | +| `preview` | the selection moved; the detail panel may show a cached, read-only preview | +| `activate` | Enter (or → on a leaf) on an `activatable` resource: the frontend performs the intent | +| `unavailable` | the resource cannot be opened here; the reason is shown, nothing happens | + +Tree behavior: → expands (or moves into an open node), ← collapses (or moves to the parent), Enter on a section +toggles it. Sections open the first time they appear; afterwards a person's expansion is kept across refreshes. +After a refresh the selection follows its id; if it vanished, the row now in its place is selected. `/` starts a +filter: matches show with their ancestors, the selection moves to the first direct match, Esc clears. + +`renderSidebar` returns exactly `rows` lines of at most `width` cells. Marks (Unicode / Safe): focused title +`▸`/`>`, selected row `›`/`>`, active resource `●`/`*`, expanded `▾`/`-`, collapsed `▸`/`+`, attention `!input`, +`!approval`, `!failed`, an exited pane `(exited)`, an old listing `(stale)`. Color only reinforces them. Compact mode +drops details. Key help is shown only while focused, derived from the bindings. + +Sections in this slice: + +- **tmux** (`tmux.ts`). Only the server NMSh runs inside (`$TMUX`), through the trusted tmux executable, with one + `list-panes -a -F` call per explicit refresh: 1.5 s and 256 KiB bounds, a minimal environment, no shell. Fields are + separated by U+001F (tmux refuses control characters in names); every row is validated, and a row with a wrong + shape or id is dropped. Session → window → pane: one session with two windows and five panes is one session. + NMSh's own pane (`$TMUX_PANE`) and the window/session holding it are `active`; tmux's own active window and pane + are named in the detail. Activation uses tmux's own commands on ids (`$1`, `@2`, `%3`), never names, as one argv: + `select-pane`/`select-window`, then `switch-client`. NMSh never creates panes or PTYs or changes layout. Outside + tmux the section says so and offers nothing. +- **Worktrees** (`sources.ts`). From the `/worktrees` snapshot: opening a worktree changes the persistent shell's + directory (the manager's own `cd` intent); a missing path or bare repository is listed with its reason. Creation and + removal stay in `/worktrees`. +- **Open**: links to the managers that own deep control (`/github`, `/agents`, `/sessions`). + +Not in this slice: NMSh live sessions and Managed Targets as first-class rows (they need the session service and +#329 target registry wired through the frontend), GitHub items (the `/github` manager is linked instead). + ## Integration contract Integration into `TerminalApp.ts` is deliberately a separate, later step: that file is under active input-routing diff --git a/src/workspace/sidebar.ts b/src/workspace/sidebar.ts new file mode 100644 index 00000000..ede0b2d7 --- /dev/null +++ b/src/workspace/sidebar.ts @@ -0,0 +1,211 @@ +import type {Key} from '../terminal/keys.js'; +import {background, foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateText} from '../util/text.js'; +import {bindingHelp, DEFAULT_BINDINGS, type Binding, type WorkspaceActionId} from './keymap.js'; +import {isStale, normalizeResource, resourceIds, visibleRows, type ActivationIntent, type ResourceRow, type WorkspaceResource} from './resources.js'; + +/** + * Workspace Sidebar (#331): a keyboard-first list of resources. Moving the + * selection only proposes a cached, read-only preview; activation happens on + * an explicit action and only for an activatable resource. The controller + * holds browsing state (selection by id, expansion, filter, scroll) and never + * performs I/O; the frontend feeds it listings and performs the intents it + * returns. + */ + +export type SidebarOutcome = + | {kind: 'none'} + | {kind: 'preview'; resource: WorkspaceResource} + | {kind: 'activate'; resource: WorkspaceResource; intent: ActivationIntent} + | {kind: 'unavailable'; resource: WorkspaceResource; reason: string}; + +export class SidebarController { + private roots: WorkspaceResource[] = []; + readonly expanded = new Set(); + selectedId?: string; + /** A filter being typed (`/`), or applied. Matching rows show with their ancestors. */ + filter = ''; + searching = false; + scroll = 0; + private seen = new Set(); + + /** Replace the listing. Selection follows its id; a vanished selection moves to the row that took its place. */ + setResources(roots: readonly WorkspaceResource[]): void { + const previousRows = this.rows(); + const previousIndex = Math.max(0, previousRows.findIndex(row => row.resource.id === this.selectedId)); + this.roots = roots.flatMap(root => normalizeResource(root) ?? []); + const ids = resourceIds(this.roots); + // Sections open the first time they appear; after that the person's choice is kept. + for (const root of this.roots) if (!this.seen.has(root.id)) { this.expanded.add(root.id); this.seen.add(root.id); } + for (const id of [...this.expanded]) if (!ids.has(id)) this.expanded.delete(id); + if (!this.selectedId || !ids.has(this.selectedId)) { + const rows = this.rows(); + this.selectedId = rows[Math.min(previousIndex, rows.length - 1)]?.resource.id; + } + } + + resources(): readonly WorkspaceResource[] { return this.roots; } + + rows(): ResourceRow[] { + const query = this.filter.trim().toLowerCase(); + if (!query) return visibleRows(this.roots, this.expanded); + // Filtering shows every match with its ancestors, regardless of expansion. + const keep = new Set(); + const visit = (resource: WorkspaceResource, ancestors: string[]): boolean => { + const own = `${resource.label} ${resource.detail ?? ''}`.toLowerCase().includes(query); + const child = (resource.children ?? []).map(item => visit(item, [...ancestors, resource.id])).some(Boolean); + if (own || child) { keep.add(resource.id); ancestors.forEach(id => keep.add(id)); } + return own || child; + }; + this.roots.forEach(root => visit(root, [])); + return visibleRows(this.roots, keep).filter(row => keep.has(row.resource.id)); + } + + selected(): ResourceRow | undefined { + return this.rows().find(row => row.resource.id === this.selectedId); + } + + /** A region action from the keymap. Focus actions are not the sidebar's (applyFocusAction handles them). */ + dispatch(action: WorkspaceActionId): SidebarOutcome { + const rows = this.rows(); + const index = rows.findIndex(row => row.resource.id === this.selectedId); + const select = (next: number): SidebarOutcome => { + const row = rows[Math.max(0, Math.min(rows.length - 1, next))]; + if (!row || row.resource.id === this.selectedId) return {kind: 'none'}; + this.selectedId = row.resource.id; + return {kind: 'preview', resource: row.resource}; + }; + const current = rows[index]; + switch (action) { + case 'sidebar.up': return select(index <= 0 ? 0 : index - 1); + case 'sidebar.down': return select(index + 1); + case 'sidebar.first': return select(0); + case 'sidebar.last': return select(rows.length - 1); + case 'sidebar.expand': + if (current?.expandable && !current.expanded) { this.expanded.add(current.resource.id); return {kind: 'none'}; } + return current?.expandable ? select(index + 1) : this.activate(current); + case 'sidebar.collapse': { + if (current?.expandable && current.expanded) { this.expanded.delete(current.resource.id); return {kind: 'none'}; } + // On a child, Left moves to its parent, as in a file tree. + const parent = current ? rows.slice(0, index).reverse().find(row => row.depth === current.depth - 1) : undefined; + return parent ? select(rows.indexOf(parent)) : {kind: 'none'}; + } + case 'sidebar.activate': return this.activate(current); + case 'sidebar.search': this.searching = true; return {kind: 'none'}; + default: return {kind: 'none'}; + } + } + + private activate(row: ResourceRow | undefined): SidebarOutcome { + if (!row) return {kind: 'none'}; + if (row.resource.support === 'activatable' && row.resource.activation) return {kind: 'activate', resource: row.resource, intent: row.resource.activation}; + if (row.expandable) { + if (row.expanded) this.expanded.delete(row.resource.id); else this.expanded.add(row.resource.id); + return {kind: 'none'}; + } + return {kind: 'unavailable', resource: row.resource, reason: row.resource.reason ?? 'this item cannot be opened here'}; + } + + /** Keys while the filter is being typed. Returns false for keys that are not the filter's (the caller handles them). */ + handleSearchKey(key: Key): boolean { + if (!this.searching) return false; + if (key.kind === 'text' || key.kind === 'paste') this.filter = (this.filter + key.value.replace(/[\p{Cc}\p{Cf}]/gu, '')).slice(0, 64); + else if (key.kind === 'backspace') this.filter = Array.from(this.filter).slice(0, -1).join(''); + else if (key.kind === 'enter') this.searching = false; + else if (key.kind === 'escape') { this.searching = false; this.filter = ''; } + else return false; + // The selection moves to the first row that matches by itself (not an ancestor shown for context). + const query = this.filter.trim().toLowerCase(); + const rows = this.rows(); + const own = (row: ResourceRow) => `${row.resource.label} ${row.resource.detail ?? ''}`.toLowerCase().includes(query); + const current = rows.find(row => row.resource.id === this.selectedId); + if (!current || (query && !own(current))) this.selectedId = (query ? rows.find(own) : undefined)?.resource.id ?? rows[0]?.resource.id; + return true; + } +} + +export interface SidebarRenderOptions { + width: number; + rows: number; + /** The sidebar owns the keyboard. */ + focused: boolean; + mode: 'docked' | 'compact' | 'drawer'; + safe: boolean; + color: boolean; + now: number; + /** Listings older than this are labelled stale. */ + freshnessMs?: number; + bindings?: readonly Binding[]; + /** A message from the last outcome (an unavailable reason, an activation failure). */ + message?: string; +} + +interface Marks {focus: string; selected: string; active: string; collapsed: string; expanded: string; attention: string} + +const marks = (safe: boolean): Marks => safe + ? {focus: '>', selected: '>', active: '*', collapsed: '+', expanded: '-', attention: '!'} + : {focus: '▸', selected: '›', active: '●', collapsed: '▸', expanded: '▾', attention: '!'}; + +/** + * Exactly `rows` lines, each at most `width` cells. Focus, selection, the active resource, attention and staleness each + * have their own mark, so none depends on color. Pure: no I/O and no clock reads beyond `now`. + */ +export function renderSidebar(controller: SidebarController, options: SidebarRenderOptions): string[] { + const width = Math.max(1, options.width); + const rows = Math.max(1, options.rows); + const m = marks(options.safe); + const reset = options.color ? '\u001b[0m' : ''; + const tone = (color: {red: number; green: number; blue: number}, text: string, bold = false) => + options.color ? `${bold ? '\u001b[1m' : ''}${foreground(color)}${text}${reset}` : text; + const fit = (text: string) => truncateText(text, width); + const lines: string[] = []; + + const title = `${options.focused ? m.focus : ' '} Workspace${options.mode === 'compact' ? '' : options.focused ? ' (focused)' : ''}`; + lines.push(tone(options.focused ? UI_COLORS.accent : UI_COLORS.secondary, fit(title), options.focused)); + const filter = controller.searching ? `/ ${controller.filter}_` : controller.filter ? `/ ${controller.filter}` : undefined; + if (filter) lines.push(tone(UI_COLORS.accent, fit(filter))); + + const body = controller.rows(); + const footer: string[] = []; + const selected = controller.selected(); + const freshness = options.freshnessMs ?? 30_000; + if (options.message) footer.push(tone(UI_COLORS.failure, fit(options.message))); + else if (selected && selected.resource.support !== 'activatable' && selected.resource.reason) footer.push(tone(UI_COLORS.subtle, fit(selected.resource.reason))); + if (options.mode !== 'compact' && options.focused) { + const help = bindingHelp(options.bindings ?? DEFAULT_BINDINGS, 'sidebar').filter(([, label]) => ['open', 'filter', 'back to the composer'].includes(label)) + .map(([chord, label]) => `${chord} ${label}`).join(' · '); + footer.push(tone(UI_COLORS.subtle, fit(help))); + } + const height = Math.max(0, rows - lines.length - footer.length); + const index = Math.max(0, body.findIndex(row => row.resource.id === controller.selectedId)); + // Keep the selection in view with the least scrolling. + if (index < controller.scroll) controller.scroll = index; + if (index >= controller.scroll + height) controller.scroll = index - height + 1; + controller.scroll = Math.max(0, Math.min(controller.scroll, Math.max(0, body.length - height))); + + if (!body.length && height) lines.push(tone(UI_COLORS.subtle, fit(controller.filter ? ' nothing matches' : ' nothing to show yet'))); + for (const row of body.slice(controller.scroll, controller.scroll + height)) { + const resource = row.resource; + const isSelected = resource.id === controller.selectedId; + const indent = ' '.repeat(Math.min(row.depth, options.mode === 'compact' ? 1 : 3)); + const expander = row.expandable ? `${row.expanded ? m.expanded : m.collapsed} ` : ' '; + const active = resource.active ? `${m.active} ` : ''; + const attention = resource.attention && resource.attention !== 'finished' ? ` ${m.attention}${resource.attention}` : resource.attention === 'finished' ? ' (exited)' : ''; + const stale = resource.kind === 'section' && resource.observedAt !== undefined && isStale(resource, options.now, freshness) ? ' (stale)' : ''; + const detail = options.mode === 'compact' || !resource.detail ? '' : ` ${resource.detail}`; + const lead = `${isSelected ? m.selected : ' '}${indent}${expander}${active}`; + const label = `${resource.label}${attention}${stale}`; + const room = Math.max(0, width - displayWidth(lead)); + const plain = truncateText(`${label}${displayWidth(label) < room ? truncateText(detail, room - displayWidth(label)) : ''}`, room); + const text = `${lead}${plain}`; + if (!options.color) { lines.push(fit(text)); continue; } + const base = resource.support === 'activatable' ? (resource.active ? UI_COLORS.accent : UI_COLORS.primary) : UI_COLORS.subtle; + const painted = isSelected && options.focused + ? `${background(UI_COLORS.selection)}${foreground(UI_COLORS.primary)}${text}${' '.repeat(Math.max(0, width - displayWidth(text)))}${reset}` + : `${foreground(base)}${text}${reset}`; + lines.push(painted); + } + while (lines.length < rows - footer.length) lines.push(''); + lines.push(...footer); + return lines.slice(0, rows); +} diff --git a/src/workspace/sources.ts b/src/workspace/sources.ts new file mode 100644 index 00000000..d2aa5b29 --- /dev/null +++ b/src/workspace/sources.ts @@ -0,0 +1,42 @@ +import {basename} from 'node:path'; +import {checkoutLabel, stateWords, type WorktreeSnapshot} from '../worktrees/model.js'; +import type {WorkspaceResource} from './resources.js'; + +/** + * Sidebar sections from data other owners already reported. Each adapter is + * pure: it shapes a snapshot the caller obtained (worktree discovery, the tmux + * listing) into resources, and claims activation only where a supported path + * exists. Deep control stays in the dedicated managers; the sidebar links to + * them instead of growing their features. + */ + +/** + * Git worktrees of the current repository. Opening one changes the persistent shell's directory (the same intent + * `/worktrees` uses); a worktree whose path is missing is listed but cannot be opened. + */ +export function worktreeSection(snapshot: WorktreeSnapshot | undefined, cwd: string): WorkspaceResource { + if (!snapshot) return {id: 'worktrees', kind: 'section', label: 'Worktrees', support: 'informational', reason: 'not in a Git repository, or not listed yet'}; + const children: WorkspaceResource[] = snapshot.worktrees.map(worktree => { + const present = worktree.pathState === 'present' && !worktree.bare; + return { + id: `worktree:${worktree.id}`, kind: 'worktree', label: checkoutLabel(worktree), detail: [basename(worktree.path), ...stateWords(worktree)].join(' · '), + ...(present ? {support: 'activatable' as const, activation: {kind: 'changeDirectory' as const, path: worktree.path}} + : {support: 'unsupported' as const, reason: worktree.bare ? 'a bare repository has no working directory' : 'the worktree path is missing'}), + active: cwd === worktree.path || cwd.startsWith(`${worktree.path}/`), + observedAt: snapshot.discoveredAt, + }; + }); + if (snapshot.truncated) children.push({id: 'worktrees:more', kind: 'section', label: 'more in /worktrees', support: 'activatable', + activation: {kind: 'openManager', command: '/worktrees'}}); + return {id: 'worktrees', kind: 'section', label: 'Worktrees', support: 'activatable', activation: {kind: 'openManager', command: '/worktrees'}, + observedAt: snapshot.discoveredAt, children}; +} + +/** Links to dedicated managers that already exist; the sidebar never duplicates their lists or actions. */ +export function managerLinks(): WorkspaceResource { + return {id: 'managers', kind: 'section', label: 'Open', support: 'informational', children: [ + {id: 'managers:github', kind: 'section', label: 'GitHub PRs and issues', detail: '/github', support: 'activatable', activation: {kind: 'openManager', command: '/github'}}, + {id: 'managers:agents', kind: 'section', label: 'Agents', detail: '/agents', support: 'activatable', activation: {kind: 'openManager', command: '/agents'}}, + {id: 'managers:sessions', kind: 'section', label: 'NMSh sessions', detail: '/sessions', support: 'activatable', activation: {kind: 'openManager', command: '/sessions'}}, + ]}; +} diff --git a/src/workspace/tmux.ts b/src/workspace/tmux.ts new file mode 100644 index 00000000..499db73e --- /dev/null +++ b/src/workspace/tmux.ts @@ -0,0 +1,160 @@ +import {spawn} from 'node:child_process'; +import {homedir} from 'node:os'; +import {isAbsolute} from 'node:path'; +import type {ActivationIntent, WorkspaceResource} from './resources.js'; +import {resourceText} from './resources.js'; + +/** + * tmux sessions, windows and panes for the Workspace Sidebar (#331, #333). + * + * tmux owns its server, sessions, windows, panes, their PTYs and layout. NMSh + * asks the server NMSh is running inside (the socket named by $TMUX) for one + * `list-panes -a` listing, and navigates only with tmux's own commands + * (`switch-client`, `select-window`, `select-pane`) on validated ids, as + * typed argv without a shell. It never creates panes or PTYs, never changes + * layout or options, and never talks to another tmux server. One session with + * two windows and five panes is one session in the tree, not five. + * + * Discovery runs only when the sidebar asks (opening it, an explicit refresh), + * never while rendering, and is bounded in time and bytes. + */ + +export interface TmuxPane { + sessionId: string; sessionName: string; sessionAttached: boolean; + windowId: string; windowIndex: number; windowName: string; windowActive: boolean; + paneId: string; paneIndex: number; paneActive: boolean; paneDead: boolean; + command: string; +} + +/** The server NMSh runs inside, from $TMUX (`socket,pid,session`) and $TMUX_PANE. */ +export interface TmuxServer {socket: string; pane?: string} + +const SEP = '\u001f'; +const FIELDS = ['session_id', 'session_name', 'session_attached', 'window_id', 'window_index', 'window_name', 'window_active', + 'pane_id', 'pane_index', 'pane_active', 'pane_dead', 'pane_current_command'] as const; +/** tmux rejects control characters in session and window names, so the unit separator cannot occur in them; rows are still validated. */ +export const LIST_FORMAT = FIELDS.map(field => `#{${field}}`).join(SEP); +export const MAX_PANES = 256; + +const SESSION_ID = /^\$\d{1,9}$/u, WINDOW_ID = /^@\d{1,9}$/u, PANE_ID = /^%\d{1,9}$/u; +export const validTmuxId = (id: string): boolean => SESSION_ID.test(id) || WINDOW_ID.test(id) || PANE_ID.test(id); + +export function tmuxServer(env: NodeJS.ProcessEnv): TmuxServer | undefined { + const socket = env.TMUX?.split(',')[0]; + if (!socket || !isAbsolute(socket) || socket.length > 1024 || /[\u0000-\u001f]/u.test(socket)) return undefined; + const pane = env.TMUX_PANE && PANE_ID.test(env.TMUX_PANE) ? env.TMUX_PANE : undefined; + return {socket, ...(pane ? {pane} : {})}; +} + +/** `list-panes -a -F LIST_FORMAT` output to panes; a row with the wrong shape or an invalid id is dropped, never guessed. */ +export function parseTmuxPanes(output: string): TmuxPane[] { + const panes: TmuxPane[] = []; + for (const line of output.split('\n')) { + if (!line || panes.length >= MAX_PANES) continue; + const parts = line.split(SEP); + if (parts.length < FIELDS.length) continue; + // The command is last: a process name is not validated by tmux, so anything after the eleventh separator belongs to it. + const [sessionId, sessionName, attached, windowId, windowIndex, windowName, windowActive, paneId, paneIndex, paneActive, paneDead] = parts; + const command = parts.slice(FIELDS.length - 1).join(' '); + if (!SESSION_ID.test(sessionId!) || !WINDOW_ID.test(windowId!) || !PANE_ID.test(paneId!)) continue; + if (!/^\d{1,6}$/u.test(windowIndex!) || !/^\d{1,6}$/u.test(paneIndex!)) continue; + panes.push({sessionId: sessionId!, sessionName: resourceText(sessionName!), sessionAttached: Number(attached) > 0, + windowId: windowId!, windowIndex: Number(windowIndex), windowName: resourceText(windowName!), windowActive: windowActive === '1', + paneId: paneId!, paneIndex: Number(paneIndex), paneActive: paneActive === '1', paneDead: paneDead === '1', command: resourceText(command, 40)}); + } + return panes; +} + +/** + * The tmux section of the sidebar: session → window → pane. `active` marks the pane NMSh itself runs in and the + * window/session holding it; tmux's own active window and pane in other sessions are marked as such in the detail. + */ +export function tmuxResources(panes: readonly TmuxPane[], server: TmuxServer | undefined, observedAt: number): WorkspaceResource { + if (!server) return {id: 'tmux', kind: 'section', label: 'tmux', support: 'informational', reason: 'not running inside tmux', observedAt}; + const sessions = new Map}>(); + for (const pane of panes) { + const session = sessions.get(pane.sessionId) ?? {name: pane.sessionName, attached: pane.sessionAttached, windows: new Map()}; + sessions.set(pane.sessionId, session); + const window = session.windows.get(pane.windowId) ?? {index: pane.windowIndex, name: pane.windowName, active: pane.windowActive, panes: []}; + session.windows.set(pane.windowId, window); + window.panes.push(pane); + } + const intent = (scope: 'session' | 'window' | 'pane', target: string): ActivationIntent => ({kind: 'tmux', scope, target, socket: server.socket}); + const children: WorkspaceResource[] = [...sessions].map(([sessionId, session]) => { + const windows = [...session.windows].sort(([, a], [, b]) => a.index - b.index).map(([windowId, window]): WorkspaceResource => { + const paneResources = window.panes.sort((a, b) => a.paneIndex - b.paneIndex).map((pane): WorkspaceResource => ({ + id: `tmux:${sessionId}:${windowId}:${pane.paneId}`, kind: 'tmuxPane', label: pane.command || `pane ${pane.paneIndex}`, + detail: [pane.paneId === server.pane ? 'this NMSh' : undefined, pane.paneActive ? 'active pane' : undefined, pane.paneDead ? 'exited' : undefined].filter(Boolean).join(' · ') || undefined, + support: 'activatable', activation: intent('pane', pane.paneId), active: pane.paneId === server.pane, observedAt, + ...(pane.paneDead ? {attention: 'finished' as const} : {}), + })); + return {id: `tmux:${sessionId}:${windowId}`, kind: 'tmuxWindow', label: `${window.index} ${window.name}`, + ...(window.active ? {detail: 'active window'} : {}), support: 'activatable', activation: intent('window', windowId), + active: window.panes.some(pane => pane.paneId === server.pane), observedAt, children: paneResources}; + }); + return {id: `tmux:${sessionId}`, kind: 'tmuxSession', label: session.name, detail: session.attached ? 'attached' : 'detached', + support: 'activatable', activation: intent('session', sessionId), active: windows.some(window => window.active), observedAt, children: windows}; + }); + return {id: 'tmux', kind: 'section', label: 'tmux', support: 'informational', observedAt, children}; +} + +/** + * tmux's own commands for an activation, as one argv (commands separated by `;` arguments, no shell). A window or pane + * is selected first, then the client switches to its session, so the client lands on what was chosen; a session + * activation only switches. Ids must be tmux ids (`$1`, `@2`, `%3`), never names. + */ +export function tmuxNavigationArgs(intent: Extract, panes: readonly TmuxPane[]): string[] | {error: string} { + if (!validTmuxId(intent.target)) return {error: 'not a tmux id'}; + if (intent.scope === 'session') return SESSION_ID.test(intent.target) ? ['switch-client', '-t', intent.target] : {error: 'not a session id'}; + const pane = panes.find(item => (intent.scope === 'pane' ? item.paneId : item.windowId) === intent.target); + if (!pane) return {error: 'that tmux object is gone; refresh the list'}; + const select = intent.scope === 'pane' + ? ['select-pane', '-t', pane.paneId, ';', 'select-window', '-t', pane.windowId] + : ['select-window', '-t', pane.windowId]; + return [...select, ';', 'switch-client', '-t', pane.sessionId]; +} + +export interface TmuxRunResult {stdout: string; code: number | null; timedOut: boolean; truncated: boolean} +export type TmuxRunner = (args: readonly string[]) => Promise; + +/** + * A bounded runner for the trusted tmux executable (resolved by the caller outside the workspace), always addressed to + * the given socket. The environment is minimal: tmux needs no configuration or credentials to list or select. + */ +export function createTmuxRunner(executable: string, server: TmuxServer, {timeoutMs = 1500, maxBytes = 256 * 1024} = {}): TmuxRunner { + return args => new Promise(resolve => { + let child; + try { + child = spawn(executable, ['-S', server.socket, ...args], { + stdio: ['ignore', 'pipe', 'ignore'], shell: false, + env: {PATH: '/usr/bin:/bin', HOME: homedir(), LANG: 'C.UTF-8', ...(process.env.TMUX ? {TMUX: process.env.TMUX} : {}), ...(server.pane ? {TMUX_PANE: server.pane} : {})}, + }); + } catch { resolve({stdout: '', code: null, timedOut: false, truncated: false}); return; } + const chunks: Buffer[] = []; + let bytes = 0, truncated = false, timedOut = false, settled = false; + const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeoutMs); + child.stdout.on('data', (chunk: Buffer) => { + if (truncated) return; + if (bytes + chunk.length > maxBytes) { truncated = true; child.kill('SIGKILL'); return; } + chunks.push(chunk); bytes += chunk.length; + }); + const finish = (code: number | null) => { + if (settled) return; + settled = true; clearTimeout(timer); + resolve({stdout: Buffer.concat(chunks).toString('utf8'), code, timedOut, truncated}); + }; + child.once('error', () => finish(null)); + child.once('close', code => finish(code)); + }); +} + +export interface TmuxListing {panes: TmuxPane[]; observedAt: number; error?: string} + +/** One listing from the server; failures are reported (and the previous listing can stay visible, marked stale), never retried in a loop. */ +export async function listTmuxPanes(run: TmuxRunner, now = Date.now()): Promise { + const result = await run(['list-panes', '-a', '-F', LIST_FORMAT]); + if (result.timedOut) return {panes: [], observedAt: now, error: 'tmux did not answer in time'}; + if (result.truncated) return {panes: [], observedAt: now, error: 'the tmux listing was too large'}; + if (result.code !== 0) return {panes: [], observedAt: now, error: 'tmux could not list panes'}; + return {panes: parseTmuxPanes(result.stdout), observedAt: now}; +} diff --git a/tests/workspaceSidebar.test.ts b/tests/workspaceSidebar.test.ts new file mode 100644 index 00000000..76dd4e44 --- /dev/null +++ b/tests/workspaceSidebar.test.ts @@ -0,0 +1,215 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {execFileSync} from 'node:child_process'; +import {existsSync} from 'node:fs'; +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {createTmuxRunner, LIST_FORMAT, listTmuxPanes, MAX_PANES, parseTmuxPanes, tmuxNavigationArgs, tmuxResources, tmuxServer, type TmuxPane} from '../src/workspace/tmux.js'; +import {renderSidebar, SidebarController} from '../src/workspace/sidebar.js'; +import {managerLinks, worktreeSection} from '../src/workspace/sources.js'; +import type {WorkspaceResource} from '../src/workspace/resources.js'; +import type {WorktreeRecord} from '../src/worktrees/model.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; +import type {Key} from '../src/terminal/keys.js'; + +const S = '\u001f'; +const row = (...fields: string[]) => fields.join(S); +// One session (qa) with two windows and five panes, plus a detached session. +const LISTING = [ + row('$1', 'qa', '1', '@1', '0', 'code', '1', '%1', '0', '1', '0', 'nmsh'), + row('$1', 'qa', '1', '@1', '0', 'code', '1', '%2', '1', '0', '0', 'nvim'), + row('$1', 'qa', '1', '@2', '1', 'servers', '0', '%3', '0', '1', '0', 'npm'), + row('$1', 'qa', '1', '@2', '1', 'servers', '0', '%4', '1', '0', '0', 'tail'), + row('$1', 'qa', '1', '@2', '1', 'servers', '0', '%5', '2', '0', '1', 'zsh'), + row('$2', 'notes|old', '0', '@3', '0', 'main', '1', '%6', '0', '1', '0', 'vim'), +].join('\n'); + +test('tmux listing: rows parse into panes; malformed rows and invalid ids are dropped, never guessed', () => { + const panes = parseTmuxPanes(`${LISTING}\n${row('1', 'x', '1', '@9', '0', 'w', '1', '%9', '0', '1', '0', 'sh')}\n${row('$3', 'x', '1', '@9', 'zero', 'w', '1', '%9', '0', '1', '0', 'sh')}\ngarbage\n\n`); + assert.equal(panes.length, 6); + assert.equal(panes[5]!.sessionName, 'notes|old', 'a pipe in a name is just text'); + assert.deepEqual(panes[4], {sessionId: '$1', sessionName: 'qa', sessionAttached: true, windowId: '@2', windowIndex: 1, windowName: 'servers', windowActive: false, + paneId: '%5', paneIndex: 2, paneActive: false, paneDead: true, command: 'zsh'}); + const odd = parseTmuxPanes(row('$1', 'qa', '1', '@1', '0', 'code', '1', '%1', '0', '1', '0', 'we\u001fird\u001b[31m')); + assert.equal(odd[0]!.command, 'we ird [31m', 'a process name with separators or controls stays one inert field'); + assert.equal(parseTmuxPanes(Array.from({length: 400}, (_, i) => row('$1', 'qa', '1', '@1', '0', 'code', '1', `%${i}`, `${i}`, '0', '0', 'sh')).join('\n')).length, MAX_PANES); + assert.match(LIST_FORMAT, /#\{session_id\}\u001f/u); +}); + +test('tmux hierarchy: one session with two windows and five panes is one session; NMSh\'s own pane is the active one', () => { + const server = {socket: '/tmp/tmux-501/default', pane: '%1'}; + const section = tmuxResources(parseTmuxPanes(LISTING), server, 1000); + assert.deepEqual(section.children!.map(session => session.label), ['qa', 'notes|old']); + const qa = section.children![0]!; + assert.deepEqual(qa.children!.map(window => [window.label, window.children!.length]), [['0 code', 2], ['1 servers', 3]]); + assert.equal(qa.active, true); + assert.equal(qa.children![0]!.active, true); + assert.equal(qa.children![0]!.children![0]!.detail, 'this NMSh · active pane'); + assert.equal(qa.children![1]!.children![2]!.attention, 'finished'); + assert.deepEqual(qa.children![1]!.activation, {kind: 'tmux', scope: 'window', target: '@2', socket: server.socket}); + assert.equal(section.children![1]!.active, false); + assert.equal(section.children![1]!.detail, 'detached'); + const outside = tmuxResources([], undefined, 1000); + assert.equal(outside.support, 'informational'); + assert.match(outside.reason!, /not running inside tmux/u); + assert.deepEqual(tmuxServer({TMUX: '/tmp/tmux-501/default,123,0', TMUX_PANE: '%7'}), {socket: '/tmp/tmux-501/default', pane: '%7'}); + assert.equal(tmuxServer({TMUX: 'relative,1,0'}), undefined); + assert.equal(tmuxServer({}), undefined); + assert.deepEqual(tmuxServer({TMUX: '/tmp/s,1,0', TMUX_PANE: 'bad'}), {socket: '/tmp/s'}); +}); + +test('tmux navigation: tmux\'s own commands on validated ids, window or pane first, then the client', () => { + const panes = parseTmuxPanes(LISTING); + assert.deepEqual(tmuxNavigationArgs({kind: 'tmux', scope: 'session', target: '$2'}, panes), ['switch-client', '-t', '$2']); + assert.deepEqual(tmuxNavigationArgs({kind: 'tmux', scope: 'window', target: '@2'}, panes), ['select-window', '-t', '@2', ';', 'switch-client', '-t', '$1']); + assert.deepEqual(tmuxNavigationArgs({kind: 'tmux', scope: 'pane', target: '%6'}, panes), + ['select-pane', '-t', '%6', ';', 'select-window', '-t', '@3', ';', 'switch-client', '-t', '$2']); + assert.deepEqual(tmuxNavigationArgs({kind: 'tmux', scope: 'session', target: 'qa'}, panes), {error: 'not a tmux id'}, 'names are never targets'); + assert.deepEqual(tmuxNavigationArgs({kind: 'tmux', scope: 'pane', target: '%99'}, panes), {error: 'that tmux object is gone; refresh the list'}); + assert.deepEqual(tmuxNavigationArgs({kind: 'tmux', scope: 'session', target: '$1; kill-server'}, panes), {error: 'not a tmux id'}); +}); + +const tmuxBinary = ['/opt/homebrew/bin/tmux', '/usr/local/bin/tmux', '/usr/bin/tmux'].find(path => existsSync(path)); + +test('tmux, for real: a private server is listed and navigated with the same argv, and nothing else is touched', {skip: tmuxBinary ? false : 'tmux is not installed'}, async () => { + // A short private socket path: Unix sockets have a small path limit. This server is the test's own and is killed afterwards. + const directory = await mkdtemp(join(process.platform === 'win32' ? tmpdir() : '/tmp', 'wsx-')); + const socket = join(directory, 's'); + const tmux = (...args: string[]) => execFileSync(tmuxBinary!, ['-f', '/dev/null', '-S', socket, ...args], {env: {PATH: '/usr/bin:/bin', HOME: directory, TERM: 'xterm-256color'}, encoding: 'utf8'}); + try { + tmux('new-session', '-d', '-s', 'qa|1', '-n', 'code', 'sleep 60'); + tmux('split-window', '-d', '-t', 'qa|1:0', 'sleep 60'); + tmux('new-window', '-d', '-t', 'qa|1', '-n', 'servers', 'sleep 60'); + const server = {socket}; + const run = createTmuxRunner(tmuxBinary!, server); + const listing = await listTmuxPanes(run, 5000); + assert.equal(listing.error, undefined); + assert.equal(listing.panes.length, 3); + const section = tmuxResources(listing.panes, server, listing.observedAt); + assert.deepEqual(section.children!.map(session => [session.label, session.children!.length]), [['qa|1', 2]]); + // Selecting the second window through the navigation argv (without switch-client: the private server has no client). + const servers = section.children![0]!.children![1]!; + const args = tmuxNavigationArgs(servers.activation as {kind: 'tmux'; scope: 'window'; target: string}, listing.panes); + assert.ok(Array.isArray(args)); + const select = (args as string[]).slice(0, (args as string[]).indexOf(';')); + assert.equal((await run(select)).code, 0); + const after = await listTmuxPanes(run); + assert.equal(after.panes.find(pane => pane.windowName === 'servers')?.windowActive, true); + const failed = await createTmuxRunner(tmuxBinary!, {socket: join(directory, 'missing')})(['list-panes', '-a', '-F', LIST_FORMAT]); + assert.notEqual(failed.code, 0, 'a server that is not there is an error, not an empty success'); + assert.match((await listTmuxPanes(createTmuxRunner(tmuxBinary!, {socket: join(directory, 'missing')}))).error ?? '', /could not list/u); + } finally { + try { tmux('kill-server'); } catch { /* already gone */ } + await rm(directory, {recursive: true, force: true}); + } +}); + +const roots = (): WorkspaceResource[] => [ + tmuxResources(parseTmuxPanes(LISTING), {socket: '/tmp/tmux-501/default', pane: '%1'}, 1000), + {id: 'info', kind: 'section', label: 'Codex', support: 'unsupported', reason: 'no supported Codex adapter yet'}, +]; +const k = (kind: Key['kind'], value?: string): Key => (value === undefined ? {kind} : {kind, value}) as Key; + +test('sidebar: moving only previews; activation needs an activatable resource; expansion and parents behave like a tree', () => { + const sidebar = new SidebarController(); + sidebar.setResources(roots()); + assert.equal(sidebar.selectedId, 'tmux'); + assert.deepEqual(sidebar.rows().map(r => r.resource.id), ['tmux', 'tmux:$1', 'tmux:$2', 'info'], 'sections open the first time they appear'); + const moved = sidebar.dispatch('sidebar.down'); + assert.equal(moved.kind, 'preview', 'selection never activates'); + assert.equal(sidebar.selectedId, 'tmux:$1'); + assert.equal(sidebar.dispatch('sidebar.expand').kind, 'none'); + assert.deepEqual(sidebar.rows().slice(1, 4).map(r => r.resource.id), ['tmux:$1', 'tmux:$1:@1', 'tmux:$1:@2']); + sidebar.dispatch('sidebar.down'); + const opened = sidebar.dispatch('sidebar.activate'); + assert.deepEqual(opened.kind === 'activate' ? opened.intent : undefined, {kind: 'tmux', scope: 'window', target: '@1', socket: '/tmp/tmux-501/default'}); + assert.equal(sidebar.dispatch('sidebar.collapse').kind, 'preview', 'Left on a child moves to its parent'); + assert.equal(sidebar.selectedId, 'tmux:$1'); + sidebar.dispatch('sidebar.collapse'); + assert.ok(!sidebar.expanded.has('tmux:$1')); + sidebar.dispatch('sidebar.last'); + const unavailable = sidebar.dispatch('sidebar.activate'); + assert.deepEqual(unavailable.kind === 'unavailable' ? unavailable.reason : undefined, 'no supported Codex adapter yet'); + assert.equal(sidebar.dispatch('sidebar.down').kind, 'none', 'the end of the list stays put'); +}); + +test('sidebar: selection follows its id across refreshes; a vanished selection moves to the row in its place; expansion is kept', () => { + const sidebar = new SidebarController(); + sidebar.setResources(roots()); + sidebar.dispatch('sidebar.down'); + sidebar.dispatch('sidebar.expand'); + sidebar.dispatch('sidebar.down'); + assert.equal(sidebar.selectedId, 'tmux:$1:@1'); + const reordered = roots(); + reordered[0] = {...reordered[0]!, children: [...reordered[0]!.children!].reverse()}; + sidebar.setResources(reordered); + assert.equal(sidebar.selectedId, 'tmux:$1:@1', 'same id, new position'); + assert.ok(sidebar.expanded.has('tmux:$1')); + sidebar.setResources(roots()); + const gone = roots(); + gone[0] = {...gone[0]!, children: gone[0]!.children!.map(session => session.id === 'tmux:$1' ? {...session, children: session.children!.slice(1)} : session)}; + sidebar.setResources(gone); + assert.equal(sidebar.selectedId, 'tmux:$1:@2', 'the row that took its place'); + sidebar.dispatch('sidebar.collapse'); sidebar.dispatch('sidebar.collapse'); + sidebar.setResources(roots()); + assert.ok(!sidebar.expanded.has('tmux:$1'), 'a person\'s collapse is kept across refreshes'); +}); + +test('sidebar filter: matches show with their ancestors; typing stays in the filter; Esc clears it', () => { + const sidebar = new SidebarController(); + sidebar.setResources(roots()); + sidebar.dispatch('sidebar.search'); + for (const ch of 'tail') assert.equal(sidebar.handleSearchKey(k('text', ch)), true); + assert.deepEqual(sidebar.rows().map(r => r.resource.id), ['tmux', 'tmux:$1', 'tmux:$1:@2', 'tmux:$1:@2:%4']); + assert.equal(sidebar.selectedId, 'tmux:$1:@2:%4', 'the selection moves to the first direct match, not an ancestor shown for context'); + assert.equal(sidebar.handleSearchKey(k('down')), false, 'navigation is not the filter\'s'); + sidebar.handleSearchKey(k('text', '\u001b[31m')); + assert.equal(sidebar.filter, 'tail[31m', 'controls never enter the filter'); + sidebar.handleSearchKey(k('escape')); + assert.equal(sidebar.filter, ''); + assert.equal(sidebar.searching, false); +}); + +test('sidebar rendering: exact size at every width; focus, selection, active and attention have marks without color', () => { + const sidebar = new SidebarController(); + sidebar.setResources([...roots(), {id: 'evil', kind: 'section', label: '\u001b]8;;x\u0007‮evil\nname', support: 'informational', reason: 'r'}]); + sidebar.dispatch('sidebar.down'); sidebar.dispatch('sidebar.expand'); sidebar.dispatch('sidebar.down'); sidebar.dispatch('sidebar.expand'); + for (const safe of [true, false]) { + for (const width of [8, 12, 18, 28, 40]) { + for (const rows of [1, 3, 8, 20]) { + for (const color of [false, true]) { + const lines = renderSidebar(sidebar, {width, rows, focused: true, mode: width < 20 ? 'compact' : 'docked', safe, color, now: 2000}); + assert.equal(lines.length, rows); + for (const line of lines) { + assert.ok(displayWidth(stripAnsi(line)) <= width, `${width}: ${JSON.stringify(stripAnsi(line))}`); + assert.doesNotMatch(stripAnsi(line), /[\u0000-\u001f\u007f‮]/u); + } + } + } + } + } + sidebar.expanded.add('tmux:$1:@2'); + const plain = renderSidebar(sidebar, {width: 40, rows: 20, focused: true, mode: 'docked', safe: true, color: false, now: 2000}); + assert.match(plain[0]!, /^> Workspace \(focused\)/u); + assert.ok(plain.some(line => /^> {4}- \* 0 code/u.test(line)), plain.join('\n')); + assert.ok(plain.some(line => /^ {9}\* nmsh {2}this NMSh/u.test(line)), 'the active pane is marked without being selected'); + assert.ok(plain.some(line => /zsh \(exited\)/u.test(line))); + const unfocused = renderSidebar(sidebar, {width: 40, rows: 20, focused: false, mode: 'docked', safe: true, color: false, now: 2000}); + assert.match(unfocused[0]!, /^ {2}Workspace$/u); + assert.ok(!unfocused.some(line => /Enter open/u.test(line)), 'key help shows only while focused'); + assert.ok(plain.some(line => /Enter open/u.test(line))); + const stale = renderSidebar(sidebar, {width: 40, rows: 20, focused: false, mode: 'docked', safe: true, color: false, now: 1000 + 60_000}); + assert.match(stale.join('\n'), /tmux \(stale\)/u); +}); + +test('worktree and manager sections: opening a worktree changes directory; a missing path is listed but not openable', () => { + const record = (path: string, extra: Partial = {}): WorktreeRecord => ({id: path, path, ref: 'refs/heads/main', branch: 'main', head: 'abc1234def', + detached: false, bare: false, main: false, locked: false, prunable: false, pathState: 'present', status: {kind: 'clean'} as WorktreeRecord['status'], ...extra}); + const section = worktreeSection({commonDir: '/r/.git', truncated: false, discoveredAt: 5, worktrees: [record('/r', {main: true}), record('/w/feature', {branch: 'feature', pathState: 'missing'})]}, '/r/src'); + assert.deepEqual(section.children!.map(child => [child.label, child.support, child.active]), [['main', 'activatable', true], ['feature', 'unsupported', false]]); + assert.deepEqual(section.children![0]!.activation, {kind: 'changeDirectory', path: '/r'}); + assert.match(section.children![1]!.reason!, /missing/u); + assert.equal(worktreeSection(undefined, '/').support, 'informational'); + assert.ok(managerLinks().children!.every(link => link.support === 'activatable' && link.activation?.kind === 'openManager')); +}); From b9047c1cb3aecdded512110a724583e36cef2196 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 08:39:04 +0530 Subject: [PATCH 2/4] Docs: the Workspace Sidebar in its own design document, so workspace slices do not edit the same file --- docs/design/workspace-sidebar.md | 40 ++++++++++++++++++++++++++++++++ docs/design/workspace.md | 39 ------------------------------- 2 files changed, 40 insertions(+), 39 deletions(-) create mode 100644 docs/design/workspace-sidebar.md diff --git a/docs/design/workspace-sidebar.md b/docs/design/workspace-sidebar.md new file mode 100644 index 00000000..9c5ce947 --- /dev/null +++ b/docs/design/workspace-sidebar.md @@ -0,0 +1,40 @@ +# Workspace Sidebar (#331) + +Part of the workspace program ([#330](https://github.com/raiseCatError/notMyShell/issues/330)); shared foundation and integration contract: [workspace.md](workspace.md). + +`SidebarController` keeps browsing state only: selection by id, expansion, a filter and scroll. Its `dispatch(action)` +returns an outcome the frontend acts on: + +| Outcome | When | +| --- | --- | +| `preview` | the selection moved; the detail panel may show a cached, read-only preview | +| `activate` | Enter (or → on a leaf) on an `activatable` resource: the frontend performs the intent | +| `unavailable` | the resource cannot be opened here; the reason is shown, nothing happens | + +Tree behavior: → expands (or moves into an open node), ← collapses (or moves to the parent), Enter on a section +toggles it. Sections open the first time they appear; afterwards a person's expansion is kept across refreshes. +After a refresh the selection follows its id; if it vanished, the row now in its place is selected. `/` starts a +filter: matches show with their ancestors, the selection moves to the first direct match, Esc clears. + +`renderSidebar` returns exactly `rows` lines of at most `width` cells. Marks (Unicode / Safe): focused title +`▸`/`>`, selected row `›`/`>`, active resource `●`/`*`, expanded `▾`/`-`, collapsed `▸`/`+`, attention `!input`, +`!approval`, `!failed`, an exited pane `(exited)`, an old listing `(stale)`. Color only reinforces them. Compact mode +drops details. Key help is shown only while focused, derived from the bindings. + +Sections in this slice: + +- **tmux** (`tmux.ts`). Only the server NMSh runs inside (`$TMUX`), through the trusted tmux executable, with one + `list-panes -a -F` call per explicit refresh: 1.5 s and 256 KiB bounds, a minimal environment, no shell. Fields are + separated by U+001F (tmux refuses control characters in names); every row is validated, and a row with a wrong + shape or id is dropped. Session → window → pane: one session with two windows and five panes is one session. + NMSh's own pane (`$TMUX_PANE`) and the window/session holding it are `active`; tmux's own active window and pane + are named in the detail. Activation uses tmux's own commands on ids (`$1`, `@2`, `%3`), never names, as one argv: + `select-pane`/`select-window`, then `switch-client`. NMSh never creates panes or PTYs or changes layout. Outside + tmux the section says so and offers nothing. +- **Worktrees** (`sources.ts`). From the `/worktrees` snapshot: opening a worktree changes the persistent shell's + directory (the manager's own `cd` intent); a missing path or bare repository is listed with its reason. Creation and + removal stay in `/worktrees`. +- **Open**: links to the managers that own deep control (`/github`, `/agents`, `/sessions`). + +Not in this slice: NMSh live sessions and Managed Targets as first-class rows (they need the session service and +#329 target registry wired through the frontend), GitHub items (the `/github` manager is linked instead). diff --git a/docs/design/workspace.md b/docs/design/workspace.md index f3ac49c2..f9aa4850 100644 --- a/docs/design/workspace.md +++ b/docs/design/workspace.md @@ -85,45 +85,6 @@ 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. -## Workspace Sidebar (`sidebar.ts`, `sources.ts`, `tmux.ts`) - -`SidebarController` keeps browsing state only: selection by id, expansion, a filter and scroll. Its `dispatch(action)` -returns an outcome the frontend acts on: - -| Outcome | When | -| --- | --- | -| `preview` | the selection moved; the detail panel may show a cached, read-only preview | -| `activate` | Enter (or → on a leaf) on an `activatable` resource: the frontend performs the intent | -| `unavailable` | the resource cannot be opened here; the reason is shown, nothing happens | - -Tree behavior: → expands (or moves into an open node), ← collapses (or moves to the parent), Enter on a section -toggles it. Sections open the first time they appear; afterwards a person's expansion is kept across refreshes. -After a refresh the selection follows its id; if it vanished, the row now in its place is selected. `/` starts a -filter: matches show with their ancestors, the selection moves to the first direct match, Esc clears. - -`renderSidebar` returns exactly `rows` lines of at most `width` cells. Marks (Unicode / Safe): focused title -`▸`/`>`, selected row `›`/`>`, active resource `●`/`*`, expanded `▾`/`-`, collapsed `▸`/`+`, attention `!input`, -`!approval`, `!failed`, an exited pane `(exited)`, an old listing `(stale)`. Color only reinforces them. Compact mode -drops details. Key help is shown only while focused, derived from the bindings. - -Sections in this slice: - -- **tmux** (`tmux.ts`). Only the server NMSh runs inside (`$TMUX`), through the trusted tmux executable, with one - `list-panes -a -F` call per explicit refresh: 1.5 s and 256 KiB bounds, a minimal environment, no shell. Fields are - separated by U+001F (tmux refuses control characters in names); every row is validated, and a row with a wrong - shape or id is dropped. Session → window → pane: one session with two windows and five panes is one session. - NMSh's own pane (`$TMUX_PANE`) and the window/session holding it are `active`; tmux's own active window and pane - are named in the detail. Activation uses tmux's own commands on ids (`$1`, `@2`, `%3`), never names, as one argv: - `select-pane`/`select-window`, then `switch-client`. NMSh never creates panes or PTYs or changes layout. Outside - tmux the section says so and offers nothing. -- **Worktrees** (`sources.ts`). From the `/worktrees` snapshot: opening a worktree changes the persistent shell's - directory (the manager's own `cd` intent); a missing path or bare repository is listed with its reason. Creation and - removal stay in `/worktrees`. -- **Open**: links to the managers that own deep control (`/github`, `/agents`, `/sessions`). - -Not in this slice: NMSh live sessions and Managed Targets as first-class rows (they need the session service and -#329 target registry wired through the frontend), GitHub items (the `/github` manager is linked instead). - ## Integration contract Integration into `TerminalApp.ts` is deliberately a separate, later step: that file is under active input-routing From 272f302f0a6b5ecf44f779b16954e61ea1a1203e Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:49:44 +0530 Subject: [PATCH 3/4] Sidebar: read tmux listings through tmux's own quoting, not a control-character separator Ubuntu CI (tmux 3.4) listed no panes: older tmux builds escape control characters in format output, so the U+001F separator never arrived as one character. Fields are now separated by `|`, names are requested with the `q:` modifier (which escapes `|`), and both that escaping and tmux's own output escaping (`\\`, `\ooo`) are undone. Rows with the wrong number of fields are dropped. Tests cover escaped separators, escaped backslashes and an escaped control character; the real-tmux test uses a session name with a `|`. --- docs/design/workspace-sidebar.md | 5 +++-- src/workspace/tmux.ts | 32 ++++++++++++++++++++++++-------- tests/workspaceSidebar.test.ts | 15 +++++++++------ 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/docs/design/workspace-sidebar.md b/docs/design/workspace-sidebar.md index 9c5ce947..b488cf0f 100644 --- a/docs/design/workspace-sidebar.md +++ b/docs/design/workspace-sidebar.md @@ -25,8 +25,9 @@ Sections in this slice: - **tmux** (`tmux.ts`). Only the server NMSh runs inside (`$TMUX`), through the trusted tmux executable, with one `list-panes -a -F` call per explicit refresh: 1.5 s and 256 KiB bounds, a minimal environment, no shell. Fields are - separated by U+001F (tmux refuses control characters in names); every row is validated, and a row with a wrong - shape or id is dropped. Session → window → pane: one session with two windows and five panes is one session. + separated by `|`; names are requested with tmux's `q:` modifier (which escapes `|`), and both that escaping and + tmux's own output escaping (`\\`, `\ooo`, used by older builds such as Ubuntu 24.04's) are undone. Every row is + validated, and a row with a wrong shape or id is dropped. Session → window → pane: one session with two windows and five panes is one session. NMSh's own pane (`$TMUX_PANE`) and the window/session holding it are `active`; tmux's own active window and pane are named in the detail. Activation uses tmux's own commands on ids (`$1`, `@2`, `%3`), never names, as one argv: `select-pane`/`select-window`, then `switch-client`. NMSh never creates panes or PTYs or changes layout. Outside diff --git a/src/workspace/tmux.ts b/src/workspace/tmux.ts index 499db73e..b44eca47 100644 --- a/src/workspace/tmux.ts +++ b/src/workspace/tmux.ts @@ -29,11 +29,29 @@ export interface TmuxPane { /** The server NMSh runs inside, from $TMUX (`socket,pid,session`) and $TMUX_PANE. */ export interface TmuxServer {socket: string; pane?: string} -const SEP = '\u001f'; const FIELDS = ['session_id', 'session_name', 'session_attached', 'window_id', 'window_index', 'window_name', 'window_active', 'pane_id', 'pane_index', 'pane_active', 'pane_dead', 'pane_current_command'] as const; -/** tmux rejects control characters in session and window names, so the unit separator cannot occur in them; rows are still validated. */ -export const LIST_FORMAT = FIELDS.map(field => `#{${field}}`).join(SEP); +const NAMES: ReadonlySet = new Set(['session_name', 'window_name', 'pane_current_command']); +/** + * Fields separated by `|`. Names may contain `|`, so they are requested with tmux's `q:` modifier, which escapes shell + * specials (`|` becomes `\|`); ids and numbers never contain it. tmux also escapes its output itself on some builds + * (`\\` for a backslash, `\ooo` for a control character), so a control-character separator is not reliable. + */ +export const LIST_FORMAT = FIELDS.map(field => (NAMES.has(field) ? `#{q:${field}}` : `#{${field}}`)).join('|'); + +/** One listing row into fields: split on unescaped `|`, undo `q:` escaping, then tmux's own `\\` and `\ooo` escapes. */ +export function splitTmuxRow(line: string): string[] { + const fields: string[] = []; + let current = ''; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]!; + if (char === '\\' && index + 1 < line.length) { current += line[++index]!; continue; } + if (char === '|') { fields.push(current); current = ''; continue; } + current += char; + } + fields.push(current); + return fields.map(field => field.replace(/\\(\\|[0-7]{3})/gu, (_, code: string) => code === '\\' ? '\\' : String.fromCharCode(parseInt(code, 8)))); +} export const MAX_PANES = 256; const SESSION_ID = /^\$\d{1,9}$/u, WINDOW_ID = /^@\d{1,9}$/u, PANE_ID = /^%\d{1,9}$/u; @@ -51,11 +69,9 @@ export function parseTmuxPanes(output: string): TmuxPane[] { const panes: TmuxPane[] = []; for (const line of output.split('\n')) { if (!line || panes.length >= MAX_PANES) continue; - const parts = line.split(SEP); - if (parts.length < FIELDS.length) continue; - // The command is last: a process name is not validated by tmux, so anything after the eleventh separator belongs to it. - const [sessionId, sessionName, attached, windowId, windowIndex, windowName, windowActive, paneId, paneIndex, paneActive, paneDead] = parts; - const command = parts.slice(FIELDS.length - 1).join(' '); + const parts = splitTmuxRow(line); + if (parts.length !== FIELDS.length) continue; + const [sessionId, sessionName, attached, windowId, windowIndex, windowName, windowActive, paneId, paneIndex, paneActive, paneDead, command = ''] = parts; if (!SESSION_ID.test(sessionId!) || !WINDOW_ID.test(windowId!) || !PANE_ID.test(paneId!)) continue; if (!/^\d{1,6}$/u.test(windowIndex!) || !/^\d{1,6}$/u.test(paneIndex!)) continue; panes.push({sessionId: sessionId!, sessionName: resourceText(sessionName!), sessionAttached: Number(attached) > 0, diff --git a/tests/workspaceSidebar.test.ts b/tests/workspaceSidebar.test.ts index 76dd4e44..ab8aa820 100644 --- a/tests/workspaceSidebar.test.ts +++ b/tests/workspaceSidebar.test.ts @@ -5,7 +5,7 @@ import {existsSync} from 'node:fs'; import {mkdtemp, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; -import {createTmuxRunner, LIST_FORMAT, listTmuxPanes, MAX_PANES, parseTmuxPanes, tmuxNavigationArgs, tmuxResources, tmuxServer, type TmuxPane} from '../src/workspace/tmux.js'; +import {createTmuxRunner, LIST_FORMAT, listTmuxPanes, MAX_PANES, parseTmuxPanes, splitTmuxRow, tmuxNavigationArgs, tmuxResources, tmuxServer, type TmuxPane} from '../src/workspace/tmux.js'; import {renderSidebar, SidebarController} from '../src/workspace/sidebar.js'; import {managerLinks, worktreeSection} from '../src/workspace/sources.js'; import type {WorkspaceResource} from '../src/workspace/resources.js'; @@ -13,7 +13,7 @@ import type {WorktreeRecord} from '../src/worktrees/model.js'; import {displayWidth, stripAnsi} from '../src/util/text.js'; import type {Key} from '../src/terminal/keys.js'; -const S = '\u001f'; +const S = '|'; const row = (...fields: string[]) => fields.join(S); // One session (qa) with two windows and five panes, plus a detached session. const LISTING = [ @@ -22,7 +22,7 @@ const LISTING = [ row('$1', 'qa', '1', '@2', '1', 'servers', '0', '%3', '0', '1', '0', 'npm'), row('$1', 'qa', '1', '@2', '1', 'servers', '0', '%4', '1', '0', '0', 'tail'), row('$1', 'qa', '1', '@2', '1', 'servers', '0', '%5', '2', '0', '1', 'zsh'), - row('$2', 'notes|old', '0', '@3', '0', 'main', '1', '%6', '0', '1', '0', 'vim'), + row('$2', 'notes\\|old', '0', '@3', '0', 'main', '1', '%6', '0', '1', '0', 'vim'), ].join('\n'); test('tmux listing: rows parse into panes; malformed rows and invalid ids are dropped, never guessed', () => { @@ -31,10 +31,13 @@ test('tmux listing: rows parse into panes; malformed rows and invalid ids are dr assert.equal(panes[5]!.sessionName, 'notes|old', 'a pipe in a name is just text'); assert.deepEqual(panes[4], {sessionId: '$1', sessionName: 'qa', sessionAttached: true, windowId: '@2', windowIndex: 1, windowName: 'servers', windowActive: false, paneId: '%5', paneIndex: 2, paneActive: false, paneDead: true, command: 'zsh'}); - const odd = parseTmuxPanes(row('$1', 'qa', '1', '@1', '0', 'code', '1', '%1', '0', '1', '0', 'we\u001fird\u001b[31m')); - assert.equal(odd[0]!.command, 'we ird [31m', 'a process name with separators or controls stays one inert field'); + const odd = parseTmuxPanes(row('$1', 'qa', '1', '@1', '0', 'a\\ b\\\\\\\\c', '1', '%1', '0', '1', '0', 'we\\|ird\\\\033[31m')); + assert.equal(odd[0]!.windowName, 'a b\\c', 'q: escaping and tmux\'s own backslash escaping are both undone'); + assert.equal(odd[0]!.command, 'we|ird [31m', 'an escaped separator stays in its field; an escaped control character is made inert'); + assert.deepEqual(splitTmuxRow('a\\|b|c'), ['a|b', 'c']); + assert.equal(parseTmuxPanes(`${row('$1', 'qa', '1', '@1', '0', 'code', '1', '%1', '0', '1', '0', 'sh')}|extra`).length, 0, 'a row with too many fields is dropped'); assert.equal(parseTmuxPanes(Array.from({length: 400}, (_, i) => row('$1', 'qa', '1', '@1', '0', 'code', '1', `%${i}`, `${i}`, '0', '0', 'sh')).join('\n')).length, MAX_PANES); - assert.match(LIST_FORMAT, /#\{session_id\}\u001f/u); + assert.match(LIST_FORMAT, /^#\{session_id\}\|#\{q:session_name\}\|/u); }); test('tmux hierarchy: one session with two windows and five panes is one session; NMSh\'s own pane is the active one', () => { From 04764c999578287895eb791004c785f50357f69d Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Sun, 11 Oct 2026 23:43:02 +0530 Subject: [PATCH 4/4] Sidebar: one current worktree when worktrees nest; the filter's key order and the cd hand-off are written down - A worktree inside the main checkout's directory (.worktrees/feature) made both it and the main worktree "active". The innermost worktree holding the shell's directory is the current one. - The tmux escaping test now expects an escaped escape sequence to be removed whole (the foundation's resourceText). - Integration notes: handleSearchKey runs before routeWorkspaceKey while filtering; changeDirectory goes through directoryCommand and is staged in an empty composer, as /worktrees does. --- docs/design/workspace-sidebar.md | 9 ++++++++- src/workspace/sources.ts | 6 +++++- tests/workspaceSidebar.test.ts | 6 +++++- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/design/workspace-sidebar.md b/docs/design/workspace-sidebar.md index b488cf0f..774b37f2 100644 --- a/docs/design/workspace-sidebar.md +++ b/docs/design/workspace-sidebar.md @@ -16,6 +16,10 @@ toggles it. Sections open the first time they appear; afterwards a person's expa After a refresh the selection follows its id; if it vanished, the row now in its place is selected. `/` starts a filter: matches show with their ancestors, the selection moves to the first direct match, Esc clears. +Key order while filtering: while `searching` is true the frontend offers each key to `handleSearchKey` **before** +`routeWorkspaceKey`, and routes it only when `handleSearchKey` returns false. Otherwise Esc would reach `focus.return` +and leave the filter half-typed, and typed letters would never reach it. + `renderSidebar` returns exactly `rows` lines of at most `width` cells. Marks (Unicode / Safe): focused title `▸`/`>`, selected row `›`/`>`, active resource `●`/`*`, expanded `▾`/`-`, collapsed `▸`/`+`, attention `!input`, `!approval`, `!failed`, an exited pane `(exited)`, an old listing `(stale)`. Color only reinforces them. Compact mode @@ -34,7 +38,10 @@ Sections in this slice: tmux the section says so and offers nothing. - **Worktrees** (`sources.ts`). From the `/worktrees` snapshot: opening a worktree changes the persistent shell's directory (the manager's own `cd` intent); a missing path or bare repository is listed with its reason. Creation and - removal stay in `/worktrees`. + removal stay in `/worktrees`. Only the innermost worktree holding the shell's directory is `active` (worktrees may + live inside another's directory). At integration the `changeDirectory` intent is performed exactly as `/worktrees` + performs its `cd`: `directoryCommand(path)` (which refuses paths it cannot quote safely) is staged in an empty + composer, never run directly and never typed over a draft. - **Open**: links to the managers that own deep control (`/github`, `/agents`, `/sessions`). Not in this slice: NMSh live sessions and Managed Targets as first-class rows (they need the session service and diff --git a/src/workspace/sources.ts b/src/workspace/sources.ts index d2aa5b29..7d2bef48 100644 --- a/src/workspace/sources.ts +++ b/src/workspace/sources.ts @@ -16,13 +16,17 @@ import type {WorkspaceResource} from './resources.js'; */ export function worktreeSection(snapshot: WorktreeSnapshot | undefined, cwd: string): WorkspaceResource { if (!snapshot) return {id: 'worktrees', kind: 'section', label: 'Worktrees', support: 'informational', reason: 'not in a Git repository, or not listed yet'}; + // Worktrees can live inside another's directory (`.worktrees/feature` in the main checkout): the current one is the + // innermost that holds the shell's directory, so only one is marked active. + const current = snapshot.worktrees.filter(worktree => cwd === worktree.path || cwd.startsWith(`${worktree.path}/`)) + .reduce((best, worktree) => (best === undefined || worktree.path.length > best.length ? worktree.path : best), undefined); const children: WorkspaceResource[] = snapshot.worktrees.map(worktree => { const present = worktree.pathState === 'present' && !worktree.bare; return { id: `worktree:${worktree.id}`, kind: 'worktree', label: checkoutLabel(worktree), detail: [basename(worktree.path), ...stateWords(worktree)].join(' · '), ...(present ? {support: 'activatable' as const, activation: {kind: 'changeDirectory' as const, path: worktree.path}} : {support: 'unsupported' as const, reason: worktree.bare ? 'a bare repository has no working directory' : 'the worktree path is missing'}), - active: cwd === worktree.path || cwd.startsWith(`${worktree.path}/`), + active: worktree.path === current, observedAt: snapshot.discoveredAt, }; }); diff --git a/tests/workspaceSidebar.test.ts b/tests/workspaceSidebar.test.ts index ab8aa820..b1ebcdca 100644 --- a/tests/workspaceSidebar.test.ts +++ b/tests/workspaceSidebar.test.ts @@ -33,7 +33,7 @@ test('tmux listing: rows parse into panes; malformed rows and invalid ids are dr paneId: '%5', paneIndex: 2, paneActive: false, paneDead: true, command: 'zsh'}); const odd = parseTmuxPanes(row('$1', 'qa', '1', '@1', '0', 'a\\ b\\\\\\\\c', '1', '%1', '0', '1', '0', 'we\\|ird\\\\033[31m')); assert.equal(odd[0]!.windowName, 'a b\\c', 'q: escaping and tmux\'s own backslash escaping are both undone'); - assert.equal(odd[0]!.command, 'we|ird [31m', 'an escaped separator stays in its field; an escaped control character is made inert'); + assert.equal(odd[0]!.command, 'we|ird', 'an escaped separator stays in its field; an escaped escape sequence is removed whole, not left as "[31m"'); assert.deepEqual(splitTmuxRow('a\\|b|c'), ['a|b', 'c']); assert.equal(parseTmuxPanes(`${row('$1', 'qa', '1', '@1', '0', 'code', '1', '%1', '0', '1', '0', 'sh')}|extra`).length, 0, 'a row with too many fields is dropped'); assert.equal(parseTmuxPanes(Array.from({length: 400}, (_, i) => row('$1', 'qa', '1', '@1', '0', 'code', '1', `%${i}`, `${i}`, '0', '0', 'sh')).join('\n')).length, MAX_PANES); @@ -214,5 +214,9 @@ test('worktree and manager sections: opening a worktree changes directory; a mis assert.deepEqual(section.children![0]!.activation, {kind: 'changeDirectory', path: '/r'}); assert.match(section.children![1]!.reason!, /missing/u); assert.equal(worktreeSection(undefined, '/').support, 'informational'); + // A worktree inside another's directory: only the innermost one holding the shell's directory is current. + const nested = worktreeSection({commonDir: '/r/.git', truncated: false, discoveredAt: 5, worktrees: [record('/r', {main: true}), record('/r/.worktrees/feature', {branch: 'feature'}), record('/r2', {branch: 'other'})]}, '/r/.worktrees/feature/src'); + assert.deepEqual(nested.children!.map(child => [child.label, child.active]), [['main', false], ['feature', true], ['other', false]]); + assert.equal(worktreeSection({commonDir: '/r/.git', truncated: false, discoveredAt: 5, worktrees: [record('/r'), record('/r2', {branch: 'other'})]}, '/r2').children![0]!.active, false, 'a sibling path with the same prefix is not inside'); assert.ok(managerLinks().children!.every(link => link.support === 'activatable' && link.activation?.kind === 'openManager')); });