diff --git a/docs/design/workspace-panel.md b/docs/design/workspace-panel.md new file mode 100644 index 00000000..d4b0c63f --- /dev/null +++ b/docs/design/workspace-panel.md @@ -0,0 +1,28 @@ +# Workspace detail panel (#332) + +Part of the workspace program ([#330](https://github.com/raiseCatError/notMyShell/issues/330)); shared foundation and integration contract: [workspace.md](workspace.md). + +The panel explains what is selected or current; it is not an editor and never interprets PTY output. Content is +typed (`heading`, `text`, `muted`, `warning`, `field`, `diff`, `blank`), names its **source** and observation time, +and says when it is **partial**. Providers are pure functions over data the caller already holds: + +| Provider | Input | Notes | +| --- | --- | --- | +| `resourceDetail` | a sidebar resource | what Enter would do, in words, or why it cannot be opened | +| `worktreeDetail` | a `/worktrees` record | Git's facts only; an unknown status is never shown as clean | +| `diffContent` | local `git diff` text | hunks through the GitHub review diff's `parsePatch` and line sanitizer; binary and rename-only files named; a line budget with an explicit "cut here"; the `diff --git` heading read from its own line in linear time (a name containing ` b/` stays whole; quoted headers show as `diff`) | +| `factDetail` | a Context Engine fact | the displayable value under its privacy policy, evidence, collection age, freshness, policy and what the capability reads (#305 explain view) | + +`PanelController` keeps scroll per content id (kept when the same content is refreshed, and restored when the +person goes back to content still in the cache) and a bounded cache of recent content by id, so moving the sidebar selection can show a **cached preview** without asking any owner again. `renderPanel` +returns exactly rows × width: title with the focus mark, a source/age row (`stale` past the freshness window, +`time unknown` without an observation), an optional partial row, wrapped text, cut (not wrapped) diff lines keeping +their `+`/`-`/`\` marker, and, while focused, the position and key help. + +Module diagnostics (why a module shows or not, collection timings, engine health) belong to #365, implemented on the +workflow track in #368 (`ModulesPanel`, `/doctor`). `factDetail` only presents what a `ContextFact` already carries; +at integration the panel should show #365's explain output for a module rather than grow a second diagnostics view. + +Not in this slice: provider task/plan presentation (needs #339's normalized, provider-owned task state), approval +details (owned by the Managed Target approval flow), GitHub PR detail (the `/github` manager renders it today), and +editor hand-off commands. diff --git a/src/workspace/panel.ts b/src/workspace/panel.ts new file mode 100644 index 00000000..9d8b1f03 --- /dev/null +++ b/src/workspace/panel.ts @@ -0,0 +1,270 @@ +import type {ContextFact} from '../context/facts.js'; +import {projectFactValue, safeContextText} from '../context/facts.js'; +import {parsePatch, type DiffLine} from '../githubWorkspace/diff.js'; +import {sanitizePatchLine} from '../githubWorkspace/sanitize.js'; +import {foreground, UI_COLORS} from '../ui/palette.js'; +import {displayWidth, truncateText} from '../util/text.js'; +import {checkoutLabel, stateWords, type WorktreeRecord} from '../worktrees/model.js'; +import {bindingHelp, DEFAULT_BINDINGS, type Binding, type WorkspaceActionId} from './keymap.js'; +import {isStale, resourceText, type ActivationIntent, type WorkspaceResource} from './resources.js'; + +/** + * Contextual detail panel (#332): explains what is selected or current + * without becoming an editor. Content is typed, bounded and read-only; + * providers are pure functions over data the caller already has (a sidebar + * resource, a worktree record, a diff text, a Context Engine fact), so the + * panel never performs I/O and never interprets arbitrary PTY output. + * Opening or editing a file is a hand-off to the person's editor. + * + * Every content names its source and, when known, when it was observed; + * partial content says what is missing. + */ + +export type PanelLine = + | {kind: 'heading' | 'text' | 'muted' | 'warning'; text: string} + | {kind: 'field'; label: string; value: string} + | {kind: 'diff'; line: DiffLine} + | {kind: 'blank'}; + +export interface PanelContent { + /** Stable identity: the same content refreshed keeps its scroll position. */ + id: string; + title: string; + /** Where the content came from, in words (`tmux list-panes`, `git worktree list`, `Context Engine: runtime.node`). */ + source: string; + observedAt?: number; + /** Why the content is incomplete, when it is. */ + partial?: string; + lines: PanelLine[]; +} + +export const MAX_PANEL_LINES = 2000; +const text = (value: string, limit = 240) => resourceText(value, limit); + +function describeIntent(intent: ActivationIntent): string { + switch (intent.kind) { + case 'tmux': return `tmux selects this ${intent.scope} and switches your client to it`; + case 'changeDirectory': return `the shell changes directory to ${text(intent.path)}`; + case 'nmshSession': return 'NMSh switches to this session'; + case 'managedTarget': return 'NMSh switches to this agent target'; + case 'openManager': return `opens ${intent.command}${intent.argument ? ` ${text(intent.argument, 80)}` : ''}`; + } +} + +/** What a sidebar resource is and what opening it would do; shown on selection (a preview), never acting. */ +export function resourceDetail(resource: WorkspaceResource): PanelContent { + const lines: PanelLine[] = [{kind: 'field', label: 'Kind', value: resource.kind}]; + if (resource.detail) lines.push({kind: 'field', label: 'Detail', value: text(resource.detail)}); + if (resource.active) lines.push({kind: 'field', label: 'State', value: 'active'}); + if (resource.attention) lines.push({kind: 'warning', text: `Needs attention: ${resource.attention}`}); + lines.push({kind: 'blank'}); + if (resource.support === 'activatable' && resource.activation) lines.push({kind: 'text', text: `Enter: ${describeIntent(resource.activation)}.`}); + else lines.push({kind: 'muted', text: `Cannot be opened here: ${text(resource.reason ?? 'no supported path')}.`}); + if (resource.children?.length) lines.push({kind: 'muted', text: `${resource.children.length} item${resource.children.length === 1 ? '' : 's'} inside.`}); + return {id: `resource:${resource.id}`, title: text(resource.label, 120), source: 'sidebar listing', ...(resource.observedAt !== undefined ? {observedAt: resource.observedAt} : {}), lines}; +} + +/** A Git worktree as `/worktrees` discovered it: every field is Git's or the filesystem's, nothing inferred. */ +export function worktreeDetail(worktree: WorktreeRecord, observedAt: number): PanelContent { + const lines: PanelLine[] = [ + {kind: 'field', label: 'Path', value: text(worktree.path)}, + {kind: 'field', label: 'Checkout', value: checkoutLabel(worktree)}, + ...(worktree.head ? [{kind: 'field' as const, label: 'HEAD', value: worktree.head.slice(0, 12)}] : []), + ]; + const status = worktree.status; + if (status.kind === 'dirty') { + const counts = Object.entries(status.counts).filter(([, count]) => typeof count === 'number' && count > 0).map(([name, count]) => `${count} ${name}`); + lines.push({kind: 'field', label: 'Status', value: counts.join(', ') || 'changes'}); + } else lines.push({kind: 'field', label: 'Status', value: status.kind === 'clean' ? 'clean' : `unknown (${text(status.reason, 120)})`}); + const words = stateWords(worktree).filter(word => !/^(clean|dirty)/u.test(word)); + if (words.length) lines.push({kind: 'muted', text: words.join(' · ')}); + lines.push({kind: 'blank'}, {kind: 'muted', text: 'Diff, creation and removal: /worktrees.'}); + return {id: `worktree:${worktree.id}`, title: checkoutLabel(worktree), source: 'git worktree list and git status', observedAt, lines}; +} + +/** + * The file a `diff --git a/X b/Y` section is about, read from that one line. Same name on both sides (the common case) + * is recognized by its two equal halves, so a name containing ` b/` stays whole; a rename splits at the first ` b/`. + * Linear: a lazy `a\/(.+?) b\/(.+)$` pattern retried across names full of ` b/` took about 2 ms per header. + */ +function diffHeading(section: string): string { + const end = section.indexOf('\n'); + const line = end === -1 ? section : section.slice(0, end); + const prefix = 'diff --git a/'; + if (!line.startsWith(prefix) || line.length > 4096) return 'diff'; + const rest = line.slice(prefix.length - 2).replace(/\r$/u, ''); + const half = (rest.length - 5) / 2; + if (Number.isInteger(half) && half > 0 && rest.slice(2 + half, 5 + half) === ' b/' && rest.slice(2, 2 + half) === rest.slice(5 + half)) return text(rest.slice(2, 2 + half)); + const split = rest.indexOf(' b/', 2); + const from = split === -1 ? '' : rest.slice(2, split), to = split === -1 ? '' : rest.slice(split + 3); + return from && to ? (from === to ? text(to) : `${text(from)} → ${text(to)}`) : 'diff'; +} + +/** + * A local unified diff (`git diff` output) as bounded, read-only lines. Hunks go through the same parser and line + * sanitizer as the GitHub review diff; file headers become headings. Binary and over-budget content is named, not shown. + */ +export function diffContent(id: string, title: string, diff: string, source: string, observedAt?: number, budget = 1500): PanelContent { + const lines: PanelLine[] = []; + let used = 0, cut = false; + const sections = diff.split(/^(?=diff --git )/mu).filter(section => section.trim()); + for (const section of sections) { + if (lines.length >= MAX_PANEL_LINES || used >= budget) { cut = true; break; } + lines.push({kind: 'heading', text: diffHeading(section)}); + if (/^Binary files .* differ$/mu.test(section)) { lines.push({kind: 'muted', text: 'binary file: not shown'}); continue; } + const start = section.search(/^@@ /mu); + if (start === -1) { lines.push({kind: 'muted', text: 'no content changes (mode or rename only)'}); continue; } + const parsed = parsePatch(section.slice(start), budget - used); + used += parsed.lines; + for (const hunk of parsed.hunks) { + lines.push({kind: 'muted', text: sanitizePatchLine(hunk.header, 240).text}); + for (const line of hunk.lines) lines.push({kind: 'diff', line}); + } + if (parsed.cut) { cut = true; lines.push({kind: 'warning', text: 'cut here: the rest is not shown'}); } + } + if (!sections.length) lines.push({kind: 'muted', text: 'no changes'}); + return {id, title: text(title, 120), source, ...(observedAt !== undefined ? {observedAt} : {}), + ...(cut ? {partial: `diff shown up to ${budget} lines; open it in your diff tool for the rest`} : {}), lines: lines.slice(0, MAX_PANEL_LINES)}; +} + +/** + * A Context Engine fact as a module's detail: its displayable value, provenance, freshness and policy. Only what the fact + * carries; diagnostics (why a module shows, timings, engine health) are #365's explain view, which the panel should + * present instead at integration. + */ +export function factDetail(capabilityId: string, title: string, reads: readonly string[], fact: ContextFact | undefined, now: number): PanelContent { + const lines: PanelLine[] = []; + const value = fact ? projectFactValue(fact, 'display') : undefined; + if (!fact) lines.push({kind: 'muted', text: 'Not collected: the module is hidden, not demanded here, or nothing applies.'}); + else if (value === undefined) lines.push({kind: 'muted', text: 'Collected, but not displayable under its privacy policy.'}); + else if (typeof value === 'object' && value !== null) { + for (const [key, item] of Object.entries(value as Record).slice(0, 32)) lines.push({kind: 'field', label: key, value: safeContextText(String(item), 120)}); + } else lines.push({kind: 'field', label: 'value', value: safeContextText(String(value), 120)}); + if (fact) { + lines.push({kind: 'blank'}, + {kind: 'field', label: 'Evidence', value: safeContextText(fact.source.evidence, 160)}, + {kind: 'field', label: 'Collected', value: fact.collectedAt ? `${Math.max(0, Math.round((now - fact.collectedAt) / 1000))} s ago` : 'unknown'}, + {kind: 'field', label: 'Freshness', value: fact.freshness}, + {kind: 'field', label: 'Policy', value: `${fact.sensitivity}, ${fact.persistence}`}); + } + lines.push({kind: 'blank'}, {kind: 'heading', text: 'Reads'}, ...reads.slice(0, 16).map(read => ({kind: 'muted' as const, text: text(read, 240)}))); + return {id: `fact:${capabilityId}`, title: text(title, 120), source: `Context Engine: ${capabilityId}`, ...(fact?.collectedAt ? {observedAt: fact.collectedAt} : {}), lines}; +} + +export class PanelController { + content?: PanelContent; + scroll = 0; + /** Recently shown content by id, so moving the sidebar selection can show a preview without asking anyone again. */ + private readonly cache = new Map(); + /** Where each cached content was scrolled to, so going back to it returns to the same place. */ + private readonly positions = new Map(); + + show(content: PanelContent): void { + if (this.content?.id !== content.id) { + if (this.content) this.positions.set(this.content.id, this.scroll); + this.scroll = this.positions.get(content.id) ?? 0; + } + this.content = {...content, lines: content.lines.slice(0, MAX_PANEL_LINES)}; + this.cache.delete(content.id); + this.cache.set(content.id, this.content); + while (this.cache.size > 32) { + const oldest = this.cache.keys().next().value!; + this.cache.delete(oldest); + this.positions.delete(oldest); + } + } + + /** Cached content for an id (a preview), or undefined: the caller decides whether fetching is worth it. */ + cached(id: string): PanelContent | undefined { + return this.cache.get(id); + } + + clear(): void { this.content = undefined; this.scroll = 0; } + + dispatch(action: WorkspaceActionId, viewRows: number): void { + const page = Math.max(1, viewRows - 1); + const delta = action === 'panel.up' ? -1 : action === 'panel.down' ? 1 : action === 'panel.pageUp' ? -page : action === 'panel.pageDown' ? page : 0; + this.scroll = Math.max(0, this.scroll + delta); + } +} + +export interface PanelRenderOptions { + width: number; + rows: number; + focused: boolean; + safe: boolean; + color: boolean; + now: number; + /** Content older than this is labelled stale. */ + freshnessMs?: number; + bindings?: readonly Binding[]; +} + +function age(now: number, at: number): string { + const seconds = Math.max(0, Math.round((now - at) / 1000)); + return seconds < 60 ? `${seconds}s ago` : seconds < 3600 ? `${Math.round(seconds / 60)}m ago` : `${Math.round(seconds / 3600)}h ago`; +} + +/** Long text wraps at the width; diff lines are cut (their columns matter), keeping the +/- marker. */ +function layoutLine(line: PanelLine, width: number): Array<{text: string; tone: 'primary' | 'secondary' | 'subtle' | 'warning' | 'add' | 'del'; bold?: boolean}> { + const wrap = (value: string, tone: 'primary' | 'secondary' | 'subtle' | 'warning', bold = false) => { + const out: Array<{text: string; tone: typeof tone; bold: boolean}> = []; + let rest = value; + while (displayWidth(rest) > width && out.length < 64) { + let cut = truncateText(rest, width + 1).replace(/…$/u, ''); + const space = cut.lastIndexOf(' '); + if (space > width / 2) cut = cut.slice(0, space); + out.push({text: cut, tone, bold}); + rest = rest.slice(cut.length).trimStart(); + } + out.push({text: rest, tone, bold}); + return out; + }; + switch (line.kind) { + case 'blank': return [{text: '', tone: 'primary'}]; + case 'heading': return wrap(line.text, 'primary', true); + case 'text': return wrap(line.text, 'primary'); + case 'muted': return wrap(line.text, 'subtle'); + case 'warning': return wrap(`! ${line.text}`, 'warning'); + case 'field': return wrap(`${line.label}: ${line.value}`, 'secondary'); + case 'diff': { + const mark = line.line.kind === 'add' ? '+' : line.line.kind === 'del' ? '-' : line.line.kind === 'note' ? '\\ ' : ' '; + return [{text: truncateText(`${mark}${line.line.text}`, width), tone: line.line.kind === 'add' ? 'add' : line.line.kind === 'del' ? 'del' : 'subtle'}]; + } + } +} + +/** Exactly `rows` lines of at most `width` cells; pure. The title marks focus; the second row names the source and age. */ +export function renderPanel(controller: PanelController, options: PanelRenderOptions): string[] { + const width = Math.max(1, options.width); + const rows = Math.max(1, options.rows); + const reset = options.color ? '\u001b[0m' : ''; + const colors = {primary: UI_COLORS.primary, secondary: UI_COLORS.secondary, subtle: UI_COLORS.subtle, warning: UI_COLORS.failure, add: UI_COLORS.success, del: UI_COLORS.failure}; + const paint = (value: string, tone: keyof typeof colors, bold = false) => options.color ? `${bold ? '\u001b[1m' : ''}${foreground(colors[tone])}${value}${reset}` : value; + const content = controller.content; + const focusMark = options.focused ? (options.safe ? '>' : '▸') : ' '; + const header: string[] = []; + const titleText = truncateText(`${focusMark} ${content?.title ?? 'Details'}`, width); + header.push(options.focused && options.color ? `\u001b[1m${foreground(UI_COLORS.accent)}${titleText}${reset}` : paint(titleText, 'primary', options.focused)); + if (content) { + const stale = content.observedAt === undefined || isStale(content, options.now, options.freshnessMs ?? 60_000); + const when = content.observedAt === undefined ? 'time unknown' : `${age(options.now, content.observedAt)}${stale ? ', stale' : ''}`; + header.push(paint(truncateText(`${content.source} · ${when}`, width), 'subtle')); + if (content.partial) header.push(paint(truncateText(`partial: ${content.partial}`, width), 'warning')); + } + const body = content ? content.lines.flatMap(line => layoutLine(line, width)) : [{text: 'Select something to see its details.', tone: 'subtle' as const}]; + const footer: string[] = []; + const height = Math.max(0, rows - header.length - (options.focused ? 1 : 0)); + controller.scroll = Math.min(controller.scroll, Math.max(0, body.length - height)); + if (options.focused) { + const position = body.length > height ? `${controller.scroll + 1}-${Math.min(body.length, controller.scroll + height)}/${body.length} · ` : ''; + const help = bindingHelp(options.bindings ?? DEFAULT_BINDINGS, 'panel').filter(([, label]) => ['scroll down', 'close panel', 'back to the composer'].includes(label)) + .map(([chord, label]) => `${chord} ${label}`).join(' · '); + footer.push(paint(truncateText(`${position}${help}`, width), 'subtle')); + } + const lines = [...header]; + for (const item of body.slice(controller.scroll, controller.scroll + height)) lines.push(paint(truncateText(item.text, width), item.tone, item.bold)); + while (lines.length < rows - footer.length) lines.push(''); + lines.push(...footer); + return lines.slice(0, rows); +} diff --git a/tests/workspacePanel.test.ts b/tests/workspacePanel.test.ts new file mode 100644 index 00000000..fad310f7 --- /dev/null +++ b/tests/workspacePanel.test.ts @@ -0,0 +1,136 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {diffContent, factDetail, PanelController, renderPanel, resourceDetail, worktreeDetail} from '../src/workspace/panel.js'; +import type {WorktreeRecord} from '../src/worktrees/model.js'; +import type {ContextFact} from '../src/context/facts.js'; +import {displayWidth, stripAnsi} from '../src/util/text.js'; + +const DIFF = [ + 'diff --git a/src/app.ts b/src/app.ts', + 'index 1111111..2222222 100644', + '--- a/src/app.ts', + '+++ b/src/app.ts', + '@@ -1,3 +1,3 @@ export function main() {', + ' const a = 1;', + '-const b = 2;', + '+const b = 3;', + '--flag stays a deletion of "-flag"', + '\\ No newline at end of file', + 'diff --git a/logo.png b/logo.png', + 'Binary files a/logo.png and b/logo.png differ', + 'diff --git a/old.txt b/new.txt', + 'similarity index 100%', + 'rename from old.txt', + 'rename to new.txt', +].join('\n'); + +test('local diff: hunks through the shared parser, binary and rename-only files named, markers kept', () => { + const content = diffContent('diff:wt', 'feature', DIFF, 'git diff', 1000); + const headings = content.lines.filter(line => line.kind === 'heading').map(line => (line as {text: string}).text); + assert.deepEqual(headings, ['src/app.ts', 'logo.png', 'old.txt → new.txt']); + const diff = content.lines.filter(line => line.kind === 'diff').map(line => line.kind === 'diff' ? [line.line.kind, line.line.text] : []); + assert.deepEqual(diff, [['ctx', 'const a = 1;'], ['del', 'const b = 2;'], ['add', 'const b = 3;'], ['del', '-flag stays a deletion of "-flag"'], ['note', 'No newline at end of file']]); + assert.ok(content.lines.some(line => line.kind === 'muted' && line.text === 'binary file: not shown')); + assert.ok(content.lines.some(line => line.kind === 'muted' && /rename only/u.test(line.text))); + assert.equal(content.partial, undefined); + const panel = new PanelController(); + panel.show(content); + const plain = renderPanel(panel, {width: 60, rows: 30, focused: false, safe: true, color: false, now: 2000}); + assert.ok(plain.includes('--flag stays a deletion of "-flag"'), 'a deleted line that starts with "-" keeps its own dash'); + assert.ok(plain.includes('+const b = 3;')); + assert.ok(plain.includes('\\ No newline at end of file')); +}); + +test('local diff: hostile content is inert and bounded; a cut diff says so', () => { + const hostile = `diff --git a/x b/x\n@@ -1 +1 @@\n-\u001b]8;;https://evil\u0007link\u001b[2J\n+‮evil\u0000\n`; + const panel = new PanelController(); + panel.show(diffContent('d', 'x', hostile, 'git diff')); + for (const line of renderPanel(panel, {width: 40, rows: 12, focused: true, safe: false, color: false, now: 0})) assert.doesNotMatch(line, /[\u0000-\u0008\u000b-\u001f\u007f‮]/u); + const big = `diff --git a/x b/x\n@@ -1,5000 +1,5000 @@\n${Array.from({length: 5000}, (_, i) => `+line ${i}`).join('\n')}\n`; + const cut = diffContent('d', 'x', big, 'git diff', undefined, 100); + assert.match(cut.partial!, /up to 100 lines/u); + assert.ok(cut.lines.filter(line => line.kind === 'diff').length <= 100); + assert.deepEqual(diffContent('d', 'x', '', 'git diff').lines, [{kind: 'muted', text: 'no changes'}]); +}); + +test('diff headings: a name containing " b/" stays whole; hostile headers are read in linear time', () => { + const heading = (diff: string) => (diffContent('d', 'x', diff, 'git diff').lines[0] as {text: string}).text; + assert.equal(heading('diff --git a/my b/file b/my b/file\nold mode 100644\nnew mode 100755\n'), 'my b/file'); + assert.equal(heading('diff --git a/old name b/new name\nsimilarity index 100%\n'), 'old name → new name'); + assert.equal(heading('diff --git "a/tab\\there" "b/tab\\there"\n'), 'diff', 'a quoted header is not guessed at'); + const name = ' b/'.repeat(400); + const hostile = Array.from({length: 1000}, (_, i) => `diff --git a/${name}${i} b/${name}${i}x${'y'.repeat(1100)}\nold mode 100644\nnew mode 100755\n`).join(''); + const started = performance.now(); + diffContent('d', 'x', hostile, 'git diff'); + const elapsed = performance.now() - started; + assert.ok(elapsed < 400, `took ${elapsed.toFixed(0)} ms`); +}); + +test('the panel returns to where each content was scrolled', () => { + const panel = new PanelController(); + const long = (id: string) => ({id, title: id, source: 'test', lines: Array.from({length: 100}, (_, i) => ({kind: 'text' as const, text: `${id} ${i}`}))}); + panel.show(long('a')); + panel.dispatch('panel.pageDown', 20); panel.dispatch('panel.pageDown', 20); + const scrolled = panel.scroll; + panel.show(long('b')); + assert.equal(panel.scroll, 0, 'new content starts at its top'); + panel.show(long('a')); + assert.equal(panel.scroll, scrolled, 'going back keeps the place'); + panel.show({...long('a'), lines: long('a').lines.slice(0, 50)}); + assert.equal(panel.scroll, scrolled, 'a refresh of the same content keeps its place (render clamps it)'); +}); + +test('resource and worktree details say what opening would do, or why it cannot; nothing is inferred', () => { + const tmux = resourceDetail({id: 'tmux:$1:@2', kind: 'tmuxWindow', label: '1 servers', support: 'activatable', activation: {kind: 'tmux', scope: 'window', target: '@2'}, observedAt: 5, children: []}); + assert.ok(tmux.lines.some(line => line.kind === 'text' && line.text === 'Enter: tmux selects this window and switches your client to it.')); + const codex = resourceDetail({id: 'x', kind: 'managedTarget', label: 'Codex', support: 'unsupported', reason: 'no supported Codex adapter yet'}); + assert.ok(codex.lines.some(line => line.kind === 'muted' && /Cannot be opened here: no supported Codex adapter yet/u.test(line.text))); + const record: WorktreeRecord = {id: '/w', path: '/w', ref: 'refs/heads/f', branch: 'f', head: 'abcdef1234567890', detached: false, bare: false, main: false, + locked: true, lockedReason: 'on a USB drive', prunable: false, pathState: 'present', status: {kind: 'unknown', reason: 'status timed out'}}; + const detail = worktreeDetail(record, 9); + assert.deepEqual(detail.lines.slice(0, 4), [{kind: 'field', label: 'Path', value: '/w'}, {kind: 'field', label: 'Checkout', value: 'f'}, + {kind: 'field', label: 'HEAD', value: 'abcdef123456'}, {kind: 'field', label: 'Status', value: 'unknown (status timed out)'}], 'an unknown status is never shown as clean'); +}); + +const fact = (value: unknown, extra: Partial> = {}): ContextFact => ({value, source: {capability: 'runtime.node', evidence: '.nvmrc, node on PATH'}, + collectedAt: 1000, freshness: 'fresh', trust: 'workspace', sensitivity: 'public', persistence: 'snapshot-safe', resolution: 'bounded-async', ...extra}); + +test('module detail: value, provenance, freshness and what the capability reads; private fields stay private', () => { + const shown = factDetail('runtime.node', 'Node.js version', ['.nvmrc', 'node_version.h'], fact({requested: '22', active: '22.11.0'}), 11_000); + assert.deepEqual(shown.lines.slice(0, 2), [{kind: 'field', label: 'requested', value: '22'}, {kind: 'field', label: 'active', value: '22.11.0'}]); + assert.ok(shown.lines.some(line => line.kind === 'field' && line.label === 'Collected' && line.value === '10 s ago')); + assert.ok(shown.lines.some(line => line.kind === 'muted' && line.text === 'node_version.h')); + const secret = factDetail('agent.claude', 'Claude', [], fact({cost: 3}, {sensitivity: 'secret'}), 0); + assert.ok(secret.lines.some(line => line.kind === 'muted' && /not displayable/u.test(line.text))); + assert.ok(factDetail('x.y', 'X', [], undefined, 0).lines[0]!.kind === 'muted'); +}); + +test('panel: exact size, focus mark without color, scrolling clamps, the same content keeps its scroll, previews come from the cache', () => { + const panel = new PanelController(); + const long = {id: 'a', title: 'Long', source: 'test', observedAt: 0, lines: Array.from({length: 80}, (_, i) => ({kind: 'text' as const, text: `line ${i} ${'word '.repeat(i % 7)}`}))}; + panel.show(long); + for (const width of [10, 24, 40, 72]) for (const rows of [1, 2, 5, 20]) for (const color of [true, false]) for (const focused of [true, false]) { + const lines = renderPanel(panel, {width, rows, focused, safe: false, color, now: 120_000}); + assert.equal(lines.length, rows); + for (const line of lines) assert.ok(displayWidth(stripAnsi(line)) <= width, `${width}x${rows}: ${stripAnsi(line)}`); + } + const focused = renderPanel(panel, {width: 40, rows: 10, focused: true, safe: true, color: false, now: 120_000}); + assert.match(focused[0]!, /^> Long/u); + assert.match(focused[1]!, /^test · 2m ago, stale/u); + assert.match(focused.at(-1)!, /^1-7\/80 · /u); + assert.match(renderPanel(panel, {width: 40, rows: 10, focused: false, safe: true, color: false, now: 1000})[0]!, /^ {2}Long/u); + panel.dispatch('panel.pageDown', 8); + assert.equal(panel.scroll, 7); + for (let i = 0; i < 50; i++) panel.dispatch('panel.pageDown', 8); + renderPanel(panel, {width: 40, rows: 10, focused: true, safe: true, color: false, now: 0}); + assert.equal(panel.scroll, 80 - 7, 'scrolling stops at the end'); + panel.show({...long, lines: long.lines.slice(0, 79)}); + assert.equal(panel.scroll, 73, 'a refresh of the same content keeps its place'); + panel.show({id: 'b', title: 'Other', source: 'test', lines: []}); + assert.equal(panel.scroll, 0); + assert.equal(panel.cached('a')?.title, 'Long'); + for (let i = 0; i < 40; i++) panel.show({id: `n${i}`, title: `${i}`, source: 'test', lines: []}); + assert.equal(panel.cached('a'), undefined, 'the preview cache is bounded'); + const empty = new PanelController(); + assert.match(renderPanel(empty, {width: 40, rows: 3, focused: false, safe: true, color: false, now: 0})[1]!, /Select something/u); +});