Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/history-scroll-and-full-errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@noormdev/cli": minor
---

Make the execution history and file-execution screens scroll, and let a failed
file's error be read in full.

Both screens drew a fixed window — `slice(0, 15)` of the history, `slice(0, 20)`
of the files — while their selection cursor ranged over every record. Past that
window the arrow keys still moved a selection that was not on screen, and the
detail box under the list described a record the reader could not see, so the
screens were not merely unscrollable: they were reporting on rows that had
scrolled out from under the cursor. Both now use `SelectList`, which owns the
cursor and the window together and sizes itself from the terminal, so what is
selected is always drawn and a tall terminal shows more rows rather than the same
fifteen. The history list also fetches 200 records instead of 50, now that
reaching past the fifteenth is possible.

A failed file's error message was rendered as one line per `\n`, with no bound.
A stack trace therefore pushed the file list, the detail box and the hotkey hints
off the bottom together — and on the history screen the same message was cut at
80 characters instead, which drops the part that names the constraint or the
syntax error. Both screens now show a bounded single line in the detail box and
open the whole message, wrapped and scrollable, on a keypress: `e` on the history
screen, Enter on a file execution.

`SelectList` gains an optional `renderItem`, which lets a screen draw its own row
body while the list keeps the cursor, the window, the scroll indicators, focus and
the mouse. Without it these two screens would have had to give up the per-status
colour a reader scans a history list for, which is why they had their own list in
the first place.
34 changes: 34 additions & 0 deletions .changeset/inspect-scroll-and-masked-secrets.md
Original file line number Diff line number Diff line change
@@ -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".
23 changes: 12 additions & 11 deletions .claude/project/followups/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,27 @@

Auto-generated by `atomic followups render`. Do not edit.

Open: 10 • Stale: 0 • Last rendered: 2026-07-14
Open: 11 • Stale: 6 • Last rendered: 2026-09-08

## 📋 plans (1)

- [configurable-sql-function-policy](configurable-sql-function-policy.md) — Configurable per-config SQL function allow/deny list + TUI editor → src/core/policy/classify.ts:79 (DESTRUCTIVE_FUNCTIONS)

## 🟡 risks (6)
## 🟡 risks (7)

- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (43d)
- [db-transfer-no-fk-negation-collision](db-transfer-no-fk-negation-collision.md) — citty --no-fk/--no-identity never actually toggle (negation collision) (1d)
- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (6d)
- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (6d)
- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (6d)
- [v1-45-rewind-tiebreak-f1](v1-45-rewind-tiebreak-f1.md) — rewind tiebreak tests rely on wall-clock timing, not forced tie (0d)
- [config-module-scope-env-snapshot](config-module-scope-env-snapshot.md) — Move makeNestedConfig to call-time in config module (99d, **stale**)
- [db-transfer-no-fk-negation-collision](db-transfer-no-fk-negation-collision.md) — citty --no-fk/--no-identity never actually toggle (negation collision) (57d)
- [downgrade-unprotects-configs](downgrade-unprotects-configs.md) — Downgrade after schemaVersion-2 migration silently unprotects all configs (62d, **stale**)
- [policy-denial-observability](policy-denial-observability.md) — Policy denials leave no server-side trace; MCP server never inits logger (62d, **stale**)
- [state-enc-atomic-write-lock](state-enc-atomic-write-lock.md) — state.enc: atomic write + inter-process lock + pre-migration backup (62d, **stale**)
- [tui-unbounded-render-sweep](tui-unbounded-render-sweep.md) — ~40 TUI screens still render unbounded content past the fold (1d)
- [v1-45-rewind-tiebreak-f1](v1-45-rewind-tiebreak-f1.md) — rewind tiebreak tests rely on wall-clock timing, not forced tie (56d)

## 🔵 nits (2)

- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (43d)
- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (6d)
- [debug-process-test-no-assertions](debug-process-test-no-assertions.md) — debug-process.test.ts has no assertions (passes unconditionally) (99d, **stale**)
- [legacy-protected-removal-trigger](legacy-protected-removal-trigger.md) — Legacy 'protected' input path has no removal trigger; export still mints it (62d, **stale**)

## ❓ questions (1)

