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
48 changes: 48 additions & 0 deletions docs/design/workspace-sidebar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# 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.

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
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 `|`; 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
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`. 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
#329 target registry wired through the frontend), GitHub items (the `/github` manager is linked instead).
211 changes: 211 additions & 0 deletions src/workspace/sidebar.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
selectedId?: string;
/** A filter being typed (`/`), or applied. Matching rows show with their ancestors. */
filter = '';
searching = false;
scroll = 0;
private seen = new Set<string>();

/** 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<string>();
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);
}
46 changes: 46 additions & 0 deletions src/workspace/sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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'};
// 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<string | undefined>((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: worktree.path === current,
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'}},
]};
}
Loading
Loading