@gonrocca/nodd 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -40,6 +40,16 @@ test("the renderer matches an expected frontmatter and body exactly", () => {
40
40
  assert.ok(rendered.endsWith("\n"));
41
41
  });
42
42
 
43
+ test("the configured thinking level reaches the generated agent", () => {
44
+ // `/nodd-models` stores a level per slot. If it stops here, the picker is
45
+ // writing a value nothing reads — a setting that looks applied and is not.
46
+ const withLevel = buildAgentFile(NODD_AGENTS[2], "anthropic/claude-opus-4-1", "high");
47
+ assert.match(withLevel, /^thinking: high$/m);
48
+
49
+ const withNone = buildAgentFile(NODD_AGENTS[2], "anthropic/claude-opus-4-1", undefined);
50
+ assert.ok(!/^thinking:/m.test(withNone), "no level rather than an empty one");
51
+ });
52
+
43
53
  test("a slot with no configured model falls back to default, then omits the line", () => {
44
54
  const withDefault = buildAgentFile(NODD_AGENTS[0], "anthropic/fallback");
45
55
  assert.match(withDefault, /^model: anthropic\/fallback$/m);
@@ -106,11 +106,18 @@ function enforcementNote(): string {
106
106
  }
107
107
 
108
108
  /** Pure: agent definition plus model in, file text out. */
109
- export function buildAgentFile(agent: NoddAgent, model: string | undefined): string {
109
+ export function buildAgentFile(
110
+ agent: NoddAgent,
111
+ model: string | undefined,
112
+ thinking?: string | undefined,
113
+ ): string {
110
114
  const front = ["---", `name: nodd-${agent.slot}`, `description: ${agent.description}`];
111
115
  // Omitted rather than emitted empty: a blank `model:` is a value pi cannot
112
116
  // resolve, and an unresolvable model fails at the moment it is needed.
113
117
  if (model) front.push(`model: ${model}`);
118
+ // The level `/nodd-models` stored. Without this line the picker would be
119
+ // writing a setting nothing reads.
120
+ if (thinking) front.push(`thinking: ${thinking}`);
114
121
  front.push(
115
122
  `tools: ${agent.tools.join(", ")}`,
116
123
  "systemPromptMode: replace",
@@ -133,6 +140,17 @@ export function buildAgentFile(agent: NoddAgent, model: string | undefined): str
133
140
  return `${front.join("\n")}\n\n${body}\n`;
134
141
  }
135
142
 
143
+ /**
144
+ * The level configured for a slot. Unlike the model there is no `default`
145
+ * fallback: an unset level means pi's own default, which is the right answer.
146
+ */
147
+ function thinkingFor(config: Record<string, unknown>, slot: string): string | undefined {
148
+ const thinking = config.thinking;
149
+ if (typeof thinking !== "object" || thinking === null) return undefined;
150
+ const level = (thinking as Record<string, unknown>)[slot];
151
+ return typeof level === "string" && level !== "" ? level : undefined;
152
+ }
153
+
136
154
  function modelFor(config: Record<string, unknown>, slot: string): string | undefined {
137
155
  const models = config.models;
138
156
  if (typeof models !== "object" || models === null) return undefined;
@@ -158,7 +176,11 @@ export function provisionAgents(home: string, config: Record<string, unknown>):
158
176
  for (const agent of NODD_AGENTS) {
159
177
  const name = `nodd-${agent.slot}`;
160
178
  try {
161
- writeFileSync(join(dir, `${name}.md`), buildAgentFile(agent, modelFor(config, agent.slot)), "utf8");
179
+ writeFileSync(
180
+ join(dir, `${name}.md`),
181
+ buildAgentFile(agent, modelFor(config, agent.slot), thinkingFor(config, agent.slot)),
182
+ "utf8",
183
+ );
162
184
  result.written.push(name);
163
185
  } catch {
164
186
  // One agent failing must not block the other two.
@@ -40,7 +40,8 @@ function session(options: { cwd?: string; entries?: Array<Record<string, unknown
40
40
  const entries = options.entries ?? [];
41
41
  const pi = {
42
42
  on: (event: string, handler: Handler) => handlers.set(event, handler),
43
- registerTool: (name: string, opts: { handler: (args: never) => unknown }) => tools.set(name, opts.handler),
43
+ // Replica `loader.js:215-222`: pi pasa UN objeto y hace `tools.set(tool.name, )`.
44
+ registerTool: (tool: { name: string; handler: (args: never) => unknown }) => tools.set(tool.name, tool.handler),
44
45
  appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
45
46
  };
46
47
  const cwd = options.cwd ?? mkdtempSync(join(tmpdir(), "nodd-enforce-"));
@@ -57,7 +57,10 @@ type AgentStartEvent = { systemPrompt?: unknown };
57
57
  type PiApi = {
58
58
  on(event: string, handler: (event: never) => unknown): void;
59
59
  appendEntry?(type: string, data?: unknown): void;
60
- registerTool?(name: string, options: unknown): void;
60
+ // Un solo objeto, con el nombre adentro. pi hace `tools.set(tool.name, )`:
61
+ // pasarle (name, options) como a registerCommand deja el nombre en undefined
62
+ // y el provider rechaza el request entero con "tools[N].name is required".
63
+ registerTool?(tool: { name: string } & Record<string, unknown>): void;
61
64
  };
62
65
 
63
66
  const INTENTS: readonly Intent[] = ["read-only", "change"];
@@ -533,11 +536,13 @@ export default function register(pi?: PiApi, cwd: string = process.cwd()): Kerne
533
536
  // one-shot hatches on top of this at runtime.
534
537
  kernel.setPolicy(readPolicy());
535
538
 
536
- pi.registerTool?.("nodd_declare", {
539
+ pi.registerTool?.({
540
+ name: "nodd_declare",
537
541
  ...DECLARE_SCHEMA,
538
542
  handler: (args: DeclareArgs) => kernel.declare(args).text,
539
543
  });
540
- pi.registerTool?.("nodd_task", {
544
+ pi.registerTool?.({
545
+ name: "nodd_task",
541
546
  ...TASK_SCHEMA,
542
547
  handler: (args: TaskArgs) => kernel.task(args).text,
543
548
  });
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { MECHANISM_PLACEHOLDER } from "../src/models/slots.ts";
5
5
  import register, { runModelsCommand, type ConfigIo } from "./nodd-models.ts";
6
+ import type { EnterResult, PickerState } from "../src/models/picker.ts";
6
7
 
7
8
  const registry = {
8
9
  getAll: () => [
@@ -126,13 +127,38 @@ test("editing a slot with a profile active mirrors into that profile", () => {
126
127
  // ---------------------------------------------------------------------------
127
128
  // The picker host
128
129
  // ---------------------------------------------------------------------------
130
+
131
+ /** Drive the real picker to a save carrying one staged model. */
132
+ function stageOneModel(): { type: "save"; state: PickerState } {
133
+ let captured: EnterResult | null = null;
134
+ const component = register.createComponent(
135
+ {
136
+ models: { implement: "anthropic/claude-opus-4-1" },
137
+ thinking: {},
138
+ groups: new Map([["anthropic", ["claude-opus-4-1"]]]),
139
+ },
140
+ (result: EnterResult) => {
141
+ captured = result;
142
+ },
143
+ );
144
+ // The menu opens on "nuevo perfil"; save is two rows down.
145
+ component.handleInput?.("\u001b[B");
146
+ component.handleInput?.("\u001b[B");
147
+ component.handleInput?.("\r");
148
+ assert.ok(captured, "the picker must close with a result");
149
+ assert.equal(captured!.type, "save");
150
+ return captured as { type: "save"; state: PickerState };
151
+ }
129
152
  test("quitting the picker produces zero writes; saving writes once", async () => {
130
153
  const quitIo = memoryIo();
131
154
  await register.runPicker({ type: "quit" }, quitIo);
132
155
  assert.equal(quitIo.writes.length, 0, "quit writes nothing");
133
156
 
157
+ // A save carries the whole staged state, so it is built from the picker
158
+ // itself rather than hand-written: a literal would drift from the real shape.
159
+ const saved = stageOneModel();
134
160
  const saveIo = memoryIo({ unrelated: true });
135
- await register.runPicker({ type: "save", models: { implement: "anthropic/claude-opus-4-1" } }, saveIo);
161
+ await register.runPicker(saved, saveIo);
136
162
  assert.equal(saveIo.writes.length, 1);
137
163
  assert.deepEqual(saveIo.writes[0].models, { implement: "anthropic/claude-opus-4-1" });
138
164
  assert.equal(saveIo.writes[0].unrelated, true, "the picker's save preserves unrelated keys");
@@ -147,14 +173,25 @@ test("the command is registered with pi under its own name", () => {
147
173
 
148
174
  test("the picker component renders rows and is a plain object, no TUI import", () => {
149
175
  const component = register.createComponent(
150
- { models: { implement: "anthropic/claude-opus-4-1" }, groups: new Map([["anthropic", ["claude-opus-4-1"]]]) },
176
+ {
177
+ models: { implement: "anthropic/claude-opus-4-1" },
178
+ thinking: {},
179
+ groups: new Map([["anthropic", ["claude-opus-4-1"]]]),
180
+ },
151
181
  () => {},
152
182
  );
153
183
  const lines = component.render(80);
154
184
  assert.ok(Array.isArray(lines), "render returns string[]");
155
- assert.ok(lines.length > 7, "every slot row is rendered");
156
- assert.ok(lines.some((line) => line.includes(MECHANISM_PLACEHOLDER)), "mechanism rows are visible in the picker");
157
- assert.ok(lines.some((line) => line.includes("anthropic/claude-opus-4-1")));
185
+ assert.ok(lines.some((line) => line.includes("guardar y salir")), "the menu offers save");
186
+
187
+ // The slots are one screen in, behind the loose-config row, which is what the
188
+ // menu offers while no profile exists.
189
+ component.handleInput?.("\u001b[B");
190
+ component.handleInput?.("\r");
191
+ const slots = component.render(80);
192
+ assert.ok(slots.length > 7, "every slot row is rendered");
193
+ assert.ok(slots.some((line) => line.includes(MECHANISM_PLACEHOLDER)), "mechanism rows are visible in the picker");
194
+ assert.ok(slots.some((line) => line.includes("anthropic/claude-opus-4-1")));
158
195
  });
159
196
 
160
197
  // ---------------------------------------------------------------------------
@@ -23,8 +23,10 @@ import { readFileSync, writeFileSync } from "node:fs";
23
23
  import { mergeConfig, noddConfigPath } from "../src/config.ts";
24
24
  import { SLOT_ROWS } from "../src/models/slots.ts";
25
25
  import { assignmentPatch, groupByProvider, parseAssignment, validateAssignment, type RegistryModel } from "../src/models/assign.ts";
26
- import { applyProfileCommand, isValidProfileName, mirrorToActiveProfile, readActiveProfile, readProfiles } from "../src/models/profiles.ts";
27
- import { createPickerState, enter, moveCursor, renderRows, stage, type EnterResult, type PickerState } from "../src/models/picker.ts";
26
+ import { applyProfileCommand, isValidProfileName, mirrorToActiveProfile, readActiveProfile, readProfiles, type Profile } from "../src/models/profiles.ts";
27
+ import { back, createPickerState, decodeKey, enter, navigate, pickerTitle, submitText, type EnterResult, type PickerState } from "../src/models/picker.ts";
28
+ import { fitRows, truncateToWidth, usableRows, windowRows } from "../src/models/layout.ts";
29
+ import { isThinkingLevel, type SlotThinking } from "../src/models/thinking.ts";
28
30
 
29
31
  export type ConfigIo = {
30
32
  readConfig(): Record<string, unknown>;
@@ -144,68 +146,169 @@ export function runModelsCommand(args: string, io: ConfigIo, registry?: ModelReg
144
146
  return `nodd · ${assignment.slot} → ${patch.models[assignment.slot]}`;
145
147
  }
146
148
 
147
- /** Persist a picker outcome. Quit writes nothing; save writes once. */
149
+ /** The thinking map as stored, keeping only the levels that are real. */
150
+ function currentThinking(raw: Record<string, unknown>): SlotThinking {
151
+ const stored = raw.thinking;
152
+ const out: SlotThinking = {};
153
+ for (const [key, value] of Object.entries(typeof stored === "object" && stored !== null ? stored : {})) {
154
+ if (isThinkingLevel(value)) out[key] = value;
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** Everything the picker opens on, read from the config in one place. */
160
+ function pickerInput(raw: Record<string, unknown>, groups: Map<string, string[]>) {
161
+ return {
162
+ models: currentModels(raw),
163
+ thinking: currentThinking(raw),
164
+ groups,
165
+ profiles: readProfiles(raw).profiles,
166
+ activeProfile: readActiveProfile(raw),
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Persist a picker outcome. Quit writes nothing; save writes once.
172
+ *
173
+ * The picker stages everything — slots, thinking levels and the whole profile
174
+ * map — and hands back the final state, so this is one write, never one per
175
+ * keystroke. `activeProfile` and `profiles` are carried through because the
176
+ * picker can create, delete, duplicate and activate them.
177
+ */
148
178
  function runPicker(result: EnterResult, io: ConfigIo): void {
149
179
  if (result.type !== "save") return;
150
180
  const raw = io.readConfig();
151
- io.writeConfig(mirrorToActiveProfile(mergeConfig(raw, { models: result.models })));
181
+ const { models, thinking, profiles, activeProfile } = result.state.edits;
182
+ io.writeConfig(
183
+ mirrorToActiveProfile(
184
+ mergeConfig(raw, { models, thinking, profiles, activeProfile }),
185
+ ),
186
+ );
152
187
  }
153
188
 
154
189
  /**
155
- * The `ctx.ui.custom` component. It holds one `PickerState` and forwards
156
- * keystrokes to the pure state machine; every decision lives there.
190
+ * The `ctx.ui.custom` component. It holds one `PickerState` and forwards every
191
+ * keystroke to the pure state machine; no decision lives here.
192
+ *
193
+ * Ported from `zero-models.ts:777-926`. Two things it must never do: throw out
194
+ * of `handleInput` (a transition bug would wedge the pi session, so the whole
195
+ * body is wrapped and a failure closes the picker), and write anything — only
196
+ * `runPicker` writes, once, on save.
157
197
  */
158
198
  function createComponent(
159
- input: { models: Record<string, string>; groups: Map<string, string[]> },
199
+ input: {
200
+ models: Record<string, string>;
201
+ thinking: SlotThinking;
202
+ groups: Map<string, string[]>;
203
+ profiles?: Record<string, Profile>;
204
+ activeProfile?: string | null;
205
+ },
160
206
  done: (result: EnterResult) => void,
161
207
  requestRender: () => void = () => {},
162
208
  ): Component {
163
209
  let state: PickerState = createPickerState(input);
164
- let choosing: { slot: string; options: string[]; cursor: number } | null = null;
210
+ // Inline text buffer non-null only while `state.textPrompt` is open.
211
+ let buffer: string | null = null;
212
+
213
+ function render(width: number): string[] {
214
+ const inner = Math.max(20, width - 2);
215
+ // pi hands the component its width but not its height, so the height comes
216
+ // from the terminal itself, minus what pi's own chrome takes.
217
+ const maxRows = usableRows(process.stdout?.rows);
218
+ const head: string[] = [pickerTitle(state), ""];
219
+ if (state.notice) head.push(state.notice, "");
220
+
221
+ if (state.textPrompt) {
222
+ return fitRows(
223
+ [...head, state.textPrompt.label, `> ${buffer ?? ""}`, "", "enter confirmar · esc volver"]
224
+ .map((line) => truncateToWidth(line, inner)),
225
+ maxRows,
226
+ );
227
+ }
228
+
229
+ // Reserve the header and footer, then window the list around the cursor so
230
+ // a long list scrolls instead of overflowing the terminal.
231
+ const capacity = Math.max(1, maxRows - head.length - 2);
232
+ const win = windowRows(state.entries.length, state.cursor, capacity);
233
+ const rows = state.entries.slice(win.start, win.end).map((entry, index) => {
234
+ const selected = win.start + index === state.cursor;
235
+ return truncateToWidth(`${selected ? "❯ " : " "}${entry.label}`, inner);
236
+ });
237
+
238
+ return fitRows(
239
+ [...head, ...rows, "", truncateToWidth("↑↓ mover · enter elegir · esc volver · q salir", inner)],
240
+ maxRows,
241
+ );
242
+ }
243
+
244
+ /** Apply an `EnterResult` — re-render on `state`, close on `save`/`quit`. */
245
+ function applyResult(result: EnterResult): void {
246
+ if (result.type === "state") {
247
+ state = result.state;
248
+ requestRender();
249
+ return;
250
+ }
251
+ done(result);
252
+ }
253
+
254
+ /** Route a keystroke while the inline text buffer is open. */
255
+ function handleTextInput(data: string): void {
256
+ const key = decodeKey(data);
257
+ if (key === "esc") {
258
+ // Esc abandons the typed value and returns to the list unchanged:
259
+ // `submitText` with an empty string is exactly that no-op.
260
+ state = submitText(state, "");
261
+ buffer = null;
262
+ } else if (key === "enter") {
263
+ state = submitText(state, buffer ?? "");
264
+ buffer = null;
265
+ } else if (key === "backspace") {
266
+ buffer = (buffer ?? "").slice(0, -1);
267
+ } else if (data.length >= 1 && data.charCodeAt(0) >= 32 && !data.startsWith("\u001b")) {
268
+ // Printable characters only; control sequences are dropped.
269
+ buffer = (buffer ?? "") + data;
270
+ }
271
+ requestRender();
272
+ }
165
273
 
166
274
  return {
167
- render(width: number): string[] {
168
- const inner = Math.max(20, width - 4);
169
- const lines = choosing
170
- ? [
171
- `elegí un modelo para ${choosing.slot}:`,
172
- ...choosing.options.map((option, i) => `${i === choosing!.cursor ? "❯" : " "} ${option}`),
173
- ]
174
- : [
175
- "nodd · modelos (↑↓ mover · enter elegir · q salir)",
176
- ...renderRows(state).map((row, i) => {
177
- const marker = i === state.cursor ? "❯" : " ";
178
- return `${marker} ${row.id.padEnd(20)} → ${row.value}`;
179
- }),
180
- ];
181
- return lines.map((line) => line.slice(0, inner));
275
+ render,
276
+ invalidate(): void {
277
+ /* stateless render — nothing cached to clear */
182
278
  },
183
-
184
279
  handleInput(data: string): void {
185
- if (choosing) {
186
- if (data === "\r" || data === "\n") {
187
- state = stage(state, choosing.slot, choosing.options[choosing.cursor]);
188
- choosing = null;
189
- } else if (data === "\u001b[A") choosing.cursor = Math.max(0, choosing.cursor - 1);
190
- else if (data === "\u001b[B") choosing.cursor = Math.min(choosing.options.length - 1, choosing.cursor + 1);
191
- else if (data === "q" || data === "\u001b") choosing = null;
192
- requestRender();
193
- return;
194
- }
280
+ try {
281
+ if (state.textPrompt) {
282
+ handleTextInput(data);
283
+ return;
284
+ }
195
285
 
196
- if (data === "\u001b[A") state = moveCursor(state, -1);
197
- else if (data === "\u001b[B") state = moveCursor(state, 1);
198
- else if (data === "q" || data === "\u001b") done({ type: "quit" });
199
- else if (data === "\r" || data === "\n") {
200
- const result = enter(state);
201
- if (result.type === "choose") choosing = { slot: result.slot, options: result.options, cursor: 0 };
202
- else done(result);
286
+ const key = decodeKey(data);
287
+ if (key === "up") state = navigate(state, -1);
288
+ else if (key === "down") state = navigate(state, 1);
289
+ else if (key === "enter") {
290
+ const result = enter(state);
291
+ // `enter` on a custom-* row opens `textPrompt`; arm the buffer.
292
+ if (result.type === "state" && result.state.textPrompt) buffer = "";
293
+ applyResult(result);
294
+ return;
295
+ } else if (key === "esc") {
296
+ applyResult(back(state));
297
+ return;
298
+ } else if (data === "q") {
299
+ done({ type: "quit" });
300
+ return;
301
+ }
302
+ requestRender();
303
+ } catch {
304
+ // A transition bug must never wedge the pi session — close instead.
305
+ done({ type: "quit" });
203
306
  }
204
- requestRender();
205
307
  },
206
308
  };
207
309
  }
208
310
 
311
+
209
312
  type PiApi = {
210
313
  registerCommand?(name: string, options: { description?: string; handler: (args: string, ctx: unknown) => unknown }): void;
211
314
  };
@@ -12,7 +12,8 @@ function fakePi() {
12
12
  tools,
13
13
  on() {},
14
14
  appendEntry() {},
15
- registerTool(name: string, options: any) { tools.set(name, options); },
15
+ // Replica `loader.js:215-222`: pi pasa UN objeto y hace `tools.set(tool.name, )`.
16
+ registerTool(tool: any) { tools.set(tool.name, tool); },
16
17
  };
17
18
  }
18
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gonrocca/nodd",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Non-negotiable Organic Driven Development — the ODD protocol as runtime mechanism for pi: blocking gates, observed evidence, and promotion to /forge.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -41,6 +41,17 @@ test("merge writes only the named section", () => {
41
41
  assert.deepEqual(merged.gates, { track: { enabled: false } });
42
42
  });
43
43
 
44
+ // `/nodd-gates` writes through this merge, so a section it does not know about
45
+ // has to survive it. Thinking levels are exactly that section: dropping them on
46
+ // an unrelated gate toggle would silently reset every slot's effort.
47
+ test("merge preserves thinking levels it was not asked to touch", () => {
48
+ const merged = mergeConfig(
49
+ { models: { implement: "a/b" }, thinking: { implement: "high" } },
50
+ { gates: { track: { enabled: false } } },
51
+ );
52
+ assert.deepEqual(merged.thinking, { implement: "high" });
53
+ });
54
+
44
55
  test("no NODD source file mentions zero.json", () => {
45
56
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
46
57
  const walk = (dir: string, out: string[] = []): string[] => {
@@ -0,0 +1,122 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import {
5
+ fitRows,
6
+ padToWidth,
7
+ stripAnsi,
8
+ truncateToWidth,
9
+ usableRows,
10
+ visibleWidth,
11
+ windowRows,
12
+ } from "./layout.ts";
13
+
14
+ const ESC = "\u001b";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Measuring what the terminal actually shows
18
+ // ---------------------------------------------------------------------------
19
+ test("stripAnsi removes CSI colour runs and leaves the printable text", () => {
20
+ assert.equal(stripAnsi(`${ESC}[31mrojo${ESC}[0m`), "rojo");
21
+ assert.equal(stripAnsi("sin escapes"), "sin escapes");
22
+ });
23
+
24
+ test("visibleWidth counts cells, not code units: escapes are free, wide glyphs cost two", () => {
25
+ assert.equal(visibleWidth(`${ESC}[31mrojo${ESC}[0m`), 4);
26
+ assert.equal(visibleWidth("漢字"), 4, "East-Asian wide glyphs take two cells each");
27
+ assert.equal(visibleWidth("e\u0301"), 1, "a combining accent takes no cell of its own");
28
+ });
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Truncating by width, never by index
32
+ // ---------------------------------------------------------------------------
33
+ test("a line that fits is returned untouched", () => {
34
+ assert.equal(truncateToWidth("corto", 10), "corto");
35
+ });
36
+
37
+ test("truncation marks the cut and never overshoots the budget", () => {
38
+ const cut = truncateToWidth("abcdefghij", 5);
39
+ assert.equal(visibleWidth(cut), 5);
40
+ assert.ok(cut.endsWith("…"), `the cut must be marked: ${cut}`);
41
+ });
42
+
43
+ test("a wide glyph is never split in half by the budget", () => {
44
+ // Budget 4 = ellipsis (1) + 3 cells, and 漢 costs 2: only one glyph fits.
45
+ const cut = truncateToWidth("漢字漢字", 4);
46
+ assert.ok(visibleWidth(cut) <= 4, `overshot the budget: ${visibleWidth(cut)}`);
47
+ assert.ok(!cut.includes("\ufffd"), "no replacement character: a code point was split");
48
+ });
49
+
50
+ test("a cut inside a styled run appends a reset, so the colour cannot bleed", () => {
51
+ const cut = truncateToWidth(`${ESC}[31mabcdefghij`, 5);
52
+ assert.ok(cut.endsWith(`${ESC}[0m`), `a styled cut must close its own run: ${JSON.stringify(cut)}`);
53
+ });
54
+
55
+ test("a non-positive budget yields an empty line", () => {
56
+ assert.equal(truncateToWidth("abc", 0), "");
57
+ });
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Padding to an exact cell count
61
+ // ---------------------------------------------------------------------------
62
+ test("padToWidth makes a line occupy exactly the width asked for", () => {
63
+ assert.equal(visibleWidth(padToWidth("ab", 6)), 6);
64
+ assert.equal(visibleWidth(padToWidth("abcdefghij", 6)), 6, "too long is truncated, not overflowed");
65
+ assert.equal(visibleWidth(padToWidth("漢字", 6)), 6, "wide glyphs are measured, not counted");
66
+ assert.equal(padToWidth("x", 0), "");
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Windowing a long list around the cursor
71
+ // ---------------------------------------------------------------------------
72
+ test("a list that fits is drawn whole, with nothing hidden", () => {
73
+ assert.deepEqual(windowRows(5, 0, 10), { start: 0, end: 5, hiddenBefore: 0, hiddenAfter: 0 });
74
+ });
75
+
76
+ test("the window keeps the cursor on screen and reports what it hides", () => {
77
+ for (const cursor of [0, 3, 9, 19]) {
78
+ const win = windowRows(20, cursor, 5);
79
+ assert.ok(cursor >= win.start && cursor < win.end, `cursor ${cursor} fell outside ${win.start}..${win.end}`);
80
+ assert.equal(win.end - win.start, 5, "the window always fills its capacity");
81
+ assert.equal(win.hiddenBefore, win.start);
82
+ assert.equal(win.hiddenAfter, 20 - win.end);
83
+ }
84
+ });
85
+
86
+ test("the window clamps at both ends instead of scrolling past them", () => {
87
+ assert.deepEqual(windowRows(20, 0, 5), { start: 0, end: 5, hiddenBefore: 0, hiddenAfter: 15 });
88
+ assert.deepEqual(windowRows(20, 19, 5), { start: 15, end: 20, hiddenBefore: 15, hiddenAfter: 0 });
89
+ });
90
+
91
+ test("no capacity and no rows are handled, not crashed", () => {
92
+ assert.deepEqual(windowRows(20, 3, 0), { start: 0, end: 0, hiddenBefore: 0, hiddenAfter: 20 });
93
+ assert.deepEqual(windowRows(0, 0, 5), { start: 0, end: 0, hiddenBefore: 0, hiddenAfter: 0 });
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Fitting the terminal's own height
98
+ // ---------------------------------------------------------------------------
99
+ test("usableRows leaves room for pi's chrome and never returns a useless height", () => {
100
+ assert.ok(usableRows(40) < 40, "pi's own chrome takes rows off the top");
101
+ assert.ok(usableRows(40) > 20, "a tall terminal gets most of its rows");
102
+ assert.ok(usableRows(4) >= 8, "a tiny terminal still gets a minimum block");
103
+ assert.ok(usableRows(undefined) > 0, "an unknown terminal height still yields a height");
104
+ assert.ok(usableRows(0) > 0);
105
+ });
106
+
107
+ test("fitRows keeps the first rows, because the frame top and title matter most", () => {
108
+ assert.deepEqual(fitRows(["a", "b", "c"], 2), ["a", "b"]);
109
+ assert.deepEqual(fitRows(["a", "b"], 5), ["a", "b"]);
110
+ assert.deepEqual(fitRows(["a"], 0), []);
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // The module boundary: pure, so node --test reaches it with no terminal.
115
+ // ---------------------------------------------------------------------------
116
+ test("the layout helpers import nothing at all", () => {
117
+ const source = readFileSync(new URL("./layout.ts", import.meta.url), "utf8")
118
+ .split("\n")
119
+ .filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
120
+ .join("\n");
121
+ assert.ok(!/^import /m.test(source), "the layout must stay dependency-free: no node:*, no pi, no TUI");
122
+ });