- [v1-21-31-hygiene-f2](v1-21-31-hygiene-f2.md) — Add release-engine paragraph to ignatius CLAUDE.md (2d)
- [v1-21-31-hygiene-f2](v1-21-31-hygiene-f2.md) — Add release-engine paragraph to ignatius CLAUDE.md (58d)
86 changes: 86 additions & 0 deletions .claude/project/followups/tui-unbounded-render-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
id: tui-unbounded-render-sweep
title: ~40 TUI screens still render unbounded content past the fold
created: "2026-09-07"
origin: |
Full sweep of src/tui/screens/** and src/tui/components/** after the third
user report of a screen that "doesn't scroll" (inspect, then history).
kind: finding
severity: risk
review_by: "2026-11-07"
status: open
file: src/tui/screens/change/ChangeListScreen.tsx:318 (worst of ~40)
---

Ink has no scroll offset: content taller than the terminal is unreachable, and it
pushes the screen's own footer off the bottom rather than clipping. Three screens
have been fixed one at a time as they were reported — explore, run inspect, then
the two change-history screens. This is the list of every remaining site, so the
next one is picked off a list rather than off a user's screen.

Four defect shapes, in descending severity:

- **A — fixed slice.** `.slice(0, N)` with a literal N and usually an "…and N
more" line. The window never moves, so the rest is unreachable.
- **B — roaming cursor past a fixed window.** A `selectedIndex` clamped to
`array.length - 1` while the render draws a fixed slice, so past row N the
cursor selects rows that are not drawn and the detail box describes a record
that is not on screen. Always co-occurs with A, and is the reason A is worse
than it looks.
- **C — unbounded map.** `.map()` over state, or over `err.split('\n')`, with no
windowing at all. Multi-line SQL errors and stack traces are the usual source.
- **D — hardcoded height.** A row budget that is a literal instead of
`useViewportRows()`. Content stays reachable; the screen just mis-sizes.

## Ranked

1. `screens/change/ChangeListScreen.tsx:318` — C+B. The primary `noorm change`
screen. Fully unbounded, no cap to even hit.
2. `screens/identity/KnownUsersScreen.tsx:164,190` — C+B, nested. Grows with team
size, which is the screen's whole purpose.
3. `screens/vault/VaultScreen.tsx:447` — C. Lists every identity about to receive
the vault master key, directly above the type-to-confirm gate it can push off
screen.
4. `screens/db/DbTruncateScreen.tsx:276,290` — A. Preview before an irreversible
TRUNCATE, capped at 10/5.
5. `screens/db/DbTransferScreen.tsx:1053,1091,1375,1392` — C ×4. Import preview,
plan warnings, export summary, failures.
6. **The `split('\n').map()` family** — C, the same defect copy-pasted across
every execution screen: `ChangeRunScreen.tsx:356,369`,
`RunBuildScreen.tsx:481,502`, `RunDirScreen.tsx:463,763,771` (763 has no "more"
indicator at all), `RunExecScreen.tsx:231`, `RunFileScreen.tsx:593`.
`TextOverlay` was built for exactly this and is the direct fix.
7. `screens/db/DtModifyScreen.tsx:963,1096` — C. Column-schema dumps; 30-60
columns is normal for a DT export.
8. `components/overlays/LogViewerOverlay.tsx:327` — C. The log-entry detail view
has no scroll keys at all, only Escape.
9. Confirm-preview family, bounded impact — `ChangeNextScreen.tsx:310`,
`ChangeFFScreen.tsx:255`, `ChangeRewindScreen.tsx:334,376` (334 has no
indicator), `ChangeRevertScreen.tsx:337`.
10. Pattern D, reachable but mis-sized — `SqlHistoryScreen.tsx:74` (reads
`terminalHeight` two lines away and still hardcodes 10),
`ResultTable.tsx:333` (default 15, not overridden by SqlHistoryScreen),
`SecretDefinitionList.tsx:319` (8), `SecretValueList.tsx:262` (10),
`DbTeardownScreen.tsx:264,459` (15), `LogViewerOverlay.tsx:44` (12).

Low volume, real but bounded in practice: `home.tsx:528`,
`ConfigValidateScreen.tsx:163`, `SecretValueForm.tsx:204`,
`DtModifyScreen.tsx:1059,1547`, `SqlInput.tsx:261`.

Checked and ruled out: `DebugDetailScreen.tsx:282` (columns come from noorm's own
six fixed tracking tables, and values truncate at 77 chars), `home.tsx`'s
`recentActivity` (capped at the query), `InitScreen`'s fixed wizard steps,
`Form.tsx`'s `SELECT_VISIBLE_OPTIONS` and `RowPeekOverlay`'s `MAX_SET_ROWS` (both
deliberate centered windows).

## Reuse, do not reinvent

- `SelectList` with `renderItem` — a list with a cursor. Owns cursor, window,
wheel, click and position memory together, which is what makes B impossible.
Target for ChangeListScreen, KnownUsersScreen, DtModifyScreen's column tables.
- `TextOverlay` — a long message at full length. Target for every
`split('\n').map()` above.
- `ScrollPane` + `rowWindow`/`scrollTarget` — a viewport over pre-laid-out lines
with no cursor.
- `useViewportRows(reserveRows)` — every hardcoded Pattern-D constant.
- `oneLine(text)` — any string entering a counted single row.
3 changes: 3 additions & 0 deletions src/core/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
88 changes: 88 additions & 0 deletions src/core/shared/mask.ts
Original file line number Diff line number Diff line change
@@ -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)}`;

}
5 changes: 3 additions & 2 deletions src/tui/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export { SelectList, SearchableList, ActionList, StatusList } from './lists/inde
export type {
SelectListProps,
SelectListItem,
SelectListRowState,
SearchableListProps,
SearchableListFilterState,
ActionListProps,
Expand Down Expand Up @@ -87,8 +88,8 @@ export type {
} from './secrets/index.js';

// Overlays
export { LogViewerOverlay } from './overlays/index.js';
export type { LogViewerOverlayProps } from './overlays/index.js';
export { LogViewerOverlay, TextOverlay } from './overlays/index.js';
export type { LogViewerOverlayProps, TextOverlayProps } from './overlays/index.js';

// Terminal (SQL REPL)
export { SqlInput, ResultTable } from './terminal/index.js';
Expand Down
Loading
Loading