diff --git a/.changeset/inspect-scroll-and-masked-secrets.md b/.changeset/inspect-scroll-and-masked-secrets.md
new file mode 100644
index 00000000..557d5ee1
--- /dev/null
+++ b/.changeset/inspect-scroll-and-masked-secrets.md
@@ -0,0 +1,34 @@
+---
+"@noormdev/cli": minor
+---
+
+Make `run inspect` scrollable, and show secrets partially rather than as a count.
+
+The inspect screen rendered its context as a nested tree that grew with the
+project, and Ink has no scroll offset — so on any real template the bottom of
+the view sat below the fold with no key that could reach it, and the screen's
+own footer was what got pushed off to make room. Every view it offers (summary,
+expanded, rendered SQL, and render errors) is now a flat list of one element per
+visual line behind a viewport, scrolled with the same `↑↓` / `^U` / `^D` keys the
+explore and SQL screens already use.
+
+`$.secrets` and `$.globalSecrets` reported a key count, which cannot answer the
+question the screen is opened to answer: a stale password and a fresh one are
+both `Object (7 keys)`. Both tiers now show a partial reveal that narrows as the
+value gets shorter — a four-character value shows nothing, a long one shows two
+characters and a four-character suffix — with the length beside it as a number,
+so a value that is set but empty is distinguishable from one that is set wrong.
+`$.env` is listed and masked on the same terms, because it is the whole of
+`process.env` and nothing in the screen can tell which of its keys are
+credentials.
+
+The mouse wheel now scrolls every viewport, which it never did. Only `SelectList`
+and `ResultTable` consumed wheel notches, so the explore detail view, the
+full-text overlay and the row viewer ignored them — and because the TUI runs in
+the alternate screen, which has no scrollback, and mouse tracking intercepts the
+notches a terminal would otherwise translate into arrow keys, turning the mouse
+on had actually removed the only wheel behaviour those panes had.
+
+Also fixes an error path that could never render: a template whose helper failed
+to load set the screen's error phase with a file selected, which no branch
+matched, so the most likely failure showed as "Unknown phase".
diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts
index fafd72b1..85ae15c4 100644
--- a/src/core/shared/index.ts
+++ b/src/core/shared/index.ts
@@ -14,6 +14,9 @@ export { OperationAbortedError, throwIfAborted, raceAbort } from './abort.js';
// Files
export { filterFilesByPaths, findUnmatchedIncludePatterns, findUnmatchedExcludePatterns } from './files.js';
+// Secret display
+export { maskSecret } from './mask.js';
+
// Dialect quoting
export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js';
diff --git a/src/core/shared/mask.ts b/src/core/shared/mask.ts
new file mode 100644
index 00000000..9eafe5cb
--- /dev/null
+++ b/src/core/shared/mask.ts
@@ -0,0 +1,88 @@
+/**
+ * Partial masking for a secret that is being shown to the person who owns it.
+ *
+ * The inspect screen exists to answer "did this template get the values I
+ * think it got", and a row of `Object (7 keys)` cannot answer it. Neither can
+ * a full reveal, which turns a screen someone leaves open during a screen
+ * share into a credential leak. What answers it is enough of the value to
+ * recognise which secret it is and to catch the two mistakes that actually
+ * happen — a stale value, and a key resolved from the wrong tier.
+ *
+ * How much is safe to show depends on how much there is. A four-character
+ * value has no middle to hide, so revealing its ends reveals the value; a
+ * forty-character token gives away nothing in six characters. The bands below
+ * reveal less as the value gets shorter, and stop revealing at all once a
+ * value is short enough that any window is most of it.
+ *
+ * The mask core is a fixed width on purpose. Sizing it to the value would
+ * publish the exact length of every secret on screen, which is a real
+ * narrowing hint against a value someone is trying to guess. Callers that
+ * want the length — inspect does, because "set but empty" and "set to the
+ * 8-character staging password" are different bugs — ask for it separately
+ * and render it as a number, where it reads as the diagnostic it is rather
+ * than as part of the value.
+ *
+ * @example
+ * maskSecret('hunter2'); // 'h*****2'
+ * maskSecret('postgres://user:pw@host/db'); // 'po*****t/db'
+ */
+
+/**
+ * What stands in for the hidden middle, at every length that has one.
+ *
+ * Fixed rather than proportional so the rendering never encodes how long the
+ * value is. See the module note.
+ */
+const MASK_CORE = '*****';
+
+/**
+ * Longest value that is shown as nothing but mask.
+ *
+ * At four characters a first-and-last window is half the value, which is not
+ * a mask.
+ */
+const OPAQUE_MAX = 4;
+
+/** Longest value that reveals only one character at each end. */
+const NARROW_MAX = 8;
+
+/** Longest value that reveals a suffix but no prefix. */
+const SUFFIX_ONLY_MAX = 12;
+
+/** What an empty value renders as, so it is not mistaken for an unset one. */
+const EMPTY_LABEL = '(empty)';
+
+/**
+ * A secret rendered for display, revealing less the shorter it is.
+ *
+ * @example
+ * maskSecret(''); // '(empty)'
+ * maskSecret('abcd'); // '*****'
+ * maskSecret('abcdefgh'); // 'a*****h'
+ * maskSecret('abcdefghijkl'); // '*****ijkl'
+ * maskSecret('abcdefghijklm'); // 'ab*****jklm'
+ */
+export function maskSecret(value: string): string {
+
+ // Code points, not `.length`. A `String.prototype.slice` offset counts
+ // UTF-16 code units, so a value ending in an emoji or any other non-BMP
+ // character gets sliced through the middle of a surrogate pair and the
+ // reveal renders as a replacement glyph — the one part of the value a
+ // reader is meant to recognise, corrupted.
+ const characters = [...value];
+ const { length } = characters;
+
+ const head = (count: number) => characters.slice(0, count).join('');
+ const tail = (count: number) => characters.slice(-count).join('');
+
+ if (length === 0) return EMPTY_LABEL;
+
+ if (length <= OPAQUE_MAX) return MASK_CORE;
+
+ if (length <= NARROW_MAX) return `${head(1)}${MASK_CORE}${tail(1)}`;
+
+ if (length <= SUFFIX_ONLY_MAX) return `${MASK_CORE}${tail(4)}`;
+
+ return `${head(2)}${MASK_CORE}${tail(4)}`;
+
+}
diff --git a/src/tui/components/terminal/RowViewOverlay.tsx b/src/tui/components/terminal/RowViewOverlay.tsx
index ece7bb95..fa881cc5 100644
--- a/src/tui/components/terminal/RowViewOverlay.tsx
+++ b/src/tui/components/terminal/RowViewOverlay.tsx
@@ -41,6 +41,7 @@ import type { ReactElement } from 'react';
import type { RowFormat } from './rowDocument.js';
import { useFocusScope } from '../../focus.js';
+import { useWheelScroll } from '../../mouse.js';
import { preferredRowFormat, rememberRowFormat, renderRowDocument } from './rowDocument.js';
import { rowBudget, rowWindow, scrollTarget, wrapText } from './viewport.js';
@@ -118,6 +119,13 @@ export function RowViewOverlay({
const view = rowWindow(lines.length, offset, budget);
const maxOffset = lines.length - view.count;
+ const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));
+
+ // Scrolls the document, not the row cursor: ←/→ change rows, and a wheel
+ // that jumped between rows would lose the reader's place in a long one.
+ // Inert without a MouseProvider above it or with the setting off.
+ useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });
+
const move = (next: number) => {
if (next < 0 || next > rows.length - 1 || next === index) return;
@@ -169,7 +177,7 @@ export function RowViewOverlay({
const target = scrollTarget(input, key, view, maxOffset);
- if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset));
+ if (target !== null) scrollTo(target);
});
diff --git a/src/tui/components/terminal/ScrollPane.tsx b/src/tui/components/terminal/ScrollPane.tsx
new file mode 100644
index 00000000..b6c99da9
--- /dev/null
+++ b/src/tui/components/terminal/ScrollPane.tsx
@@ -0,0 +1,93 @@
+/**
+ * ScrollPane - a vertical viewport over a flat list of pre-laid-out lines.
+ *
+ * Ink has no scroll offset. A screen that renders more rows than the terminal
+ * holds does not clip them, it pushes its own footer off the bottom, so the
+ * content past the fold is not merely unscrolled — it is unreachable. Every
+ * screen that can overflow therefore has to flatten itself to one element per
+ * visual line and draw a slice of that list, which is what this does.
+ *
+ * It is the plain form of the pattern: offset state, the shared window
+ * arithmetic, the shared scroll keys, and the two "more above / more below"
+ * indicators. `ExploreDetailScreen`'s `ScrollView` is the same viewport with a
+ * full-text overlay and a row peek switched in over the top of it, and it stays
+ * where it is — its overlays are built from explore's own layout module, so
+ * pulling it down here would drag a screen's vocabulary into a shared
+ * component for no gain. Anything that needs a viewport and not those overlays
+ * uses this.
+ *
+ * Takes `height` and `isFocused` as props rather than measuring or scoping for
+ * itself, so the screen stays the single place that accounts for chrome, and so
+ * a test can pin a viewport without a terminal to measure.
+ *
+ * @example
+ *
+ */
+import { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+
+import type { ReactElement } from 'react';
+
+import { useWheelScroll } from '../../mouse.js';
+import { rowWindow, scrollTarget } from './viewport.js';
+
+/**
+ * Props for the scroll pane.
+ */
+export interface ScrollPaneProps {
+
+ /**
+ * One element per visual line, each carrying its own `key`.
+ *
+ * One *visual* line: an element that wraps to two rows makes the window
+ * arithmetic wrong by one, so callers wrap their own text (`wrapText`) or
+ * truncate it (`wrap="truncate"`) before handing it over.
+ */
+ lines: ReactElement[];
+
+ /** Rows the viewport may draw, indicators included. */
+ height: number;
+
+ /** Focus comes from the screen; this component opens no scope of its own. */
+ isFocused: boolean;
+
+}
+
+/**
+ * ScrollPane component.
+ */
+export function ScrollPane({ lines, height, isFocused }: ScrollPaneProps): ReactElement {
+
+ const [offset, setOffset] = useState(0);
+
+ const view = rowWindow(lines.length, offset, height);
+ const maxOffset = lines.length - view.count;
+
+ const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));
+
+ // Inert without a MouseProvider above it or with the setting off.
+ useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });
+
+ useInput((input, key) => {
+
+ if (!isFocused) return;
+
+ // Rebases on `view.start` rather than on `offset`: the window clamps
+ // what it draws, so a stale offset left by a resize or by shorter
+ // content cannot send the next keypress somewhere the viewport never
+ // was.
+ const target = scrollTarget(input, key, view, maxOffset);
+
+ if (target !== null) scrollTo(target);
+
+ });
+
+ return (
+
+ {view.above > 0 && ↑ {view.above} more}
+ {lines.slice(view.start, view.start + view.count)}
+ {view.below > 0 && ↓ {view.below} more}
+
+ );
+
+}
diff --git a/src/tui/components/terminal/index.ts b/src/tui/components/terminal/index.ts
index 05cd92fe..16c33a06 100644
--- a/src/tui/components/terminal/index.ts
+++ b/src/tui/components/terminal/index.ts
@@ -17,6 +17,9 @@ export type { ResultBrowserProps } from './ResultBrowser.js';
export { RowViewOverlay } from './RowViewOverlay.js';
export type { RowViewOverlayProps } from './RowViewOverlay.js';
+export { ScrollPane } from './ScrollPane.js';
+export type { ScrollPaneProps } from './ScrollPane.js';
+
export { fitGridColumns, fitPeekColumns, PEEK_COLUMN_CAP } from './columnFit.js';
export type { GridColumnFit, PeekColumnFit } from './columnFit.js';
diff --git a/src/tui/mouse.tsx b/src/tui/mouse.tsx
index f88e7b12..94282eb9 100644
--- a/src/tui/mouse.tsx
+++ b/src/tui/mouse.tsx
@@ -435,6 +435,72 @@ export function useMouseTransport(): MouseTransport {
}
+/**
+ * Options for useWheelScroll.
+ */
+export interface WheelScrollOptions {
+
+ /**
+ * Whether the component owning the viewport currently has input.
+ *
+ * The same guard its `useInput` handler uses: a wheel notch acts on
+ * whatever already has focus, so a pane sitting behind an overlay must not
+ * move under it.
+ */
+ isActive: boolean;
+
+ /** A wheel notch: -1 for up, 1 for down. */
+ onWheel: (delta: -1 | 1) => void;
+
+}
+
+/**
+ * Wheel notches for a viewport that scrolls but has no rows to click.
+ *
+ * `useRowMouse` already carries wheel handling, but it is built around
+ * hit-testing a list of registered row refs, and a viewport has none — it draws
+ * a slice of a flat line list where nothing is selectable. Subscribing for the
+ * wheel alone is the whole of what those panes need.
+ *
+ * Without this the wheel is not merely unhandled, it is broken: the TUI runs in
+ * the alternate screen, which has no scrollback of its own, and mouse tracking
+ * takes the notches that a terminal would otherwise translate into arrow keys.
+ * So turning the mouse on removes the only wheel behaviour a viewport had.
+ *
+ * @example
+ * useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });
+ */
+export function useWheelScroll({ isActive, onWheel }: WheelScrollOptions): void {
+
+ const { enabled, subscribe } = useMouseTransport();
+
+ // Registered once, reading the latest props through this ref. Listing them
+ // as dependencies would resubscribe on every render, since every caller
+ // passes an inline arrow.
+ const latest = useRef({ isActive, onWheel });
+
+ latest.current = { isActive, onWheel };
+
+ useEffect(() => {
+
+ if (!enabled) return;
+
+ return subscribe((event) => {
+
+ const current = latest.current;
+
+ if (!current.isActive || event.kind !== 'press') return;
+
+ if (event.button === 'wheel-up') current.onWheel(-1);
+
+ else if (event.button === 'wheel-down') current.onWheel(1);
+
+ });
+
+ }, [enabled, subscribe]);
+
+}
+
/**
* Options for useRowMouse.
*/
diff --git a/src/tui/screens/db/explore/ExploreDetailScreen.tsx b/src/tui/screens/db/explore/ExploreDetailScreen.tsx
index a387b49d..a7ef498f 100644
--- a/src/tui/screens/db/explore/ExploreDetailScreen.tsx
+++ b/src/tui/screens/db/explore/ExploreDetailScreen.tsx
@@ -34,6 +34,7 @@ import type { ScreenProps } from '../../../types.js';
import { useRouter } from '../../../router.js';
import { useFocusScope } from '../../../focus.js';
+import { useWheelScroll } from '../../../mouse.js';
import { useAppContext } from '../../../app-context.js';
import { Panel, Spinner } from '../../../components/index.js';
import { useConnection, useAsyncEffect } from '../../../hooks/index.js';
@@ -614,6 +615,13 @@ export function ScrollView({
// cannot send the next keypress somewhere the viewport never was.
const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));
+ // Only while this component owns the viewport: an overlay draws over it and
+ // scrolls itself, so the pane underneath must not move under the notch.
+ useWheelScroll({
+ isActive: isFocused && overlay === 'none',
+ onWheel: (delta) => scrollTo(view.start + delta),
+ });
+
const open = (next: DetailOverlay) => {
setOverlay(next);
diff --git a/src/tui/screens/db/explore/FullTextOverlay.tsx b/src/tui/screens/db/explore/FullTextOverlay.tsx
index 65845f2a..42d4d19f 100644
--- a/src/tui/screens/db/explore/FullTextOverlay.tsx
+++ b/src/tui/screens/db/explore/FullTextOverlay.tsx
@@ -28,6 +28,7 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
import type { ReactElement } from 'react';
import { useFocusScope } from '../../../focus.js';
+import { useWheelScroll } from '../../../mouse.js';
import { rowBudget, rowWindow, scrollTarget, wrapText } from './layout.js';
/**
@@ -96,6 +97,11 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve
const view = rowWindow(lines.length, offset, budget);
const maxOffset = lines.length - view.count;
+ const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));
+
+ // Inert without a MouseProvider above it or with the setting off.
+ useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });
+
useInput((input, key) => {
if (!isFocused) return;
@@ -110,7 +116,7 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve
const target = scrollTarget(input, key, view, maxOffset);
- if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset));
+ if (target !== null) scrollTo(target);
});
diff --git a/src/tui/screens/run/RunInspectScreen.tsx b/src/tui/screens/run/RunInspectScreen.tsx
index 36eef206..91cf8a26 100644
--- a/src/tui/screens/run/RunInspectScreen.tsx
+++ b/src/tui/screens/run/RunInspectScreen.tsx
@@ -10,8 +10,8 @@
* noorm run inspect sql/users/001_create.sql.tmpl # With pre-filled path
* ```
*/
-import { useState, useCallback, useEffect } from 'react';
-import { Box, Text, useInput } from 'ink';
+import { useState, useCallback, useEffect, useMemo } from 'react';
+import { Box, Text, useInput, useWindowSize } from 'ink';
import { join, relative } from 'path';
import type { ReactElement } from 'react';
@@ -22,7 +22,9 @@ import { useRouter } from '../../router.js';
import { useFocusScope } from '../../focus.js';
import { useSettings, useAppContext } from '../../app-context.js';
import { Panel, Spinner, SearchableList } from '../../components/index.js';
-import { useAsyncEffect, useConnection } from '../../hooks/index.js';
+import { ScrollPane, rowBudget, wrapText } from '../../components/terminal/index.js';
+import { useAsyncEffect, useConnection, viewportRows } from '../../hooks/index.js';
+import { maskSecret } from '../../../core/shared/index.js';
import { discoverFiles } from '../../../core/runner/index.js';
import { buildContext } from '../../../core/template/context.js';
import { processFile } from '../../../core/template/engine.js';
@@ -43,7 +45,27 @@ type Phase = 'loading' | 'picker' | 'inspecting' | 'expanded' | 'preview' | 'err
const BUILTIN_HELPERS = new Set(['quote', 'escape', 'uuid', 'now', 'json', 'include']);
const STANDARD_KEYS = new Set(['config', 'secrets', 'globalSecrets', 'env']);
-interface CategorizedContext {
+/**
+ * Rows a scrolling phase spends inside its Panel before the pane starts.
+ *
+ * The `File:` line and the gap under it. The file name stays out of the pane on
+ * purpose: it is the one thing a reader needs at every scroll position, and a
+ * heading that scrolls away is a heading that is missing when it is wanted.
+ */
+const HEADER_ROWS = 2;
+
+/**
+ * A template context split into the groups the screen draws as sections.
+ *
+ * Exported because the line builders below are unit-tested directly, the way
+ * `ExploreDetailScreen`'s row builders are — rendering the whole screen to
+ * assert on a mask would mean standing up a project, a config and a connection
+ * for a pure function.
+ *
+ * @example
+ * const lines = contextLines(categorizeContext(ctx, helperKeys, []), projectRoot, 96);
+ */
+export interface CategorizedContext {
dataFiles: Array<{ key: string; value: unknown }>;
helpers: Array<{ key: string; value: unknown }>;
helperErrors: HelperLoadError[];
@@ -276,42 +298,244 @@ async function resolveRenderSecrets(
}
+/** Widest the name column grows before it truncates. */
+const NAME_CAP = 30;
+
+/** Narrowest the name column shrinks to, however little the terminal offers. */
+const NAME_MIN = 12;
+
+/** Left inset every entry under a section heading shares. */
+const ENTRY_INDENT = 2;
+
/**
- * Component that handles keyboard input for a specific focus scope.
+ * Width of the name column, derived from what this context actually holds.
+ *
+ * Same idiom as the explore rows and the Form label gutter: size once from the
+ * content, cap it, truncate past the cap. A context of short names does not pay
+ * for the one environment variable with a sixty-character name.
*/
-function KeyHandler({
- focusLabel,
- onEscape,
- onKey,
-}: {
- focusLabel: string;
- onEscape?: () => void;
- onKey?: (input: string, key: { escape: boolean }) => void;
-}): null {
-
- const { isFocused } = useFocusScope(focusLabel);
+function nameColumnWidth(names: string[], budget: number): number {
- useInput((input, key) => {
+ let widest = 0;
- if (!isFocused) return;
+ for (const name of names) {
+
+ if (name.length > widest) widest = name.length;
- if (key.escape && onEscape) {
+ }
- onEscape();
+ return Math.max(NAME_MIN, Math.min(widest, NAME_CAP, budget - ENTRY_INDENT - NAME_MIN));
- return;
+}
- }
+/**
+ * Text with its line breaks flattened, so it can occupy exactly one row.
+ *
+ * `wrap="truncate"` bounds a line's width, not its height: Ink still breaks on
+ * an embedded newline, so a single `` holding one draws two rows and puts
+ * the viewport's arithmetic out by one for everything below it. Nothing on this
+ * screen controls the strings it displays — a secret can be a PEM key, an
+ * environment variable can hold anything, a helper's error message can be a
+ * multi-line diagnostic — so the flattening happens where text enters a
+ * one-row cell rather than at each of those sources.
+ */
+function oneLine(text: string): string {
+
+ return text.replace(/[\r\n]+/g, ' ');
- if (onKey) {
+}
+
+/**
+ * One `name detail` line, exactly one row tall.
+ *
+ * `flexShrink={0}` on the name cell because Ink's `width` is a flex basis and
+ * flex items shrink by default: without it a long detail squeezes the name on
+ * that row alone, and the column wanders down the page. Both cells truncate
+ * rather than wrap, which bounds their width; `oneLine` is what bounds their
+ * height.
+ */
+function entryRow(key: string, name: string, color: string, detail: string, width: number): ReactElement {
- onKey(input, key);
+ return (
+
+
+ {oneLine(name)}
+
+ {oneLine(detail)}
+
+ );
- }
+}
+
+/**
+ * A heading, its entries, and the blank line under them.
+ *
+ * An empty section contributes nothing rather than a bare heading, so a project
+ * with no data files does not scroll past a promise of some.
+ */
+function sectionLines(key: string, title: string, rows: ReactElement[]): ReactElement[] {
+
+ if (rows.length === 0) return [];
+
+ return [
+ {title},
+ ...rows,
+ ,
+ ];
+
+}
+
+/**
+ * Secret keys, each with as much of its value as is safe to show.
+ *
+ * A count answers "is anything there". The question this screen is actually
+ * asked is "did this template get the value I think it got", and only the value
+ * answers that — a stale password and a fresh one are both `Object (7 keys)`.
+ * How much of it is safe to show is `maskSecret`'s decision, not this
+ * component's; see `core/shared/mask.ts`. The length rides alongside as a
+ * number rather than as mask width so that "set but empty" and "set to the
+ * wrong 8-character value" stay distinguishable without the asterisks
+ * themselves leaking anything.
+ */
+function secretRows(prefix: string, values: Record, color: string, width: number): ReactElement[] {
+
+ return Object.keys(values).sort().map((key) => {
+
+ const value = values[key] ?? '';
+
+ // Code points, matching how `maskSecret` counts. Reporting UTF-16 units
+ // beside a mask banded on characters would call the same value two
+ // different lengths.
+ const count = [...value].length;
+
+ return entryRow(`${prefix}:${key}`, key, color, `${maskSecret(value)} (${count} chars)`, width);
});
- return null;
+}
+
+/**
+ * The summary view as one element per visual line.
+ *
+ * Flattened rather than nested because Ink has no scroll offset: the only way
+ * to reach content past the bottom of the terminal is to draw a slice of a flat
+ * list, and a tree cannot be sliced.
+ *
+ * `$.env` is listed and masked like the other two secret tiers. It is the whole
+ * of `process.env` (`core/template/context.ts`), which on a developer's machine
+ * routinely carries tokens that never went near noorm's vault, and nothing here
+ * can tell which of its keys those are. Masking every value is the answer that
+ * is wrong in the harmless direction.
+ *
+ * @example
+ *
+ */
+export function contextLines(context: CategorizedContext, projectRoot: string, budget: number): ReactElement[] {
+
+ const envKeys = Object.keys(context.env);
+ const width = nameColumnWidth(
+ [
+ ...context.dataFiles.map(({ key }) => `$.${key}`),
+ ...context.helpers.map(({ key }) => `$.${key}`),
+ ...context.builtins.map(({ key }) => `$.${key}`),
+ ...Object.keys(context.secrets),
+ ...Object.keys(context.globalSecrets),
+ ...envKeys,
+ '$.config',
+ ],
+ budget,
+ );
+
+ // A helper error gets the whole row rather than the two-column treatment.
+ // The name column is sized from the `$.name` entries beside it, which are
+ // short, and a path is the one thing this row exists to say — put it in
+ // that column and `sql/helpers/slug.js` renders as `sql/helpers…`, naming
+ // no file at all.
+ const helperEntries = [
+ ...context.helpers.map(({ key, value }) =>
+ entryRow(`helper:${key}`, `$.${key}`, 'magenta', describeType(value), width)),
+ ...context.helperErrors.map(({ filepath, error }) => (
+
+ {oneLine(`${' '.repeat(ENTRY_INDENT)}${relative(projectRoot, filepath)} — ${error.message}`)}
+
+ )),
+ ];
+
+ return [
+ ...sectionLines('data', 'Data Files', context.dataFiles.map(({ key, value }) =>
+ entryRow(`data:${key}`, `$.${key}`, 'green', describeType(value), width))),
+ ...sectionLines('helpers', 'Helpers ($helpers)', helperEntries),
+ ...sectionLines('builtins', 'Built-ins', context.builtins.map(({ key }) =>
+ entryRow(`builtin:${key}`, `$.${key}`, 'blue', 'Function', width))),
+ ...sectionLines('config', 'Config', [
+ entryRow('config', '$.config', 'yellow', context.config ? describeType(context.config) : '(not set)', width),
+ ]),
+ ...sectionLines(
+ 'secrets',
+ `Secrets ($.secrets — ${Object.keys(context.secrets).length})`,
+ secretRows('secret', context.secrets, 'red', width),
+ ),
+ ...sectionLines(
+ 'globalSecrets',
+ `Global Secrets ($.globalSecrets — ${Object.keys(context.globalSecrets).length})`,
+ secretRows('globalSecret', context.globalSecrets, 'red', width),
+ ),
+ ...sectionLines(
+ 'env',
+ `Environment ($.env — ${envKeys.length})`,
+ secretRows('env', context.env, 'gray', width),
+ ),
+ ];
+
+}
+
+/**
+ * Plain text as one element per visual line.
+ *
+ * Wrapped here rather than left to Ink because a `` that wraps itself
+ * occupies however many rows the terminal decides, and the viewport has to know
+ * the count before Ink lays it out.
+ */
+function textLines(key: string, text: string, budget: number, style: { color?: string; dim?: boolean } = {}): ReactElement[] {
+
+ return wrapText(text, budget).map((line, index) => (
+ {line}
+ ));
+
+}
+
+/**
+ * The expanded view as one element per visual line.
+ *
+ * Reports shapes rather than values, so what can overflow a row here is a long
+ * key or a wide shape summary, and both are wrapped to the budget rather than
+ * truncated — the expanded view exists to show what a summary cut.
+ *
+ * @example
+ *
+ */
+export function expandedLines(context: CategorizedContext, budget: number): ReactElement[] {
+
+ const lines: ReactElement[] = [];
+
+ for (const { key, value } of context.dataFiles) {
+
+ lines.push({oneLine(`$.${key}`)});
+ lines.push(...describeTypeExpanded(value, 1).flatMap((line, index) =>
+ textLines(`exp:${key}:${index}`, line, budget, { dim: true })));
+ lines.push( );
+
+ }
+
+ if (context.config !== undefined && context.config !== null) {
+
+ lines.push($.config);
+ lines.push(...describeTypeExpanded(context.config, 1).flatMap((line, index) =>
+ textLines(`exp:config:${index}`, line, budget, { dim: true })));
+
+ }
+
+ return lines;
}
@@ -325,6 +549,12 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
const { settings } = useSettings();
const { db, dialect } = useConnection();
+ // useWindowSize, not useStdout: stdout.columns and .rows mutate on resize
+ // without telling React, so anything derived from them would freeze at
+ // mount size. Above the early returns, or the hook count changes once the
+ // load resolves.
+ const { columns: terminalColumns, rows: terminalRows } = useWindowSize();
+
const [phase, setPhase] = useState('loading');
const [allFiles, setAllFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(params.path ?? null);
@@ -333,6 +563,18 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
const [renderDuration, setRenderDuration] = useState(null);
const [error, setError] = useState(null);
+ // One scope for the whole screen rather than one per phase, because the
+ // scroll pane and the action keys have to agree on who is focused and two
+ // scopes cannot: React runs a child's effects before its parent's, so a
+ // screen-level push lands *above* its own child's and takes the keys the
+ // child was mounted to receive. `skip` is how a screen that sometimes hosts
+ // a focusable child stays out of the stack while that child is up — here,
+ // the file picker's `SearchableList`.
+ const { isFocused } = useFocusScope({
+ label: 'RunInspect',
+ skip: phase === 'picker' && allFiles.length > 0,
+ });
+
const projectRoot = process.cwd();
// Load template files on mount
@@ -511,42 +753,79 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
});
- // Handlers for inspecting phase
- const handleInspectKey = useCallback((input: string) => {
+ const handleInspectEscape = useCallback(() => {
- if (input === 'e') {
+ setSelectedFile(null);
+ setContext(null);
+ setError(null);
+ setPhase('picker');
- setPhase('expanded');
+ }, []);
- }
- else if (input === 'p') {
+ const displayPath = selectedFile ? relative(projectRoot, selectedFile) : '';
- handlePreview();
+ const paneHeight = viewportRows(terminalRows, HEADER_ROWS);
+ const budget = rowBudget(terminalColumns);
+
+ const summaryLines = useMemo(
+ () => (context ? contextLines(context, projectRoot, budget) : []),
+ [context, projectRoot, budget],
+ );
+
+ const detailLines = useMemo(
+ () => (context ? expandedLines(context, budget) : []),
+ [context, budget],
+ );
+
+ // The render error and the rendered SQL share the pane, because they are
+ // the same thing to a reader: what came back from asking for this template.
+ // A stack trace overflows a terminal as readily as a schema does.
+ const previewLines = useMemo(
+ () => (error !== null
+ ? textLines('previewError', error, budget, { color: 'red' })
+ : textLines('preview', renderedSql ?? '', budget)),
+ [error, renderedSql, budget],
+ );
+
+ const errorLines = useMemo(
+ () => textLines('error', error ?? 'Unknown error', budget, { dim: true }),
+ [error, budget],
+ );
- }
- else if (input === 'r') {
+ useInput((input, key) => {
- handleRefresh();
+ if (!isFocused) return;
- }
+ if (key.escape) {
- }, [handlePreview, handleRefresh]);
+ // An error raised against a chosen template goes back to the
+ // picker, like a successful inspection does. Only a failure to
+ // discover any templates at all leaves the screen, because there is
+ // no picker to go back to.
+ if (phase === 'inspecting' || (phase === 'error' && selectedFile)) handleInspectEscape();
+ else if (phase === 'expanded' || phase === 'preview') setPhase('inspecting');
+ else back();
- const handleInspectEscape = useCallback(() => {
+ return;
- setSelectedFile(null);
- setContext(null);
- setPhase('picker');
+ }
- }, []);
+ if (phase !== 'inspecting') return;
- const handleBackToInspect = useCallback(() => {
+ // Ink reports a Ctrl chord as the bare letter with `key.ctrl` set, so
+ // without this Ctrl+E would expand and Ctrl+R would re-render. Ctrl+D
+ // is safe either way — it arrives as `d`, which none of these match —
+ // but the pane below reads it, so the modifier check has to happen
+ // before any of them.
+ if (key.ctrl || key.meta) return;
- setPhase('inspecting');
+ if (input === 'e') setPhase('expanded');
- }, []);
+ if (input === 'p') handlePreview();
- const displayPath = selectedFile ? relative(projectRoot, selectedFile) : '';
+ if (input === 'r') handleRefresh();
+
+ });
// Loading
if (phase === 'loading') {
@@ -561,19 +840,26 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
}
- // Error (for context loading errors)
- if (phase === 'error' && !selectedFile) {
+ // Error, from discovering the file list or from building the context.
+ //
+ // Both, deliberately: this used to require `!selectedFile`, which is true
+ // only of a discovery failure, so a template whose helper threw set
+ // `phase: 'error'` with a file selected and fell through every branch to
+ // "Unknown phase" — the one error a reader is most likely to hit was the
+ // one the screen would not show.
+ if (phase === 'error') {
return (
-
- Error
- {error}
+ Error{displayPath ? `: ${displayPath}` : ''}
+
+ [↑↓] Scroll
+ [^U/^D] Half
[Esc] Back
@@ -608,7 +894,6 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
>
) : (
<>
-
No template files found in {sqlPath}/
@@ -639,111 +924,19 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
return (
-
-
- File:
+
+ File:
{displayPath}
-
-
- {context.dataFiles.length > 0 && (
-
- Data Files
- {context.dataFiles.map(({ key, value }) => (
-
-
- $.{key}
-
- {describeType(value)}
-
- ))}
-
- )}
-
- {(context.helpers.length > 0 || context.helperErrors.length > 0) && (
-
- Helpers ($helpers)
- {context.helpers.map(({ key, value }) => (
-
-
- $.{key}
-
- {describeType(value)}
-
- ))}
- {context.helperErrors.map(({ filepath, error: helperErr }) => (
-
- Failed to load: {relative(projectRoot, filepath)}
- {helperErr.message}
-
- ))}
-
- )}
-
-
- Built-ins
- {context.builtins.map(({ key }) => (
-
-
- $.{key}
-
- Function
-
- ))}
-
-
-
- Config
-
-
- $.config
-
-
- {context.config ? describeType(context.config) : '(not set)'}
-
-
-
-
-
- Secrets
-
-
- $.secrets
-
-
- Object ({Object.keys(context.secrets).length} keys)
-
-
-
-
- $.globalSecrets
-
-
- Object ({Object.keys(context.globalSecrets).length} keys)
-
-
-
-
-
- Environment
-
-
- $.env
-
-
- Object ({Object.keys(context.env).length} keys)
-
-
-
+
+
+ [↑↓] Scroll
+ [^U/^D] Half
[e] Expand
[p] Preview SQL
[r] Refresh
@@ -759,38 +952,19 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
return (
-
-
- File:
+
+ File:
{displayPath}
-
-
- {context.dataFiles.map(({ key, value }) => (
-
- $.{key}
- {describeTypeExpanded(value, 1).map((line, i) => (
- {line}
- ))}
-
- ))}
-
- {context.config !== undefined && context.config !== null && (
-
- $.config
- {describeTypeExpanded(context.config, 1).map((line, i) => (
- {line}
- ))}
-
- )}
+
+
+ [↑↓] Scroll
+ [^U/^D] Half
[Esc] Back to summary
@@ -802,13 +976,10 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement {
if (phase === 'preview') {
const hasError = error !== null;
+ const timing = renderDuration !== null ? ` · ${renderDuration.toFixed(1)}ms` : '';
return (
-
-
- File:
+
+ File:
{displayPath}
-
-
- {!hasError && renderDuration !== null && (
-
- Rendered in:
- {renderDuration.toFixed(1)}ms
-
- )}
-
-
- {hasError ? (
- {error}
- ) : (
- {renderedSql}
- )}
-
+ {hasError ? '' : timing}
+
+
+ [↑↓] Scroll
+ [^U/^D] Half
[Esc] Back to summary
diff --git a/tests/cli/components/scroll-pane.test.tsx b/tests/cli/components/scroll-pane.test.tsx
new file mode 100644
index 00000000..38b5f698
--- /dev/null
+++ b/tests/cli/components/scroll-pane.test.tsx
@@ -0,0 +1,261 @@
+/**
+ * ScrollPane tests.
+ *
+ * Ink has no scroll offset, so content past the bottom of the terminal is not
+ * merely unscrolled — it is unreachable, and the screen's own footer is what
+ * gets pushed off to make room for it. The contract pinned here is that every
+ * line handed to the pane can be brought on screen by a key, and that the pane
+ * never draws more rows than the budget it was given, because the budget is
+ * what the screen subtracted its chrome from.
+ *
+ * The "not focused" case is load-bearing rather than incidental: the pane takes
+ * focus as a prop and registers its handler unconditionally (Ink's `useInput`
+ * never re-registers once skipped), so the guard inside the handler is the only
+ * thing stopping a background pane from consuming a focused screen's arrows.
+ */
+import { describe, it, expect } from 'bun:test';
+import { render } from 'ink-testing-library';
+import { Text } from 'ink';
+import React from 'react';
+
+import { ScrollPane } from '../../../src/tui/components/terminal/ScrollPane.js';
+import { MouseProvider } from '../../../src/tui/mouse.js';
+
+/** An SGR wheel-down press. 64 is the wheel bit; the low bit picks the direction. */
+const WHEEL_DOWN = '\x1B[<65;10;10M';
+
+/** An SGR wheel-up press. */
+const WHEEL_UP = '\x1B[<64;10;10M';
+
+/** Rows the pane may draw, indicators included. */
+const HEIGHT = 10;
+
+/** More lines than the height, so there is always something below the fold. */
+const TOTAL = 40;
+
+// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point
+const ANSI_PATTERN = /\[[0-9;]*m/g;
+
+function strip(frame: string | undefined): string {
+
+ return (frame ?? '').replace(ANSI_PATTERN, '');
+
+}
+
+/**
+ * Poll rather than sleep a guessed duration: a fixed wait is the suite's known
+ * flake class under load.
+ */
+async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise {
+
+ const deadline = Date.now() + timeoutMs;
+
+ while (!predicate() && Date.now() < deadline) {
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ }
+
+}
+
+function lines(count = TOTAL) {
+
+ return Array.from({ length: count }, (_, index) => (
+ line-{index}
+ ));
+
+}
+
+/**
+ * One keypress, then wait for Ink to take it.
+ *
+ * Writes in a tight loop are coalesced: twenty synchronous `\x04` writes reach
+ * `useInput` as one twenty-character string, which is not twenty Ctrl+D events
+ * and scrolls exactly one half-page. Yielding between presses is what makes a
+ * press a press.
+ */
+async function press(stdin: { write: (data: string) => void }, sequence: string): Promise {
+
+ stdin.write(sequence);
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+}
+
+describe('cli: ScrollPane', () => {
+
+ it('should draw no more rows than the height it was given', async () => {
+
+ const { stdin, lastFrame, unmount } = render(
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ // At rest and mid-scroll both, because the second is where the pane is
+ // tallest: `rowWindow` reserves the two indicator rows as a pair, so at
+ // the ends of the scroll one of them is unused and the pane is a row
+ // shorter than its budget rather than a row longer.
+ expect(strip(lastFrame()).split('\n').length).toBeLessThanOrEqual(HEIGHT);
+
+ await press(stdin, '\x04');
+
+ expect(strip(lastFrame()).split('\n').length).toBeLessThanOrEqual(HEIGHT);
+ expect(strip(lastFrame())).toContain('↑');
+
+ unmount();
+
+ });
+
+ it('should hold back the lines past the fold, and say how many', async () => {
+
+ const { lastFrame, unmount } = render(
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ const frame = strip(lastFrame());
+
+ expect(frame).toContain('line-0');
+ expect(frame).not.toContain('line-39');
+ expect(frame).toContain('more');
+
+ unmount();
+
+ });
+
+ it('should reach the last line by paging', async () => {
+
+ const { stdin, lastFrame, unmount } = render(
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ // Ctrl+D, the advertised half-page key, enough times to pass the end.
+ for (let count = 0; count < 20; count += 1) {
+
+ await press(stdin, '\x04');
+
+ if (strip(lastFrame()).includes('line-39')) break;
+
+ }
+
+ expect(strip(lastFrame())).toContain('line-39');
+
+ unmount();
+
+ });
+
+ it('should move one line at a time on an arrow', async () => {
+
+ const { stdin, lastFrame, unmount } = render(
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ stdin.write('\x1B[B');
+
+ await waitFor(() => !strip(lastFrame()).includes('line-0'));
+
+ expect(strip(lastFrame())).toContain('line-1');
+ expect(strip(lastFrame())).not.toContain('line-0');
+
+ unmount();
+
+ });
+
+ it('should ignore every scroll key while it is not focused', async () => {
+
+ const { stdin, lastFrame, unmount } = render(
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ const before = strip(lastFrame());
+
+ stdin.write('\x1B[B');
+ stdin.write('\x04');
+
+ await new Promise((resolve) => setTimeout(resolve, 50));
+
+ expect(strip(lastFrame())).toBe(before);
+
+ unmount();
+
+ });
+
+ it('should scroll on a wheel notch', async () => {
+
+ // The reason this is not optional: the TUI runs in the alternate
+ // screen, which has no scrollback, and mouse tracking takes the wheel
+ // notches a terminal would otherwise turn into arrow keys. Unhandled
+ // here means the wheel does nothing at all.
+ const { stdin, lastFrame, unmount } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ await press(stdin, WHEEL_DOWN);
+ await press(stdin, WHEEL_DOWN);
+
+ await waitFor(() => !strip(lastFrame()).includes('line-0'));
+
+ expect(strip(lastFrame())).toContain('line-2');
+ expect(strip(lastFrame())).not.toContain('line-0');
+
+ await press(stdin, WHEEL_UP);
+
+ await waitFor(() => strip(lastFrame()).includes('line-1'));
+
+ expect(strip(lastFrame())).toContain('line-1');
+
+ unmount();
+
+ });
+
+ it('should ignore a wheel notch aimed at a pane that is not focused', async () => {
+
+ const { stdin, lastFrame, unmount } = render(
+
+
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ const before = strip(lastFrame());
+
+ await press(stdin, WHEEL_DOWN);
+ await press(stdin, WHEEL_DOWN);
+
+ expect(strip(lastFrame())).toBe(before);
+
+ unmount();
+
+ });
+
+ it('should draw content that fits without stealing a row for an indicator', async () => {
+
+ const { lastFrame, unmount } = render(
+ ,
+ );
+
+ await waitFor(() => strip(lastFrame()).includes('line-0'));
+
+ const frame = strip(lastFrame());
+
+ expect(frame.split('\n')).toHaveLength(3);
+ expect(frame).not.toContain('more');
+
+ unmount();
+
+ });
+
+});
diff --git a/tests/cli/screens/run/inspect-lines.test.tsx b/tests/cli/screens/run/inspect-lines.test.tsx
new file mode 100644
index 00000000..06c26eef
--- /dev/null
+++ b/tests/cli/screens/run/inspect-lines.test.tsx
@@ -0,0 +1,238 @@
+/**
+ * Inspect context line-builder tests.
+ *
+ * The inspect screen rendered a nested tree that grew with the context, so on
+ * any real project the bottom of it — secrets, global secrets, environment —
+ * sat below the fold with no key that could reach it. Ink has no scroll offset,
+ * so the fix is structural: the view is built as a flat list of one element per
+ * visual line, which is the only shape a viewport can slice. These tests pin
+ * that shape and what the lines are allowed to say.
+ *
+ * The secret cases are the load-bearing ones. `$.secrets` used to show a key
+ * count, which cannot answer the question the screen is opened to answer, and
+ * the fix is a partial reveal — so what is pinned is that the plaintext is
+ * absent and the masked form is present. Asserting only the latter would pass
+ * on a line that printed both.
+ */
+import { describe, it, expect } from 'bun:test';
+import { render } from 'ink-testing-library';
+import { Box } from 'ink';
+import React from 'react';
+
+import type { CategorizedContext } from '../../../../src/tui/screens/run/RunInspectScreen.js';
+
+import { contextLines, expandedLines } from '../../../../src/tui/screens/run/RunInspectScreen.js';
+
+/** Row budget inside the inspect Panel on a 100-column terminal. */
+const WIDE = 96;
+
+// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point
+const ANSI_PATTERN = /\[[0-9;]*m/g;
+
+function draw(lines: React.ReactElement[]): string {
+
+ const { lastFrame, unmount } = render({lines});
+ const frame = (lastFrame() ?? '').replace(ANSI_PATTERN, '');
+
+ unmount();
+
+ return frame;
+
+}
+
+function makeContext(overrides: Partial = {}): CategorizedContext {
+
+ return {
+ dataFiles: [{ key: 'users', value: [{ id: 1, name: 'ada' }] }],
+ helpers: [],
+ helperErrors: [],
+ builtins: [{ key: 'quote', value: () => '' }],
+ config: { host: 'localhost' },
+ secrets: {},
+ globalSecrets: {},
+ env: {},
+ ...overrides,
+ };
+
+}
+
+describe('cli: inspect context lines', () => {
+
+ it('should reveal part of a secret rather than only a key count', () => {
+
+ const lines = contextLines(
+ makeContext({ secrets: { DB_PASSWORD: 'sup3rs3cr3tvalue' } }),
+ '/project',
+ WIDE,
+ );
+ const frame = draw(lines);
+
+ expect(frame).toContain('DB_PASSWORD');
+ expect(frame).toContain('su*****alue');
+ expect(frame).not.toContain('sup3rs3cr3tvalue');
+
+ });
+
+ it('should report the length as a number rather than as mask width', () => {
+
+ const frame = draw(contextLines(
+ makeContext({ secrets: { TOKEN: 'sup3rs3cr3tvalue' } }),
+ '/project',
+ WIDE,
+ ));
+
+ expect(frame).toContain('(16 chars)');
+
+ });
+
+ it('should tell a secret that is set but empty from one that is short', () => {
+
+ const frame = draw(contextLines(
+ makeContext({ secrets: { BLANK: '', TINY: 'abc' } }),
+ '/project',
+ WIDE,
+ ));
+
+ expect(frame).toContain('(empty)');
+ expect(frame).toContain('(0 chars)');
+ expect(frame).not.toContain('abc');
+
+ });
+
+ it('should mask global secrets on the same terms as config-scoped ones', () => {
+
+ const frame = draw(contextLines(
+ makeContext({ globalSecrets: { LICENSE: 'aaaabbbbccccdddd' } }),
+ '/project',
+ WIDE,
+ ));
+
+ expect(frame).toContain('aaaa'.slice(0, 2) + '*****' + 'dddd');
+ expect(frame).not.toContain('aaaabbbbccccdddd');
+
+ });
+
+ it('should mask environment values, which carry secrets nothing here can identify', () => {
+
+ const frame = draw(contextLines(
+ makeContext({ env: { AWS_SECRET_ACCESS_KEY: 'abcdefghijklmnopqrst' } }),
+ '/project',
+ WIDE,
+ ));
+
+ expect(frame).toContain('AWS_SECRET_ACCESS_KEY');
+ expect(frame).toContain('ab*****qrst');
+ expect(frame).not.toContain('abcdefghijklmnopqrst');
+
+ });
+
+ it('should keep a multi-line secret to one row', () => {
+
+ // `wrap="truncate"` bounds width, not height: Ink still breaks on an
+ // embedded newline, so one of these rows would draw three and put the
+ // viewport's count out by two for everything below it.
+ //
+ // The newline has to fall inside the revealed window to reach the
+ // screen at all. A whole PEM key does not test this: the mask keeps
+ // two leading and four trailing characters, which for
+ // `-----BEGIN…-----` are dashes, so the newlines never survive masking
+ // and the case passes whether or not the guard is there.
+ const lines = contextLines(
+ makeContext({ secrets: { TLS_KEY: '\nMIIEvQIBADANBgkq\n' } }),
+ '/project',
+ WIDE,
+ );
+
+ expect(draw(lines).split('\n')).toHaveLength(lines.length);
+
+ });
+
+ it('should keep a multi-line environment value and helper error to one row', () => {
+
+ const lines = contextLines(
+ makeContext({
+ env: { SSH_KEY: 'line one\nline two\nline three' },
+ helperErrors: [{
+ filepath: '/project/sql/helpers/slug.js',
+ error: new Error('Unexpected token\n at line 3\n at line 4'),
+ }],
+ }),
+ '/project',
+ WIDE,
+ );
+
+ expect(draw(lines).split('\n')).toHaveLength(lines.length);
+
+ });
+
+ it('should keep a data-file string preview with a newline to one row', () => {
+
+ // `describeType` truncates a string preview by character count and was
+ // safe under the old nested tree, which counted no rows.
+ const lines = contextLines(
+ makeContext({ dataFiles: [{ key: 'banner', value: 'first line\nsecond line' }] }),
+ '/project',
+ WIDE,
+ );
+
+ expect(draw(lines).split('\n')).toHaveLength(lines.length);
+
+ });
+
+ it('should draw one row per line, so a viewport can count them', () => {
+
+ const lines = contextLines(
+ makeContext({
+ secrets: Object.fromEntries(
+ Array.from({ length: 12 }, (_, index) => [`SECRET_${index}`, 'x'.repeat(20)]),
+ ),
+ }),
+ '/project',
+ WIDE,
+ );
+
+ expect(draw(lines).split('\n')).toHaveLength(lines.length);
+
+ });
+
+ it('should omit a section that has nothing in it', () => {
+
+ const frame = draw(contextLines(makeContext({ dataFiles: [] }), '/project', WIDE));
+
+ expect(frame).not.toContain('Data Files');
+ expect(frame).toContain('Built-ins');
+
+ });
+
+ it('should name the helper that failed to load, and why', () => {
+
+ const frame = draw(contextLines(
+ makeContext({
+ helperErrors: [{
+ filepath: '/project/sql/helpers/slug.js',
+ error: new Error('Unexpected token'),
+ }],
+ }),
+ '/project',
+ WIDE,
+ ));
+
+ expect(frame).toContain('sql/helpers/slug.js');
+ expect(frame).toContain('Unexpected token');
+
+ });
+
+ it('should wrap the expanded view so a narrow terminal still gets one row per line', () => {
+
+ // A long key, not a long value: the expanded view reports shapes and
+ // never prints a value, so the key is what can overflow a row.
+ const value = { ['deeply_nested_'.repeat(30)]: 1 };
+ const wide = expandedLines(makeContext({ dataFiles: [{ key: 'doc', value }] }), WIDE);
+ const narrow = expandedLines(makeContext({ dataFiles: [{ key: 'doc', value }] }), 20);
+
+ expect(narrow.length).toBeGreaterThan(wide.length);
+ expect(draw(narrow).split('\n')).toHaveLength(narrow.length);
+
+ });
+
+});
diff --git a/tests/core/shared/mask.test.ts b/tests/core/shared/mask.test.ts
new file mode 100644
index 00000000..51b72a08
--- /dev/null
+++ b/tests/core/shared/mask.test.ts
@@ -0,0 +1,119 @@
+/**
+ * maskSecret band tests.
+ *
+ * The bands exist so that the shorter a value is, the less of it a reader is
+ * shown: a four-character secret has no middle to hide, so revealing its ends
+ * would reveal the value. What is pinned here is how much of the input survives
+ * each band, not how the asterisks look — widening a band fails these tests
+ * even when the output still reads as masked, which is the point. A test that
+ * only checked for the presence of a `*` would pass on a full reveal with one
+ * asterisk appended.
+ */
+import { describe, it, expect } from 'bun:test';
+
+import { maskSecret } from '../../../src/core/shared/mask.js';
+
+/** Everything of the value that survived the mask, in order. */
+function revealed(value: string): string {
+
+ return maskSecret(value).replaceAll('*', '');
+
+}
+
+describe('core: maskSecret', () => {
+
+ it('should distinguish a value that is set but empty from one that is masked', () => {
+
+ expect(maskSecret('')).toBe('(empty)');
+
+ });
+
+ it('should reveal nothing of a value with no middle to hide', () => {
+
+ for (const value of ['a', 'ab', 'abc', 'abcd']) {
+
+ expect(maskSecret(value)).toBe('*****');
+ expect(revealed(value)).toBe('');
+
+ }
+
+ });
+
+ it('should reveal one character at each end from five through eight', () => {
+
+ expect(maskSecret('abcde')).toBe('a*****e');
+ expect(maskSecret('hunter2!')).toBe('h*****!');
+ expect(revealed('hunter2!')).toHaveLength(2);
+
+ });
+
+ it('should reveal only a suffix from nine through twelve', () => {
+
+ expect(maskSecret('abcdefghi')).toBe('*****fghi');
+ expect(maskSecret('abcdefghijkl')).toBe('*****ijkl');
+ expect(revealed('abcdefghijkl')).toHaveLength(4);
+
+ });
+
+ it('should reveal a short prefix and a suffix past twelve', () => {
+
+ expect(maskSecret('abcdefghijklm')).toBe('ab*****jklm');
+ expect(maskSecret('postgres://user:pw@host/db')).toBe('po*****t/db');
+
+ });
+
+ it('should reveal no more of a long value than of a barely-long one', () => {
+
+ expect(revealed('x'.repeat(4096))).toHaveLength(6);
+ expect(revealed('abcdefghijklm')).toHaveLength(6);
+
+ });
+
+ it('should leak no interior character, however recognisable', () => {
+
+ const value = 'PREFIXsecretmiddleSUFFIX';
+
+ expect(maskSecret(value)).toBe('PR*****FFIX');
+ expect(maskSecret(value)).not.toContain('secretmiddle');
+
+ });
+
+ it('should never split a surrogate pair', () => {
+
+ // `.slice` counts UTF-16 code units, so slicing a non-BMP character in
+ // half yields a lone surrogate and the reveal renders as a replacement
+ // glyph — corrupting the one part a reader is meant to recognise.
+ const masked = maskSecret('🔥alpha-beta-gamma-omega🎉');
+
+ expect(masked).toBe('🔥a*****ega🎉');
+
+ // `for...of` walks code points, so a paired emoji arrives whole and a
+ // broken one arrives as a bare surrogate in D800-DFFF.
+ for (const character of masked) {
+
+ const codePoint = character.codePointAt(0) ?? 0;
+
+ expect(codePoint < 0xD800 || codePoint > 0xDFFF).toBe(true);
+
+ }
+
+ });
+
+ it('should count bands in characters, not code units', () => {
+
+ // Four emoji are eight code units. Counting units would put this in the
+ // reveal-both-ends band, showing half of a four-character value.
+ expect(maskSecret('🔥🎉🚀🌟')).toBe('*****');
+
+ });
+
+ it('should never encode the length in the width of the mask', () => {
+
+ const short = maskSecret('abcdefghijklm');
+ const long = maskSecret('abcdefghijklm'.repeat(20));
+
+ expect(short.length).toBe(long.length);
+
+ });
+
+});