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
5 changes: 5 additions & 0 deletions .changeset/tui-clicks-long-values-set-password.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@noormdev/cli": minor
---

TUI: every actionable element answers the mouse, and the one under the pointer gets a background a step off the terminal's own (queried at startup; ANSI bright black when the terminal does not answer). Forms (click a field, toggle a checkbox, Submit/Cancel, wheel), confirm dialogs (Yes/No), every `[key] Label` hint (footers, Quick Actions, Available Actions, More; a click presses the key), earlier breadcrumb crumbs, the idle search row, and the file picker, log viewer, SQL history/clear and known-users rows are clickable. The terminal now reports pointer motion for the hover shade; set `ui.mouse: false` to turn all mouse handling off. A long value no longer wraps and pushes the action row off screen: form values, list rows, panel titles, log rows and Home rows truncate with `…`, and an open text field scrolls around its cursor. Press `x` on a password field to empty it before typing a replacement. New `noorm config set-password <name> [value] [--stdin]` stores a rotated database password headlessly under the config's `config:write` policy.
2 changes: 1 addition & 1 deletion .claude/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ expect(events[0].status).toBe('failed');

## Module mocking

Bun's `mock.module` registry is process-global and never restores, so a mocking file poisons every file loaded after it. CI works around this by splitting the suite into five separate processes. The mechanics and the reasoning are in the root `CLAUDE.md`; read it before adding `mock.module` to a new file.
Bun's `mock.module` registry is process-global and never restores, so a mocking file poisons every file loaded after it. CI works around this by splitting the suite into six separate processes. The mechanics and the reasoning are in the root `CLAUDE.md`; read it before adding `mock.module` to a new file.


