@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.
- package/extensions/nodd-agents.test.ts +10 -0
- package/extensions/nodd-agents.ts +24 -2
- package/extensions/nodd-enforcement.test.ts +2 -1
- package/extensions/nodd-kernel.ts +8 -3
- package/extensions/nodd-models.test.ts +42 -5
- package/extensions/nodd-models.ts +145 -42
- package/extensions/nodd-tools.test.ts +2 -1
- package/package.json +1 -1
- package/src/config.test.ts +11 -0
- package/src/models/layout.test.ts +122 -0
- package/src/models/layout.ts +187 -0
- package/src/models/picker.test.ts +467 -77
- package/src/models/picker.ts +644 -65
- package/src/models/profiles.test.ts +86 -0
- package/src/models/profiles.ts +65 -4
- package/src/models/slots.ts +4 -3
- package/src/models/thinking.test.ts +14 -0
- package/src/models/thinking.ts +22 -0
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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?.(
|
|
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?.(
|
|
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(
|
|
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
|
-
{
|
|
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.
|
|
156
|
-
|
|
157
|
-
|
|
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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
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: {
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
}
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
+
});
|