/** * Cockpit review pane (DRAFT-001 v0.3.0). * * Right-anchored overlay: file list on the left, content on the right. * Three modes (B2): * - view : rendered markdown (default) * - diff : unified diff vs session-start snapshot * - select : raw source with line numbers; range selection with j/k * * Keys (mode-dependent): * global: tab/shift+tab cycle files, esc close (or exit mode) * view : j/k scroll, pgup/pgdn page, g/G top/bot, d → diff, v → select * diff : j/k scroll, pgup/pgdn page, g/G top/bot, d → view * select: j extend down, k extend up, J contract from top, K contract * from bottom, enter commits (calls onQuote), esc cancels */ import * as fs from "node:fs"; import { type Component, Markdown, matchesKey, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import type { CockpitState, FileEntry } from "./state"; import { type DiffLine, unifiedDiff } from "./diff"; import { formatQuote } from "./quote"; import { applyStatusFlip, nextStatus, type SpecStatus, todayIso } from "./frontmatter"; import { type JournalPlan, planJournalAppend } from "./actions/journal"; import { type ChangelogPlan, type ChangelogSection, CHANGELOG_SECTIONS, planChangelogAppend, } from "./actions/changelog"; interface Theme { fg: (color: string, text: string) => string; bg: (color: string, text: string) => string; bold?: (s: string) => string; } // Opaque to us; comes from getMarkdownTheme(). type MarkdownTheme = unknown; export interface CockpitPaneOptions { state: CockpitState; theme: Theme; mdTheme: MarkdownTheme; onClose: () => void; requestRender: () => void; getMaxRows: () => number; /** Called when the user commits a line-selection; pane should be closed by caller. */ onQuote: (text: string) => void; /** User-facing notification (e.g. when a status flip fails). */ notify?: (msg: string, level?: "info" | "warning" | "error") => void; /** Absolute path of the @cleepi/sdd package, used to find templates/. */ sddPackageRoot: string; } type Mode = "view" | "diff" | "select"; const LIST_WIDTH = 30; // 3-col gutter: space + vertical bar + space. Wider than 1 col so the // divider reads as a real boundary, not a column of accidental whitespace. const GUTTER = 3; const MIN_VIEWER_WIDTH = 24; export class CockpitPane implements Component { private opts: CockpitPaneOptions; private files: FileEntry[] = []; private focusedIdx = 0; private scrollY = 0; private listScroll = 0; private mode: Mode = "view"; // Selection state (only meaningful in `select` mode). 0-based source line indices. private selAnchor = 0; private selCursor = 0; // Filter state. `filterText` empty == no filter. `filterTyping` true means // keyboard input is routed to the filter editor (added to filterText). // focusedIdx indexes into the *filtered* file list when a filter is active. private filterText = ""; private filterTyping = false; /** Status filter applied on top of the text filter. "all" disables it. */ private statusFilter: "all" | SpecStatus = "all"; /** When true, only files touched by the agent this session are shown. */ private editedOnly = false; /** Pending status-flip confirmation (inline confirm bar). */ private pendingFlip: { from: SpecStatus; to: SpecStatus; absPath: string; relPath: string } | null = null; /** * Pending journal/changelog input. `kind` selects which action; for `c`, * a null `section` means we're still on the section-picker step. */ private pendingInput: { kind: "j" | "c"; section: ChangelogSection | null; text: string; } | null = null; /** * Pending journal/changelog write: a built plan plus diff lines for the * preview view. y commits, n/esc cancels. */ private pendingWrite: { kind: "j" | "c"; targetPath: string; relTargetPath: string; originalContents: string; newContents: string; isNew: boolean; /** Unstyled diff ops; styled at render time using actual viewer width. */ diffOps: DiffLine[]; } | null = null; private previewStyledKey: number | null = null; private previewStyledLines: string[] = []; /** * Transient in-pane message (overrides footer hints until the user moves). * The chat-level notify is invisible while the overlay is open, so important * feedback ("no spec status to flip", write failures) is duplicated here. */ private flash: { text: string; level: "info" | "warning" | "error" } | null = null; // Per-mode content caches. Keyed by `${absPath}|${width}|${mode}`. private viewCacheKey: string | null = null; private viewLines: string[] = []; private diffCacheKey: string | null = null; private diffLines: string[] = []; // pre-styled private rawCacheKey: string | null = null; private rawSource: string[] = []; // raw lines, no styling private rawLineNumWidth = 1; // Render cache for the whole pane. private cachedWidth: number | null = null; private cachedHeight: number | null = null; private cachedLines: string[] | null = null; private lastBodyH = 25; constructor(opts: CockpitPaneOptions) { this.opts = opts; this.refreshFiles(); } // --- public --- refreshFiles(): void { const prevPath = this.displayFiles()[this.focusedIdx]?.absPath; this.files = sortFiles(this.opts.state.all(), this.opts.state.current()); const display = this.displayFiles(); if (display.length === 0) { this.focusedIdx = 0; } else { const newIdx = display.findIndex((f) => f.absPath === prevPath); this.focusedIdx = newIdx >= 0 ? newIdx : 0; } this.invalidate(); } /** Files visible after applying text + status + edited-only filters. */ private displayFiles(): FileEntry[] { const q = this.filterText.trim().toLowerCase(); return this.files.filter((f) => { if (q.length > 0 && !f.relPath.toLowerCase().includes(q)) return false; if (this.editedOnly && f.edits === 0) return false; if (this.statusFilter !== "all") { const s = this.opts.state.getStatus(f.absPath); if (s !== this.statusFilter) return false; } return true; }); } // --- Component --- handleInput(data: string): void { // Pending write preview has top priority. if (this.pendingWrite) { this.handleWritePreviewInput(data); return; } // Pending input (j/c) is next. if (this.pendingInput) { this.handleActionInput(data); return; } // Pending status-flip confirm. if (this.pendingFlip) { this.handleFlipConfirmInput(data); return; } // Filter typing has next priority — it captures most printable input. if (this.filterTyping) { this.handleFilterTypingInput(data); return; } // Global: esc backs out of the innermost context. if (matchesKey(data, "escape")) { if (this.mode === "select" || this.mode === "diff") { this.mode = "view"; this.scrollY = 0; this.invalidate(); this.opts.requestRender(); return; } // From view: clear filter if any, otherwise close. if (this.filterText.length > 0 || this.statusFilter !== "all") { this.clearFilter(); return; } this.opts.onClose(); return; } if (this.files.length === 0) return; // Mode-specific input. if (this.mode === "select") { this.handleSelectInput(data); return; } // Enter filter typing from view/diff (but not select — select uses "/" // for nothing currently so safe even there, but disable to avoid surprise). if (data === "/") { this.filterTyping = true; this.invalidate(); this.opts.requestRender(); return; } // Navigation across the *filtered* file list (view + diff). const display = this.displayFiles(); if (matchesKey(data, "tab")) { if (display.length === 0) return; this.focusFile((this.focusedIdx + 1) % display.length); return; } if (matchesKey(data, "shift+tab")) { if (display.length === 0) return; this.focusFile((this.focusedIdx - 1 + display.length) % display.length); return; } // Status filters (digits map to status buckets). if (data === "1") { this.setStatusFilter("all"); return; } if (data === "2") { this.setStatusFilter("draft"); return; } if (data === "3") { this.setStatusFilter("accepted"); return; } if (data === "4") { this.setStatusFilter("shipped"); return; } // Toggle "only files edited this session". if (data === "e") { this.editedOnly = !this.editedOnly; this.clearFlash(); this.clampFocusToDisplay(); this.invalidate(); this.opts.requestRender(); return; } // Status flip: only available when focused file has a parsed spec status. if (data === "s") { this.beginStatusFlip(); return; } // Structured-action entries (v0.3.2). Uppercase to avoid colliding with // `j` (scroll down) and the implicit lowercase-letter convention. if (data === "J") { this.beginAction("j"); return; } if (data === "C") { this.beginAction("c"); return; } // Mode toggles. if (data === "d") { const focused = this.files[this.focusedIdx]!; if (this.mode === "diff") { this.mode = "view"; } else { // Can show diff even with no snapshot (treat as all-add or "no changes"). this.mode = "diff"; } this.scrollY = 0; this.invalidate(); this.opts.requestRender(); return; } if ((data === "v" || data === "V") && this.mode === "view") { this.enterSelect(); return; } // Scroll. if (matchesKey(data, "j") || matchesKey(data, "down")) { this.clearFlash(); this.scrollY += 1; this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "k") || matchesKey(data, "up")) { this.clearFlash(); this.scrollY = Math.max(0, this.scrollY - 1); this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "pageDown")) { this.scrollY += Math.max(1, this.lastBodyH - 2); this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "pageUp")) { this.scrollY = Math.max(0, this.scrollY - Math.max(1, this.lastBodyH - 2)); this.invalidate(); this.opts.requestRender(); return; } if (data === "g") { this.scrollY = 0; this.invalidate(); this.opts.requestRender(); return; } if (data === "G") { this.scrollY = Number.MAX_SAFE_INTEGER; this.invalidate(); this.opts.requestRender(); return; } } invalidate(): void { this.cachedWidth = null; this.cachedHeight = null; this.cachedLines = null; } render(width: number): string[] { const maxRows = Math.max(8, this.opts.getMaxRows()); if (this.cachedLines && this.cachedWidth === width && this.cachedHeight === maxRows) { return this.cachedLines; } const theme = this.opts.theme; const lines: string[] = []; const listW = Math.min(LIST_WIDTH, Math.max(10, width - MIN_VIEWER_WIDTH - GUTTER)); const viewerW = Math.max(MIN_VIEWER_WIDTH, width - listW - GUTTER); const headerH = this.pendingFlip || this.pendingInput || this.pendingWrite ? 2 : 1; const footerH = 1; const bodyH = Math.max(3, maxRows - headerH - footerH); this.lastBodyH = bodyH; const focused = this.displayFiles()[this.focusedIdx]; const gutterStr = " " + theme.fg("border", "│") + " "; // visible width = 3 // Header (with optional confirm bar above) const confirmBar = this.buildConfirmBar(width); if (confirmBar) { lines.push(truncateToWidth(confirmBar, width)); } lines.push(truncateToWidth(this.buildHeader(focused, viewerW, listW, gutterStr), width)); // Body const listRows = this.renderList(listW, bodyH); const viewerRows = this.renderViewer(focused, viewerW, bodyH); for (let i = 0; i < bodyH; i++) { const left = padToWidth(listRows[i] ?? "", listW); const right = padToWidth(viewerRows[i] ?? "", viewerW); lines.push(truncateToWidth(left + gutterStr + right, width)); } // Footer lines.push(truncateToWidth(this.buildFooter(focused, width), width)); this.cachedWidth = width; this.cachedHeight = maxRows; this.cachedLines = lines; return lines; } // --- internals --- private focusFile(idx: number): void { this.focusedIdx = idx; this.scrollY = 0; // Don't preserve mode across file switches — back to view. this.mode = "view"; this.invalidate(); this.opts.requestRender(); } private handleFilterTypingInput(data: string): void { // Commit filter, stay applied, exit typing. if (matchesKey(data, "enter")) { this.filterTyping = false; this.clampFocusToDisplay(); this.invalidate(); this.opts.requestRender(); return; } // Cancel filter entirely. if (matchesKey(data, "escape")) { this.clearFilter(); return; } // Arrow keys + tab navigate the filtered list *without* exiting typing // mode — the user can refine their query and move between matches at // the same time, the way every modern file picker works. const display = this.displayFiles(); if (matchesKey(data, "down") || matchesKey(data, "tab")) { if (display.length > 0) { this.focusedIdx = (this.focusedIdx + 1) % display.length; this.scrollY = 0; this.mode = "view"; this.invalidate(); this.opts.requestRender(); } return; } if (matchesKey(data, "up") || matchesKey(data, "shift+tab")) { if (display.length > 0) { this.focusedIdx = (this.focusedIdx - 1 + display.length) % display.length; this.scrollY = 0; this.mode = "view"; this.invalidate(); this.opts.requestRender(); } return; } // Backspace: delete last char. Empty filter + backspace = no-op. if (matchesKey(data, "backspace")) { if (this.filterText.length > 0) { this.filterText = this.filterText.slice(0, -1); this.clampFocusToDisplay(); this.invalidate(); this.opts.requestRender(); } return; } // Printable ASCII: append to filter. if (isPrintableSingleChar(data)) { this.filterText += data; this.clampFocusToDisplay(); this.invalidate(); this.opts.requestRender(); return; } // Anything else (ctrl+x, function keys, etc.) is swallowed while typing. } private clearFilter(): void { if ( this.filterText.length === 0 && !this.filterTyping && this.statusFilter === "all" && !this.editedOnly ) return; this.filterText = ""; this.filterTyping = false; this.statusFilter = "all"; this.editedOnly = false; this.focusedIdx = 0; this.invalidate(); this.opts.requestRender(); } private setStatusFilter(s: "all" | SpecStatus): void { if (this.statusFilter === s) return; this.statusFilter = s; this.clearFlash(); this.clampFocusToDisplay(); this.invalidate(); this.opts.requestRender(); } private setFlash(text: string, level: "info" | "warning" | "error"): void { this.flash = { text, level }; this.invalidate(); this.opts.requestRender(); } private clearFlash(): void { if (this.flash === null) return; this.flash = null; this.invalidate(); } private beginStatusFlip(): void { const focused = this.displayFiles()[this.focusedIdx]; if (!focused) { this.setFlash("no file focused", "warning"); return; } const current = this.opts.state.getStatus(focused.absPath); if (!current) { this.setFlash( `'${pathBasename(focused.relPath)}' has no spec status (not a spec file?)`, "warning", ); this.opts.notify?.( `spec cockpit: ${focused.relPath} has no spec status to flip`, "warning", ); return; } const next = nextStatus(current); if (!next) { this.setFlash(`already ${current}, no next status`, "info"); this.opts.notify?.( `spec cockpit: ${focused.relPath} is already ${current}; no next status`, "info", ); return; } this.pendingFlip = { from: current, to: next, absPath: focused.absPath, relPath: focused.relPath, }; this.invalidate(); this.opts.requestRender(); } private beginAction(kind: "j" | "c"): void { const focused = this.displayFiles()[this.focusedIdx]; if (!focused) { this.setFlash("no file focused", "warning"); return; } this.pendingInput = { kind, section: null, text: "" }; this.invalidate(); this.opts.requestRender(); } private handleActionInput(data: string): void { if (!this.pendingInput) return; // Section-picker phase for changelog. if (this.pendingInput.kind === "c" && this.pendingInput.section === null) { if (matchesKey(data, "escape")) { this.pendingInput = null; this.invalidate(); this.opts.requestRender(); return; } const idx = parseInt(data, 10); if (Number.isInteger(idx) && idx >= 1 && idx <= CHANGELOG_SECTIONS.length) { this.pendingInput.section = CHANGELOG_SECTIONS[idx - 1]!; this.invalidate(); this.opts.requestRender(); } return; } // Text-input phase. if (matchesKey(data, "escape")) { this.pendingInput = null; this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "enter")) { this.commitActionInput(); return; } if (matchesKey(data, "backspace")) { if (this.pendingInput.text.length > 0) { this.pendingInput.text = this.pendingInput.text.slice(0, -1); this.invalidate(); this.opts.requestRender(); } return; } if (isPrintableSingleChar(data)) { this.pendingInput.text += data; this.invalidate(); this.opts.requestRender(); } } private commitActionInput(): void { if (!this.pendingInput) return; const focused = this.displayFiles()[this.focusedIdx]; if (!focused) { this.pendingInput = null; this.setFlash("no file focused", "warning"); return; } const text = this.pendingInput.text.trim(); if (text.length === 0) { this.setFlash("entry is empty", "warning"); return; } if (this.pendingInput.kind === "j") { const result = planJournalAppend(focused.absPath, text, this.opts.sddPackageRoot); if (!result.ok) { this.pendingInput = null; this.setFlash(`journal: ${result.reason}`, "warning"); return; } this.openPreview("j", result.plan); } else { const section = this.pendingInput.section; if (!section) return; // shouldn't happen — section is required before typing const result = planChangelogAppend( focused.absPath, this.opts.state.repoRoot, section, text, ); if (!result.ok) { this.pendingInput = null; this.setFlash(`changelog: ${result.reason}`, "warning"); return; } this.openPreview("c", result.plan); } } private openPreview(kind: "j" | "c", plan: JournalPlan | ChangelogPlan): void { const ops = unifiedDiff(plan.isNew ? null : plan.originalContents, plan.newContents); this.pendingInput = null; this.pendingWrite = { kind, targetPath: plan.targetPath, relTargetPath: plan.relTargetPath, originalContents: plan.originalContents, newContents: plan.newContents, isNew: plan.isNew, diffOps: ops, }; this.previewStyledKey = null; this.previewStyledLines = []; this.scrollY = 0; this.invalidate(); this.opts.requestRender(); } private handleWritePreviewInput(data: string): void { if (!this.pendingWrite) return; if (data === "y" || data === "Y") { const pw = this.pendingWrite; this.pendingWrite = null; try { fs.writeFileSync(pw.targetPath, pw.newContents, "utf8"); } catch (e) { this.setFlash(`write failed: ${(e as Error).message}`, "error"); this.invalidate(); this.opts.requestRender(); return; } // Invalidate per-file caches that read disk. this.opts.state.invalidateStatus(pw.targetPath); this.viewCacheKey = null; this.rawCacheKey = null; this.diffCacheKey = null; const label = pw.kind === "j" ? "journal" : "changelog"; const verb = pw.isNew ? "created" : "appended"; this.setFlash(`${label} ${verb}: ${pw.relTargetPath}`, "info"); this.opts.notify?.(`spec cockpit: ${label} ${verb} → ${pw.relTargetPath}`, "info"); this.invalidate(); this.opts.requestRender(); return; } if (data === "n" || data === "N" || matchesKey(data, "escape")) { this.pendingWrite = null; this.setFlash("write cancelled", "info"); this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "down")) { this.scrollY += 1; this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "up")) { this.scrollY = Math.max(0, this.scrollY - 1); this.invalidate(); this.opts.requestRender(); return; } } private handleFlipConfirmInput(data: string): void { if (!this.pendingFlip) return; if (data === "y" || data === "Y") { const { absPath, to, relPath } = this.pendingFlip; this.pendingFlip = null; const result = applyStatusFlip(absPath, to, todayIso()); if (!result.ok) { this.setFlash(`flip failed: ${result.reason}`, "warning"); this.opts.notify?.( `spec cockpit: cannot flip status of ${relPath}: ${result.reason}`, "warning", ); this.invalidate(); this.opts.requestRender(); return; } try { fs.writeFileSync(absPath, result.contents, "utf8"); } catch (e) { this.setFlash(`write failed: ${(e as Error).message}`, "error"); this.opts.notify?.( `spec cockpit: write failed for ${relPath}: ${(e as Error).message}`, "error", ); this.invalidate(); this.opts.requestRender(); return; } // Bust caches that read disk content. this.opts.state.invalidateStatus(absPath); this.viewCacheKey = null; this.rawCacheKey = null; this.diffCacheKey = null; this.setFlash(`→ ${to}`, "info"); this.opts.notify?.( `spec cockpit: ${relPath} → ${to}`, "info", ); this.invalidate(); this.opts.requestRender(); return; } if (data === "n" || data === "N" || matchesKey(data, "escape")) { this.pendingFlip = null; this.invalidate(); this.opts.requestRender(); return; } // Any other input is swallowed so stray keystrokes can't dismiss the // confirm. } private clampFocusToDisplay(): void { const n = this.displayFiles().length; if (n === 0) { this.focusedIdx = 0; } else if (this.focusedIdx >= n) { this.focusedIdx = n - 1; } else if (this.focusedIdx < 0) { this.focusedIdx = 0; } } private enterSelect(): void { const focused = this.files[this.focusedIdx]; if (!focused) return; this.ensureRawSource(focused); const total = this.rawSource.length; if (total === 0) return; this.mode = "select"; // Note: scrollY in `view` mode counts wrapped markdown lines, not source // lines, so reusing it as the source-line anchor would jump unpredictably. // Anchor at the top of the source instead; the user can j/g/G from there. this.scrollY = 0; this.selAnchor = 0; this.selCursor = 0; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); } private handleSelectInput(data: string): void { const focused = this.files[this.focusedIdx]; if (!focused) return; const total = this.rawSource.length; if (total === 0) return; if (matchesKey(data, "enter")) { const [a, b] = this.selRange(); const selected = this.rawSource.slice(a, b + 1); const text = formatQuote(focused.relPath, a + 1, b + 1, selected); this.opts.onQuote(text); return; } // Move cursor (anchor follows): plain j/k, arrow keys. if (data === "j" || matchesKey(data, "down")) { this.selCursor = Math.min(total - 1, this.selCursor + 1); this.selAnchor = this.selCursor; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } if (data === "k" || matchesKey(data, "up")) { this.selCursor = Math.max(0, this.selCursor - 1); this.selAnchor = this.selCursor; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } // Extend selection (anchor pinned): shift+j / shift+k. if (data === "J" || matchesKey(data, "shift+down")) { this.selCursor = Math.min(total - 1, this.selCursor + 1); this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } if (data === "K" || matchesKey(data, "shift+up")) { this.selCursor = Math.max(0, this.selCursor - 1); this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } // Jump cursor (anchor follows). if (data === "g") { this.selCursor = 0; this.selAnchor = 0; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } if (data === "G") { this.selCursor = total - 1; this.selAnchor = total - 1; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "pageDown")) { this.selCursor = Math.min(total - 1, this.selCursor + Math.max(1, this.lastBodyH - 2)); this.selAnchor = this.selCursor; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } if (matchesKey(data, "pageUp")) { this.selCursor = Math.max(0, this.selCursor - Math.max(1, this.lastBodyH - 2)); this.selAnchor = this.selCursor; this.ensureSelectionVisible(); this.invalidate(); this.opts.requestRender(); return; } } private selRange(): [number, number] { return this.selAnchor <= this.selCursor ? [this.selAnchor, this.selCursor] : [this.selCursor, this.selAnchor]; } private ensureSelectionVisible(): void { const top = this.scrollY; const bot = this.scrollY + this.lastBodyH - 1; if (this.selCursor < top) this.scrollY = this.selCursor; else if (this.selCursor > bot) this.scrollY = this.selCursor - this.lastBodyH + 1; } private buildHeader(focused: FileEntry | undefined, viewerW: number, listW: number, gutterStr: string): string { const theme = this.opts.theme; let leftLabel: string; if (this.filterTyping || this.filterText.length > 0 || this.statusFilter !== "all" || this.editedOnly) { const total = this.files.length; const matched = this.displayFiles().length; const cursor = this.filterTyping ? theme.fg("accent", "_") : ""; const sfTag = this.statusFilter === "all" ? "" : theme.fg("muted", ` [${this.statusFilter}]`); const eoTag = this.editedOnly ? theme.fg("muted", " [edited]") : ""; const count = this.filterTyping ? "" : theme.fg("muted", ` (${matched}/${total})`); leftLabel = theme.fg("accent", "⌕ ") + theme.fg("text", this.filterText) + cursor + sfTag + eoTag + count; } else { leftLabel = theme.fg("accent", "SDD cockpit"); } const leftPadded = padToWidth(leftLabel, listW); if (!focused) { return leftPadded + gutterStr + theme.fg("muted", "no specs tracked"); } const modeLabel = this.mode === "diff" ? theme.fg("warning", "[diff]") : this.mode === "select" ? theme.fg("accent", `[select L${this.selRange()[0] + 1}-L${this.selRange()[1] + 1}]`) : ""; const total = this.mode === "view" ? this.viewLines.length : this.mode === "diff" ? this.diffLines.length : this.rawSource.length; const visibleTop = clamp(this.scrollY, 0, Math.max(0, total - 1)); const visibleBot = Math.min(total, visibleTop + this.lastBodyH); const pos = total > 0 ? `L${visibleTop + 1}–${visibleBot}/${total}` : "empty"; const editsLabel = focused.edits > 0 ? ` · ${focused.edits} edit${focused.edits === 1 ? "" : "s"}` : ""; const right = `${theme.fg("accent", focused.relPath)}${theme.fg("muted", editsLabel)} ${modeLabel}${modeLabel ? " " : ""}${theme.fg("dim", pos)}`; return leftPadded + gutterStr + right; } private buildConfirmBar(width: number): string { const theme = this.opts.theme; if (this.pendingWrite) { const pw = this.pendingWrite; const label = pw.kind === "j" ? "journal" : "changelog"; const verb = pw.isNew ? "create" : "append to"; const msg = `Preview — ${verb} ${label}: ${pw.relTargetPath}? (y/n)`; return theme.bg("toolPendingBg", padToWidth(theme.fg("warning", " " + msg), width)); } if (this.pendingInput) { const pi = this.pendingInput; if (pi.kind === "c" && pi.section === null) { const list = CHANGELOG_SECTIONS.map((s, i) => `${i + 1}=${s}`).join(" "); const msg = `Changelog section? ${list} (esc cancel)`; return theme.bg("toolPendingBg", padToWidth(theme.fg("warning", " " + msg), width)); } const cursor = theme.fg("accent", "_"); const prefix = pi.kind === "j" ? "Journal entry:" : `Changelog entry (${pi.section}):`; const msg = ` ${prefix} ${theme.fg("text", pi.text)}${cursor}`; return theme.bg("toolPendingBg", padToWidth(theme.fg("warning", msg), width)); } if (this.pendingFlip) { const { from, to, relPath } = this.pendingFlip; const stamp = to === "accepted" || to === "shipped" ? `, stamp ${to}: ${todayIso()}` : ""; const msg = `Flip ${relPath}: ${from} → ${to}${stamp}? (y/n)`; return theme.bg("toolPendingBg", padToWidth(theme.fg("warning", " " + msg), width)); } return ""; } private buildFooter(focused: FileEntry | undefined, _width: number): string { const theme = this.opts.theme; // Flash beats everything: this is how the user finds out a press did // something when chat-level notify is hidden behind the overlay. if (this.flash) { const color = this.flash.level === "error" ? "error" : this.flash.level === "warning" ? "warning" : "accent"; return theme.fg(color, "⚠ " + this.flash.text); } if (!focused) return theme.fg("dim", "tab files · esc close"); let hints: string; if (this.pendingWrite) { hints = "y write · n / esc cancel · j/k scroll preview"; } else if (this.pendingInput) { if (this.pendingInput.kind === "c" && this.pendingInput.section === null) { hints = "1–6 pick section · esc cancel"; } else { hints = "type · backspace delete · enter preview · esc cancel"; } } else if (this.pendingFlip) { hints = "y confirm · n / esc cancel"; } else if (this.filterTyping) { hints = "type to filter · ↑↓ / tab navigate matches · enter apply · esc cancel"; } else if (this.mode === "select") { hints = "j/k move · J/K extend · g/G top/bot · enter quote → chat · esc cancel"; } else if (this.mode === "diff") { hints = "j/k scroll · d back to view · / filter · tab files · esc close"; } else { const filterActive = this.filterText.length > 0 || this.statusFilter !== "all" || this.editedOnly; const filterHint = filterActive ? " · esc clear filter" : ""; hints = `j/k scroll · d diff · v select · s flip · J journal · C changelog · e edited${filterHint} · esc close`; } return theme.fg("dim", hints); } private renderList(width: number, height: number): string[] { const theme = this.opts.theme; const display = this.displayFiles(); if (this.files.length === 0) return [theme.fg("muted", "(no .md files found)")]; if (display.length === 0) { return [theme.fg("muted", "(no matches)")]; } // Package-keyed tree: // pkg = "packages/" if relPath starts with packages/, else "(root)" // sub = path within the package (the file's containing directory). // "(top)" for files sitting directly in the package root. // Groups with zero matching files are omitted (Q5 resolution: hide empties). type Row = | { kind: "pkgHeader"; label: string } | { kind: "subHeader"; label: string } | { kind: "file"; displayIdx: number; file: FileEntry }; const tree = new Map>>(); const pkgOrder: string[] = []; display.forEach((f, i) => { const { pkg, sub } = treeGroupOf(f.relPath); if (!tree.has(pkg)) { tree.set(pkg, new Map()); pkgOrder.push(pkg); } const subMap = tree.get(pkg)!; if (!subMap.has(sub)) subMap.set(sub, []); subMap.get(sub)!.push({ displayIdx: i, file: f }); }); // Sort: "(root)" first, then packages alphabetically. pkgOrder.sort((a, b) => { if (a === "(root)") return -1; if (b === "(root)") return 1; return a.localeCompare(b); }); const rows: Row[] = []; let focusedRow = 0; for (const pkg of pkgOrder) { rows.push({ kind: "pkgHeader", label: pkg }); const subMap = tree.get(pkg)!; const subOrder = Array.from(subMap.keys()).sort((a, b) => { if (a === "(top)") return -1; if (b === "(top)") return 1; return a.localeCompare(b); }); for (const sub of subOrder) { if (sub !== "(top)") { rows.push({ kind: "subHeader", label: sub }); } for (const item of subMap.get(sub)!) { if (item.displayIdx === this.focusedIdx) focusedRow = rows.length; rows.push({ kind: "file", displayIdx: item.displayIdx, file: item.file }); } } } // Auto-scroll to keep the focused row visible. if (focusedRow < this.listScroll) this.listScroll = focusedRow; if (focusedRow >= this.listScroll + height) { this.listScroll = focusedRow - height + 1; } const out: string[] = []; for (let i = 0; i < height; i++) { const idx = this.listScroll + i; if (idx >= rows.length) { out.push(""); continue; } const row = rows[idx]!; if (row.kind === "pkgHeader") { const label = compactPath(row.label, Math.max(4, width)); out.push(theme.fg("accent", label)); } else if (row.kind === "subHeader") { const label = compactPath(row.label, Math.max(4, width - 2)); out.push(" " + theme.fg("muted", label)); } else { const f = row.file; const isFocused = row.displayIdx === this.focusedIdx; const marker = isFocused ? "► " : " "; const status = this.opts.state.getStatus(f.absPath); const badge = renderStatusBadge(status, theme); const badgeVisibleW = status ? 2 : 0; // " " const editSuffix = f.edits > 0 ? ` ·${f.edits}` : f.touchedAt > 0 ? " ·" : ""; const basename = pathBasename(f.relPath); const indent = " "; // 4 cols (2 for pkgHeader-level, 2 more for subHeader-level) const labelMax = Math.max( 2, width - indent.length - marker.length - badgeVisibleW - editSuffix.length, ); const label = compactPath(basename, labelMax); const labelText = marker + label + editSuffix; const styledLabel = isFocused ? theme.fg("accent", labelText) : f.edits > 0 ? theme.fg("text", labelText) : theme.fg("muted", labelText); out.push(indent + badge + styledLabel); } } return out; } private renderViewer(focused: FileEntry | undefined, width: number, height: number): string[] { if (!focused) return [this.opts.theme.fg("muted", "select a file")]; // Preview takes over the viewer when a write is pending. if (this.pendingWrite) { return this.renderPreview(width, height); } // When a filter is applied, focused may be a different file each render; // make sure modes always look at the currently focused entry. const display = this.displayFiles(); focused = display[this.focusedIdx] ?? focused; if (this.mode === "view") return this.renderViewMode(focused, width, height); if (this.mode === "diff") return this.renderDiffMode(focused, width, height); return this.renderSelectMode(focused, width, height); } private renderPreview(width: number, height: number): string[] { if (!this.pendingWrite) return []; if (this.previewStyledKey !== width) { const theme = this.opts.theme; const ops = this.pendingWrite.diffOps; this.previewStyledLines = ops.length === 0 ? [theme.fg("muted", "(no change)")] : ops.map((op) => formatDiffLine(op, width, theme)); this.previewStyledKey = width; } if (this.previewStyledLines.length === 0) { return [this.opts.theme.fg("muted", "(no change)")]; } return this.sliceWithScroll(this.previewStyledLines, height); } private renderViewMode(focused: FileEntry, width: number, height: number): string[] { this.ensureViewLines(focused, width); return this.sliceWithScroll(this.viewLines, height); } private renderDiffMode(focused: FileEntry, width: number, height: number): string[] { this.ensureDiffLines(focused, width); return this.sliceWithScroll(this.diffLines, height); } private renderSelectMode(focused: FileEntry, width: number, height: number): string[] { this.ensureRawSource(focused); const total = this.rawSource.length; if (total === 0) return [this.opts.theme.fg("muted", "(empty file)")]; if (this.scrollY > Math.max(0, total - height)) { this.scrollY = Math.max(0, total - height); } const [a, b] = this.selRange(); const gutterW = this.rawLineNumWidth; const innerW = Math.max(1, width - gutterW - 1); const out: string[] = []; for (let i = 0; i < height; i++) { const idx = this.scrollY + i; if (idx >= total) { out.push(""); continue; } const ln = String(idx + 1).padStart(gutterW, " "); const raw = this.rawSource[idx] ?? ""; const body = truncateToWidth(raw, innerW); const padded = padToWidth(body, innerW); const selected = idx >= a && idx <= b; const lineNum = this.opts.theme.fg("dim", ln); const sep = " "; const text = selected ? this.opts.theme.bg("selectedBg", padded) : padded; out.push(lineNum + sep + text); } return out; } private sliceWithScroll(lines: string[], height: number): string[] { const total = lines.length; if (total === 0) return [this.opts.theme.fg("muted", "(empty)")]; if (this.scrollY > Math.max(0, total - height)) { this.scrollY = Math.max(0, total - height); } const slice = lines.slice(this.scrollY, this.scrollY + height); while (slice.length < height) slice.push(""); return slice; } private ensureViewLines(focused: FileEntry, viewerW: number): void { const key = `${focused.absPath}|${viewerW}`; if (this.viewCacheKey === key) return; const content = readFileSafe(focused.absPath); const md = new Markdown(content, 0, 0, this.opts.mdTheme as never); this.viewLines = md.render(viewerW); this.viewCacheKey = key; } private ensureRawSource(focused: FileEntry): void { const key = focused.absPath; if (this.rawCacheKey === key) return; const content = readFileSafe(focused.absPath); this.rawSource = content.split("\n"); // Strip trailing empty line from trailing newline split artifact. if (this.rawSource.length > 1 && this.rawSource[this.rawSource.length - 1] === "") { this.rawSource.pop(); } this.rawLineNumWidth = Math.max(2, String(Math.max(1, this.rawSource.length)).length); this.rawCacheKey = key; } private ensureDiffLines(focused: FileEntry, viewerW: number): void { const key = `${focused.absPath}|${viewerW}`; if (this.diffCacheKey === key) return; const theme = this.opts.theme; const current = readFileSafe(focused.absPath); const snapshot = focused.snapshot; // Special case: file has never been edited by the agent this session. // `snapshot` is null because we only snapshot on first tool touch — not // at discovery time. Treat "no snapshot + no edits" as "no changes"; // only show a creation diff (all-add) when edits > 0 AND the snapshot // is null (= the file was created by the agent this session). if (snapshot === null && focused.edits === 0) { this.diffLines = [theme.fg("muted", "(no changes since session start)")]; this.diffCacheKey = key; return; } if (snapshot === current) { this.diffLines = [theme.fg("muted", "(no changes since session start)")]; this.diffCacheKey = key; return; } const ops = unifiedDiff(snapshot, current); if (ops.length === 0) { this.diffLines = [theme.fg("muted", "(no changes since session start)")]; this.diffCacheKey = key; return; } const innerW = Math.max(1, viewerW); this.diffLines = ops.map((op) => formatDiffLine(op, innerW, theme)); this.diffCacheKey = key; } } // --- helpers --- function formatDiffLine(op: DiffLine, width: number, theme: Theme): string { if (op.kind === "hunk") { return theme.fg("toolDiffContext", truncateToWidth(op.text, width)); } const marker = op.kind === "add" ? "+" : op.kind === "remove" ? "-" : " "; const raw = marker + op.text; const trunc = truncateToWidth(raw, width); if (op.kind === "add") return theme.fg("toolDiffAdded", trunc); if (op.kind === "remove") return theme.fg("toolDiffRemoved", trunc); return theme.fg("toolDiffContext", trunc); } function readFileSafe(p: string): string { try { return fs.readFileSync(p, "utf8"); } catch (e) { return `*(failed to read file: ${(e as Error).message})*`; } } function sortFiles(all: FileEntry[], _current: FileEntry | undefined): FileEntry[] { // Sort by directory, then by basename within. The grouped rendering in // renderList() relies on this stable order. Recency is *not* used to // reorder — the user wants to see files in their natural folder layout, // not have edited specs jump around. The current/touched files are still // visually marked (► and ·N) in the list. return [...all].sort((a, b) => { const da = dirGroupOf(a.relPath); const db = dirGroupOf(b.relPath); if (da !== db) return da.localeCompare(db); return pathBasename(a.relPath).localeCompare(pathBasename(b.relPath)); }); } function dirGroupOf(relPath: string): string { const idx = relPath.lastIndexOf("/"); return idx < 0 ? "(root)" : relPath.slice(0, idx); } function pathBasename(relPath: string): string { const idx = relPath.lastIndexOf("/"); return idx < 0 ? relPath : relPath.slice(idx + 1); } function treeGroupOf(relPath: string): { pkg: string; sub: string } { // Top-level package detection: "packages//..." const segs = relPath.split("/"); let pkg: string; let inner: string[]; if (segs.length >= 3 && segs[0] === "packages") { pkg = `packages/${segs[1]}`; inner = segs.slice(2); } else { pkg = "(root)"; inner = segs.slice(); } // Inner contains [..., basename]. The "sub" group is the inner dir, or // "(top)" when the file lives directly inside the package root. const sub = inner.length === 1 ? "(top)" : inner.slice(0, -1).join("/"); return { pkg, sub }; } function renderStatusBadge(status: SpecStatus | undefined, theme: Theme): string { if (!status) return ""; if (status === "draft") return theme.fg("warning", "○ "); if (status === "accepted") return theme.fg("success", "● "); if (status === "shipped") return theme.fg("accent", "◐ "); return theme.fg("muted", "× "); } function padToWidth(s: string, width: number): string { const w = visibleWidth(s); if (w >= width) return truncateToWidth(s, width); return s + " ".repeat(width - w); } function compactPath(p: string, maxWidth: number): string { if (p.length <= maxWidth) return p; const ellipsis = "…/"; const keep = maxWidth - ellipsis.length; if (keep <= 0) return p.slice(-maxWidth); return ellipsis + p.slice(-keep); } function clamp(n: number, lo: number, hi: number): number { if (n < lo) return lo; if (n > hi) return hi; return n; } function isPrintableSingleChar(data: string): boolean { // Single visible ASCII character (covers ticket IDs, dir names, basenames). // Unicode and multi-byte sequences are filtered out on purpose: arrow keys // and similar arrive as multi-char escape sequences that we want to ignore // while typing into the filter. if (data.length !== 1) return false; const code = data.charCodeAt(0); return code >= 32 && code <= 126; }