## Helpers
Expand Down
23 changes: 13 additions & 10 deletions .claude/rules/tui-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ Registered: `TextInput` (while not `isDisabled`, which also covers `SearchableLi

| Component | Use it? | Reason |
|-----------|---------|--------|
| `TextInput` | No — use ours | `src/tui/components/forms/TextInput.tsx` is upstream's, plus the mouse-report guard and the Ctrl-chord guard. Still display-only when `isDisabled`, so the focus stack stays authoritative |
| `TextInput` | No — use ours | `src/tui/components/forms/TextInput.tsx` is upstream's, plus the mouse-report guard, the Ctrl-chord guard, and a measured single-line scroll window (`windowValue`) so a long value never wraps. Still display-only when `isDisabled`, so the focus stack stays authoritative |
| `Spinner`, `Badge`, `ProgressBar`, `Alert`, `StatusMessage` | Yes | Display-only, no input handling |
| `Select`, `MultiSelect`, `ConfirmInput` | No | Drive Ink's internal focus, which our stack never sees |

Expand Down Expand Up @@ -231,7 +231,7 @@ Write no `?? 24` fallback. `useWindowSize` already falls back to the `terminal-s
Use the hooks in `src/tui/hooks/useObserver.ts` for event subscriptions. They handle unsubscribe on unmount.

```tsx
import { useOnEvent, useOnceEvent, useEmit, useOnScreenPopped } from '../hooks/index.js';
import { useOnEvent, useOnceEvent, useEmit } from '../hooks/index.js';

useOnEvent('changeset:complete', (data) => {

Expand All @@ -243,11 +243,10 @@ useOnceEvent('build:complete', (data) => setFinalResult(data), []);

const emitStart = useEmit('build:start');
emitStart({ schemaPath, fileCount });

// Reset local state when a screen is popped off the router stack
useOnScreenPopped('db/explore', () => clearExploreFilters());
```

State that must clear when a screen is popped belongs to something that stays mounted (`AppContextProvider` clears the explore filters on `router:popped`), not to the screen: a breadcrumb jump (`backBy`) skips the screens it passes, and a skipped screen never mounts to hear the event.


## Feedback patterns

Expand Down Expand Up @@ -378,6 +377,7 @@ There is no `k` on Home. Secrets belong to a config, so `k` opens them from the
| `s` | Lock List | status |
| `c` | Home | config |
| `c` | config list | copy |
| `x` | Form, password field with a value (browse mode) | empty it |
| `c` | DB list | create |

**Global (every screen, via `GlobalKeyboard`; all but `Ctrl+C` inactive while typing in a field):**
Expand Down Expand Up @@ -410,9 +410,10 @@ There is no mouse support in Ink 7.1.1 — no hook, no parsing. `src/tui/mouse.t

It is **on** unless `ui.mouse` is false in `.noorm/settings.yml`. Off means inert: no escape sequence, no `useInput` registration, no `process` listener, no refs on rows. What an absent flag means is decided once, by `isMouseEnabled` in `src/core/settings/defaults.ts`; never read `settings?.ui?.mouse` directly.

- **Never write `?1000h` / `?1006h` anywhere else.** A second writer means a second thing that has to disable on exit, and a terminal left in mouse mode outlives the process — click-drag selection stays broken in every shell in that window.
- **Answer clicks with `useRowMouse`**, not by parsing in a component. It takes the same `isActive` guard the keyboard handler uses, because a click acts on whatever already has focus.
- **A handler with a catch-all character branch needs `isMouseReport(input)`.** Reports reach every `useInput` as a plain string, so `ResultTable`'s filter box and `SqlInput` would otherwise type `[<0;12;5M` into themselves. A handler that only tests named keys and exact characters needs no guard — `SelectList` has none, and the mutation harness is why.
- **Never write `?1000h` / `?1003h` / `?1006h` anywhere else.** A second writer means a second thing that has to disable on exit, and a terminal left in mouse mode outlives the process — click-drag selection stays broken in every shell in that window.
- **Answer clicks with `useRowMouse`**, not by parsing in a component. It takes the same `isActive` guard the keyboard handler uses, because a click acts on whatever already has focus. It also returns `hovered`, the row under the pointer, which is null unless `isActive`; shade it with `backgroundColor={hoverBackground()}` on the row's Box, and never on the cursor row. A motion report decodes to `kind: 'move'`, never a press, so a handler that tests `press` ignores it. Pass `matchColumn: true` when targets share a line (`Form`'s Submit/Cancel, `Confirm`'s Yes/No); list rows leave it off so a click past the end of a short label still hits the row. Pass `ignoreRepeats: true` where a double click means nothing extra (buttons, hints, crumbs): the second press otherwise lands on whatever the first brought under the pointer.
- **A handler with a catch-all character branch, or one that acts on any key, needs `isMouseReport(input)`.** Reports reach every `useInput` as a plain string, so `ResultTable`'s filter box and `SqlInput` would otherwise type `[<0;12;5M` into themselves. Motion reports arrive on every pointer move, so an "any key closes this" handler (help, the "press any key to continue" screens) would otherwise close on a mouse move. A handler that only tests named keys and exact characters needs no guard — `SelectList` has none, and the mutation harness is why.
- **Draw a `[key] Label` hint with `Hotkey`, `HotkeyLine` or `KeyTarget`** (`src/tui/components/layout/Hotkey.tsx`), never a bare `<Text dimColor>[a] Add</Text>`. A click emits the key through Ink's input emitter, so the focused component handles it exactly as the keyboard would; the screen needs no second code path. A hint naming no single key (`↑↓`, `any`, `Esc Esc`, `Ctrl+U/D`) stays plain text: `resolveHotkey` resolves a compound label only if every part is a key, because `Ctrl+U/D` must not become a bare `D`. A hint is a `Box`, so it cannot sit inside a `Text`. A hint must not stay drawn while a component that gives its key another meaning has focus (a footer `[Enter] Edit` under an open delete confirmation): draw it only in the view where the key means that. `Confirm` and `ProtectedConfirm` hold the `modal` guard (`useModal`), which makes every hint inert as a backstop, and a running screen holds `busy` (`useBusy(step === 'running')`), which stops a breadcrumb click from leaving it. The one read of an Ink internal is the `internal_eventEmitter` lookup in `Hotkey.tsx`, which `hotkey.test.tsx` pins.
- **Use `TextInput` from `src/tui/components/forms/`, never from `@inkjs/ui`.** Upstream's handler ends in an unconditional `state.insert(input)`, so a click while a field is in edit mode types the report into the field — and where that field feeds a derived value, such as `ChangeAddScreen`'s change-folder name, the report reaches disk. `forms/TextInput.tsx` is upstream's component with that one guard added; the exports map publishes only the package root, so wrapping it was not possible and copying it was.

`measureElement`'s `x`/`y` are live-region coordinates and SGR's are 1-based terminal coordinates; the conversion is one constant in `mouse.tsx`. It holds because the TUI renders in the alternate screen with no `<Static>` in the tree, so the frame starts at the home position. Add a `<Static>` and that constant stops being a constant.
Expand All @@ -425,9 +426,11 @@ It is **on** unless `ui.mouse` is false in `.noorm/settings.yml`. Off means iner
| API | What it covers |
|-----|----------------|
| `usePaste` | Bracketed paste delivered as a single string. `SqlInput` currently submits only the first line of a multi-line paste. |
| `useBoxMetrics` | Used once, for the height of `Form`'s status error. Still unused for measured box width/height/left/top instead of the hand-counted chrome constants at `SqlTerminalScreen.tsx:57` and `ConfigEditScreen.tsx:268`. |
| `useBoxMetrics` | Used for the height of `Form`'s status error and the width `TextInput` scrolls within. Still unused for measured box width/height/left/top instead of the hand-counted chrome constants at `SqlTerminalScreen.tsx:57` and `ConfigEditScreen.tsx:268`. |
| `useAnimation`, `suspendTerminal()`, `interactive` | No current need. |

`<Text>` no longer accepts `wrap="end"` or `wrap="middle"`. Both were undocumented no-ops on 6.8.0 and were dropped from the type in 7.0.0. This codebase uses only `wrap="wrap"` and `wrap="truncate"`, so nothing changed.
`<Text>` no longer accepts `wrap="end"` or `wrap="middle"`. Both were undocumented no-ops on 6.8.0 and were dropped from the type in 7.0.0. This codebase uses `wrap="wrap"`, `wrap="truncate"`, and `wrap="truncate-start"` (`FilePicker` paths, where the end is what identifies a file), so nothing changed.

Text drawn into a row the layout counts (list rows, form values, panel titles) takes `wrap="truncate"`; a long value otherwise wraps and the rows below it are pushed off screen. Text that carries an instruction in its tail, such as the `repair-access` hint in an error toast or the Home access warning, stays wrapped.

`useFocusManager` gained `activeId` in Ink 7. It reports Ink's own focus registry, which `useFocusScope` never writes to, so it does not tell you what this codebase considers focused. The focus rules above stand unchanged.
9 changes: 8 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,21 @@ jobs:
FORCE_COLOR: 'true'
run: |
bun test --serial $(find tests/cli \( -name '*.test.ts' -o -name '*.test.tsx' \) \
! -name 'cli-logger-settings.test.ts' | sort | tr '\n' ' ')
! -name 'cli-logger-settings.test.ts' ! -name 'mouse-rows.test.tsx' | sort | tr '\n' ' ')
# Isolated: other CLI test files leave `getSettingsManager` mocked for the rest
# of their process, and bun picks the file order. See "Running Tests" in CLAUDE.md.
- name: Run CLI logger settings tests (isolated)
env:
CI: 'false'
FORCE_COLOR: 'true'
run: bun test --serial tests/cli/cli-logger-settings.test.ts
# Isolated for the same reason, from the other side: this file mocks
# `getStateManager`/`getSettingsManager` itself and would poison the group above.
- name: Run mouse rows tests (isolated)
env:
CI: 'false'
FORCE_COLOR: 'true'
run: bun test --serial tests/cli/mouse-rows.test.tsx
# ca.pem is the public CA; the private keys beside it stay in the container.
- name: Export the MySQL server CA for the live TLS test
run: |
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ bun run typecheck # Type check

## Running Tests

A single `bun test` (whole suite, one process) does **not** reflect CI. CI splits the suite into five
A single `bun test` (whole suite, one process) does **not** reflect CI. CI splits the suite into six
independent `bun test --serial` invocations, each in a fresh process — see `.github/workflows/ci.yml`
for the split and [`docs/wiki/index.md`](docs/wiki/index.md) for the exact per-group commands.

Expand Down
4 changes: 3 additions & 1 deletion docs/dev/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ ui:

| Property | Default | Description |
|----------|---------|-------------|
| `mouse` | `true` | Answer clicks and wheel notches in lists and result grids |
| `mouse` | `true` | Answer clicks, wheel notches and hover (a shade, derived from the terminal's background, on what a click would act on) in lists, forms, dialogs, hints and result grids |

There is no settings-screen toggle and no accessor on `SettingsManager`. What an
absent flag means is decided in exactly one place, `isMouseEnabled` in
Expand Down Expand Up @@ -629,6 +629,8 @@ terminal mouse-tracking mode takes click-drag text selection away from the
terminal unless the user holds a modifier (Option on macOS Terminal and iTerm2,
Shift elsewhere). A user who hits that reports it as "text selection stopped
working", which is why the help screen (`?`) names `ui.mouse: false` directly.
Hover adds a second cost: motion tracking (`?1003`) makes the terminal send one
report per cell the pointer crosses, which is noticeable over a slow SSH link.
See `docs/tui.md` for what the mouse does.


Expand Down
2 changes: 1 addition & 1 deletion docs/guide/environments/configs.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ noorm config validate review

`create` saves the stage's defaults with one-time connection overrides. To update an existing config from changed defaults, use `noorm config sync --stage staging --name review`; it previews changes and asks before saving. Neither command stores a stage link. The [CLI reference](/headless#config-create) lists flags, password rules, and overwrite authorization.

`noorm config add` and `config edit` exit 1 and point you at the TUI. For a complete config object, import JSON instead. This `staging.json` is hidden from agents:
`noorm config add` and `config edit` exit 1 and point you at the TUI. To change only a stored password, for example after a rotation, use `noorm config set-password <name> --stdin --yes`. For a complete config object, import JSON instead. This `staging.json` is hidden from agents:

```json
{
Expand Down
23 changes: 22 additions & 1 deletion docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Through the CLI, an `agent` channel `confirm` cell is a deny, so `--yes` never u
| `db:teardown` | `db teardown` | deny | deny | confirm |
| `db:destroy` | `db drop` | deny | deny | confirm |
| `change:rm` | `change rm` | deny | confirm | confirm |
| `config:rm`, `config:write` | `config rm`, overwrites with `config import` or `config create`, `config sync` | deny | confirm | confirm |
| `config:rm`, `config:write` | `config rm`, `config set-password`, overwrites with `config import` or `config create`, `config sync` | deny | confirm | confirm |
| `vault:read`, `secret:read` | `vault list`, `secret list`, `config export` | deny | allow | allow |
| `vault:write`, `secret:write` | `vault init`, `set`, `rm`, `cp`, and `secret set`, `rm` | deny | confirm | allow |
| `vault:propagate` | `vault propagate`, `ci identity enroll` | deny | confirm | confirm |
Expand Down Expand Up @@ -402,6 +402,27 @@ noorm config repair-access staging --user admin --agent false --yes
Both replacement assignments are required and must name current built-in or project roles; `--agent false` disables agent access. The user channel must explicitly pass `--yes`; agents cannot repair access even with confirmation. When the user role is still valid, its `config:write` permission applies, so an unknown agent role cannot bypass a user-role denial. Valid access cannot be edited through this command. No role is automatically substituted.


#### `config set-password <name> [value]`


Replace the database password of a stored config, for rotation jobs that issue a new password and need it saved without the TUI:

```bash
echo "$NEW_PASSWORD" | noorm config set-password prod --stdin --yes
```

| Flag | Effect |
|---|---|
| `[value]` | The new password. Visible in the process table and shell history, so prefer `--stdin` |
| `--stdin` | Read the password from stdin. One trailing newline is stripped |
| `--yes`, `-y` | Confirm the `config:write` policy cell. `NOORM_YES` works too |
| `--json` | Return `{ success: true, name }`. The password is never printed |

Only `connection.password` changes. Other connection fields, access, and the config's secrets stay as they were. The command does not connect: run `noorm config validate <name>` afterwards to check the new password. It requires the config's `config:write` permission, so a viewer config is refused. SQLite configs are refused because SQLite has no password. An empty password is refused, so a rotation script whose variable came back empty cannot wipe the stored one. An unknown config name or a config with malformed access exits 2. A missing or empty value, a denied policy, or a missing confirmation exits 1. Every refusal leaves state unchanged.

A stage's `locked: true` does not block this command; it only blocks deletion.


#### `config rm <name>`


Expand Down
Loading
Loading