diff --git a/docs/prompt-history.md b/docs/prompt-history.md index 80139a0aa..9d9053a03 100644 --- a/docs/prompt-history.md +++ b/docs/prompt-history.md @@ -130,6 +130,27 @@ rm -rf ~/.pi/agent/history/projects/ # one project (see registry.json) As with other editing keys, the list selection returns to the first match. +## Selector layout + +The selector is always 30 rows tall. Its header adapts to the width: + +- **Wide:** title, position, loaded count, and the scope radio + (`◉ Current project | ○ All projects`) share one row, with the filter hint + below. +- **Medium:** the radio moves to its own row under the counts, taking the + hint's row. +- **Narrow:** the title and position, the loaded count, and the radio each + take a row, and the list shows 9 prompts instead of 10. If the full radio + does not fit, the inactive scope is shortened + (`◉ Current project | ○ All`). + +In fullscreen, when the Gentle sidebar is showing (140 columns or wider, +Status placement `auto` or `right`), the selector stays in the editor column, +one column short of the sidebar gap, instead of covering the sidebar. The +sidebar publishes its width through the terminal-owned sidebar state +(`railColumns` in `lib/shell-sidebar.ts`). The selector checks it on every +render, so resizing across the breakpoint moves an open selector. + ## Delete The selector's delete key (`ctrl+shift+backspace`) is a two-step y/n diff --git a/extensions/history/index.ts b/extensions/history/index.ts index fa3a31af5..4e7a5c7b3 100644 --- a/extensions/history/index.ts +++ b/extensions/history/index.ts @@ -33,7 +33,6 @@ import { Input, matchesKey, stripTerminalSequences, - Text, type TUI, type TuiMouseEvent, truncateToWidth, @@ -69,8 +68,10 @@ import { deleteConfirmFooterText, deleteConfirmStep, deletionActionsFor, + editorOverlayMargin, filterPrompts, getVisiblePromptRecords, + type HeaderLayoutMode, initialLoadedCount, loadedCountAfterDelete, loadedCountForQuery, @@ -78,6 +79,8 @@ import { moveSelectedIndex, nextLoadedCount, pageSelectedIndex, + planHeaderLayout, + scopeRadioText, shouldGrowWindow, STORE_DELETE_FAILED_TEXT, storeDeleteFollowUp, @@ -100,12 +103,16 @@ const INITIAL_BATCH = 10; const BATCH_SIZE = 10; const PRELOAD_BUFFER = 3; // Wheel regions over the fixed 30-row overlay geometry (design §D6): the -// list container renders at rows 5-14 and the preview container at rows -// 17-26; every other row is a consumed no-op. +// preview container always renders at rows 17-26. The list region is +// mode-dependent (see listWheelFirstRow): the responsive header reclaims +// rows without changing the 30-row total, and only the compact mode both +// shifts the list start (border at row 5) and paints one list row fewer. const LIST_WHEEL_Y_FIRST = 5; const LIST_WHEEL_Y_LAST = 14; const PREVIEW_WHEEL_Y_FIRST = 17; const PREVIEW_WHEEL_Y_LAST = 26; +/** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */ +const HEADER_INLINE_MIN_GAP = 4; // Default selector footer line (PR #1393): shown whenever a delete is not // armed; the armed state swaps it for the confirmation copy. @@ -279,6 +286,22 @@ class FixedRowText { } } +/** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */ +class OptionalRow { + private text = ""; + + setText(next: string): void { + this.text = next; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.text.length === 0) return []; + return [truncateToWidth(this.text, width, "…")]; + } +} + /** Word-wrap plain text so each line fits within maxWidth characters. */ function wordWrapText(text: string, maxWidth: number): string[] { if (maxWidth <= 0) return [text || " "]; @@ -317,6 +340,12 @@ class PromptHistorySelector extends Container implements Focusable { private readonly previewContainer: Container; private readonly listContainer: Container; private readonly headerRow: FixedRowText; + private readonly headerLine2: OptionalRow; + private readonly headerLine3: OptionalRow; + private readonly hintRow: OptionalRow; + private readonly hintText: string; + /** Current responsive header mode; drives the list wheel region. */ + private headerMode: HeaderLayoutMode = "inline"; private readonly previewLabelRow: FixedRowText; private readonly footerRow: FixedRowText; private records: PromptRecord[]; @@ -433,13 +462,15 @@ class PromptHistorySelector extends Container implements Focusable { theme.fg("accent", theme.bold(" History Search ")), ); this.addChild(this.headerRow); - this.addChild( - new Text( - theme.fg("dim", "Type to filter (multi-word AND substring, case-insensitive)"), - 0, - 0, - ), - ); + this.headerLine2 = new OptionalRow(); + this.headerLine3 = new OptionalRow(); + this.addChild(this.headerLine2); + this.addChild(this.headerLine3); + this.hintText = + "Type to filter (multi-word AND substring, case-insensitive)"; + this.hintRow = new OptionalRow(); + this.hintRow.setText(this.theme.fg("dim", this.hintText)); + this.addChild(this.hintRow); this.searchInput = new Input(); this.searchInput.onSubmit = () => this.selectCurrent(); this.searchInput.onEscape = () => this.onCancel(); @@ -497,33 +528,78 @@ class PromptHistorySelector extends Container implements Focusable { this.rebuildListWithWidth(this.lastWidth); } - /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows. */ + /** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */ + private headerCountsText( + titleText: string, + positionText: string, + loadedText: string, + ): string { + return ( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", positionText) + + this.theme.fg("dim", loadedText) + ); + } + + /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */ private rebuildListWithWidth(width: number): void { const count = this.filteredRecords.length; const position = count === 0 ? 0 : this.selectedIndex + 1; - this.headerRow.setText( - this.theme.fg("accent", this.theme.bold(" History Search ")) + - this.theme.fg("dim", ` · ${position} of ${count} `) + + const titleText = " History Search "; + const positionText = ` · ${position} of ${count} `; + const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `; + const leftWidth = + titleText.length + positionText.length + loadedText.length; + const radioFull = scopeRadioText(this.scope, false); + // Radio label compaction is fit-driven too: abbreviate only when the + // full radio cannot fit the row it would occupy (user-directed paste). + const radioText = + width >= radioFull.length ? radioFull : scopeRadioText(this.scope, true); + const mode = planHeaderLayout( + width, + leftWidth, + radioFull.length, + HEADER_INLINE_MIN_GAP, + ); + this.headerMode = mode; + if (mode === "inline") { + this.headerRow.setText( + this.headerCountsText(titleText, positionText, loadedText) + + // Right-aligned scope radio: pad from plain-text lengths so the + // radio ends flush at the header's last column at any width. + " ".repeat(Math.max(1, width - leftWidth - radioText.length)) + + this.theme.fg("dim", radioText), + ); + this.headerLine2.setText(""); + this.headerLine3.setText(""); + } else if (mode === "stacked") { + // Tablet: the spacer is deleted — the radio wraps to its own row + // under the full counts line (user-directed paste, leading space). + this.headerRow.setText( + this.headerCountsText(titleText, positionText, loadedText), + ); + this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`); + this.headerLine3.setText(""); + } else { + // Compact (mobile): three rows — counts split off, radio abbreviated + // (user-directed paste). + this.headerRow.setText( + this.theme.fg("accent", this.theme.bold(titleText)) + + this.theme.fg("dim", ` · ${position} of ${count}`), + ); + // Leading space aligns both rows with the title's own left padding + // space (user-directed compact paste). + this.headerLine2.setText( this.theme.fg( "dim", - ` · loaded ${this.loadedCount} of ${this.records.length} `, - ) + - // Right-aligned scope radio: pad from plain-text lengths so the - // radio ends flush at the header's last column at any width. - (() => { - const scopeRadio = - this.scope === "project" - ? "◉ Current project | ○ All projects" - : "○ Current project | ◉ All projects"; - const leftWidth = - " History Search ".length + - ` · ${position} of ${count} `.length + - ` · loaded ${this.loadedCount} of ${this.records.length} `.length; - return ( - " ".repeat(Math.max(1, width - leftWidth - scopeRadio.length)) + - this.theme.fg("dim", scopeRadio) - ); - })(), + ` loaded ${this.loadedCount} of ${this.records.length}`, + ), + ); + this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`); + } + // Stacked modes reclaim the hint row so the overlay stays 30 rows. + this.hintRow.setText( + mode === "inline" ? this.theme.fg("dim", this.hintText) : "", ); this.listContainer.clear(); @@ -531,18 +607,24 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild( new FixedRowText(this.theme.fg("warning", "No matching prompts")), ); - for (let i = 1; i < MAX_VISIBLE; i++) { + // Compact still paints one list row fewer in the empty state, or the + // 3-row header would push the fixed 30-row overlay to 31 rows. + const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; + for (let i = 1; i < listRows; i++) { this.listContainer.addChild(new FixedRowText()); } return; } + // Compact paints one list row fewer (reclaimed by the 3-row header); + // the preview block keeps PREVIEW_ROWS so the 30-row total holds. + const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE; const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH; const visible = getVisiblePromptRecords( this.filteredRecords, this.selectedIndex, - MAX_VISIBLE, + listRows, ); for (const { record, isSelected } of visible) { @@ -562,11 +644,18 @@ class PromptHistorySelector extends Container implements Focusable { this.listContainer.addChild(new FixedRowText(line)); } - for (let i = visible.length; i < MAX_VISIBLE; i++) { + for (let i = visible.length; i < listRows; i++) { this.listContainer.addChild(new FixedRowText()); } } + /** List wheel region start: compact shifts the list down one row. */ + private get listWheelFirstRow(): number { + return this.headerMode === "compact" + ? LIST_WHEEL_Y_FIRST + 1 + : LIST_WHEEL_Y_FIRST; + } + /** * Rebuild preview: word-wrap the full selected prompt text and show * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset. @@ -977,7 +1066,7 @@ class PromptHistorySelector extends Container implements Focusable { // the next delete press re-arms for the NEW row first (PR #1393). if (this.confirmArmed) this.disarmDeleteConfirm(); const delta = event.wheelDelta ?? 0; - if (event.y >= LIST_WHEEL_Y_FIRST && event.y <= LIST_WHEEL_Y_LAST) { + if (event.y >= this.listWheelFirstRow && event.y <= LIST_WHEEL_Y_LAST) { const steps = Math.min(Math.abs(delta), this.filteredRecords.length); for (let i = 0; i < steps; i++) { if (delta > 0) this.moveDown(); @@ -1055,8 +1144,8 @@ function castSelectorArgs(tui: unknown, theme: unknown): [TUI, Theme] { return [tui as TUI, theme as Theme]; } -/** TUI handle captured when the selector overlay mounts. */ -let selectorTui: { requestRender(): void } | null = null; +/** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */ +let selectorTui: { requestRender(): void; terminal?: unknown } | null = null; /** Stored close callback for the currently-open overlay. Null when closed. */ let activeOverlayClose: (() => void) | null = null; @@ -1067,7 +1156,7 @@ function createPromptHistorySelectorFactory( store?: SelectorStore, ): SelectorFactory { return (tui, theme, _keybindings, done) => { - selectorTui = tui as { requestRender(): void }; + selectorTui = tui as { requestRender(): void; terminal?: unknown }; const finish = (result: PromptRecord | null) => { activeOverlayClose = null; done(result); @@ -1105,7 +1194,30 @@ async function runPromptHistorySelection( ), { overlay: true, - overlayOptions: { anchor: "bottom-center", width: "100%", offsetY: 5 }, + // pi-tui keeps the options object from showOverlay time, but calls + // visible() on EVERY render pass before resolving the overlay layout + // (compositeOverlays filters visible entries first), and re-reads + // margin per layout resolution — the getter below therefore stays + // live: resizing across the sidebar breakpoint re-seats the picker + // while it stays open. While the gentle-shell fullscreen sidebar + // paints, the margin confines width "100%" (and the bottom-center + // anchor) to the editor column, less 1 column of padding; 0 keeps + // the native full-window behavior. + overlayOptions: () => { + let rightMargin = editorOverlayMargin(selectorTui?.terminal); + return { + anchor: "bottom-center" as const, + width: "100%" as const, + offsetY: 5, + get margin() { + return rightMargin > 0 ? { right: rightMargin } : undefined; + }, + visible: () => { + rightMargin = editorOverlayMargin(selectorTui?.terminal); + return true; + }, + }; + }, }, ), ); diff --git a/extensions/history/selector-helpers.ts b/extensions/history/selector-helpers.ts index 42a0a3998..c0b288f56 100644 --- a/extensions/history/selector-helpers.ts +++ b/extensions/history/selector-helpers.ts @@ -437,3 +437,102 @@ export function filterPrompts( return filtered.slice(0, MAX_RESULTS); } + +/** + * Cross-extension fullscreen-sidebar state contract (gentle-shell): stored on + * the shared ProcessTerminal under a global-registry symbol so any extension + * can read it without importing gentle-shell. Shape per lib/shell-sidebar.ts: + * `{ active: boolean; ownsHost?: () => boolean; railColumns?: number; ... }`. + */ +const SIDEBAR_STATE_SYMBOL = Symbol.for("gentle-pi.experimental-sidebar.state"); + +/** + * Visual breathing room between the picker and the sidebar rail, added on top + * of the rail reservation (user-directed: 1 column, 2026-09-21). + */ +export const SIDEBAR_OVERLAY_PADDING = 1; + +interface SidebarStateShape { + active?: unknown; + ownsHost?: () => unknown; + railColumns?: unknown; +} + +/** + * Overlay right margin for the current terminal: the rail reservation the + * gentle-shell sidebar publishes (`railColumns`: rail width plus gap) plus + * padding while the rail is painting, else 0 (native full-window overlay). + * pi-tui resolves overlay width "100%" and the bottom-center anchor inside + * `[0, columns - margin)`, which is then exactly the editor column. Reads the + * terminal-owned state contract defensively — any absent, malformed, or + * non-owning state degrades to 0 so the picker keeps opening. Purity note: + * this returns the CURRENT margin per call; live refresh while an overlay + * stays open is the caller's job (the picker wires visible() plus a getter + * margin — pi-tui re-reads both every render). + */ +export function editorOverlayMargin(terminal: unknown): number { + if (typeof terminal !== "object" || terminal === null) return 0; + const state = (terminal as Record)[SIDEBAR_STATE_SYMBOL] as + | SidebarStateShape + | undefined; + if (typeof state !== "object" || state === null) return 0; + if (state.active !== true || typeof state.ownsHost !== "function") return 0; + const railColumns = state.railColumns; + if ( + typeof railColumns !== "number" || + !Number.isInteger(railColumns) || + railColumns <= 0 + ) { + return 0; + } + try { + return state.ownsHost() === true + ? railColumns + SIDEBAR_OVERLAY_PADDING + : 0; + } catch { + return 0; + } +} + +/** Responsive picker-header mode at the current render width. */ +export type HeaderLayoutMode = "inline" | "stacked" | "compact"; + +/** + * Fit-driven header plan (user-directed responsive header): "inline" keeps + * title + counts + right-flushed radio on one row; "stacked" (tablet) deletes + * the spacer — the radio wraps to its own row under the full counts line; + * "compact" (mobile) further splits the counts off and abbreviates the radio. + * Thresholds derive from the ACTUAL text widths, so any count size flips the + * mode at the exact column where the previous layout stops fitting. + */ +export function planHeaderLayout( + width: number, + leftWidth: number, + radioWidth: number, + minGap: number, +): HeaderLayoutMode { + if (width >= leftWidth + minGap + radioWidth) return "inline"; + if (width >= leftWidth) return "stacked"; + return "compact"; +} + +/** Full scope radio: both scope labels spelled out. */ +export const SCOPE_RADIO_FULL_PROJECT = "◉ Current project | ○ All projects"; +export const SCOPE_RADIO_FULL_GLOBAL = "○ Current project | ◉ All projects"; +/** Abbreviated radio: the ACTIVE scope keeps its full label, the other shortens. */ +export const SCOPE_RADIO_COMPACT_PROJECT = "◉ Current project | ○ All"; +export const SCOPE_RADIO_COMPACT_GLOBAL = "○ Current | ◉ All projects"; + +/** + * Scope radio text for the current width: abbreviated only when the full + * radio cannot fit the row it would occupy (compact widths). + */ +export function scopeRadioText( + scope: "project" | "global", + compact: boolean, +): string { + if (scope === "project") { + return compact ? SCOPE_RADIO_COMPACT_PROJECT : SCOPE_RADIO_FULL_PROJECT; + } + return compact ? SCOPE_RADIO_COMPACT_GLOBAL : SCOPE_RADIO_FULL_GLOBAL; +} diff --git a/lib/shell-sidebar-layout.ts b/lib/shell-sidebar-layout.ts index 1a7d90ced..981958c2d 100644 --- a/lib/shell-sidebar-layout.ts +++ b/lib/shell-sidebar-layout.ts @@ -13,6 +13,8 @@ const RAIL_PADDING = 1; // the card instead of touching the terminal edge. const HEADER_RIGHT_INSET = RAIL_PADDING + 1; const GAP = 3; +/** Right-edge columns the painting rail takes from the editor column; published as `railColumns`. */ +export const SIDEBAR_RAIL_COLUMNS = RAIL_WIDTH + GAP; // Experimental Pi 0.85.1 internals. Only the fullscreen layout tree is adapted; // regular mode keeps native scrollback and the original bottom components. const NODE = Symbol.for("@earendil-works/pi-tui/layout-node"); @@ -120,6 +122,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () => const headerOwnsStatus = () => !stopped && !failed && headerLines.length > 0 && headerPlacement() === "top" && narrowStatusOwner({ mode: host.mode, columns: tui.terminal.columns, statusPlacement: placement(), headerPlacement: headerPlacement() }) === STATUS_OWNER.HEADER; state.headerOwnsStatus = headerOwnsStatus; + state.railColumns = SIDEBAR_RAIL_COLUMNS; state.ownsHost = () => !stopped && host.mode === "fullscreen" && tui.terminal.columns >= SIDEBAR_BREAKPOINT && (placement() === "auto" || placement() === "right") && !!host.layoutRoot && roots.has(host.layoutRoot); const rail: Component = { render: () => railLines, diff --git a/lib/shell-sidebar.ts b/lib/shell-sidebar.ts index 9b4d4dfcf..bf76a8eb3 100644 --- a/lib/shell-sidebar.ts +++ b/lib/shell-sidebar.ts @@ -9,6 +9,13 @@ export interface SidebarState { active: boolean; visibility?: { todo?: boolean }; ownsHost?: () => boolean; + /** + * Columns the fullscreen rail reserves at the right edge (rail plus gap) + * while `active && ownsHost()`. Overlays read it to stay inside the editor + * column (the prompt-history picker's right margin) without importing + * gentle-shell. + */ + railColumns?: number; /** True while Status placement is "hidden": the bottom Status bar paints nothing at any width or mode. */ statusHidden?: () => boolean; /** True while a painting top header is the only status row of a narrow fullscreen terminal: the bottom Status bar steps aside. */ diff --git a/odd/tasks/history-followups.md b/odd/tasks/history-followups.md index b13905e88..776af7595 100644 --- a/odd/tasks/history-followups.md +++ b/odd/tasks/history-followups.md @@ -21,7 +21,7 @@ Delegated direct: each task touches 2+ non-trivial files; one writer at a time. ## Tasks - [x] F1: Persisted History capture toggle in Customize with env precedence, docs and tests. Route: delegated (Customize + history + docs). - [x] F2: History reliability follow-ups: GC carry-over after unlink, perpetual line-threshold compaction, still-valid review advisories, `Home`/`End` search caret. Route: delegated. -- [ ] F3: Restore responsive header and sidebar-aware overlay margin from `e2cca1f9b^` onto current main with tests. Route: delegated. +- [x] F3: Restore responsive header and sidebar-aware overlay margin from `e2cca1f9b^` onto current main with tests. Route: delegated. - [ ] F4: Remove temporary worktrees from the chain work. Route: inline. ## Acceptance and checks @@ -49,5 +49,14 @@ Delegated direct: each task touches 2+ non-trivial files; one writer at a time. - `Home`/`End` conflict (search input always focused vs. list jumps pinned by §B2/§D7) resolved by user choice 2, "by query": with any text in the search box (whitespace included) `Home`/`End` fall through to the search input and move its caret, and `End` never jumps or loads the list; with an empty search box the §B2/§D7 list jumps stay. Implemented as a `listOwnsHomeEnd()` guard on the two existing dispatch entries (12 entries, order, and handlers unchanged). New behavior suite `tests/history-search-caret-keys.test.ts` drives the real selector through the `history` command with a fake overlay host. RED: 3 failed (`Home` then "p" pasted nothing; caret-to-end then "3" pasted nothing; `End` with a query or whitespace-only query selected `p00`); the empty-query case passed before and after (pins existing jumps). GREEN: 4/4, plus `history-dispatch` and `history-lazy-windowing` unchanged and passing. Docs: new "Selector keys" section in `docs/prompt-history.md`. - Checks after `Home`/`End`: `node --experimental-strip-types --test tests/*.test.ts` 3869 tests, 3826 pass, 0 fail, 43 skipped; `node scripts/check-types.mjs` exit 0, 188 baseline, no regressions; `git diff --check` clean (the new untracked test file also has no trailing whitespace). Uncommitted; no review invoked by the writer. +2026-09-26 F3 (delegated writer, branch `feat/history-responsive-header` from main `5167c5ce6`): responsive header and sidebar-aware overlay margin restored; uncommitted. +- Attribution (from `e2cca1f9b^`, Carolina / carolitascl; the commit carries `Co-authored-by: Carolina <26188349+carolitascl@users.noreply.github.com>`): `planHeaderLayout`/`HeaderLayoutMode`, `SCOPE_RADIO_*`, `scopeRadioText`, `editorOverlayMargin`, `SIDEBAR_OVERLAY_PADDING` (`extensions/history/selector-helpers.ts`); `OptionalRow`, `headerLine2`/`headerLine3`/`hintRow`, `headerCountsText`, the inline/stacked/compact branches of `rebuildListWithWidth`, compact `listRows`, `listWheelFirstRow`, the live `overlayOptions` getter (`extensions/history/index.ts`); `tests/history-header-layout.test.ts` (her 5 helper tests verbatim) and `tests/history-overlay-margin.test.ts` (her 8 cases, adapted); the constructor child-count pin `14` and the `listWheelFirstRow` wheel pins. +- Adapted to main: overlay options live in main's `runPromptHistorySelection`/`createOpenFlow` structure; no reintroduced env gates, module-level writers, or store resolution. The margin no longer hardcodes `SIDEBAR_RAIL_OVERLAY_MARGIN = 53`: main exposed `active`/`ownsHost()` on the terminal-owned sidebar state but not the rail width (`RAIL_WIDTH`/`GAP` were private), so the smallest seam was added: `SidebarState.railColumns` (`lib/shell-sidebar.ts`), published by `installSidebar` as the new `SIDEBAR_RAIL_COLUMNS = RAIL_WIDTH + GAP` (`lib/shell-sidebar-layout.ts`). A missing or invalid `railColumns` degrades to margin 0 (native full-width). `extensions/gentle-shell.ts` unchanged. Contributor comment corrected: the padding is 1 column (not 3). +- New tests beyond the contributor's: rendered selector through the `history` command at inline/stacked/compact/abbreviated widths (30 rows, radio placement, hint reclaim, 9-row compact list), wheel band per mode, real `installSidebar` publishing `railColumns` and margin tracking the 140-column breakpoint, live `overlayOptions` margin via `visible()`. +- RED observed: both new suites failed to import (`SCOPE_RADIO_COMPACT_GLOBAL`, `SIDEBAR_OVERLAY_PADDING` missing); 4 pins failed (constructor child count 12 vs 14 in lazy-windowing and openflow-integration; `listWheelFirstRow` absent in 2 wheel-mouse tests). GREEN: 55/55 across the five suites. Mutation check (`planHeaderLayout` forced to `inline`): 6 header tests failed, including all rendered stacked/compact/wheel tests; restored, 10/10. +- Checks: `node --experimental-strip-types --test tests/*.test.ts` 3891 tests, 3848 pass, 0 fail, 43 skipped; `node scripts/check-types.mjs` exit 0, 188 baseline, no regressions; `git diff --check` clean (new untracked tests have no trailing whitespace). +- Docs: new "Selector layout" section in `docs/prompt-history.md`. +- Size: ~286 insertions/44 deletions tracked plus ~527 lines in two new test files (above the advisory heuristic because of tests; not split). + ## Next step -Parent: review F2 and close it with a work-unit commit. +Parent: review F3 and close it with a work-unit commit carrying the Co-authored-by trailer for Carolina. diff --git a/tests/history-header-layout.test.ts b/tests/history-header-layout.test.ts new file mode 100644 index 000000000..e188aac99 --- /dev/null +++ b/tests/history-header-layout.test.ts @@ -0,0 +1,252 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import promptHistoryExtension from "../extensions/history/index.ts"; +import { + planHeaderLayout, + SCOPE_RADIO_COMPACT_GLOBAL, + SCOPE_RADIO_COMPACT_PROJECT, + SCOPE_RADIO_FULL_GLOBAL, + SCOPE_RADIO_FULL_PROJECT, + scopeRadioText, +} from "../extensions/history/selector-helpers.ts"; +import { projectHash } from "../extensions/history/store.ts"; + +// Responsive selector header (restored from Carolina's #1394 work, +// e2cca1f9b^): the header is fit-driven — inline, stacked (tablet), or +// compact (mobile) — while the overlay stays exactly 30 rows. + +const LEFT = + " History Search ".length + + " · 1 of 10 ".length + + " · loaded 10 of 27 ".length; +const RADIO = SCOPE_RADIO_FULL_PROJECT.length; +const GAP = 4; + +test("inline while counts plus radio plus minimum gap fit the width", () => { + assert.equal( + planHeaderLayout(LEFT + GAP + RADIO, LEFT, RADIO, GAP), + "inline", + ); + assert.equal(planHeaderLayout(200, LEFT, RADIO, GAP), "inline"); +}); + +test("stacked (tablet) once the spacer would drop below the minimum gap", () => { + assert.equal( + planHeaderLayout(LEFT + GAP + RADIO - 1, LEFT, RADIO, GAP), + "stacked", + ); + assert.equal(planHeaderLayout(LEFT, LEFT, RADIO, GAP), "stacked"); +}); + +test("compact (mobile) when even the counts line no longer fits", () => { + assert.equal(planHeaderLayout(LEFT - 1, LEFT, RADIO, GAP), "compact"); + assert.equal(planHeaderLayout(30, LEFT, RADIO, GAP), "compact"); +}); + +test("radio pins the user-directed labels", () => { + assert.equal(SCOPE_RADIO_FULL_PROJECT, "◉ Current project | ○ All projects"); + assert.equal(SCOPE_RADIO_FULL_GLOBAL, "○ Current project | ◉ All projects"); + assert.equal(SCOPE_RADIO_COMPACT_PROJECT, "◉ Current project | ○ All"); + assert.equal(SCOPE_RADIO_COMPACT_GLOBAL, "○ Current | ◉ All projects"); +}); + +test("scopeRadioText abbreviates only in compact mode", () => { + assert.equal(scopeRadioText("project", false), SCOPE_RADIO_FULL_PROJECT); + assert.equal(scopeRadioText("global", false), SCOPE_RADIO_FULL_GLOBAL); + assert.equal(scopeRadioText("project", true), SCOPE_RADIO_COMPACT_PROJECT); + assert.equal(scopeRadioText("global", true), SCOPE_RADIO_COMPACT_GLOBAL); +}); + +// --------------------------------------------------------------------------- +// Rendered selector: the real overlay component driven through the history +// command with a fake overlay host. Fixtures live under os.tmpdir(). +// --------------------------------------------------------------------------- + +const CWD = "/pi-history-fixtures/project-header-layout"; +const ENTER = "\r"; +// 15 prompts, oldest p00 .. newest p14: the header reads +// " History Search · 1 of 10 · loaded 10 of 15 ". +const PROMPT_COUNT = 15; +const RENDERED_LEFT = + " History Search ".length + + " · 1 of 10 ".length + + " · loaded 10 of 15 ".length; +const INLINE_WIDTH = RENDERED_LEFT + GAP + RADIO; +const STACKED_WIDTH = INLINE_WIDTH - 1; +const COMPACT_WIDTH = RENDERED_LEFT - 1; +const HINT = "Type to filter (multi-word AND substring, case-insensitive)"; + +interface Selector { + handleInput(data: string): void; + handleMouse(event: unknown): unknown; + render(width: number): string[]; +} + +function makeRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-header-")); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + const lines = Array.from({ length: PROMPT_COUNT }, (_, i) => + JSON.stringify({ + v: 1, + text: `p${String(i).padStart(2, "0")}`, + ts: 1_700_000_000_000 + i, + }), + ); + fs.writeFileSync(path.join(dir, "store.jsonl"), `${lines.join("\n")}\n`); + return root; +} + +/** + * Open the selector through the history command, run `drive` against the + * mounted component, press Enter, and return the pasted prompt. + */ +async function withSelector( + drive: (selector: Selector) => void, +): Promise { + const root = makeRoot(); + const commands: Array<[string, { handler: unknown }]> = []; + promptHistoryExtension( + { + on: () => {}, + registerShortcut: () => {}, + registerCommand: (name: string, def: { handler: unknown }) => { + commands.push([name, def]); + }, + } as never, + { + env: { GENTLE_PI_HISTORY_CAPTURE: "1" }, + gentlePiConfigHome: fs.mkdtempSync( + path.join(os.tmpdir(), "pi-history-header-config-"), + ), + root, + cwd: CWD, + instanceId: "inst-header", + agentDir: path.join(root, "agent"), + sessionsRoot: path.join(root, "sessions"), + }, + ); + const command = commands.find(([name]) => name === "history"); + assert.ok(command, "the history command must be registered"); + const handler = command[1].handler as ( + args: unknown, + ctx: unknown, + ) => Promise; + + let pasted: string | null = null; + const plain = (_color: string, text: string) => text; + const ctx = { + ui: { + notify: (message: string) => { + assert.fail(`unexpected notification: ${message}`); + }, + pasteToEditor: (text: string) => { + pasted = text; + }, + custom: ( + factory: ( + tui: unknown, + theme: unknown, + keybindings: unknown, + done: (result: unknown) => void, + ) => Selector, + ) => + new Promise((resolve) => { + const selector = factory( + { requestRender: () => {} }, + { fg: plain, bg: plain, bold: (text: string) => text }, + undefined, + resolve, + ); + drive(selector); + selector.handleInput(ENTER); + resolve(null); + }), + }, + }; + await handler([], ctx); + await new Promise((resolve) => setTimeout(resolve, 0)); + return pasted; +} + +async function renderAt(width: number): Promise { + let lines: string[] = []; + await withSelector((selector) => { + lines = selector.render(width); + }); + return lines; +} + +function wheelAt(y: number, width: number): Promise { + return withSelector((selector) => { + selector.render(width); + selector.handleMouse({ + type: "wheel", + wheelDelta: 1, + x: 1, + y, + screenX: 1, + screenY: y, + width, + height: 30, + }); + }); +} + +test("inline: title, counts and a right-flushed radio share one row above the hint", async () => { + const lines = await renderAt(INLINE_WIDTH); + assert.equal(lines.length, 30, "the overlay stays exactly 30 rows"); + assert.ok(lines[1]!.startsWith(" History Search · 1 of 10 · loaded 10 of 15 ")); + assert.ok( + lines[1]!.endsWith(SCOPE_RADIO_FULL_PROJECT), + "the radio ends flush at the header's last column", + ); + assert.equal(lines[2]!.trimEnd(), HINT); + assert.ok(lines[5]!.startsWith("→ p14"), "the list starts at row 5"); +}); + +test("stacked (tablet): the radio wraps under the counts and the hint row is reclaimed", async () => { + const lines = await renderAt(STACKED_WIDTH); + assert.equal(lines.length, 30, "the overlay stays exactly 30 rows"); + assert.equal( + lines[1]!.trimEnd(), + " History Search · 1 of 10 · loaded 10 of 15", + ); + assert.equal(lines[2]!.trimEnd(), ` ${SCOPE_RADIO_FULL_PROJECT}`); + assert.ok( + !lines.some((line) => line.includes(HINT)), + "the hint row gives its row to the radio", + ); + assert.ok(lines[5]!.startsWith("→ p14"), "the list keeps rows 5-14"); +}); + +test("compact (mobile): counts split across three rows and the list gives up one row", async () => { + const lines = await renderAt(COMPACT_WIDTH); + assert.equal(lines.length, 30, "the overlay stays exactly 30 rows"); + assert.equal(lines[1]!.trimEnd(), " History Search · 1 of 10"); + assert.equal(lines[2]!.trimEnd(), " loaded 10 of 15"); + assert.equal(lines[3]!.trimEnd(), ` ${SCOPE_RADIO_FULL_PROJECT}`); + assert.ok(lines[6]!.startsWith("→ p14"), "the list starts one row lower"); + assert.ok( + lines[14]!.startsWith(" p06"), + "compact paints nine list rows (p14..p06)", + ); + assert.ok(lines[16]!.includes("Preview"), "the preview block keeps its rows"); +}); + +test("compact abbreviates the radio only when the full radio cannot fit", async () => { + const lines = await renderAt(RADIO - 1); + assert.equal(lines.length, 30, "the overlay stays exactly 30 rows"); + assert.equal(lines[3]!.trimEnd(), ` ${SCOPE_RADIO_COMPACT_PROJECT}`); +}); + +test("the list wheel band follows the header mode", async () => { + // Inline: row 5 is the first list row — wheel down selects p13. + assert.equal(await wheelAt(5, INLINE_WIDTH), "p13"); + // Compact: row 5 is the search border — a consumed no-op. + assert.equal(await wheelAt(5, COMPACT_WIDTH), "p14"); + assert.equal(await wheelAt(6, COMPACT_WIDTH), "p13"); +}); diff --git a/tests/history-lazy-windowing.test.ts b/tests/history-lazy-windowing.test.ts index 5ee40838a..601454c0e 100644 --- a/tests/history-lazy-windowing.test.ts +++ b/tests/history-lazy-windowing.test.ts @@ -491,7 +491,7 @@ test("the header keeps the position segment plus the loaded suffix on the existi const ctorEnd = selectorSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = selectorSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); }); // T14 — AC-L2-3 revision (user-directed 2026-09-08): a non-empty query diff --git a/tests/history-openflow-integration.test.ts b/tests/history-openflow-integration.test.ts index 7d88296a2..4ffbff2fb 100644 --- a/tests/history-openflow-integration.test.ts +++ b/tests/history-openflow-integration.test.ts @@ -169,5 +169,5 @@ test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segmen const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt); const ctorAddChild = indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1; - assert.equal(ctorAddChild, 12, "the constructor child sequence is unchanged"); + assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged"); }); diff --git a/tests/history-overlay-margin.test.ts b/tests/history-overlay-margin.test.ts new file mode 100644 index 000000000..428dab14d --- /dev/null +++ b/tests/history-overlay-margin.test.ts @@ -0,0 +1,275 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { TUI } from "@earendil-works/pi-tui"; +import promptHistoryExtension from "../extensions/history/index.ts"; +import { + editorOverlayMargin, + SIDEBAR_OVERLAY_PADDING, +} from "../extensions/history/selector-helpers.ts"; +import { projectHash } from "../extensions/history/store.ts"; +import { + installSidebar, + SIDEBAR_RAIL_COLUMNS, +} from "../lib/shell-sidebar-layout.ts"; +import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts"; + +// Sidebar-aware overlay margin (restored from Carolina's #1394 work, +// e2cca1f9b^). The history extension reads the terminal-owned sidebar state +// contract (lib/shell-sidebar.ts) without importing gentle-shell; the rail +// width comes from the published `railColumns` field instead of a constant +// duplicated in the history extension. + +function terminalWithState(state: unknown): object { + return { + [Symbol.for("gentle-pi.experimental-sidebar.state")]: state, + } as object; +} + +const OWNING = { + active: true, + ownsHost: () => true, + railColumns: SIDEBAR_RAIL_COLUMNS, +}; + +test("the published rail reservation pins the gentle-shell rail geometry (RAIL_WIDTH 50 + GAP 3)", () => { + assert.equal(SIDEBAR_RAIL_COLUMNS, 53); +}); + +test("padding constant pins the user-directed 1-column breathing room", () => { + assert.equal(SIDEBAR_OVERLAY_PADDING, 1); +}); + +test("returns 0 for absent, primitive, or null terminals", () => { + assert.equal(editorOverlayMargin(undefined), 0); + assert.equal(editorOverlayMargin(null), 0); + assert.equal(editorOverlayMargin(42), 0); + assert.equal(editorOverlayMargin("terminal"), 0); +}); + +test("returns 0 when no sidebar state is stored on the terminal", () => { + assert.equal(editorOverlayMargin({}), 0); +}); + +test("returns 0 for malformed state shapes", () => { + assert.equal(editorOverlayMargin(terminalWithState(undefined)), 0); + assert.equal(editorOverlayMargin(terminalWithState(null)), 0); + assert.equal(editorOverlayMargin(terminalWithState("active")), 0); +}); + +test("returns 0 unless active is exactly true AND ownsHost is a function", () => { + assert.equal( + editorOverlayMargin(terminalWithState({ ...OWNING, ownsHost: undefined })), + 0, + "active without ownsHost", + ); + assert.equal( + editorOverlayMargin(terminalWithState({ ...OWNING, active: false })), + 0, + "inactive", + ); + assert.equal( + editorOverlayMargin(terminalWithState({ ...OWNING, active: 1 })), + 0, + "non-boolean truthy active", + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ ...OWNING, ownsHost: "not-a-function" }), + ), + 0, + "non-function ownsHost", + ); +}); + +test("returns 0 unless the sidebar publishes a positive integer rail reservation", () => { + for (const railColumns of [undefined, 0, -53, 53.5, "53", Number.NaN]) { + assert.equal( + editorOverlayMargin(terminalWithState({ ...OWNING, railColumns })), + 0, + `railColumns ${String(railColumns)}`, + ); + } +}); + +test("returns the rail reservation plus padding only while the sidebar owns the host", () => { + assert.equal(editorOverlayMargin(terminalWithState(OWNING)), 54); + assert.equal( + editorOverlayMargin(terminalWithState({ ...OWNING, railColumns: 40 })), + 41, + "the margin follows the published reservation", + ); + assert.equal( + editorOverlayMargin( + terminalWithState({ ...OWNING, ownsHost: () => false }), + ), + 0, + "state present but host not owned (regular mode / unpatched root)", + ); +}); + +test("a throwing ownsHost degrades to 0 instead of breaking the picker", () => { + assert.equal( + editorOverlayMargin( + terminalWithState({ + ...OWNING, + ownsHost: () => { + throw new Error("boom"); + }, + }), + ), + 0, + ); +}); + +// --------------------------------------------------------------------------- +// Seam: the real gentle-shell sidebar publishes the reservation it paints. +// --------------------------------------------------------------------------- + +const NODE = Symbol.for("@earendil-works/pi-tui/layout-node"); +const shellTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +function sidebarHost(columns: number) { + const root = { + render: () => ["transcript"], + invalidate() {}, + [NODE]: () => ({ type: "vstack", entries: [] }), + }; + const host = { + mode: "fullscreen", + terminal: { columns }, + layoutRoot: root, + requestRender() {}, + }; + const tui = host as unknown as TUI; + sidebarPart(tui, "footer", { + render: (_width: number) => ["Status"], + invalidate() {}, + }); + return { host, tui, root }; +} + +test("the installed sidebar publishes its rail reservation; the margin tracks the breakpoint", (t) => { + const { host, tui, root } = sidebarHost(140); + t.after(installSidebar(tui, shellTheme)); + assert.equal(sidebarState(tui).railColumns, SIDEBAR_RAIL_COLUMNS); + root[NODE](); + assert.equal( + editorOverlayMargin(host.terminal), + SIDEBAR_RAIL_COLUMNS + SIDEBAR_OVERLAY_PADDING, + "fullscreen at the breakpoint with a painting rail", + ); + host.terminal.columns = 139; + root[NODE](); + assert.equal(editorOverlayMargin(host.terminal), 0, "below the breakpoint"); +}); + +// --------------------------------------------------------------------------- +// Overlay options: the picker re-reads the margin on every render pass. +// --------------------------------------------------------------------------- + +const CWD = "/pi-history-fixtures/project-overlay-margin"; + +interface OverlayOptions { + anchor?: string; + width?: string; + offsetY?: number; + margin?: { right: number }; + visible?: (columns: number, rows: number) => boolean; +} + +async function captureOverlayOptions(terminal: object): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-margin-")); + const dir = path.join(root, "projects", projectHash(CWD)); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, "store.jsonl"), + `${JSON.stringify({ v: 1, text: "p00", ts: 1_700_000_000_000 })}\n`, + ); + const commands: Array<[string, { handler: unknown }]> = []; + promptHistoryExtension( + { + on: () => {}, + registerShortcut: () => {}, + registerCommand: (name: string, def: { handler: unknown }) => { + commands.push([name, def]); + }, + } as never, + { + env: { GENTLE_PI_HISTORY_CAPTURE: "1" }, + gentlePiConfigHome: fs.mkdtempSync( + path.join(os.tmpdir(), "pi-history-margin-config-"), + ), + root, + cwd: CWD, + instanceId: "inst-margin", + agentDir: path.join(root, "agent"), + sessionsRoot: path.join(root, "sessions"), + }, + ); + const command = commands.find(([name]) => name === "history"); + assert.ok(command, "the history command must be registered"); + const handler = command[1].handler as ( + args: unknown, + ctx: unknown, + ) => Promise; + let captured: OverlayOptions | undefined; + const plain = (_color: string, text: string) => text; + await handler([], { + ui: { + notify: (message: string) => assert.fail(`unexpected: ${message}`), + pasteToEditor: () => {}, + custom: ( + factory: ( + tui: unknown, + theme: unknown, + keybindings: unknown, + done: (result: unknown) => void, + ) => unknown, + options: { overlayOptions?: OverlayOptions | (() => OverlayOptions) }, + ) => { + // Mount first, like pi: the factory captures the TUI handle, then + // the host resolves the overlay options. + factory( + { requestRender: () => {}, terminal }, + { fg: plain, bg: plain, bold: (text: string) => text }, + undefined, + () => {}, + ); + const resolved = options.overlayOptions; + captured = typeof resolved === "function" ? resolved() : resolved; + return Promise.resolve(null); + }, + }, + }); + assert.ok(captured, "the selector must pass overlay options"); + return captured; +} + +test("the picker keeps the native full-window overlay without a painting sidebar", async () => { + const options = await captureOverlayOptions({}); + assert.equal(options.anchor, "bottom-center"); + assert.equal(options.width, "100%"); + assert.equal(options.offsetY, 5); + assert.equal(options.margin, undefined); + assert.equal(options.visible?.(200, 50), true); +}); + +test("the picker margin stays live while the overlay is open", async () => { + const state = { ...OWNING }; + const options = await captureOverlayOptions(terminalWithState(state)); + assert.deepEqual(options.margin, { right: 54 }); + // Resizing below the breakpoint: pi-tui calls visible() on every render + // pass, then re-reads margin while resolving the layout. + state.active = false; + assert.equal(options.visible?.(139, 50), true); + assert.equal(options.margin, undefined); + state.active = true; + assert.equal(options.visible?.(140, 50), true); + assert.deepEqual(options.margin, { right: 54 }); +}); diff --git a/tests/history-wheel-mouse.test.ts b/tests/history-wheel-mouse.test.ts index fbb4a33b7..4a1842636 100644 --- a/tests/history-wheel-mouse.test.ts +++ b/tests/history-wheel-mouse.test.ts @@ -142,7 +142,7 @@ test("region constants 5-14 / 17-26 route the y comparisons (AC-L6-3)", () => { const body = selectorSource.slice(decl, end); assert.ok( - body.includes("event.y >= LIST_WHEEL_Y_FIRST") && + body.includes("event.y >= this.listWheelFirstRow") && body.includes("event.y <= LIST_WHEEL_Y_LAST"), "the list branch must compare y against the list band", ); @@ -169,7 +169,7 @@ test("list wheel routes sign-clamped steps through moveDown/moveUp (AC-L6-4)", ( "delta must default an absent wheelDelta to 0", ); - const listStart = body.indexOf("if (event.y >= LIST_WHEEL_Y_FIRST"); + const listStart = body.indexOf("if (event.y >= this.listWheelFirstRow"); const listEnd = body.indexOf("} else if (", listStart); assert.ok( listStart >= 0 && listEnd > listStart,