diff --git a/.agents/skills/pi-processes-testing/SKILL.md b/.agents/skills/pi-processes-testing/SKILL.md index 8e89346..c4cf4b9 100644 --- a/.agents/skills/pi-processes-testing/SKILL.md +++ b/.agents/skills/pi-processes-testing/SKILL.md @@ -65,6 +65,7 @@ When a feature needs a human in the loop (visual layout, keybinding feel, widget - `s` cycles sort (status, started, name) - `f` cycles filter (all, running, finished) - `/` opens a name quick filter; `enter` applies, `esc` clears +- `?` opens the keybinds overlay (`?`, `esc`, `enter`, `q`, `ctrl+c` close it) - `q` or `esc` closes ### /ps:logs overlay @@ -73,11 +74,27 @@ When a feature needs a human in the loop (visual layout, keybinding feel, widget - `tab` / `shift+tab` switch process tabs (viewer state is cached per process) - `g/G` jump to top or bottom - `j/k` or arrow keys scroll +- `pgup/pgdn` scroll by a full viewport +- `ctrl+u`/`ctrl+d` scroll by half a viewport - `s` switches between combined, stdout, and stderr - `f` toggles follow mode +- `w` toggles soft wrap - `/` enters search, `n/N` cycles matches, `esc` clears search +- `?` opens the keybinds overlay (`?`, `esc`, `enter`, `q`, `ctrl+c` close it) - `q` or `esc` closes +### Footer hints and the keybinds overlay + +- Single-letter shortcuts whose key letter occurs in their word render as the + word with the key letter highlighted (accent + bold): `wrap`, `follow`, + `clear`, `sort`, `filter`, `kill`. +- Hints whose key is not in the word keep the classic ` ` display + (`q close`, `/ search`, `j/k scroll`). +- When the hint list does not fit the footer width, a leading `? more` + affordance appears and remaining hints are dropped from the right. +- `?` opens a stacked keybinds panel (herdr-style groups: scrolling, view, + tabs, general — plus a search group while a search is active). + ### Dock and pin - `/ps:dock expand|collapse|close` controls dock visibility diff --git a/.changeset/logs-paging-and-hints.md b/.changeset/logs-paging-and-hints.md new file mode 100644 index 0000000..f21a151 --- /dev/null +++ b/.changeset/logs-paging-and-hints.md @@ -0,0 +1,13 @@ +--- +"@aliou/pi-processes": minor +--- + +The `/ps:logs` overlay now supports page scrolling: `PageUp`/`PageDown` +move by a full viewport and `ctrl+u`/`ctrl+d` by half a viewport. + +Footers (`/ps` overview and `/ps:logs` overlay) now render compact +shortcut hints: when a hint's key letter appears in its word, only the +word is shown with the key highlighted in accent + bold ("w wrap" becomes +"wrap"). When the hint list does not fit the footer width, a leading +"? more" affordance appears; pressing `?` opens a stacked shortcuts +overlay listing every available key. diff --git a/extensions/processes-logs/components/log-overlay-component.test.ts b/extensions/processes-logs/components/log-overlay-component.test.ts index 6cf1927..46cd6f5 100644 --- a/extensions/processes-logs/components/log-overlay-component.test.ts +++ b/extensions/processes-logs/components/log-overlay-component.test.ts @@ -17,6 +17,7 @@ const theme = { } as unknown as Theme; const TAB = "\t"; +const ESC = String.fromCharCode(27); function makeProcess(overrides: Partial = {}): ProcessInfo { return { @@ -198,3 +199,141 @@ describe("LogOverlayComponent wrap toggle", () => { expect(footer(overlay, width)).toContain("wrap"); }); }); + +// --- page scrolling --- + +const PAGE_DOWN = `${ESC}[6~`; +const PAGE_UP = `${ESC}[5~`; +const CTRL_D = String.fromCharCode(4); +const CTRL_U = String.fromCharCode(21); + +/** Log lines in the paging fixtures. */ +const TOTAL_LINES = 50; +/** Terminal rows in the paging fixtures. */ +const TERMINAL_ROWS = 40; +/** Viewport height (logRows()) for TERMINAL_ROWS under makeConfig(). */ +const VIEWPORT_ROWS = 24; +/** Half-viewport rows scrolled by ctrl+u / ctrl+d. */ +const HALF_PAGE_ROWS = VIEWPORT_ROWS / 2; + +/** Overlay over a single process with `count` log lines. */ +function makePagedOverlay(count: number, tuiOverrides: object = {}) { + const proc = makeProcess(); + const initialLines: Record = { + proc_1: Array.from({ length: count }, (_, i) => ({ + type: "stdout" as const, + text: `line ${i}`, + })), + }; + const events = makeEvents([proc], initialLines); + const tui = { + requestRender: () => {}, + terminal: { rows: TERMINAL_ROWS, columns: 120 }, + ...tuiOverrides, + } as unknown as TUI; + const overlay = new LogOverlayComponent({ + events: events as never, + tui: tui as never, + theme: theme as never, + config: makeConfig(), + onClose: () => {}, + initialProcessId: "proc_1", + }); + return { overlay }; +} + +describe("LogOverlayComponent page scrolling", () => { + it("pageDown leaves follow mode and moves the viewport", () => { + const { overlay } = makePagedOverlay(TOTAL_LINES); + expect(footer(overlay, 120)).toContain("following"); + + overlay.handleInput(PAGE_DOWN); + const after = footer(overlay, 120); + expect(after).not.toContain("following"); + expect(after).toContain(`L${TOTAL_LINES}/${TOTAL_LINES}`); + }); + + it("pageUp scrolls up by a full viewport", () => { + const { overlay } = makePagedOverlay(TOTAL_LINES); + overlay.handleInput(PAGE_DOWN); // stop following + + overlay.handleInput(PAGE_UP); + expect(footer(overlay, 120)).toContain( + `L${TOTAL_LINES - VIEWPORT_ROWS}/${TOTAL_LINES}`, + ); + }); + + it("ctrl+d / ctrl+u scroll by half a viewport", () => { + const { overlay } = makePagedOverlay(TOTAL_LINES); + overlay.handleInput(PAGE_DOWN); // stop following + + overlay.handleInput(CTRL_U); + expect(footer(overlay, 120)).toContain( + `L${TOTAL_LINES - HALF_PAGE_ROWS}/${TOTAL_LINES}`, + ); + + overlay.handleInput(CTRL_D); + expect(footer(overlay, 120)).toContain(`L${TOTAL_LINES}/${TOTAL_LINES}`); + }); + + it("pageUp clamps so the viewport never ends above its own height", () => { + const { overlay } = makePagedOverlay(TOTAL_LINES); + overlay.handleInput(PAGE_DOWN); + overlay.handleInput(PAGE_UP); + overlay.handleInput(PAGE_UP); + expect(footer(overlay, 120)).toContain(`L${VIEWPORT_ROWS}/${TOTAL_LINES}`); + }); +}); + +// --- "?" shortcuts overlay --- + +/** Width wide enough for the stacked keybinds panel to render untruncated. */ +const KEYBINDS_PANEL_WIDTH = 48; + +describe("LogOverlayComponent shortcuts overlay", () => { + function makeOverlayWithTui() { + const hide = vi.fn(); + const shown: { component: unknown; options: unknown }[] = []; + const tui = { + requestRender: () => {}, + terminal: { rows: 40, columns: 120 }, + showOverlay: vi.fn((component: unknown, options: unknown) => { + shown.push({ component, options }); + return { hide }; + }), + }; + const { overlay } = makePagedOverlay(5, { showOverlay: tui.showOverlay }); + return { overlay, hide, shown }; + } + + it("opens a stacked overlay with ? and closes it via the overlay's esc", () => { + const { overlay, hide, shown } = makeOverlayWithTui(); + + overlay.handleInput("?"); + expect(shown.length).toBe(1); + + const help = shown[0]?.component as { + handleInput: (data: string) => void; + render: (width: number) => string[]; + }; + const lines = help.render(KEYBINDS_PANEL_WIDTH).join("\n"); + expect(lines).toContain("keybinds"); + expect(lines).toContain("esc close"); + help.handleInput(`${ESC}`); + expect(hide).toHaveBeenCalledTimes(1); + }); + + it("hides the shortcuts overlay when the log overlay closes", () => { + const { overlay, hide } = makeOverlayWithTui(); + overlay.handleInput("?"); + overlay.handleInput("q"); + expect(hide).toHaveBeenCalledTimes(1); + }); + + it("does not open a second shortcuts overlay while one is open", () => { + const { overlay, shown } = makeOverlayWithTui(); + overlay.handleInput("?"); + overlay.handleInput("?"); + expect(shown.length).toBe(1); + }); +}); diff --git a/extensions/processes-logs/components/log-overlay-component.ts b/extensions/processes-logs/components/log-overlay-component.ts index e49ef03..101be50 100644 --- a/extensions/processes-logs/components/log-overlay-component.ts +++ b/extensions/processes-logs/components/log-overlay-component.ts @@ -4,7 +4,7 @@ import { type Component, Input, Key, - matchesKey, + parseKey, type TUI, visibleWidth, } from "@earendil-works/pi-tui"; @@ -18,6 +18,15 @@ import { type ProcessProtocolConfig, type ProcessProtocolNotificationPayload, } from "../../shared/protocol"; +import { + renderShortcutHints, + SHORTCUTS_KEY, + type ShortcutHint, +} from "../../shared/shortcut-hints"; +import { + type ShortcutGroup, + showShortcutsOverlay, +} from "../../shared/shortcuts-overlay"; import { truncateToWidth } from "../../shared/truncate"; import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui"; import { requestProcessList } from "../client"; @@ -81,6 +90,8 @@ export class LogOverlayComponent implements Component { * survive a round-trip. Mirrors the notifyMarkers persistence pattern. */ private readonly viewers = new Map(); + /** Disposer for the "?" shortcuts overlay, when open. */ + private shortcutsHelp: (() => void) | null = null; constructor(private readonly opts: LogOverlayOptions) { this.configureSearchInput(); @@ -144,6 +155,31 @@ export class LogOverlayComponent implements Component { return panel.render(width); } + /** + * Key dispatch for normal mode, keyed by parsed key id. Close keys and + * the mode-specific search keys are handled in `handleInput` before the + * table is consulted. + */ + private readonly keyActions: Record void> = { + [Key.tab]: () => this.selectRelative(1), + [Key.shift("tab")]: () => this.selectRelative(-1), + [Key.down]: () => this.viewer?.scrollBy(-1), + [Key.up]: () => this.viewer?.scrollBy(1), + [Key.pageDown]: () => this.viewer?.scrollBy(-this.logRows()), + [Key.pageUp]: () => this.viewer?.scrollBy(this.logRows()), + [Key.ctrl("d")]: () => this.viewer?.scrollBy(-this.halfPageRows()), + [Key.ctrl("u")]: () => this.viewer?.scrollBy(this.halfPageRows()), + j: () => this.viewer?.scrollBy(-1), + k: () => this.viewer?.scrollBy(1), + g: () => this.viewer?.scrollToTop(), + G: () => this.viewer?.scrollToBottom(), + s: () => this.viewer?.cycleStreamFilter(), + f: () => this.viewer?.toggleFollow(), + w: () => this.viewer?.toggleWrap(), + "/": () => this.startSearch(), + [SHORTCUTS_KEY]: () => this.openShortcutsHelp(), + }; + handleInput(data: string): void { if (this.mode === "search-typing") { this.searchInput.handleInput?.(data); @@ -151,25 +187,27 @@ export class LogOverlayComponent implements Component { return; } + const key = parseKey(data); + if (this.mode === "search-active") { - if (matchesKey(data, Key.escape)) { + if (key === "escape") { this.viewer?.clearSearch(); this.searchInput.setValue(""); this.mode = "normal"; this.opts.tui.requestRender(); return; } - if (data === "n") { + if (key === "n") { this.viewer?.nextMatch(); this.opts.tui.requestRender(); return; } - if (data === "N") { + if (key === "N") { this.viewer?.previousMatch(); this.opts.tui.requestRender(); return; } - if (data === "/") { + if (key === "/") { this.searchInput.setValue(this.viewer?.getSearchInfo()?.query ?? ""); this.mode = "search-typing"; this.opts.tui.requestRender(); @@ -177,27 +215,12 @@ export class LogOverlayComponent implements Component { } } - if ( - matchesKey(data, Key.escape) || - matchesKey(data, Key.ctrl("c")) || - data === "q" || - data === "Q" - ) { + if (key === "escape" || key === "ctrl+c" || key === "q" || key === "Q") { this.close(); return; } - if (matchesKey(data, Key.tab)) this.selectRelative(1); - else if (matchesKey(data, Key.shift("tab"))) this.selectRelative(-1); - else if (matchesKey(data, Key.down) || data === "j") - this.viewer?.scrollBy(-1); - else if (matchesKey(data, Key.up) || data === "k") this.viewer?.scrollBy(1); - else if (data === "g") this.viewer?.scrollToTop(); - else if (data === "G") this.viewer?.scrollToBottom(); - else if (data === "s") this.viewer?.cycleStreamFilter(); - else if (data === "f") this.viewer?.toggleFollow(); - else if (data === "w") this.viewer?.toggleWrap(); - else if (data === "/") this.startSearch(); + this.keyActions[key ?? ""]?.(); this.opts.tui.requestRender(); } @@ -224,6 +247,8 @@ export class LogOverlayComponent implements Component { dispose(): void { if (this.disposed) return; this.disposed = true; + this.shortcutsHelp?.(); + this.shortcutsHelp = null; if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = null; @@ -319,6 +344,24 @@ export class LogOverlayComponent implements Component { this.opts.onClose(); } + /** Rows scrolled by ctrl+u / ctrl+d (half a viewport). */ + private halfPageRows(): number { + return Math.max(1, Math.floor(this.logRows() / 2)); + } + + /** + * Open the "?" shortcuts overlay on top of this overlay. While open it + * captures input; closing it restores focus here. Disposed with the + * overlay so a background close (auto-hide, kill) cannot leave it behind. + */ + private openShortcutsHelp(): void { + if (this.shortcutsHelp) return; + this.shortcutsHelp = showShortcutsOverlay(this.opts.tui, { + theme: this.opts.theme, + groups: this.shortcutGroups(), + }); + } + private refreshProcesses(preferredProcessId?: string): void { this.processes = this.sortProcesses(requestProcessList(this.opts.events)); if (this.processes.some((process) => process.status === "running")) { @@ -605,40 +648,98 @@ export class LogOverlayComponent implements Component { const leftPrefix = this.message ?? statusLeft.join(" "); const prefix = leftPrefix ? `${leftPrefix} ` : ""; - const keys = this.renderFooterKeys( + const keys = renderShortcutHints( + this.footerHints(), + this.opts.theme, Math.max(1, width - visibleWidth(prefix)), ); return truncateToWidth(`${prefix}${keys}`, width); } - private renderFooterKeys(width: number): string { - const dim = (value: string) => this.opts.theme.fg("dim", value); - const accent = (value: string) => this.opts.theme.fg("accent", value); - + /** Footer hint list; search mode adds its extra keys up front. */ + private footerHints(): ShortcutHint[] { + const hints: ShortcutHint[] = []; if (this.mode === "search-active") { - return truncateToWidth( - `${dim("n")} next ${dim("N")} prev ${dim("/")} edit ${dim("esc")} clear ${dim("j/k")} scroll ${dim("q")} close`, - width, + hints.push( + { key: "n", label: "next" }, + { key: "N", label: "prev" }, + { key: "/", label: "edit" }, + { key: "esc", label: "clear" }, ); } - const streamFilter = this.viewer?.getStreamFilter() ?? "both"; - const stdout = - streamFilter === "both" || streamFilter === "stdout" - ? accent("stdout") - : dim("stdout"); - const stderr = - streamFilter === "both" || streamFilter === "stderr" - ? accent("stderr") - : dim("stderr"); - + const stream = (on: boolean) => (on ? "accent" : "dim"); const wrapOn = this.viewer?.isWrapEnabled() ?? false; - const wrap = wrapOn ? accent("wrap") : dim("wrap"); + hints.push( + { + key: "w", + label: [{ text: "wrap", style: wrapOn ? "accent" : "dim" }], + }, + { key: "f", label: "follow" }, + { key: "/", label: "search" }, + { + key: "s", + label: [ + { text: "stdout", style: stream(streamFilter !== "stderr") }, + { text: "+", style: "dim" }, + { text: "stderr", style: stream(streamFilter !== "stdout") }, + ], + }, + { key: "j/k", label: "scroll" }, + { key: "pgup/pgdn", label: "page" }, + { key: "^u/^d", label: "half-page" }, + { key: "q", label: "close" }, + { key: "g/G", label: "top/bot" }, + { key: "tab/shift+tab", label: "switch" }, + ); + return hints; + } - return truncateToWidth( - `${dim("tab/shift+tab")} switch ${dim("g/G")} top/bot ${dim("j/k")} scroll ${dim("/")} search ${dim("s:")}${stdout}${dim("+")}${stderr} ${dim("f")} follow ${dim("w:")}${wrap} ${dim("q")} close`, - width, + /** + * Groups for the "?" shortcuts overlay. The search group is only present + * while a search is active; every other key works in both modes. + */ + private shortcutGroups(): ShortcutGroup[] { + const groups: ShortcutGroup[] = []; + if (this.mode === "search-active") { + groups.push({ + title: "search", + rows: [ + { keys: "n / N", description: "next / previous match" }, + { keys: "/", description: "edit query" }, + { keys: "esc", description: "clear search" }, + ], + }); + } + groups.push( + { + title: "scrolling", + rows: [ + { keys: "j / k", description: "line up / down" }, + { keys: "pgup / pgdn", description: "page up / down" }, + { keys: "ctrl+u / ctrl+d", description: "half page up / down" }, + { keys: "g / G", description: "top / bottom" }, + ], + }, + { + title: "view", + rows: [ + { keys: "w", description: "wrap long lines" }, + { keys: "f", description: "follow newest output" }, + { keys: "s", description: "stream: stdout + stderr" }, + { keys: "/", description: "search" }, + ], + }, + { + title: "tabs", + rows: [{ keys: "tab / shift+tab", description: "switch process" }], + }, + { + title: "general", + rows: [{ keys: "q", description: "close" }], + }, ); + return groups; } } diff --git a/extensions/processes/components/overview-component.ts b/extensions/processes/components/overview-component.ts index 6fc31bd..665d485 100644 --- a/extensions/processes/components/overview-component.ts +++ b/extensions/processes/components/overview-component.ts @@ -4,7 +4,7 @@ import { type Component, Input, Key, - matchesKey, + parseKey, type TUI, visibleWidth, } from "@earendil-works/pi-tui"; @@ -21,6 +21,15 @@ import { type ProcessesOutputChangedPayload, type ProcessProtocolConfig, } from "../../shared/protocol"; +import { + SHORTCUTS_KEY, + type ShortcutHint, + ShortcutHintsComponent, +} from "../../shared/shortcut-hints"; +import { + type ShortcutGroup, + showShortcutsOverlay, +} from "../../shared/shortcuts-overlay"; import { truncateToWidth } from "../../shared/truncate"; import { LineComponent, LinesComponent, statusColor } from "../../shared/ui"; import { @@ -92,6 +101,8 @@ export class OverviewComponent implements Component { private runningProcessCount = 0; private readonly disposers: Array<() => void> = []; private disposed = false; + /** Disposer for the "?" shortcuts overlay, when open. */ + private shortcutsHelp: (() => void) | null = null; constructor(private readonly opts: OverviewOptions) { this.configureFilterInput(); @@ -123,6 +134,34 @@ export class OverviewComponent implements Component { return panel.render(width); } + /** + * Key dispatch for normal mode, keyed by parsed key id. Close keys are + * handled in `handleInput` before the table is consulted. + */ + private readonly keyActions: Record void> = { + [Key.down]: () => this.moveSelection(1), + [Key.up]: () => this.moveSelection(-1), + [Key.enter]: () => void this.pinSelected(), + j: () => this.moveSelection(1), + k: () => this.moveSelection(-1), + J: () => this.scrollPreview(1), + K: () => this.scrollPreview(-1), + g: () => { + this.previewOffset = 0; + this.requestRender(); + }, + G: () => { + this.previewOffset = Math.max(0, this.previewLines.length - 1); + this.requestRender(); + }, + x: () => this.killSelected(), + c: () => this.clearFinished(), + s: () => this.cycleSort(), + f: () => this.cycleFilter(), + "/": () => this.startQuickFilter(), + [SHORTCUTS_KEY]: () => this.openShortcutsHelp(), + }; + handleInput(data: string): void { if (this.mode === "filter-typing") { this.filterInput.handleInput?.(data); @@ -130,30 +169,13 @@ export class OverviewComponent implements Component { return; } - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) { + const key = parseKey(data); + if (key === "escape" || key === "ctrl+c" || key === "q" || key === "Q") { this.close(); return; } - if (data === "q" || data === "Q") { - this.close(); - return; - } - if (matchesKey(data, Key.down) || data === "j") this.moveSelection(1); - else if (matchesKey(data, Key.up) || data === "k") this.moveSelection(-1); - else if (data === "J") this.scrollPreview(1); - else if (data === "K") this.scrollPreview(-1); - else if (data === "g") { - this.previewOffset = 0; - this.requestRender(); - } else if (data === "G") { - this.previewOffset = Math.max(0, this.previewLines.length - 1); - this.requestRender(); - } else if (data === "x") this.killSelected(); - else if (data === "c") this.clearFinished(); - else if (data === "s") this.cycleSort(); - else if (data === "f") this.cycleFilter(); - else if (data === "/") this.startQuickFilter(); - else if (matchesKey(data, Key.enter)) void this.pinSelected(); + + this.keyActions[key ?? ""]?.(); } invalidate(): void {} @@ -161,6 +183,8 @@ export class OverviewComponent implements Component { dispose(): void { if (this.disposed) return; this.disposed = true; + this.shortcutsHelp?.(); + this.shortcutsHelp = null; for (const dispose of this.disposers.splice(0)) dispose(); } @@ -450,6 +474,19 @@ export class OverviewComponent implements Component { this.opts.onClose(); } + /** + * Open the "?" shortcuts overlay on top of the overview. While open it + * captures input; closing it restores focus here. Disposed with the + * overview so a background close cannot leave it behind. + */ + private openShortcutsHelp(): void { + if (this.shortcutsHelp) return; + this.shortcutsHelp = showShortcutsOverlay(this.opts.tui, { + theme: this.opts.theme, + groups: this.shortcutGroups(), + }); + } + private requestRender(): void { this.opts.tui.requestRender(); } @@ -661,35 +698,65 @@ export class OverviewComponent implements Component { } private buildFooter(): Component { - return new LineComponent((width) => this.renderFooterLine(width)); - } - - private renderFooterLine(width: number): string { - const t = this.opts.theme; - const dim = (value: string) => t.fg("dim", value); - if (this.mode === "filter-typing") { - const rendered = this.filterInput.render(60)[0] ?? ""; - return truncateToWidth( - `${dim("/")}${rendered} ${dim("enter")} apply ${dim("esc")} cancel`, - width, - "", - true, - ); + return new LineComponent((width) => { + const dim = (value: string) => this.opts.theme.fg("dim", value); + const rendered = this.filterInput.render(60)[0] ?? ""; + return truncateToWidth( + `${dim("/")}${rendered} ${dim("enter")} apply ${dim("esc")} cancel`, + width, + "", + true, + ); + }); } + return new ShortcutHintsComponent( + () => this.overviewHints(), + this.opts.theme, + ); + } + + /** Footer hint list for the overview. */ + private overviewHints(): ShortcutHint[] { + return [ + { key: "j/k", label: "move" }, + { key: "J/K", label: "scroll" }, + { key: "enter", label: this.renderPinHint() }, + { key: "x", label: "kill" }, + { key: "c", label: "clear" }, + { key: "s", label: "sort" }, + { key: "f", label: "filter" }, + { key: "/", label: "find" }, + { key: "g/G", label: "top/bot" }, + { key: "q", label: "close" }, + ]; + } - const keys = [ - `${dim("j/k")} move`, - `${dim("J/K")} scroll`, - `${dim("enter")} ${this.renderPinHint()}`, - `${dim("x")} kill`, - `${dim("c")} clear`, - `${dim("s")} sort`, - `${dim("f")} filter`, - `${dim("/")} find`, - `${dim("q")} close`, + /** Groups for the "?" shortcuts overlay. */ + private shortcutGroups(): ShortcutGroup[] { + return [ + { + title: "list", + rows: [ + { keys: "j / k", description: "move selection" }, + { keys: "g / G", description: "preview top / bottom" }, + { keys: "enter", description: this.renderPinHint() }, + { keys: "x", description: "kill" }, + { keys: "c", description: "clear finished" }, + { keys: "s", description: "sort" }, + { keys: "f", description: "filter" }, + { keys: "/", description: "find by name" }, + ], + }, + { + title: "preview", + rows: [{ keys: "J / K", description: "scroll output" }], + }, + { + title: "general", + rows: [{ keys: "q", description: "close" }], + }, ]; - return truncateToWidth(keys.join(" "), width, "", true); } } diff --git a/extensions/processes/components/overview-render.test.ts b/extensions/processes/components/overview-render.test.ts index 805c67d..354cf7f 100644 --- a/extensions/processes/components/overview-render.test.ts +++ b/extensions/processes/components/overview-render.test.ts @@ -523,3 +523,116 @@ describe("overview panel render width safety", () => { ).toHaveLength(requestsBefore); }); }); + +// --- minimal footer hints + "?" shortcuts overlay --- + +const ANSI_ESC = String.fromCharCode(27); +const ANSI_CODES: Record = { dim: "2", accent: "36" }; +/** Emits real ANSI so width math stays correct while styles stay visible. */ +const ansiTheme = { + fg: (color: string, text: string) => + `${ANSI_ESC}[${ANSI_CODES[color] ?? "0"}m${text}${ANSI_ESC}[0m`, + bg: (_color: string, text: string) => text, + bold: (text: string) => `${ANSI_ESC}[1m${text}${ANSI_ESC}[22m`, +} as unknown; + +const DIM = (text: string) => `${ANSI_ESC}[2m${text}${ANSI_ESC}[0m`; +const ACCENT = (text: string) => `${ANSI_ESC}[36m${text}${ANSI_ESC}[0m`; +const BOLD = (text: string) => `${ANSI_ESC}[1m${text}${ANSI_ESC}[22m`; + +function makeFooterComponent(tuiOverrides: object = {}, themeOverride = theme) { + const processes = [ + makeProcess({ id: "proc_1", name: "dev", status: "running" }), + ]; + const events = makeEvents(processes); + const tui = { + requestRender: () => {}, + terminal: { rows: 24, columns: TERMINAL_WIDTH }, + ...tuiOverrides, + } as unknown; + return new OverviewComponent({ + events: events as never, + tui: tui as never, + theme: themeOverride as never, + config: makeConfig() as never, + onClose: () => {}, + }); +} + +/** Width the full overview hint list fits in. */ +const TERMINAL_WIDTH = 112; +/** Width too narrow for the full hint list (forces the ? more collapse). */ +const NARROW_WIDTH = 40; +/** Width wide enough for the stacked keybinds panel to render untruncated. */ +const PANEL_WIDTH = 48; + +describe("overview footer shortcut hints", () => { + it("renders single-letter keys inside their word (minimal hints)", () => { + const component = makeFooterComponent({}, ansiTheme); + const footer = component.render(TERMINAL_WIDTH).join("\n"); + // c clear / s sort / f filter collapse to the word with the key letter + // highlighted in accent + bold. + expect(footer).toContain(`${ACCENT(BOLD("c"))}lear`); + expect(footer).toContain(`${ACCENT(BOLD("s"))}ort`); + expect(footer).toContain(`${ACCENT(BOLD("f"))}ilter`); + // Multi-char and non-matching keys keep the classic display. + expect(footer).toContain(`${DIM("j/k")} move`); + expect(footer).toContain(`${DIM("enter")} ${ACCENT("pin")}`); + expect(footer).toContain(`${DIM("q")} close`); + }); + + it("prefixes the ? affordance when the footer overflows", () => { + const component = makeFooterComponent({}, ansiTheme); + const footer = component.render(NARROW_WIDTH).join("\n"); + expect(footer).toContain(`${ACCENT(BOLD("?"))}${DIM(" more")}`); + // Everything must still fit the panel width. + for (const line of component.render(NARROW_WIDTH)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(NARROW_WIDTH); + } + }); +}); + +describe("overview shortcuts overlay", () => { + function makeWithHelp() { + const hide = vi.fn(); + const shown: { component: unknown }[] = []; + const tui = { + requestRender: () => {}, + showOverlay: vi.fn((component: unknown) => { + shown.push({ component }); + return { hide }; + }), + }; + return { + component: makeFooterComponent({ showOverlay: tui.showOverlay }), + hide, + shown, + }; + } + + it("opens a stacked shortcuts overlay with ?", () => { + const { component, shown } = makeWithHelp(); + component.handleInput("?"); + expect(shown.length).toBe(1); + }); + + it("hides the shortcuts overlay when the overview closes", () => { + const { component, hide } = makeWithHelp(); + component.handleInput("?"); + component.handleInput("q"); + expect(hide).toHaveBeenCalledTimes(1); + }); + + it("renders the shortcuts list inside the stacked overlay", () => { + const { component, shown } = makeWithHelp(); + component.handleInput("?"); + const help = shown[0]?.component as { + render: (width: number) => string[]; + }; + const rendered = help.render(PANEL_WIDTH).join("\n"); + expect(rendered).toContain("keybinds"); + expect(rendered).toContain("move selection"); + expect(rendered).toContain("clear finished"); + expect(rendered).toContain("esc close"); + }); +}); diff --git a/extensions/shared/shortcut-hints.test.ts b/extensions/shared/shortcut-hints.test.ts new file mode 100644 index 0000000..b64f2c1 --- /dev/null +++ b/extensions/shared/shortcut-hints.test.ts @@ -0,0 +1,148 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, it } from "vitest"; +import { renderShortcutHints, type ShortcutHint } from "./shortcut-hints"; + +/** + * Theme fake that emits real ANSI escapes so `visibleWidth` sees the same + * widths the renderer does while assertions can still match exact styles. + */ +const ESC = String.fromCharCode(27); +const ANSI_CODES: Record = { dim: "2", accent: "36" }; +const theme = { + fg: (color: string, text: string) => + `${ESC}[${ANSI_CODES[color] ?? "0"}m${text}${ESC}[0m`, + bg: (_color: string, text: string) => text, + bold: (text: string) => `${ESC}[1m${text}${ESC}[22m`, +} as unknown as Theme; + +const DIM = (text: string) => `${ESC}[2m${text}${ESC}[0m`; +const ACCENT = (text: string) => `${ESC}[36m${text}${ESC}[0m`; +const BOLD = (text: string) => `${ESC}[1m${text}${ESC}[22m`; + +/** The renderer pads to the requested width; drop that padding for exact asserts. */ +function unpad(line: string): string { + return line.replace(/ +$/, ""); +} + +/** Width generous enough that a single hint never overflows. */ +const SINGLE_HINT_WIDTH = 40; + +/** Render one hint alone, wide enough to avoid the ? more collapse. */ +function renderHint(hint: ShortcutHint): string { + return unpad(renderShortcutHints([hint], theme, SINGLE_HINT_WIDTH)); +} + +describe("shortcut hint rendering", () => { + it("renders minimal form when the single-char key occurs in the word", () => { + expect(renderHint({ key: "w", label: "wrap" })).toBe( + `${ACCENT(BOLD("w"))}rap`, + ); + }); + + it("keeps the classic form for keys not contained in the word", () => { + expect(renderHint({ key: "q", label: "close" })).toBe(`${DIM("q")} close`); + }); + + it("keeps the classic form for multi-char keys", () => { + expect(renderHint({ key: "j/k", label: "scroll" })).toBe( + `${DIM("j/k")} scroll`, + ); + }); + + it("keeps the classic form for non-word keys", () => { + expect(renderHint({ key: "/", label: "search" })).toBe( + `${DIM("/")} search`, + ); + }); + + it("matches the key case-sensitively (N prev stays classic)", () => { + expect(renderHint({ key: "N", label: "prev" })).toBe(`${DIM("N")} prev`); + expect(renderHint({ key: "n", label: "next" })).toBe( + `${ACCENT(BOLD("n"))}ext`, + ); + }); + + it("applies segment styles to the non-key remainder in minimal form", () => { + expect( + renderHint({ key: "w", label: [{ text: "wrap", style: "accent" }] }), + ).toBe(`${ACCENT(BOLD("w"))}${ACCENT("rap")}`); + expect( + renderHint({ key: "w", label: [{ text: "wrap", style: "dim" }] }), + ).toBe(`${ACCENT(BOLD("w"))}${DIM("rap")}`); + }); + + it("highlights every occurrence of the key across segments", () => { + const hint: ShortcutHint = { + key: "s", + label: [ + { text: "stdout", style: "accent" }, + { text: "+", style: "dim" }, + { text: "stderr", style: "dim" }, + ], + }; + expect(renderHint(hint)).toBe( + `${ACCENT(BOLD("s"))}${ACCENT("tdout")}${DIM("+")}${ACCENT(BOLD("s"))}${DIM("tderr")}`, + ); + }); + + it("renders styled segments in classic form too", () => { + const hint: ShortcutHint = { + key: "x", + label: [ + { text: "a", style: "dim" }, + { text: "b", style: "accent" }, + { text: "c" }, + ], + }; + expect(renderHint(hint)).toBe(`${DIM("x")} ${DIM("a")}${ACCENT("b")}c`); + }); +}); + +describe("renderShortcutHints", () => { + const WRAP_HINT = { key: "w", label: "wrap" } as const; + const SCROLL_HINT = { key: "j/k", label: "scroll" } as const; + const CLOSE_HINT = { key: "q", label: "close" } as const; + const hints: ShortcutHint[] = [WRAP_HINT, SCROLL_HINT, CLOSE_HINT]; + + const WRAP_WIDTH = "wrap".length; + const SCROLL_HINT_WIDTH = "j/k scroll".length; + const CLOSE_HINT_WIDTH = "q close".length; + const MORE_WIDTH = "? more".length; + const SEPARATOR_WIDTH = 2; + + it("joins all hints when the list fits", () => { + expect(unpad(renderShortcutHints(hints, theme, 100))).toBe( + `${ACCENT(BOLD("w"))}rap ${DIM("j/k")} scroll ${DIM("q")} close`, + ); + }); + + it("prefixes the ? affordance and keeps the hints that fit", () => { + const fitsWrapAndScroll = + MORE_WIDTH + + SEPARATOR_WIDTH + + WRAP_WIDTH + + SEPARATOR_WIDTH + + SCROLL_HINT_WIDTH; + expect(unpad(renderShortcutHints(hints, theme, fitsWrapAndScroll))).toBe( + `${ACCENT(BOLD("?"))}${DIM(" more")} ${ACCENT(BOLD("w"))}rap ${DIM("j/k")} scroll`, + ); + // One column less and "j/k scroll" no longer fits. + expect( + unpad(renderShortcutHints(hints, theme, fitsWrapAndScroll - 1)), + ).toBe(`${ACCENT(BOLD("?"))}${DIM(" more")} ${ACCENT(BOLD("w"))}rap`); + }); + + it("shows only the ? affordance when nothing else fits", () => { + expect(unpad(renderShortcutHints(hints, theme, CLOSE_HINT_WIDTH))).toBe( + `${ACCENT(BOLD("?"))}${DIM(" more")}`, + ); + }); + + it("never exceeds the requested width", () => { + for (let width = 1; width <= 40; width += 1) { + const line = renderShortcutHints(hints, theme, width); + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); +}); diff --git a/extensions/shared/shortcut-hints.ts b/extensions/shared/shortcut-hints.ts new file mode 100644 index 0000000..50113e0 --- /dev/null +++ b/extensions/shared/shortcut-hints.ts @@ -0,0 +1,150 @@ +/** + * Footer shortcut-hint rendering shared by the `/ps` overview and the + * `/ps:logs` overlay footers. + * + * Each hint renders in one of two modes: + * + * - Minimal: when the hint's key is a single character that occurs in its + * label, only the word is shown and every occurrence of the key letter is + * rendered in accent + bold. "w wrap" becomes "wrap" with a bold accent + * "w"; the remainder of the word keeps the segment style (so a stateful + * label like "wrap" can stay dim while off and accent while on). + * - Classic: `