@noir-ai/cli 1.7.4 → 1.8.0-beta.1

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/dist/tui/index.js CHANGED
@@ -4,11 +4,9 @@ import {
4
4
  } from "./chunk-FQCYGUCG.js";
5
5
  import "./chunk-X2CROVY6.js";
6
6
  import {
7
+ doctor,
7
8
  init
8
- } from "./chunk-KBM2UXAC.js";
9
- import {
10
- doctor
11
- } from "./chunk-MGRKJMHG.js";
9
+ } from "./chunk-XFBZ5X3B.js";
12
10
  import {
13
11
  EXIT,
14
12
  NoirCliError,
@@ -32,23 +30,198 @@ import {
32
30
  } from "./chunk-A2Q74DOV.js";
33
31
 
34
32
  // src/tui/index.tsx
33
+ import { loadProjectInfo as loadProjectInfo8 } from "@noir-ai/core";
35
34
  import { render } from "ink";
36
35
 
37
- // src/commands/status.ts
38
- import { loadProjectInfo as loadProjectInfo7, NOIR_VERSION as NOIR_VERSION4 } from "@noir-ai/core";
39
-
40
- // src/daemon-client.ts
41
- import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
42
- import { loadProjectInfo as loadProjectInfo6, NOIR_VERSION as NOIR_VERSION3 } from "@noir-ai/core";
43
- import { ensureDaemonRunning as ensureDaemonRunning3, pidAlive as pidAlive2, readDaemonRecord as readDaemonRecord2 } from "@noir-ai/daemon";
44
-
45
36
  // src/bin.ts
46
37
  import { realpathSync } from "fs";
47
38
  import { pathToFileURL } from "url";
48
39
  import { SUPPORTED_HOSTS } from "@noir-ai/adapters";
49
- import { NOIR_VERSION as NOIR_VERSION2 } from "@noir-ai/core";
40
+ import { NOIR_VERSION as NOIR_VERSION4 } from "@noir-ai/core";
50
41
  import { Command, Option } from "commander";
51
42
 
43
+ // src/daemon-client.ts
44
+ import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
45
+ import { ContextEngine, createEmbedFn, resolveEmbedderConfig } from "@noir-ai/context";
46
+ import { loadProjectInfo, NOIR_VERSION } from "@noir-ai/core";
47
+ import { ensureDaemonRunning, pidAlive, readDaemonRecord } from "@noir-ai/daemon";
48
+ import { createMemoryEngine } from "@noir-ai/memory";
49
+ import { openStore } from "@noir-ai/store";
50
+ import { WorkflowEngine } from "@noir-ai/workflow";
51
+ var DAEMON_DOWN_HINT = "daemon not reachable \u2014 try `noir daemon start`";
52
+ var PROBE_TIMEOUT_MS = 1500;
53
+ function describeCause(cause) {
54
+ if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
55
+ try {
56
+ return JSON.stringify(cause);
57
+ } catch {
58
+ return String(cause);
59
+ }
60
+ }
61
+ function failDaemonDown(opts, cause) {
62
+ if (opts.verbose) {
63
+ process.stderr.write(`noir: daemon transport detail: ${describeCause(cause)}
64
+ `);
65
+ }
66
+ fail(EXIT.DAEMON_DOWN, DAEMON_DOWN_HINT, opts);
67
+ }
68
+ async function probeDaemon(opts = {}) {
69
+ const rec = readDaemonRecord();
70
+ if (!rec) {
71
+ if (opts.verbose) process.stderr.write("noir: daemon probe: no daemon record\n");
72
+ return { running: false };
73
+ }
74
+ if (!pidAlive(rec.pid)) {
75
+ if (opts.verbose) process.stderr.write(`noir: daemon probe: pid ${rec.pid} not alive
76
+ `);
77
+ return { running: false };
78
+ }
79
+ try {
80
+ const res = await fetch(`http://127.0.0.1:${rec.port}/health`, {
81
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
82
+ });
83
+ if (!res.ok) {
84
+ if (opts.verbose) process.stderr.write(`noir: daemon probe: /health \u2192 HTTP ${res.status}
85
+ `);
86
+ return { running: false };
87
+ }
88
+ const body = await res.json().catch(() => null);
89
+ if (body?.ok !== true) {
90
+ if (opts.verbose) process.stderr.write("noir: daemon probe: /health body not ok\n");
91
+ return { running: false };
92
+ }
93
+ return {
94
+ running: true,
95
+ pid: typeof body.pid === "number" ? body.pid : rec.pid,
96
+ port: rec.port,
97
+ uptimeSec: typeof body.uptimeSec === "number" ? body.uptimeSec : void 0
98
+ };
99
+ } catch (err) {
100
+ if (opts.verbose)
101
+ process.stderr.write(`noir: daemon probe: unreachable (${describeCause(err)})
102
+ `);
103
+ return { running: false };
104
+ }
105
+ }
106
+ async function resolveDaemon(opts) {
107
+ const project = opts.project ?? loadProjectInfo(process.cwd());
108
+ const idleTimeoutSec = opts.idleTimeoutSec ?? project.config.daemon.idleTimeoutSec;
109
+ try {
110
+ const ensured = await ensureDaemonRunning({ project, idleTimeoutSec });
111
+ return { url: ensured.url, stop: ensured.stop };
112
+ } catch (err) {
113
+ failDaemonDown(opts, err);
114
+ }
115
+ }
116
+ async function connectClient(client, url, opts) {
117
+ try {
118
+ await client.connect(new StreamableHTTPClientTransport(new URL(url)));
119
+ } catch (err) {
120
+ failDaemonDown(opts, err);
121
+ }
122
+ }
123
+ async function callToolParse(client, opts, name, args) {
124
+ const result = await client.callTool({ name, arguments: args }).catch((err) => failDaemonDown(opts, err));
125
+ const block = result.content?.[0];
126
+ if (block?.type !== "text" || typeof block.text !== "string") {
127
+ failDaemonDown(opts, new Error(`tool '${name}' returned no text content`));
128
+ }
129
+ const text = block.text;
130
+ try {
131
+ return JSON.parse(text);
132
+ } catch (err) {
133
+ failDaemonDown(opts, err);
134
+ }
135
+ }
136
+ function buildCaller(client, opts) {
137
+ return {
138
+ callTool: (name, args = {}) => callToolParse(client, opts, name, args),
139
+ listTools: async () => {
140
+ const res = await client.listTools().catch((err) => failDaemonDown(opts, err));
141
+ const tools = res?.tools;
142
+ if (!Array.isArray(tools)) return [];
143
+ const names = [];
144
+ for (const t of tools) {
145
+ const name = t?.name;
146
+ if (typeof name === "string") names.push(name);
147
+ }
148
+ return names;
149
+ }
150
+ };
151
+ }
152
+ async function withDaemon(opts, fn) {
153
+ const { url, stop } = await resolveDaemon(opts);
154
+ let client;
155
+ try {
156
+ client = new Client(
157
+ { name: "noir-cli", version: NOIR_VERSION },
158
+ { versionNegotiation: { mode: "auto" } }
159
+ );
160
+ const connected = client;
161
+ await connectClient(connected, url, opts);
162
+ return await fn(buildCaller(connected, opts));
163
+ } finally {
164
+ if (client) {
165
+ await client.close().catch(() => {
166
+ });
167
+ }
168
+ await stop().catch(() => {
169
+ });
170
+ }
171
+ }
172
+ async function withRunningDaemon(opts, fn, probe) {
173
+ const p = probe ?? await probeDaemon(opts);
174
+ if (!p.running) return null;
175
+ const url = `http://127.0.0.1:${p.port}/mcp`;
176
+ let client;
177
+ try {
178
+ client = new Client(
179
+ { name: "noir-cli", version: NOIR_VERSION },
180
+ { versionNegotiation: { mode: "auto" } }
181
+ );
182
+ const connected = client;
183
+ await connected.connect(new StreamableHTTPClientTransport(new URL(url)));
184
+ return await fn(buildCaller(connected, opts));
185
+ } catch {
186
+ return null;
187
+ } finally {
188
+ if (client) {
189
+ await client.close().catch(() => {
190
+ });
191
+ }
192
+ }
193
+ }
194
+ async function callDaemonTool(opts, name, args = {}) {
195
+ return withDaemon(opts, (caller) => caller.callTool(name, args));
196
+ }
197
+ async function withInProcessRead(opts, fn) {
198
+ const project = opts.project ?? loadProjectInfo(process.cwd());
199
+ const store = await openStore({ projectId: project.id, root: project.root, readonly: true });
200
+ try {
201
+ const embedderCfg = resolveEmbedderConfig(project.config.context);
202
+ const embed = createEmbedFn(embedderCfg).embed;
203
+ const context = new ContextEngine({
204
+ store,
205
+ root: project.root,
206
+ projectId: project.id,
207
+ embedderCfg,
208
+ storeDegraded: true
209
+ });
210
+ const memory = createMemoryEngine({
211
+ store,
212
+ root: project.root,
213
+ projectId: project.id,
214
+ embed,
215
+ storeDegraded: true
216
+ });
217
+ const workflow = new WorkflowEngine(store, project.root, project.id);
218
+ return await fn({ context, memory, workflow });
219
+ } finally {
220
+ await store.close().catch(() => {
221
+ });
222
+ }
223
+ }
224
+
52
225
  // src/commands/context.ts
53
226
  function parseLimit(raw, label, opts) {
54
227
  if (raw === void 0) return void 0;
@@ -73,6 +246,39 @@ function toHit(raw) {
73
246
  }
74
247
  async function contextSearch(opts) {
75
248
  const limit = parseLimit(opts.limit, "context search", opts);
249
+ let probe;
250
+ try {
251
+ probe = await probeDaemon(opts);
252
+ } catch {
253
+ probe = { running: true };
254
+ }
255
+ if (!probe.running) {
256
+ await withInProcessRead(opts, async (engines) => {
257
+ const result = await engines.context.search(opts.query, {
258
+ ...limit === void 0 ? {} : { limit }
259
+ });
260
+ const data2 = {
261
+ query: opts.query,
262
+ hits: result.results.map((h) => ({
263
+ path: h.path,
264
+ score: h.score,
265
+ snippet: h.snippet,
266
+ source: h.source
267
+ })),
268
+ consumedTokens: result.consumedTokens,
269
+ truncated: result.truncated,
270
+ degraded: result.degraded,
271
+ mode: result.mode
272
+ };
273
+ if (opts.json === true) {
274
+ process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
275
+ `);
276
+ return;
277
+ }
278
+ renderSearch(data2, opts);
279
+ });
280
+ return;
281
+ }
76
282
  const res = await callDaemonTool(opts, "context_search", {
77
283
  query: opts.query,
78
284
  ...limit === void 0 ? {} : { limit }
@@ -112,13 +318,8 @@ function renderSearch(data, opts) {
112
318
  );
113
319
  }
114
320
  async function contextIndex(opts) {
115
- if (opts.force === true) {
116
- info(
117
- "context index: --force is recognized but not yet honored (content-hash is always incremental).",
118
- opts
119
- );
120
- }
121
321
  const args = opts.paths && opts.paths.length > 0 ? { paths: opts.paths } : {};
322
+ if (opts.force === true) args.force = true;
122
323
  const res = await callDaemonTool(opts, "context_index", args);
123
324
  if (res.ok !== true) failTool("context index", res, opts);
124
325
  const data = {
@@ -187,12 +388,13 @@ function renderStatus(data, opts) {
187
388
  }
188
389
 
189
390
  // src/commands/daemon.ts
190
- import { loadProjectInfo } from "@noir-ai/core";
391
+ import { loadProjectInfo as loadProjectInfo2 } from "@noir-ai/core";
191
392
  import {
192
393
  clearDaemonRecord,
193
- ensureDaemonRunning,
194
- pidAlive,
195
- readDaemonRecord
394
+ ensureDaemonRunning as ensureDaemonRunning2,
395
+ pidAlive as pidAlive2,
396
+ readDaemonRecord as readDaemonRecord2,
397
+ spawnDetachedDaemon
196
398
  } from "@noir-ai/daemon";
197
399
  var MODE = "foreground";
198
400
  function formatUptime(sec) {
@@ -201,13 +403,70 @@ function formatUptime(sec) {
201
403
  if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
202
404
  return `${Math.floor(sec / 3600)}h ${Math.floor(sec % 3600 / 60)}m`;
203
405
  }
406
+ async function isHealthy(port) {
407
+ try {
408
+ const res = await fetch(`http://127.0.0.1:${port}/health`);
409
+ return res.status === 200;
410
+ } catch {
411
+ return false;
412
+ }
413
+ }
204
414
  async function daemonStart(opts) {
415
+ const project = loadProjectInfo2(process.cwd());
416
+ if (opts.detachChild === true) {
417
+ const child = await ensureDaemonRunning2({
418
+ project,
419
+ idleTimeoutSec: project.config.daemon.idleTimeoutSec
420
+ });
421
+ if (child.started) {
422
+ if (opts.json === true) {
423
+ process.stdout.write(`${JSON.stringify({ ok: true, data: { mode: "detached" } })}
424
+ `);
425
+ return;
426
+ }
427
+ info("noir daemon: detached foreground. Ctrl+C to stop.", opts);
428
+ log(`Noir daemon listening at ${child.url}`, opts);
429
+ return;
430
+ }
431
+ if (opts.json === true) {
432
+ process.stdout.write(
433
+ `${JSON.stringify({ ok: true, data: { mode: "detached", reused: true } })}
434
+ `
435
+ );
436
+ return;
437
+ }
438
+ info("Noir daemon already running; detached child exiting.", opts);
439
+ return;
440
+ }
205
441
  if (opts.detach === true) {
206
- fail(EXIT.USAGE, "not implemented (tracked: v1.x)", opts);
442
+ const existing = readDaemonRecord2();
443
+ if (existing && pidAlive2(existing.pid) && await isHealthy(existing.port)) {
444
+ if (opts.json === true) {
445
+ process.stdout.write(
446
+ `${JSON.stringify({ ok: true, data: { mode: "detached", reused: true } })}
447
+ `
448
+ );
449
+ return;
450
+ }
451
+ info(
452
+ `Noir daemon already running at http://127.0.0.1:${existing.port}/mcp (detach not needed).`,
453
+ opts
454
+ );
455
+ return;
456
+ }
457
+ const spawned = await spawnDetachedDaemon({ project });
458
+ if (opts.json === true) {
459
+ process.stdout.write(
460
+ `${JSON.stringify({ ok: true, data: { mode: "detached", pid: spawned.pid, port: spawned.port } })}
461
+ `
462
+ );
463
+ return;
464
+ }
465
+ info(`Noir daemon started in the background (pid ${spawned.pid}, port ${spawned.port}).`, opts);
466
+ return;
207
467
  }
208
468
  const ds = spinner("Starting Noir daemon...", opts).start();
209
- const project = loadProjectInfo(process.cwd());
210
- const ensured = await ensureDaemonRunning({
469
+ const ensured = await ensureDaemonRunning2({
211
470
  project,
212
471
  idleTimeoutSec: project.config.daemon.idleTimeoutSec
213
472
  });
@@ -229,7 +488,7 @@ async function daemonStart(opts) {
229
488
  );
230
489
  return;
231
490
  }
232
- info("noir daemon: foreground mode (backgrounding deferred to v1.x). Ctrl+C to stop.", opts);
491
+ info("noir daemon: foreground mode. Ctrl+C to stop.", opts);
233
492
  log(`Noir daemon listening at ${ensured.url}`, opts);
234
493
  return;
235
494
  }
@@ -244,7 +503,7 @@ async function daemonStart(opts) {
244
503
  log(`Noir daemon already running at ${ensured.url}`, opts);
245
504
  }
246
505
  async function daemonStop(opts) {
247
- const rec = readDaemonRecord();
506
+ const rec = readDaemonRecord2();
248
507
  if (!rec) {
249
508
  if (opts.json === true) {
250
509
  process.stdout.write(
@@ -290,11 +549,11 @@ async function daemonStop(opts) {
290
549
  }
291
550
  }
292
551
  async function daemonStatus(opts) {
293
- const rec = readDaemonRecord();
552
+ const rec = readDaemonRecord2();
294
553
  if (!rec) {
295
554
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (start with `noir daemon start`).", opts);
296
555
  }
297
- if (!pidAlive(rec.pid)) {
556
+ if (!pidAlive2(rec.pid)) {
298
557
  clearDaemonRecord();
299
558
  fail(EXIT.DAEMON_DOWN, "Noir daemon is not running (stale record removed).", opts);
300
559
  }
@@ -325,7 +584,9 @@ async function daemonStatus(opts) {
325
584
  port: rec.port,
326
585
  startedAt: rec.startedAt,
327
586
  uptimeSec,
328
- mode: MODE
587
+ // Honest ownership: the detached child writes `mode:'detached'`; anything
588
+ // else (legacy record, foreground start) reports 'foreground'.
589
+ mode: rec.mode ?? MODE
329
590
  };
330
591
  if (opts.json === true) {
331
592
  process.stdout.write(`${JSON.stringify({ ok: true, data })}
@@ -350,40 +611,202 @@ import {
350
611
  hostLaunchDirective,
351
612
  resolveAdapter
352
613
  } from "@noir-ai/adapters";
353
- import { loadProjectInfo as loadProjectInfo2, NOIR_DIR } from "@noir-ai/core";
614
+ import { loadProjectInfo as loadProjectInfo4, NOIR_DIR } from "@noir-ai/core";
354
615
 
355
- // src/commands/task.ts
356
- var PHASE_SKILL = {
357
- intake: "noir-intake",
358
- clarify: "noir-clarify",
359
- spec: "noir-spec",
360
- plan: "noir-plan",
361
- execute: "noir-execute",
362
- verify: "noir-verify",
363
- document: "noir-document"
364
- };
365
- function skillFor(phase) {
366
- if (typeof phase !== "string") return null;
367
- return PHASE_SKILL[phase] ?? null;
616
+ // src/commands/status.ts
617
+ import { loadProjectInfo as loadProjectInfo3, NOIR_VERSION as NOIR_VERSION2 } from "@noir-ai/core";
618
+ async function tryTool(caller, name, args) {
619
+ try {
620
+ return await caller.callTool(name, args);
621
+ } catch {
622
+ return null;
623
+ }
368
624
  }
369
- async function fetchStatus(opts, taskId) {
370
- const args = typeof taskId === "string" && taskId.length > 0 ? { taskId } : {};
371
- const res = await callDaemonTool(
372
- opts,
373
- "workflow_status",
374
- args
375
- );
376
- if (res.ok === true) return res;
377
- const detail = res.error ?? "task not found";
378
- fail(EXIT.NOT_FOUND, `task: ${detail}`, opts);
625
+ function pickStore(s) {
626
+ if (s?.ok !== true) return null;
627
+ return {
628
+ docCount: s.docCount,
629
+ vecCount: s.vecCount,
630
+ dbPath: s.dbPath,
631
+ degraded: s.degraded === true
632
+ };
379
633
  }
380
- function formatStamp(ms) {
381
- if (typeof ms !== "number" || ms <= 0) return "-";
382
- return new Date(ms).toISOString().replace("T", " ").replace(/\..*$/, "Z");
634
+ function describeEmbedder2(e) {
635
+ if (!e || e.kind === "none") return "none (BM25-only)";
636
+ const model = typeof e.model === "string" && e.model.length > 0 ? e.model : "<unset>";
637
+ return `${e.kind} \xB7 ${model} (${e.dim}-dim)`;
383
638
  }
384
- function renderStatusRow(s, opts) {
385
- const flag = s.degraded === true ? ` ${badge("warn", "degraded")}` : "";
386
- log(`task status \u2014 ${s.taskId}${flag}`, opts);
639
+ function pickContext(c2) {
640
+ if (c2?.ok !== true) return null;
641
+ return {
642
+ docCount: c2.docCount,
643
+ vecCount: c2.vecCount,
644
+ indexedFiles: c2.indexedFiles,
645
+ embedder: describeEmbedder2(c2.embedder),
646
+ degraded: c2.degraded === true
647
+ };
648
+ }
649
+ function pickWorkflow(w) {
650
+ if (w?.ok !== true) return null;
651
+ return {
652
+ taskId: w.taskId,
653
+ phase: w.phase,
654
+ state: w.state,
655
+ mode: w.mode,
656
+ nextGate: w.nextGate,
657
+ degraded: w.degraded === true
658
+ };
659
+ }
660
+ function pickMemory(m) {
661
+ if (m?.ok !== true || !Array.isArray(m.sessions)) return null;
662
+ let observations = 0;
663
+ for (const s of m.sessions) observations += s.count;
664
+ return { sessions: m.sessions.length, observations };
665
+ }
666
+ function buildPayload(project, probe, host, store, context, workflow, memory) {
667
+ return {
668
+ noir: NOIR_VERSION2,
669
+ project: { id: project.id, name: project.name },
670
+ host: project.config.host,
671
+ daemon: {
672
+ running: probe.running,
673
+ ...probe.pid !== void 0 ? { pid: probe.pid } : {},
674
+ ...probe.uptimeSec !== void 0 ? { uptimeSec: probe.uptimeSec } : {},
675
+ ...host && typeof host.transport === "string" ? { transport: host.transport } : {}
676
+ },
677
+ store: pickStore(store),
678
+ context: pickContext(context),
679
+ workflow: pickWorkflow(workflow),
680
+ memory: pickMemory(memory)
681
+ };
682
+ }
683
+ function formatDuration(sec) {
684
+ if (typeof sec !== "number" || sec < 0) return "unknown";
685
+ if (sec < 60) return `${sec}s`;
686
+ if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
687
+ return `${Math.floor(sec / 3600)}h ${Math.floor(sec % 3600 / 60)}m`;
688
+ }
689
+ function describeDaemon(d) {
690
+ if (!d.running) return "not running (start with `noir daemon start`)";
691
+ const pid = typeof d.pid === "number" ? `pid ${d.pid}` : "pid unknown";
692
+ const up = typeof d.uptimeSec === "number" ? `, up ${formatDuration(d.uptimeSec)}` : "";
693
+ const transport = typeof d.transport === "string" ? `, ${d.transport}` : "";
694
+ return `running (${pid}${up}${transport})`;
695
+ }
696
+ function describeStore(s) {
697
+ if (!s) return "unavailable (store engine not wired)";
698
+ const flag = s.degraded ? ` ${badge("warn", "degraded: read-only")}` : "";
699
+ return `${s.docCount} docs / ${s.vecCount} vecs${flag}`;
700
+ }
701
+ function describeContext(c2) {
702
+ if (!c2) return "unavailable (context engine not wired)";
703
+ const flag = c2.degraded ? ` ${badge("warn", "degraded")}` : "";
704
+ return `${c2.docCount} docs, ${c2.vecCount} vecs, ${c2.indexedFiles} files \xB7 embedder ${c2.embedder}${flag}`;
705
+ }
706
+ function describeWorkflow(w) {
707
+ if (!w) return "no active task";
708
+ const gate = w.nextGate ? ` \u2192 next gate: ${w.nextGate}` : "";
709
+ const flag = w.degraded ? ` ${badge("warn", "degraded")}` : "";
710
+ return `${w.phase} (${w.state}, ${w.mode}) \xB7 task ${w.taskId}${gate}${flag}`;
711
+ }
712
+ function describeMemory(m) {
713
+ if (!m) return "unavailable (memory engine not wired)";
714
+ return `${m.observations} observations across ${m.sessions} session${m.sessions === 1 ? "" : "s"}`;
715
+ }
716
+ function renderHuman(p, opts) {
717
+ log(`noir status \u2014 ${p.project.name} (${p.project.id})`, opts);
718
+ definitionList(
719
+ [
720
+ { label: "Project", value: `${p.project.name} (${p.project.id})` },
721
+ { label: "Host", value: p.host },
722
+ { label: "Noir", value: p.noir },
723
+ { label: "Daemon", value: describeDaemon(p.daemon) },
724
+ { label: "Store", value: describeStore(p.store) },
725
+ { label: "Context", value: describeContext(p.context) },
726
+ { label: "Workflow", value: describeWorkflow(p.workflow) },
727
+ { label: "Memory", value: describeMemory(p.memory) }
728
+ ],
729
+ opts
730
+ );
731
+ }
732
+ async function gatherStatusPayload(opts) {
733
+ const project = loadProjectInfo3(process.cwd());
734
+ const probe = await probeDaemon(opts);
735
+ let host = null;
736
+ let store = null;
737
+ let context = null;
738
+ let workflow = null;
739
+ let memory = null;
740
+ if (probe.running) {
741
+ await withRunningDaemon(
742
+ opts,
743
+ async (caller) => {
744
+ host = await tryTool(caller, "host_status");
745
+ store = await tryTool(caller, "store_status");
746
+ context = await tryTool(caller, "context_status");
747
+ workflow = await tryTool(caller, "workflow_status");
748
+ memory = await tryTool(caller, "memory_sessions");
749
+ },
750
+ probe
751
+ );
752
+ }
753
+ return buildPayload(project, probe, host, store, context, workflow, memory);
754
+ }
755
+ async function status(opts) {
756
+ void runAsyncUpdateCheck({
757
+ env: process.env,
758
+ configUpdate: (() => {
759
+ try {
760
+ const info2 = loadProjectInfo3(process.cwd());
761
+ return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
762
+ } catch {
763
+ return DEFAULT_UPDATE_CONFIG;
764
+ }
765
+ })(),
766
+ quiet: opts.json === true || opts.quiet === true || !process.stdout.isTTY
767
+ });
768
+ const payload = await gatherStatusPayload(opts);
769
+ if (opts.json === true) {
770
+ process.stdout.write(`${JSON.stringify({ ok: true, data: payload })}
771
+ `);
772
+ return;
773
+ }
774
+ renderHuman(payload, opts);
775
+ }
776
+
777
+ // src/commands/task.ts
778
+ import { PHASES } from "@noir-ai/workflow";
779
+ var PHASE_SKILL = {
780
+ intake: "noir-intake",
781
+ clarify: "noir-clarify",
782
+ spec: "noir-spec",
783
+ plan: "noir-plan",
784
+ execute: "noir-execute",
785
+ verify: "noir-verify",
786
+ document: "noir-document"
787
+ };
788
+ function skillFor(phase) {
789
+ if (typeof phase !== "string") return null;
790
+ return PHASE_SKILL[phase] ?? null;
791
+ }
792
+ async function fetchStatus(opts, taskId) {
793
+ const args = typeof taskId === "string" && taskId.length > 0 ? { taskId } : {};
794
+ const res = await callDaemonTool(
795
+ opts,
796
+ "workflow_status",
797
+ args
798
+ );
799
+ if (res.ok === true) return res;
800
+ const detail = res.error ?? "task not found";
801
+ fail(EXIT.NOT_FOUND, `task: ${detail}`, opts);
802
+ }
803
+ function formatStamp(ms) {
804
+ if (typeof ms !== "number" || ms <= 0) return "-";
805
+ return new Date(ms).toISOString().replace("T", " ").replace(/\..*$/, "Z");
806
+ }
807
+ function renderStatusRow(s, opts) {
808
+ const flag = s.degraded === true ? ` ${badge("warn", "degraded")}` : "";
809
+ log(`task status \u2014 ${s.taskId}${flag}`, opts);
387
810
  definitionList(
388
811
  [
389
812
  { label: "Task", value: s.taskId },
@@ -396,7 +819,49 @@ function renderStatusRow(s, opts) {
396
819
  opts
397
820
  );
398
821
  }
822
+ var GATE_PHASES = ["spec", "plan", "verify"];
823
+ function nextGateAfter(phase) {
824
+ const cur = PHASES.indexOf(phase);
825
+ for (const p of GATE_PHASES) {
826
+ if (PHASES.indexOf(p) > cur) return p;
827
+ }
828
+ return null;
829
+ }
399
830
  async function taskStatus(opts) {
831
+ let probe;
832
+ try {
833
+ probe = await probeDaemon(opts);
834
+ } catch {
835
+ probe = { running: true };
836
+ }
837
+ if (!probe.running) {
838
+ const s2 = await withInProcessRead(opts, async (engines) => {
839
+ const id = typeof opts.id === "string" && opts.id.length > 0 ? opts.id : null;
840
+ const taskId = id ?? engines.workflow.activeTaskId();
841
+ if (!taskId) fail(EXIT.NOT_FOUND, "task: no active task", opts);
842
+ const task = engines.workflow.status(taskId);
843
+ if (!task) fail(EXIT.NOT_FOUND, `task: unknown task '${taskId}'`, opts);
844
+ const stopped = task.state === "blocked" || task.state === "abandoned";
845
+ return {
846
+ ok: true,
847
+ taskId: task.taskId,
848
+ phase: task.phase,
849
+ state: task.state,
850
+ mode: task.mode,
851
+ nextGate: stopped ? null : nextGateAfter(task.phase),
852
+ history: task.history,
853
+ updatedAt: task.updatedAt,
854
+ degraded: true
855
+ };
856
+ });
857
+ if (opts.json === true) {
858
+ process.stdout.write(`${JSON.stringify({ ok: true, data: s2 })}
859
+ `);
860
+ return;
861
+ }
862
+ renderStatusRow(s2, opts);
863
+ return;
864
+ }
400
865
  const s = await fetchStatus(opts, opts.id);
401
866
  if (opts.json === true) {
402
867
  process.stdout.write(`${JSON.stringify({ ok: true, data: s })}
@@ -634,7 +1099,7 @@ async function probeOnly(opts) {
634
1099
  return probeDaemon(opts);
635
1100
  }
636
1101
  async function handoff(opts) {
637
- const project = loadProjectInfo2(process.cwd());
1102
+ const project = loadProjectInfo4(process.cwd());
638
1103
  const host = project.config.host;
639
1104
  const snapshot = await gatherStatusPayload(opts);
640
1105
  const probe = await probeOnly(opts);
@@ -679,8 +1144,8 @@ async function handoff(opts) {
679
1144
  // src/commands/home.ts
680
1145
  import { hostLaunchDirective as hostLaunchDirective2 } from "@noir-ai/adapters";
681
1146
  import {
682
- loadProjectInfo as loadProjectInfo3,
683
- NOIR_VERSION,
1147
+ loadProjectInfo as loadProjectInfo5,
1148
+ NOIR_VERSION as NOIR_VERSION3,
684
1149
  readInstallRecord
685
1150
  } from "@noir-ai/core";
686
1151
 
@@ -719,7 +1184,7 @@ function shouldShowBanner(opts) {
719
1184
  // src/commands/home.ts
720
1185
  function tryProject() {
721
1186
  try {
722
- const info2 = loadProjectInfo3(process.cwd());
1187
+ const info2 = loadProjectInfo5(process.cwd());
723
1188
  return { id: info2.id, name: info2.name, host: info2.config.host };
724
1189
  } catch {
725
1190
  return null;
@@ -749,7 +1214,7 @@ async function runMenu(opts, deps) {
749
1214
  env: process.env,
750
1215
  configUpdate: (() => {
751
1216
  try {
752
- const info2 = loadProjectInfo3(process.cwd());
1217
+ const info2 = loadProjectInfo5(process.cwd());
753
1218
  return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
754
1219
  } catch {
755
1220
  return DEFAULT_UPDATE_CONFIG;
@@ -771,7 +1236,7 @@ ${COMMANDS_HINT}
771
1236
  );
772
1237
  }
773
1238
  const rec = readInstallRecord();
774
- if (rec && shouldShowMigrationBanner(rec, NOIR_VERSION)) {
1239
+ if (rec && shouldShowMigrationBanner(rec, NOIR_VERSION3)) {
775
1240
  process.stderr.write(
776
1241
  `
777
1242
  ${c.warn(`noir installed via ${rec.method}`)} \u2014 consider \`noir install\` for the native path (auto-update, no npm prefix/PATH issues). Dismiss with: \`noir install --dismiss\` (persisted per version).
@@ -877,6 +1342,27 @@ function stamp(ts) {
877
1342
  }
878
1343
  async function memoryRecall(opts) {
879
1344
  const limit = parseLimit2(opts.limit, "memory recall", opts);
1345
+ let probe;
1346
+ try {
1347
+ probe = await probeDaemon(opts);
1348
+ } catch {
1349
+ probe = { running: true };
1350
+ }
1351
+ if (!probe.running) {
1352
+ await withInProcessRead(opts, async (engines) => {
1353
+ const result = await engines.memory.recall(opts.query, {
1354
+ ...limit === void 0 ? {} : { limit }
1355
+ });
1356
+ const data2 = { query: opts.query, hits: result, degraded: false };
1357
+ if (opts.json === true) {
1358
+ process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
1359
+ `);
1360
+ return;
1361
+ }
1362
+ renderRecall(data2.query, result, data2.degraded, opts);
1363
+ });
1364
+ return;
1365
+ }
880
1366
  const res = await callDaemonTool(opts, "memory_recall", {
881
1367
  query: opts.query,
882
1368
  ...limit === void 0 ? {} : { limit }
@@ -962,6 +1448,30 @@ function renderObservation(obs, opts) {
962
1448
  ${content}`, opts);
963
1449
  }
964
1450
  async function memorySessions(opts) {
1451
+ let probe;
1452
+ try {
1453
+ probe = await probeDaemon(opts);
1454
+ } catch {
1455
+ probe = { running: true };
1456
+ }
1457
+ if (!probe.running) {
1458
+ await withInProcessRead(opts, async (engines) => {
1459
+ const raw = engines.memory.sessions();
1460
+ const sessions2 = raw.map((s) => ({
1461
+ id: s.id,
1462
+ count: s.count,
1463
+ lastTs: s.lastTs
1464
+ }));
1465
+ const data2 = { sessions: sessions2 };
1466
+ if (opts.json === true) {
1467
+ process.stdout.write(`${JSON.stringify({ ok: true, data: data2 })}
1468
+ `);
1469
+ return;
1470
+ }
1471
+ renderSessions(sessions2, opts);
1472
+ });
1473
+ return;
1474
+ }
965
1475
  const res = await callDaemonTool(opts, "memory_sessions");
966
1476
  if (res.ok !== true) failTool2("memory sessions", res, opts);
967
1477
  const sessions = Array.isArray(res.sessions) ? res.sessions.map((raw) => {
@@ -978,6 +1488,9 @@ async function memorySessions(opts) {
978
1488
  `);
979
1489
  return;
980
1490
  }
1491
+ renderSessions(sessions, opts);
1492
+ }
1493
+ function renderSessions(sessions, opts) {
981
1494
  log(`memory sessions \u2014 ${sessions.length} session${sessions.length === 1 ? "" : "s"}`, opts);
982
1495
  table(
983
1496
  sessions.map((s) => ({ Session: s.id, Observations: s.count, "Last seen": stamp(s.lastTs) })),
@@ -1053,7 +1566,7 @@ async function memoryConsolidate(opts) {
1053
1566
 
1054
1567
  // src/commands/skills.ts
1055
1568
  import { claudeAdapter } from "@noir-ai/adapters";
1056
- import { loadProjectInfo as loadProjectInfo4 } from "@noir-ai/core";
1569
+ import { loadProjectInfo as loadProjectInfo6 } from "@noir-ai/core";
1057
1570
  import {
1058
1571
  discoverAll,
1059
1572
  emitSkillsToDir
@@ -1147,7 +1660,7 @@ async function skillsList(opts) {
1147
1660
  }
1148
1661
  async function skillsSync(opts) {
1149
1662
  const root = process.cwd();
1150
- const project = loadProjectInfo4(root);
1663
+ const project = loadProjectInfo6(root);
1151
1664
  if (!claudeAdapter.skillsDir) {
1152
1665
  const data2 = {
1153
1666
  emitted: [],
@@ -1190,16 +1703,16 @@ async function skillsSync(opts) {
1190
1703
  }
1191
1704
 
1192
1705
  // src/serve.ts
1193
- import { loadProjectInfo as loadProjectInfo5 } from "@noir-ai/core";
1194
- import { ensureDaemonRunning as ensureDaemonRunning2, startStdioServer } from "@noir-ai/daemon";
1706
+ import { loadProjectInfo as loadProjectInfo7 } from "@noir-ai/core";
1707
+ import { ensureDaemonRunning as ensureDaemonRunning3, startStdioServer } from "@noir-ai/daemon";
1195
1708
  async function serve(opts) {
1196
- const project = loadProjectInfo5(process.cwd());
1709
+ const project = loadProjectInfo7(process.cwd());
1197
1710
  if (opts.stdio) {
1198
1711
  await startStdioServer({ project, transport: "stdio", daemon: false });
1199
1712
  return;
1200
1713
  }
1201
1714
  try {
1202
- const { url } = await ensureDaemonRunning2({
1715
+ const { url } = await ensureDaemonRunning3({
1203
1716
  project,
1204
1717
  idleTimeoutSec: project.config.daemon.idleTimeoutSec
1205
1718
  });
@@ -1216,7 +1729,70 @@ async function serve(opts) {
1216
1729
  }
1217
1730
  }
1218
1731
 
1732
+ // src/tui/commands/registry.ts
1733
+ var DESTRUCTIVE_PREFIXES = [
1734
+ ["context", "index"],
1735
+ ["context", "forget"],
1736
+ ["memory", "save"],
1737
+ ["memory", "forget"],
1738
+ ["memory", "consolidate"],
1739
+ ["task", "new"],
1740
+ ["task", "advance"],
1741
+ ["skills", "sync"],
1742
+ ["daemon", "start"],
1743
+ ["daemon", "stop"],
1744
+ ["daemon", "restart"],
1745
+ ["init"],
1746
+ ["create"],
1747
+ ["sync"],
1748
+ ["install"],
1749
+ ["update"]
1750
+ ];
1751
+ function isDestructive(argv) {
1752
+ return DESTRUCTIVE_PREFIXES.some((prefix) => {
1753
+ if (argv.length < prefix.length) return false;
1754
+ return prefix.every((tok, i) => argv[i] === tok);
1755
+ });
1756
+ }
1757
+ function capitalizeFirst(s) {
1758
+ if (s.length === 0) return s;
1759
+ return `${s[0]?.toUpperCase() ?? ""}${s.slice(1)}`;
1760
+ }
1761
+ function hasAction(cmd) {
1762
+ return cmd._actionHandler != null;
1763
+ }
1764
+ function collectLeaves(cmd, path, out) {
1765
+ const children = cmd.commands;
1766
+ const isLeaf = hasAction(cmd) && children.length === 0;
1767
+ if (isLeaf) {
1768
+ const argv = path;
1769
+ const id = argv.join(" ");
1770
+ const description = cmd.description() || argv.join(" ");
1771
+ out.push({
1772
+ id,
1773
+ label: argv.map(capitalizeFirst).join(": "),
1774
+ argv,
1775
+ description,
1776
+ category: argv[0] ?? "",
1777
+ keywords: argv,
1778
+ destructive: isDestructive(argv)
1779
+ });
1780
+ return;
1781
+ }
1782
+ for (const child of children) {
1783
+ collectLeaves(child, [...path, child.name()], out);
1784
+ }
1785
+ }
1786
+ function buildPaletteCommands(program2) {
1787
+ const out = [];
1788
+ collectLeaves(program2, [], out);
1789
+ return out;
1790
+ }
1791
+
1219
1792
  // src/bin.ts
1793
+ function buildPaletteCommandsForTui() {
1794
+ return buildPaletteCommands(createProgram());
1795
+ }
1220
1796
  function actionGlobals(args) {
1221
1797
  const last = args[args.length - 1];
1222
1798
  return last?.optsWithGlobals() ?? {};
@@ -1283,7 +1859,7 @@ function emitDeprecationHintsFor(cmd, opts) {
1283
1859
  }
1284
1860
  function createProgram() {
1285
1861
  const program2 = new Command();
1286
- program2.name("noir").description("Noir \u2014 discipline, context, and memory layer for agentic CLIs.").version(NOIR_VERSION2, "-v, --version").addOption(new Option("--json", "emit machine-readable JSON to stdout")).addOption(new Option("--no-input", "never prompt; error if input is required")).addOption(new Option("--quiet", "suppress non-essential diagnostics")).addOption(new Option("--verbose", "show additional diagnostic detail")).addOption(new Option("--cwd <path>", "run as if started in <path>")).addOption(
1862
+ program2.name("noir").description("Noir \u2014 discipline, context, and memory layer for agentic CLIs.").version(NOIR_VERSION4, "-v, --version").addOption(new Option("--json", "emit machine-readable JSON to stdout")).addOption(new Option("--no-input", "never prompt; error if input is required")).addOption(new Option("--quiet", "suppress non-essential diagnostics")).addOption(new Option("--verbose", "show additional diagnostic detail")).addOption(new Option("--cwd <path>", "run as if started in <path>")).addOption(
1287
1863
  new Option("--tui", "prefer the interactive home menu for bare `noir` (advisory; TTY-only)")
1288
1864
  ).addOption(
1289
1865
  new Option(
@@ -1321,7 +1897,7 @@ function createProgram() {
1321
1897
  program2.command("init").description("scaffold Noir in the current project (.noir/, .mcp.json, CLAUDE.md, skills)").option("--transport <transport>", "stdio | streamable-http (default: stdio)", "stdio").option("--url <url>", "streamable-http daemon URL (localhost only)").option(
1322
1898
  "--upgrade",
1323
1899
  "run scaffold migrations before re-emitting (re-run on an existing project)"
1324
- ).option("--force", "re-scaffold even if already initialized (bypasses the already-init no-op)").addOption(
1900
+ ).option("--force", "re-scaffold even if already initialized (bypasses the already-init no-op)").option("--dry-run", "report planned writes without writing anything").option("--preview", "alias for --dry-run").addOption(
1325
1901
  // S10: target host. Defaults to `'claude'` (the regression anchor). The
1326
1902
  // chosen host is forwarded to scaffold() + skills emission via
1327
1903
  // resolveAdapter. The choice list mirrors `SUPPORTED_HOSTS` so a new host
@@ -1336,19 +1912,21 @@ function createProgram() {
1336
1912
  const upgrade = opts.upgrade === true;
1337
1913
  const force = opts.force === true;
1338
1914
  const host = parseHost(opts.host);
1915
+ const dryRun = opts.dryRun === true || opts.preview === true;
1339
1916
  const result = await init(process.cwd(), {
1340
1917
  transport,
1341
1918
  url: opts.url,
1342
1919
  ...upgrade ? { upgrade } : {},
1343
1920
  ...force ? { force } : {},
1344
- ...host !== void 0 ? { host } : {}
1921
+ ...host !== void 0 ? { host } : {},
1922
+ ...dryRun ? { dryRun } : {}
1345
1923
  });
1346
1924
  if (cmd.optsWithGlobals().json === true && result !== void 0) {
1347
1925
  json({ ok: true, data: result });
1348
1926
  }
1349
1927
  }
1350
1928
  );
1351
- program2.command("create [dir]").description("bootstrap the Noir AI layer in a new or empty directory").option("--transport <transport>", "stdio | streamable-http (default: stdio)", "stdio").option("--url <url>", "streamable-http daemon URL (localhost only)").option("--force", "re-scaffold even if already initialized (bypasses the already-init no-op)").addOption(
1929
+ program2.command("create [dir]").description("bootstrap the Noir AI layer in a new or empty directory").option("--transport <transport>", "stdio | streamable-http (default: stdio)", "stdio").option("--url <url>", "streamable-http daemon URL (localhost only)").option("--force", "re-scaffold even if already initialized (bypasses the already-init no-op)").option("--dry-run", "report planned writes without writing anything").option("--preview", "alias for --dry-run").addOption(
1352
1930
  new Option(
1353
1931
  "--host <id>",
1354
1932
  "target agentic CLI (default: claude) \u2014 drives host-side emission"
@@ -1358,12 +1936,14 @@ function createProgram() {
1358
1936
  const transport = opts.transport === "streamable-http" ? "streamable-http" : "stdio";
1359
1937
  const force = opts.force === true;
1360
1938
  const host = parseHost(opts.host);
1361
- const { create } = await import("./create-DH2VPDVS.js");
1939
+ const dryRun = opts.dryRun === true || opts.preview === true;
1940
+ const { create } = await import("./create-Z5HEBB7K.js");
1362
1941
  const result = await create(dir, {
1363
1942
  transport,
1364
1943
  url: opts.url,
1365
1944
  ...force ? { force } : {},
1366
- ...host !== void 0 ? { host } : {}
1945
+ ...host !== void 0 ? { host } : {},
1946
+ ...dryRun ? { dryRun } : {}
1367
1947
  });
1368
1948
  if (cmd.optsWithGlobals().json === true && result !== void 0) {
1369
1949
  json({ ok: true, data: result });
@@ -1378,7 +1958,7 @@ function createProgram() {
1378
1958
  ).option(
1379
1959
  "--merge",
1380
1960
  "three-way merge managed regions (default since 1.3.0; flag kept for compatibility)"
1381
- ).addOption(
1961
+ ).option("--dry-run", "report planned writes without writing anything").option("--preview", "alias for --dry-run").addOption(
1382
1962
  // Opt OUT of managed-region merge (restore strip-replace). Commander
1383
1963
  // stores `--no-merge-regions` under `mergeRegions` (default true; flag →
1384
1964
  // false). Bare `noir sync` keeps the merge default (true).
@@ -1396,16 +1976,18 @@ function createProgram() {
1396
1976
  ).choices(SUPPORTED_HOSTS)
1397
1977
  ).action(
1398
1978
  async (opts, cmd) => {
1399
- const { sync } = await import("./sync-DT4JKXZK.js");
1979
+ const { sync } = await import("./sync-5M2UEOKW.js");
1400
1980
  const host = parseHost(opts.host);
1401
1981
  const force = opts.force === true;
1402
1982
  const merge = opts.merge === true;
1403
1983
  const noMergeRegions = opts.mergeRegions === false;
1404
- const result = host === void 0 && !force && !merge && !noMergeRegions ? await sync(process.cwd()) : await sync(process.cwd(), {
1984
+ const dryRun = opts.dryRun === true || opts.preview === true;
1985
+ const result = host === void 0 && !force && !merge && !noMergeRegions && !dryRun ? await sync(process.cwd()) : await sync(process.cwd(), {
1405
1986
  ...host !== void 0 ? { host } : {},
1406
1987
  ...force ? { force } : {},
1407
1988
  ...merge ? { merge } : {},
1408
- ...noMergeRegions ? { mergeManagedRegions: false } : {}
1989
+ ...noMergeRegions ? { mergeManagedRegions: false } : {},
1990
+ ...dryRun ? { dryRun } : {}
1409
1991
  });
1410
1992
  if (cmd.optsWithGlobals().json === true) {
1411
1993
  json({ ok: true, data: result });
@@ -1420,11 +2002,18 @@ function createProgram() {
1420
2002
  throw new NoirCliError(EXIT.USAGE, "Usage: noir mcp serve [--stdio]");
1421
2003
  });
1422
2004
  const daemonGrp = program2.command("daemon").description("control the Noir daemon");
1423
- daemonGrp.command("start").description("start the Noir daemon (foreground; backgrounding deferred)").option("--detach", "run in the background (not yet implemented; exits 2)").action(async (...args) => {
2005
+ daemonGrp.command("start").description("start the Noir daemon (foreground, or background with --detach)").option("--detach", "run the daemon in the background and exit").addOption(
2006
+ new Option("--_detached-child", "reserved: detached daemon child (internal)").hideHelp()
2007
+ ).action(async (...args) => {
1424
2008
  const cmd = trailingCmd(args);
1425
2009
  const g = cmd.optsWithGlobals();
1426
2010
  const detach = g.detach === true;
1427
- await daemonStart({ ...toCliOptions(g), ...detach ? { detach } : {} });
2011
+ const detachChild = g._detachedChild === true;
2012
+ await daemonStart({
2013
+ ...toCliOptions(g),
2014
+ ...detach ? { detach } : {},
2015
+ ...detachChild ? { detachChild } : {}
2016
+ });
1428
2017
  });
1429
2018
  daemonGrp.command("stop").description("stop the Noir daemon").action(async (...args) => {
1430
2019
  await daemonStop(toCliOptions(trailingCmd(args).optsWithGlobals()));
@@ -1466,7 +2055,7 @@ function createProgram() {
1466
2055
  "path to index (repeatable)",
1467
2056
  (val, acc) => [...acc, val],
1468
2057
  []
1469
- ).option("--force", "ignore content-hash caching (recognized; not yet honored)").action(async (...args) => {
2058
+ ).option("--force", "force a full reindex (drop all chunks+vectors, re-index from scratch)").action(async (...args) => {
1470
2059
  const cmd = trailingCmd(args);
1471
2060
  const g = cmd.optsWithGlobals();
1472
2061
  const rawPaths = Array.isArray(g.path) ? g.path.filter((p) => typeof p === "string") : [];
@@ -1685,332 +2274,27 @@ if (isMainModule) {
1685
2274
  void run(process.argv.slice(2));
1686
2275
  }
1687
2276
 
1688
- // src/daemon-client.ts
1689
- var DAEMON_DOWN_HINT = "daemon not reachable \u2014 try `noir daemon start`";
1690
- function describeCause(cause) {
1691
- if (cause instanceof Error) return `${cause.name}: ${cause.message}`;
1692
- try {
1693
- return JSON.stringify(cause);
1694
- } catch {
1695
- return String(cause);
2277
+ // src/tui/App.tsx
2278
+ import { Box as Box2, Text as Text8, useApp, useInput as useInput2 } from "ink";
2279
+ import { useEffect, useState as useState3 } from "react";
2280
+
2281
+ // src/tui/CommandInput.tsx
2282
+ import { Text } from "ink";
2283
+ import { jsx, jsxs } from "react/jsx-runtime";
2284
+ function CommandInput({ buffer, running }) {
2285
+ if (running) {
2286
+ return /* @__PURE__ */ jsx(Text, { children: c.dim("\u2026") });
1696
2287
  }
1697
- }
1698
- function failDaemonDown(opts, cause) {
1699
- if (opts.verbose) {
1700
- process.stderr.write(`noir: daemon transport detail: ${describeCause(cause)}
1701
- `);
2288
+ if (buffer.length === 0) {
2289
+ return /* @__PURE__ */ jsx(Text, { children: c.dim("type a /command (e.g. /status, /sync, /task next), or q to quit") });
1702
2290
  }
1703
- fail(EXIT.DAEMON_DOWN, DAEMON_DOWN_HINT, opts);
1704
- }
1705
- async function probeDaemon(opts = {}) {
1706
- const rec = readDaemonRecord2();
1707
- if (!rec) {
1708
- if (opts.verbose) process.stderr.write("noir: daemon probe: no daemon record\n");
1709
- return { running: false };
1710
- }
1711
- if (!pidAlive2(rec.pid)) {
1712
- if (opts.verbose) process.stderr.write(`noir: daemon probe: pid ${rec.pid} not alive
1713
- `);
1714
- return { running: false };
1715
- }
1716
- try {
1717
- const res = await fetch(`http://127.0.0.1:${rec.port}/health`);
1718
- if (!res.ok) {
1719
- if (opts.verbose) process.stderr.write(`noir: daemon probe: /health \u2192 HTTP ${res.status}
1720
- `);
1721
- return { running: false };
1722
- }
1723
- const body = await res.json().catch(() => null);
1724
- if (body?.ok !== true) {
1725
- if (opts.verbose) process.stderr.write("noir: daemon probe: /health body not ok\n");
1726
- return { running: false };
1727
- }
1728
- return {
1729
- running: true,
1730
- pid: typeof body.pid === "number" ? body.pid : rec.pid,
1731
- port: rec.port,
1732
- uptimeSec: typeof body.uptimeSec === "number" ? body.uptimeSec : void 0
1733
- };
1734
- } catch (err) {
1735
- if (opts.verbose)
1736
- process.stderr.write(`noir: daemon probe: unreachable (${describeCause(err)})
1737
- `);
1738
- return { running: false };
1739
- }
1740
- }
1741
- async function resolveDaemon(opts) {
1742
- const project = opts.project ?? loadProjectInfo6(process.cwd());
1743
- const idleTimeoutSec = opts.idleTimeoutSec ?? project.config.daemon.idleTimeoutSec;
1744
- try {
1745
- const ensured = await ensureDaemonRunning3({ project, idleTimeoutSec });
1746
- return { url: ensured.url, stop: ensured.stop };
1747
- } catch (err) {
1748
- failDaemonDown(opts, err);
1749
- }
1750
- }
1751
- async function connectClient(client, url, opts) {
1752
- try {
1753
- await client.connect(new StreamableHTTPClientTransport(new URL(url)));
1754
- } catch (err) {
1755
- failDaemonDown(opts, err);
1756
- }
1757
- }
1758
- async function callToolParse(client, opts, name, args) {
1759
- const result = await client.callTool({ name, arguments: args }).catch((err) => failDaemonDown(opts, err));
1760
- const block = result.content?.[0];
1761
- if (block?.type !== "text" || typeof block.text !== "string") {
1762
- failDaemonDown(opts, new Error(`tool '${name}' returned no text content`));
1763
- }
1764
- const text = block.text;
1765
- try {
1766
- return JSON.parse(text);
1767
- } catch (err) {
1768
- failDaemonDown(opts, err);
1769
- }
1770
- }
1771
- function buildCaller(client, opts) {
1772
- return {
1773
- callTool: (name, args = {}) => callToolParse(client, opts, name, args),
1774
- listTools: async () => {
1775
- const res = await client.listTools().catch((err) => failDaemonDown(opts, err));
1776
- const tools = res?.tools;
1777
- if (!Array.isArray(tools)) return [];
1778
- const names = [];
1779
- for (const t of tools) {
1780
- const name = t?.name;
1781
- if (typeof name === "string") names.push(name);
1782
- }
1783
- return names;
1784
- }
1785
- };
1786
- }
1787
- async function withDaemon(opts, fn) {
1788
- const { url, stop } = await resolveDaemon(opts);
1789
- let client;
1790
- try {
1791
- client = new Client(
1792
- { name: "noir-cli", version: NOIR_VERSION3 },
1793
- { versionNegotiation: { mode: "auto" } }
1794
- );
1795
- const connected = client;
1796
- await connectClient(connected, url, opts);
1797
- return await fn(buildCaller(connected, opts));
1798
- } finally {
1799
- if (client) {
1800
- await client.close().catch(() => {
1801
- });
1802
- }
1803
- await stop().catch(() => {
1804
- });
1805
- }
1806
- }
1807
- async function withRunningDaemon(opts, fn, probe) {
1808
- const p = probe ?? await probeDaemon(opts);
1809
- if (!p.running) return null;
1810
- const url = `http://127.0.0.1:${p.port}/mcp`;
1811
- let client;
1812
- try {
1813
- client = new Client(
1814
- { name: "noir-cli", version: NOIR_VERSION3 },
1815
- { versionNegotiation: { mode: "auto" } }
1816
- );
1817
- const connected = client;
1818
- await connected.connect(new StreamableHTTPClientTransport(new URL(url)));
1819
- return await fn(buildCaller(connected, opts));
1820
- } catch {
1821
- return null;
1822
- } finally {
1823
- if (client) {
1824
- await client.close().catch(() => {
1825
- });
1826
- }
1827
- }
1828
- }
1829
- async function callDaemonTool(opts, name, args = {}) {
1830
- return withDaemon(opts, (caller) => caller.callTool(name, args));
1831
- }
1832
-
1833
- // src/commands/status.ts
1834
- async function tryTool(caller, name, args) {
1835
- try {
1836
- return await caller.callTool(name, args);
1837
- } catch {
1838
- return null;
1839
- }
1840
- }
1841
- function pickStore(s) {
1842
- if (s?.ok !== true) return null;
1843
- return {
1844
- docCount: s.docCount,
1845
- vecCount: s.vecCount,
1846
- dbPath: s.dbPath,
1847
- degraded: s.degraded === true
1848
- };
1849
- }
1850
- function describeEmbedder2(e) {
1851
- if (!e || e.kind === "none") return "none (BM25-only)";
1852
- const model = typeof e.model === "string" && e.model.length > 0 ? e.model : "<unset>";
1853
- return `${e.kind} \xB7 ${model} (${e.dim}-dim)`;
1854
- }
1855
- function pickContext(c2) {
1856
- if (c2?.ok !== true) return null;
1857
- return {
1858
- docCount: c2.docCount,
1859
- vecCount: c2.vecCount,
1860
- indexedFiles: c2.indexedFiles,
1861
- embedder: describeEmbedder2(c2.embedder),
1862
- degraded: c2.degraded === true
1863
- };
1864
- }
1865
- function pickWorkflow(w) {
1866
- if (w?.ok !== true) return null;
1867
- return {
1868
- taskId: w.taskId,
1869
- phase: w.phase,
1870
- state: w.state,
1871
- mode: w.mode,
1872
- nextGate: w.nextGate,
1873
- degraded: w.degraded === true
1874
- };
1875
- }
1876
- function pickMemory(m) {
1877
- if (m?.ok !== true || !Array.isArray(m.sessions)) return null;
1878
- let observations = 0;
1879
- for (const s of m.sessions) observations += s.count;
1880
- return { sessions: m.sessions.length, observations };
1881
- }
1882
- function buildPayload(project, probe, host, store, context, workflow, memory) {
1883
- return {
1884
- noir: NOIR_VERSION4,
1885
- project: { id: project.id, name: project.name },
1886
- host: project.config.host,
1887
- daemon: {
1888
- running: probe.running,
1889
- ...probe.pid !== void 0 ? { pid: probe.pid } : {},
1890
- ...probe.uptimeSec !== void 0 ? { uptimeSec: probe.uptimeSec } : {},
1891
- ...host && typeof host.transport === "string" ? { transport: host.transport } : {}
1892
- },
1893
- store: pickStore(store),
1894
- context: pickContext(context),
1895
- workflow: pickWorkflow(workflow),
1896
- memory: pickMemory(memory)
1897
- };
1898
- }
1899
- function formatDuration(sec) {
1900
- if (typeof sec !== "number" || sec < 0) return "unknown";
1901
- if (sec < 60) return `${sec}s`;
1902
- if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`;
1903
- return `${Math.floor(sec / 3600)}h ${Math.floor(sec % 3600 / 60)}m`;
1904
- }
1905
- function describeDaemon(d) {
1906
- if (!d.running) return "not running (start with `noir daemon start`)";
1907
- const pid = typeof d.pid === "number" ? `pid ${d.pid}` : "pid unknown";
1908
- const up = typeof d.uptimeSec === "number" ? `, up ${formatDuration(d.uptimeSec)}` : "";
1909
- const transport = typeof d.transport === "string" ? `, ${d.transport}` : "";
1910
- return `running (${pid}${up}${transport})`;
1911
- }
1912
- function describeStore(s) {
1913
- if (!s) return "unavailable (store engine not wired)";
1914
- const flag = s.degraded ? ` ${badge("warn", "degraded: read-only")}` : "";
1915
- return `${s.docCount} docs / ${s.vecCount} vecs${flag}`;
1916
- }
1917
- function describeContext(c2) {
1918
- if (!c2) return "unavailable (context engine not wired)";
1919
- const flag = c2.degraded ? ` ${badge("warn", "degraded")}` : "";
1920
- return `${c2.docCount} docs, ${c2.vecCount} vecs, ${c2.indexedFiles} files \xB7 embedder ${c2.embedder}${flag}`;
1921
- }
1922
- function describeWorkflow(w) {
1923
- if (!w) return "no active task";
1924
- const gate = w.nextGate ? ` \u2192 next gate: ${w.nextGate}` : "";
1925
- const flag = w.degraded ? ` ${badge("warn", "degraded")}` : "";
1926
- return `${w.phase} (${w.state}, ${w.mode}) \xB7 task ${w.taskId}${gate}${flag}`;
1927
- }
1928
- function describeMemory(m) {
1929
- if (!m) return "unavailable (memory engine not wired)";
1930
- return `${m.observations} observations across ${m.sessions} session${m.sessions === 1 ? "" : "s"}`;
1931
- }
1932
- function renderHuman(p, opts) {
1933
- log(`noir status \u2014 ${p.project.name} (${p.project.id})`, opts);
1934
- definitionList(
1935
- [
1936
- { label: "Project", value: `${p.project.name} (${p.project.id})` },
1937
- { label: "Host", value: p.host },
1938
- { label: "Noir", value: p.noir },
1939
- { label: "Daemon", value: describeDaemon(p.daemon) },
1940
- { label: "Store", value: describeStore(p.store) },
1941
- { label: "Context", value: describeContext(p.context) },
1942
- { label: "Workflow", value: describeWorkflow(p.workflow) },
1943
- { label: "Memory", value: describeMemory(p.memory) }
1944
- ],
1945
- opts
1946
- );
1947
- }
1948
- async function gatherStatusPayload(opts) {
1949
- const project = loadProjectInfo7(process.cwd());
1950
- const probe = await probeDaemon(opts);
1951
- let host = null;
1952
- let store = null;
1953
- let context = null;
1954
- let workflow = null;
1955
- let memory = null;
1956
- if (probe.running) {
1957
- await withRunningDaemon(
1958
- opts,
1959
- async (caller) => {
1960
- host = await tryTool(caller, "host_status");
1961
- store = await tryTool(caller, "store_status");
1962
- context = await tryTool(caller, "context_status");
1963
- workflow = await tryTool(caller, "workflow_status");
1964
- memory = await tryTool(caller, "memory_sessions");
1965
- },
1966
- probe
1967
- );
1968
- }
1969
- return buildPayload(project, probe, host, store, context, workflow, memory);
1970
- }
1971
- async function status(opts) {
1972
- void runAsyncUpdateCheck({
1973
- env: process.env,
1974
- configUpdate: (() => {
1975
- try {
1976
- const info2 = loadProjectInfo7(process.cwd());
1977
- return info2.config.update ?? DEFAULT_UPDATE_CONFIG;
1978
- } catch {
1979
- return DEFAULT_UPDATE_CONFIG;
1980
- }
1981
- })(),
1982
- quiet: opts.json === true || opts.quiet === true || !process.stdout.isTTY
1983
- });
1984
- const payload = await gatherStatusPayload(opts);
1985
- if (opts.json === true) {
1986
- process.stdout.write(`${JSON.stringify({ ok: true, data: payload })}
1987
- `);
1988
- return;
1989
- }
1990
- renderHuman(payload, opts);
1991
- }
1992
-
1993
- // src/tui/App.tsx
1994
- import { Box, Text as Text6, useApp, useInput } from "ink";
1995
- import { useEffect, useState } from "react";
1996
-
1997
- // src/tui/CommandInput.tsx
1998
- import { Text } from "ink";
1999
- import { jsx, jsxs } from "react/jsx-runtime";
2000
- function CommandInput({ buffer, running }) {
2001
- if (running) {
2002
- return /* @__PURE__ */ jsx(Text, { children: c.dim("\u2026") });
2003
- }
2004
- if (buffer.length === 0) {
2005
- return /* @__PURE__ */ jsx(Text, { children: c.dim("type a /command (e.g. /status, /sync, /task next), or q to quit") });
2006
- }
2007
- const isCommand = buffer.startsWith("/");
2008
- return /* @__PURE__ */ jsxs(Text, { children: [
2009
- /* @__PURE__ */ jsx(Text, { children: isCommand ? c.accent("\u25B8") : c.dim("\u25B8") }),
2010
- " ",
2011
- buffer,
2012
- /* @__PURE__ */ jsx(Text, { children: c.dim("\u258C") })
2013
- ] });
2291
+ const isCommand = buffer.startsWith("/");
2292
+ return /* @__PURE__ */ jsxs(Text, { children: [
2293
+ /* @__PURE__ */ jsx(Text, { children: isCommand ? c.accent("\u25B8") : c.dim("\u25B8") }),
2294
+ " ",
2295
+ buffer,
2296
+ /* @__PURE__ */ jsx(Text, { children: c.dim("\u258C") })
2297
+ ] });
2014
2298
  }
2015
2299
 
2016
2300
  // src/tui/capture.ts
@@ -2046,7 +2330,9 @@ function Footer({ running }) {
2046
2330
  if (running) {
2047
2331
  return /* @__PURE__ */ jsx2(Text2, { children: c.dim("running\u2026 (Ctrl+C to force exit)") });
2048
2332
  }
2049
- return /* @__PURE__ */ jsx2(Text2, { children: c.dim("? help \xB7 q/Esc quit \xB7 \u2191/\u2193 scroll \xB7 Enter run \xB7 Ctrl+C exit") });
2333
+ return /* @__PURE__ */ jsx2(Text2, { children: c.dim(
2334
+ "? help \xB7 q/Esc quit \xB7 \u2191/\u2193 scroll \xB7 Enter run \xB7 Ctrl+K palette \xB7 Ctrl+F find \xB7 Ctrl+C exit"
2335
+ ) });
2050
2336
  }
2051
2337
 
2052
2338
  // src/tui/format.ts
@@ -2077,24 +2363,70 @@ function Header({ tagline }) {
2077
2363
  ] });
2078
2364
  }
2079
2365
 
2366
+ // src/tui/hooks/useInputBuffer.ts
2367
+ import { useRef, useState } from "react";
2368
+ function useInputBuffer() {
2369
+ const [buffer, setBuffer] = useState("");
2370
+ const historyRef = useRef([]);
2371
+ const cursorRef = useRef(-1);
2372
+ function pushHistory(text) {
2373
+ if (text.length === 0) return;
2374
+ const history = historyRef.current;
2375
+ if (history.length > 0 && history[0] === text) return;
2376
+ historyRef.current = [text, ...history];
2377
+ }
2378
+ function recall(dir) {
2379
+ const history = historyRef.current;
2380
+ if (history.length === 0) return null;
2381
+ let cursor = cursorRef.current;
2382
+ if (dir === "up") {
2383
+ cursor = cursor === -1 ? 0 : Math.min(cursor + 1, history.length - 1);
2384
+ } else {
2385
+ cursor = cursor === -1 ? -1 : cursor - 1;
2386
+ }
2387
+ cursorRef.current = cursor;
2388
+ return cursor === -1 ? null : history[cursor] ?? null;
2389
+ }
2390
+ function clear() {
2391
+ setBuffer(() => "");
2392
+ cursorRef.current = -1;
2393
+ }
2394
+ return { buffer, setBuffer, pushHistory, recall, clear };
2395
+ }
2396
+
2080
2397
  // src/tui/OutputPane.tsx
2081
2398
  import { Text as Text4 } from "ink";
2082
2399
  import { Fragment, jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
2083
- function toRows(visible) {
2400
+ function toRows(visible, baseIndex) {
2084
2401
  const rows = [];
2085
2402
  const seen = /* @__PURE__ */ new Map();
2086
- for (const text of visible) {
2403
+ for (let i = 0; i < visible.length; i++) {
2404
+ const text = visible[i] ?? "";
2087
2405
  const n = (seen.get(text) ?? 0) + 1;
2088
2406
  seen.set(text, n);
2089
- rows.push({ key: n === 1 ? text || "_blank" : `${text || "_blank"}#${n}`, text });
2407
+ rows.push({
2408
+ key: n === 1 ? text || "_blank" : `${text || "_blank"}#${n}`,
2409
+ text,
2410
+ index: baseIndex + i
2411
+ });
2090
2412
  }
2091
2413
  return rows;
2092
2414
  }
2415
+ function highlightFirst(text, query) {
2416
+ if (query.length === 0) return text;
2417
+ const idx = text.toLowerCase().indexOf(query.toLowerCase());
2418
+ if (idx < 0) return text;
2419
+ return `${text.slice(0, idx)}${c.bold(text.slice(idx, idx + query.length))}${text.slice(
2420
+ idx + query.length
2421
+ )}`;
2422
+ }
2093
2423
  function OutputPane({
2094
2424
  lines,
2095
2425
  scrollOffset,
2096
2426
  height = 12,
2097
- title
2427
+ title,
2428
+ highlightQuery,
2429
+ activeLine
2098
2430
  }) {
2099
2431
  const width = terminalWidth();
2100
2432
  if (lines.length === 0) {
@@ -2102,25 +2434,260 @@ function OutputPane({
2102
2434
  }
2103
2435
  const maxOffset = Math.max(0, lines.length - height);
2104
2436
  const offset = Math.min(Math.max(0, scrollOffset), maxOffset);
2105
- const rows = toRows(lines.slice(offset, offset + height));
2437
+ const rows = toRows(lines.slice(offset, offset + height), offset);
2106
2438
  return /* @__PURE__ */ jsxs3(Fragment, { children: [
2107
2439
  title !== void 0 ? /* @__PURE__ */ jsx4(Text4, { children: c.dim(`\u2500\u2500 ${title} \u2500\u2500`) }) : null,
2108
- rows.map((row) => /* @__PURE__ */ jsx4(Text4, { children: row.text.length > width ? `${row.text.slice(0, Math.max(1, width - 1))}\u2026` : row.text }, row.key))
2440
+ rows.map((row) => {
2441
+ const truncated = row.text.length > width ? `${row.text.slice(0, Math.max(1, width - 1))}\u2026` : row.text;
2442
+ let body = truncated;
2443
+ if (activeLine !== void 0 && row.index === activeLine) {
2444
+ body = c.accent(truncated);
2445
+ } else if (highlightQuery !== void 0 && highlightQuery.length > 0) {
2446
+ body = highlightFirst(truncated, highlightQuery);
2447
+ }
2448
+ return /* @__PURE__ */ jsx4(Text4, { children: body }, row.key);
2449
+ })
2109
2450
  ] });
2110
2451
  }
2111
2452
 
2112
- // src/tui/StatusBar.tsx
2453
+ // src/tui/overlays/ConfirmOverlay.tsx
2113
2454
  import { Text as Text5 } from "ink";
2114
2455
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
2456
+ function ConfirmOverlay({ argv }) {
2457
+ return /* @__PURE__ */ jsxs4(Text5, { children: [
2458
+ c.warn(`! run /${argv.join(" ")}? (y/N)`),
2459
+ /* @__PURE__ */ jsx5(Text5, { children: c.dim(" [y approve \xB7 n/Esc back to palette]") })
2460
+ ] });
2461
+ }
2462
+
2463
+ // src/tui/overlays/SearchMode.ts
2464
+ function computeMatches(lines, query) {
2465
+ if (query.length === 0) return [];
2466
+ const needle = query.toLowerCase();
2467
+ const out = [];
2468
+ for (let i = 0; i < lines.length; i++) {
2469
+ if ((lines[i] ?? "").toLowerCase().includes(needle)) out.push(i);
2470
+ }
2471
+ return out;
2472
+ }
2473
+
2474
+ // src/tui/palette/fuzzyMatch.ts
2475
+ var WEIGHTS = {
2476
+ label: 1,
2477
+ keyword: 0.7,
2478
+ description: 0.5
2479
+ };
2480
+ var LABEL_TIE_BONUS = 50;
2481
+ function fuzzyScore(query, text) {
2482
+ if (query.length === 0) return { score: 0, indices: [] };
2483
+ const q = query.toLowerCase();
2484
+ const t = text.toLowerCase();
2485
+ const indices = [];
2486
+ let score = 0;
2487
+ let prevMatchedIndex = -1;
2488
+ let qi = 0;
2489
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
2490
+ if (t[ti] === q[qi]) {
2491
+ indices.push(ti);
2492
+ const consecutive = prevMatchedIndex !== -1 && ti === prevMatchedIndex + 1;
2493
+ if (consecutive) score += 8;
2494
+ if (ti < q.length) score += 4;
2495
+ if (prevMatchedIndex !== -1) {
2496
+ const skipped = ti - prevMatchedIndex - 1;
2497
+ if (skipped > 0) score -= 0.1 * skipped;
2498
+ }
2499
+ prevMatchedIndex = ti;
2500
+ qi++;
2501
+ }
2502
+ }
2503
+ if (qi < q.length) return null;
2504
+ return { score, indices };
2505
+ }
2506
+ function matchCommand(query, item) {
2507
+ if (query.length === 0) {
2508
+ return { item, score: 0, matchedIndices: [] };
2509
+ }
2510
+ const best = {
2511
+ score: Number.NEGATIVE_INFINITY,
2512
+ indices: []
2513
+ };
2514
+ let matched = false;
2515
+ const consider = (score, indices) => {
2516
+ if (!matched || score > best.score) {
2517
+ best.score = score;
2518
+ best.indices = indices;
2519
+ matched = true;
2520
+ }
2521
+ };
2522
+ const labelHit = fuzzyScore(query, item.label);
2523
+ if (labelHit) {
2524
+ consider(labelHit.score * WEIGHTS.label + LABEL_TIE_BONUS, labelHit.indices);
2525
+ }
2526
+ if (item.keywords) {
2527
+ let bestKw = null;
2528
+ for (const kw of item.keywords) {
2529
+ const kwHit = fuzzyScore(query, kw);
2530
+ if (kwHit && (bestKw === null || kwHit.score > bestKw.score)) bestKw = kwHit;
2531
+ }
2532
+ if (bestKw) {
2533
+ consider(bestKw.score * WEIGHTS.keyword, bestKw.indices);
2534
+ }
2535
+ }
2536
+ if (item.description) {
2537
+ const descHit = fuzzyScore(query, item.description);
2538
+ if (descHit) {
2539
+ consider(descHit.score * WEIGHTS.description, descHit.indices);
2540
+ }
2541
+ }
2542
+ if (!matched) return null;
2543
+ return { item, score: best.score, matchedIndices: best.indices };
2544
+ }
2545
+
2546
+ // src/tui/palette/matcher.ts
2547
+ var handRolledMatcher = {
2548
+ search(query, items, limit) {
2549
+ if (query.length === 0) {
2550
+ return items.slice(0, Math.max(0, limit)).map((item) => ({
2551
+ item,
2552
+ score: 0,
2553
+ matchedIndices: []
2554
+ }));
2555
+ }
2556
+ const matched = [];
2557
+ for (const item of items) {
2558
+ const m = matchCommand(query, item);
2559
+ if (m) matched.push(m);
2560
+ }
2561
+ matched.sort((a, b) => b.score - a.score);
2562
+ return matched.slice(0, Math.max(0, limit));
2563
+ }
2564
+ };
2565
+
2566
+ // src/tui/palette/Palette.tsx
2567
+ import { Box, Text as Text6, useInput } from "ink";
2568
+ import { useState as useState2 } from "react";
2569
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
2570
+ var VISIBLE_ROWS = 10;
2571
+ var MATCH_LIMIT = 20;
2572
+ function rankPalette(query, matcher, commands, recent) {
2573
+ if (query.length === 0) {
2574
+ const recents = recentDeduped(recent, commands);
2575
+ const recentIds = new Set(recents.map((r) => r.id));
2576
+ const rest = commands.filter((cmd) => !recentIds.has(cmd.id));
2577
+ return [
2578
+ ...recents.map((cmd) => ({ cmd, indices: [] })),
2579
+ ...rest.map((cmd) => ({ cmd, indices: [] }))
2580
+ ];
2581
+ }
2582
+ return matcher.search(query, commands, MATCH_LIMIT).map((m) => ({
2583
+ cmd: m.item,
2584
+ indices: m.matchedIndices
2585
+ }));
2586
+ }
2587
+ function highlight(label, matchedIndices) {
2588
+ const set = new Set(matchedIndices);
2589
+ const chars = [];
2590
+ for (let i = 0; i < label.length; i++) {
2591
+ const ch = label[i] ?? "";
2592
+ chars.push(set.has(i) ? /* @__PURE__ */ jsx6(Text6, { children: c.accent(ch) }, i) : /* @__PURE__ */ jsx6(Text6, { children: ch }, i));
2593
+ }
2594
+ return /* @__PURE__ */ jsx6(Fragment2, { children: chars });
2595
+ }
2596
+ function Palette({
2597
+ commands,
2598
+ matcher,
2599
+ recent,
2600
+ onSelect,
2601
+ onClose
2602
+ }) {
2603
+ const [query, setQuery] = useState2("");
2604
+ const [activeIndex, setActiveIndex] = useState2(0);
2605
+ const ranked = rankPalette(query, matcher, commands, recent);
2606
+ const visible = ranked.slice(0, VISIBLE_ROWS);
2607
+ const active = Math.min(Math.max(activeIndex, 0), Math.max(0, visible.length - 1));
2608
+ const recents = recentDeduped(recent, commands);
2609
+ useInput((input, key) => {
2610
+ if (key.escape) {
2611
+ onClose();
2612
+ return;
2613
+ }
2614
+ if (key.return) {
2615
+ const hit = visible[active];
2616
+ if (hit) onSelect(hit.cmd);
2617
+ return;
2618
+ }
2619
+ if (key.upArrow) {
2620
+ setActiveIndex((i) => Math.max(0, i - 1));
2621
+ return;
2622
+ }
2623
+ if (key.downArrow) {
2624
+ setActiveIndex((i) => i + 1);
2625
+ return;
2626
+ }
2627
+ if (key.backspace || key.delete) {
2628
+ setQuery((q) => q.slice(0, -1));
2629
+ setActiveIndex(0);
2630
+ return;
2631
+ }
2632
+ if (key.ctrl) return;
2633
+ if (input.length > 0) {
2634
+ setQuery((q) => q + input);
2635
+ setActiveIndex(0);
2636
+ }
2637
+ });
2638
+ const rows = [
2639
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2640
+ c.bold("\u25B8 palette "),
2641
+ /* @__PURE__ */ jsx6(Text6, { children: c.dim(`search (${visible.length} shown)`) })
2642
+ ] }, "header"),
2643
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2644
+ c.dim("> "),
2645
+ query,
2646
+ /* @__PURE__ */ jsx6(Text6, { children: c.dim("\u258C") })
2647
+ ] }, "query")
2648
+ ];
2649
+ let lastCategory = null;
2650
+ for (const { cmd, indices } of visible) {
2651
+ const isRecent = recents.some((r) => r.id === cmd.id);
2652
+ if (query.length === 0) {
2653
+ if (isRecent && lastCategory !== "recent") {
2654
+ rows.push(/* @__PURE__ */ jsx6(Text6, { children: c.dim("\u2500\u2500 recent \u2500\u2500") }, "cat:recent"));
2655
+ lastCategory = "recent";
2656
+ } else if (!isRecent && cmd.category !== lastCategory) {
2657
+ rows.push(/* @__PURE__ */ jsx6(Text6, { children: c.dim(`\u2500\u2500 ${cmd.category} \u2500\u2500`) }, `cat:${cmd.category}`));
2658
+ lastCategory = cmd.category;
2659
+ }
2660
+ }
2661
+ const prefix = cmd.id === visible[active]?.cmd.id ? "\u25B8 " : " ";
2662
+ rows.push(
2663
+ /* @__PURE__ */ jsxs5(Text6, { children: [
2664
+ isRecent && query.length === 0 ? /* @__PURE__ */ jsx6(Text6, { children: c.dim("\u21BA ") }) : null,
2665
+ prefix,
2666
+ highlight(cmd.label, indices),
2667
+ /* @__PURE__ */ jsx6(Text6, { children: c.dim(` ${cmd.description}`) })
2668
+ ] }, cmd.id)
2669
+ );
2670
+ }
2671
+ rows.push(/* @__PURE__ */ jsx6(Text6, { children: c.dim("\u2191/\u2193 navigate \xB7 Enter run \xB7 Esc close") }, "footer"));
2672
+ return /* @__PURE__ */ jsx6(Box, { flexDirection: "column", children: rows.map((row) => row) });
2673
+ }
2674
+ function recentDeduped(recent, commands) {
2675
+ const known = new Set(commands.map((cmd) => cmd.id));
2676
+ return recent.filter((cmd) => known.has(cmd.id));
2677
+ }
2678
+
2679
+ // src/tui/StatusBar.tsx
2680
+ import { Text as Text7 } from "ink";
2681
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2115
2682
  function Cell({
2116
2683
  label,
2117
2684
  value,
2118
2685
  accent
2119
2686
  }) {
2120
- return /* @__PURE__ */ jsxs4(Text5, { children: [
2121
- /* @__PURE__ */ jsx5(Text5, { children: c.dim(`${label}:`) }),
2687
+ return /* @__PURE__ */ jsxs6(Text7, { children: [
2688
+ /* @__PURE__ */ jsx7(Text7, { children: c.dim(`${label}:`) }),
2122
2689
  " ",
2123
- /* @__PURE__ */ jsx5(Text5, { children: accent ? c.accent(value) : value })
2690
+ /* @__PURE__ */ jsx7(Text7, { children: accent ? c.accent(value) : value })
2124
2691
  ] });
2125
2692
  }
2126
2693
  function StatusBar({ payload, loading }) {
@@ -2129,14 +2696,14 @@ function StatusBar({ payload, loading }) {
2129
2696
  const phase = payload?.workflow?.phase ?? (loading ? "\u2026" : "idle");
2130
2697
  const daemonRunning = payload?.daemon.running === true;
2131
2698
  const daemon = payload === null && loading ? "\u2026" : daemonRunning ? "up" : "down";
2132
- return /* @__PURE__ */ jsxs4(Text5, { children: [
2133
- /* @__PURE__ */ jsx5(Cell, { label: "host", value: host, accent: true }),
2134
- /* @__PURE__ */ jsx5(Text5, { children: c.dim(" \xB7 ") }),
2135
- /* @__PURE__ */ jsx5(Cell, { label: "mode", value: mode }),
2136
- /* @__PURE__ */ jsx5(Text5, { children: c.dim(" \xB7 ") }),
2137
- /* @__PURE__ */ jsx5(Cell, { label: "phase", value: phase }),
2138
- /* @__PURE__ */ jsx5(Text5, { children: c.dim(" \xB7 ") }),
2139
- /* @__PURE__ */ jsxs4(Text5, { children: [
2699
+ return /* @__PURE__ */ jsxs6(Text7, { children: [
2700
+ /* @__PURE__ */ jsx7(Cell, { label: "host", value: host, accent: true }),
2701
+ /* @__PURE__ */ jsx7(Text7, { children: c.dim(" \xB7 ") }),
2702
+ /* @__PURE__ */ jsx7(Cell, { label: "mode", value: mode }),
2703
+ /* @__PURE__ */ jsx7(Text7, { children: c.dim(" \xB7 ") }),
2704
+ /* @__PURE__ */ jsx7(Cell, { label: "phase", value: phase }),
2705
+ /* @__PURE__ */ jsx7(Text7, { children: c.dim(" \xB7 ") }),
2706
+ /* @__PURE__ */ jsxs6(Text7, { children: [
2140
2707
  c.dim("daemon:"),
2141
2708
  " ",
2142
2709
  daemonRunning ? c.ok(daemon) : c.warn(daemon)
@@ -2145,18 +2712,33 @@ function StatusBar({ payload, loading }) {
2145
2712
  }
2146
2713
 
2147
2714
  // src/tui/App.tsx
2148
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
2715
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2149
2716
  function App({ deps, initialPayload = null, refreshMs = 5e3 }) {
2150
2717
  const { exit } = useApp();
2151
- const [buffer, setBuffer] = useState("");
2152
- const [payload, setPayload] = useState(initialPayload);
2153
- const [loading, setLoading] = useState(initialPayload === null);
2154
- const [output, setOutput] = useState(null);
2155
- const [scrollOffset, setScrollOffset] = useState(0);
2156
- const [help, setHelp] = useState(false);
2157
- const [running, setRunning] = useState(false);
2158
- const [notice, setNotice] = useState(null);
2159
- const [pending, setPending] = useState(null);
2718
+ const { buffer, setBuffer, pushHistory, recall, clear } = useInputBuffer();
2719
+ const [mode, setMode] = useState3({ kind: "dashboard" });
2720
+ const [payload, setPayload] = useState3(initialPayload);
2721
+ const [loading, setLoading] = useState3(initialPayload === null);
2722
+ const [output, setOutput] = useState3(null);
2723
+ const [searchLines, setSearchLines] = useState3([]);
2724
+ const [scrollOffset, setScrollOffset] = useState3(0);
2725
+ const [help, setHelp] = useState3(false);
2726
+ const [running, setRunning] = useState3(false);
2727
+ const [notice, setNotice] = useState3(null);
2728
+ const [pending, setPending] = useState3(null);
2729
+ const [recent, setRecent] = useState3([]);
2730
+ useEffect(() => {
2731
+ if (!deps.loadRecent) return;
2732
+ const loader = deps.loadRecent;
2733
+ let cancelled = false;
2734
+ void loader().then((entries) => {
2735
+ if (!cancelled) setRecent(entries);
2736
+ }).catch(() => {
2737
+ });
2738
+ return () => {
2739
+ cancelled = true;
2740
+ };
2741
+ }, [deps.loadRecent]);
2160
2742
  useEffect(() => {
2161
2743
  if (running) return;
2162
2744
  let cancelled = false;
@@ -2205,7 +2787,12 @@ function App({ deps, initialPayload = null, refreshMs = 5e3 }) {
2205
2787
  active = false;
2206
2788
  };
2207
2789
  }, [pending, deps]);
2208
- useInput((input, key) => {
2790
+ function dispatchCmd(argv) {
2791
+ setOutput(null);
2792
+ setRunning(true);
2793
+ setPending([...argv]);
2794
+ }
2795
+ function handleDashboardInput(input, key) {
2209
2796
  if (running) return;
2210
2797
  setNotice(null);
2211
2798
  if (help) {
@@ -2217,7 +2804,7 @@ function App({ deps, initialPayload = null, refreshMs = 5e3 }) {
2217
2804
  return;
2218
2805
  }
2219
2806
  if (key.escape) {
2220
- if (buffer.length > 0) setBuffer("");
2807
+ if (buffer.length > 0) clear();
2221
2808
  else if (output !== null) setOutput(null);
2222
2809
  else exit();
2223
2810
  return;
@@ -2227,14 +2814,38 @@ function App({ deps, initialPayload = null, refreshMs = 5e3 }) {
2227
2814
  return;
2228
2815
  }
2229
2816
  if (key.upArrow) {
2817
+ if (buffer === "/") {
2818
+ const entry = recall("up");
2819
+ if (entry !== null) {
2820
+ setBuffer(() => entry);
2821
+ return;
2822
+ }
2823
+ }
2230
2824
  setScrollOffset((o) => Math.max(0, o - 1));
2231
2825
  return;
2232
2826
  }
2233
2827
  if (key.downArrow) {
2828
+ if (buffer === "/") {
2829
+ const entry = recall("down");
2830
+ if (entry !== null) {
2831
+ setBuffer(() => entry);
2832
+ return;
2833
+ }
2834
+ }
2234
2835
  setScrollOffset((o) => o + 1);
2235
2836
  return;
2236
2837
  }
2237
- if (key.ctrl) return;
2838
+ if (key.ctrl) {
2839
+ if (input === "k") {
2840
+ openPalette();
2841
+ return;
2842
+ }
2843
+ if (input === "f" && output !== null) {
2844
+ openSearch();
2845
+ return;
2846
+ }
2847
+ return;
2848
+ }
2238
2849
  if (input.length === 1) {
2239
2850
  if (buffer.length === 0) {
2240
2851
  if (input === "q") {
@@ -2252,11 +2863,108 @@ function App({ deps, initialPayload = null, refreshMs = 5e3 }) {
2252
2863
  if (input.length > 1) {
2253
2864
  setBuffer((b) => b + input);
2254
2865
  }
2866
+ }
2867
+ function openPalette() {
2868
+ setMode({ kind: "palette" });
2869
+ setNotice(null);
2870
+ }
2871
+ function openSearch() {
2872
+ if (output === null) return;
2873
+ setSearchLines(output.lines);
2874
+ setMode({ kind: "search", query: "", matches: [], active: -1 });
2875
+ setNotice(null);
2876
+ }
2877
+ function handlePaletteSelect(cmd) {
2878
+ if (cmd.destructive) {
2879
+ setMode({ kind: "confirm", argv: [...cmd.argv] });
2880
+ return;
2881
+ }
2882
+ dispatchCmd([...cmd.argv]);
2883
+ void recordRuns([...cmd.argv]);
2884
+ setMode({ kind: "dashboard" });
2885
+ }
2886
+ function handleConfirmApprove() {
2887
+ if (mode.kind !== "confirm") return;
2888
+ dispatchCmd([...mode.argv]);
2889
+ void recordRuns([...mode.argv]);
2890
+ setMode({ kind: "dashboard" });
2891
+ }
2892
+ function handleConfirmDecline() {
2893
+ setMode({ kind: "palette" });
2894
+ }
2895
+ function recordRuns(argv) {
2896
+ const recorder = deps.record;
2897
+ if (recorder) void recorder(argv);
2898
+ }
2899
+ function handleConfirmInput(input, key) {
2900
+ if (key.escape || input === "n" || input === "N") {
2901
+ handleConfirmDecline();
2902
+ return;
2903
+ }
2904
+ if (input === "y" || input === "Y") {
2905
+ handleConfirmApprove();
2906
+ return;
2907
+ }
2908
+ }
2909
+ function handleSearchInput(input, key) {
2910
+ if (mode.kind !== "search") return;
2911
+ if (key.escape) {
2912
+ setMode({ kind: "dashboard" });
2913
+ return;
2914
+ }
2915
+ if (key.backspace || key.delete) {
2916
+ const nextQuery = mode.query.slice(0, -1);
2917
+ setMode(searchFor(nextQuery));
2918
+ return;
2919
+ }
2920
+ if (key.ctrl) return;
2921
+ if (key.return) {
2922
+ advanceActive(1);
2923
+ return;
2924
+ }
2925
+ if (input === "n" && mode.matches.length > 0) {
2926
+ advanceActive(1);
2927
+ return;
2928
+ }
2929
+ if (input === "N" && mode.matches.length > 0) {
2930
+ advanceActive(-1);
2931
+ return;
2932
+ }
2933
+ if (input.length > 0) {
2934
+ setMode(searchFor(mode.query + input));
2935
+ }
2936
+ }
2937
+ function searchFor(query) {
2938
+ const matches = computeMatches(searchLines, query);
2939
+ return { kind: "search", query, matches, active: matches.length > 0 ? 0 : -1 };
2940
+ }
2941
+ function advanceActive(delta) {
2942
+ if (mode.kind !== "search") return;
2943
+ const n = mode.matches.length;
2944
+ if (n === 0) return;
2945
+ const next = (mode.active + delta + n) % n;
2946
+ setMode({ ...mode, active: next });
2947
+ }
2948
+ useInput2((input, key) => {
2949
+ switch (mode.kind) {
2950
+ case "dashboard":
2951
+ handleDashboardInput(input, key);
2952
+ return;
2953
+ case "palette":
2954
+ return;
2955
+ // Palette renders its own useInput; no App-level keys here
2956
+ case "confirm":
2957
+ handleConfirmInput(input, key);
2958
+ return;
2959
+ case "search":
2960
+ handleSearchInput(input, key);
2961
+ return;
2962
+ }
2255
2963
  });
2256
2964
  function submit() {
2257
2965
  const text = buffer;
2258
2966
  if (text.length === 0) return;
2259
- setBuffer("");
2967
+ clear();
2260
2968
  if (!text.startsWith("/")) {
2261
2969
  setNotice("type a /command to run it (bare text is just a hint)");
2262
2970
  return;
@@ -2266,52 +2974,160 @@ function App({ deps, initialPayload = null, refreshMs = 5e3 }) {
2266
2974
  setNotice("empty /command");
2267
2975
  return;
2268
2976
  }
2269
- setOutput(null);
2270
- setRunning(true);
2271
- setPending(argv);
2977
+ pushHistory(text);
2978
+ dispatchCmd(argv);
2979
+ void recordRuns(argv);
2272
2980
  }
2273
2981
  if (help) {
2274
- return /* @__PURE__ */ jsx6(Help, {});
2982
+ return /* @__PURE__ */ jsx8(Help, {});
2983
+ }
2984
+ if (mode.kind === "palette") {
2985
+ return /* @__PURE__ */ jsxs7(Box2, { flexDirection: "column", children: [
2986
+ /* @__PURE__ */ jsx8(Header, { tagline: "palette" }),
2987
+ /* @__PURE__ */ jsx8(
2988
+ Palette,
2989
+ {
2990
+ commands: deps.commands ?? [],
2991
+ matcher: deps.matcher ?? handRolledMatcher,
2992
+ recent,
2993
+ onSelect: handlePaletteSelect,
2994
+ onClose: () => setMode({ kind: "dashboard" })
2995
+ }
2996
+ ),
2997
+ /* @__PURE__ */ jsx8(Footer, { running: false })
2998
+ ] });
2999
+ }
3000
+ if (mode.kind === "confirm") {
3001
+ return /* @__PURE__ */ jsxs7(Box2, { flexDirection: "column", children: [
3002
+ /* @__PURE__ */ jsx8(Header, { tagline: "confirm" }),
3003
+ /* @__PURE__ */ jsx8(ConfirmOverlay, { argv: mode.argv }),
3004
+ /* @__PURE__ */ jsx8(Footer, { running: false })
3005
+ ] });
3006
+ }
3007
+ if (mode.kind === "search") {
3008
+ const activeLine = mode.active >= 0 ? mode.matches[mode.active] : void 0;
3009
+ const matchCount = mode.matches.length;
3010
+ return /* @__PURE__ */ jsxs7(Box2, { flexDirection: "column", children: [
3011
+ /* @__PURE__ */ jsx8(Header, { tagline: "search" }),
3012
+ /* @__PURE__ */ jsx8(
3013
+ OutputPane,
3014
+ {
3015
+ lines: searchLines,
3016
+ scrollOffset,
3017
+ title: output?.title,
3018
+ highlightQuery: mode.query,
3019
+ activeLine
3020
+ }
3021
+ ),
3022
+ /* @__PURE__ */ jsxs7(Text8, { children: [
3023
+ c.dim("search: "),
3024
+ mode.query.length > 0 ? mode.query : c.dim("(type to filter output)"),
3025
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" \u258C") })
3026
+ ] }),
3027
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(
3028
+ matchCount === 0 ? "no matches \xB7 Esc exit" : `${mode.active + 1}/${matchCount} matches \xB7 n/Enter next \xB7 N prev \xB7 Esc exit`
3029
+ ) }),
3030
+ /* @__PURE__ */ jsx8(Footer, { running: false })
3031
+ ] });
2275
3032
  }
2276
3033
  const snapshotLines = formatStatusPayload(payload);
2277
3034
  const paneLines = output?.lines ?? snapshotLines ?? [];
2278
3035
  const paneTitle = output?.title ?? "snapshot";
2279
- return /* @__PURE__ */ jsxs5(Box, { flexDirection: "column", children: [
2280
- /* @__PURE__ */ jsx6(Header, { tagline: "dashboard" }),
2281
- /* @__PURE__ */ jsx6(StatusBar, { payload, loading }),
2282
- /* @__PURE__ */ jsx6(OutputPane, { lines: paneLines, scrollOffset, title: paneTitle }),
2283
- /* @__PURE__ */ jsx6(CommandInput, { buffer, running }),
2284
- notice !== null ? /* @__PURE__ */ jsx6(Text6, { children: c.dim(notice) }) : null,
2285
- /* @__PURE__ */ jsx6(Footer, { running })
3036
+ return /* @__PURE__ */ jsxs7(Box2, { flexDirection: "column", children: [
3037
+ /* @__PURE__ */ jsx8(Header, { tagline: "dashboard" }),
3038
+ /* @__PURE__ */ jsx8(StatusBar, { payload, loading }),
3039
+ /* @__PURE__ */ jsx8(OutputPane, { lines: paneLines, scrollOffset, title: paneTitle }),
3040
+ /* @__PURE__ */ jsx8(CommandInput, { buffer, running }),
3041
+ notice !== null ? /* @__PURE__ */ jsx8(Text8, { children: c.dim(notice) }) : null,
3042
+ /* @__PURE__ */ jsx8(Footer, { running })
2286
3043
  ] });
2287
3044
  }
2288
3045
  function Help() {
2289
- return /* @__PURE__ */ jsxs5(Box, { flexDirection: "column", children: [
2290
- /* @__PURE__ */ jsx6(Header, { tagline: "help" }),
2291
- /* @__PURE__ */ jsx6(Text6, { children: " " }),
2292
- /* @__PURE__ */ jsx6(Text6, { children: c.bold("Keybindings") }),
2293
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" /&lt;command&gt; run a Noir sub-command (e.g. /status, /sync, /task next)") }),
2294
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" Enter run the typed /command") }),
2295
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" Esc back: clear input \u2192 dismiss output \u2192 quit") }),
2296
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" q quit (when the input is empty)") }),
2297
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" \u2191 / \u2193 scroll the output pane") }),
2298
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" ? toggle this help") }),
2299
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" Ctrl+C force exit") }),
2300
- /* @__PURE__ */ jsx6(Text6, { children: " " }),
2301
- /* @__PURE__ */ jsx6(Text6, { children: c.bold("Commands") }),
2302
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" Dispatched through the same routing as `noir` at the prompt.") }),
2303
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(
3046
+ return /* @__PURE__ */ jsxs7(Box2, { flexDirection: "column", children: [
3047
+ /* @__PURE__ */ jsx8(Header, { tagline: "help" }),
3048
+ /* @__PURE__ */ jsx8(Text8, { children: " " }),
3049
+ /* @__PURE__ */ jsx8(Text8, { children: c.bold("Keybindings") }),
3050
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" /&lt;command&gt; run a Noir sub-command (e.g. /status, /sync, /task next)") }),
3051
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Enter run the typed /command") }),
3052
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Esc back: clear input \u2192 dismiss output \u2192 quit") }),
3053
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" q quit (when the input is empty)") }),
3054
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" \u2191 / \u2193 scroll the output pane") }),
3055
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" ? toggle this help") }),
3056
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Ctrl+K open the command palette") }),
3057
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Ctrl+F find in the dispatched output pane") }),
3058
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" n / N next / previous match in search (Enter = next)") }),
3059
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" y / n approve / decline a destructive command prompt") }),
3060
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Ctrl+C force exit") }),
3061
+ /* @__PURE__ */ jsx8(Text8, { children: " " }),
3062
+ /* @__PURE__ */ jsx8(Text8, { children: c.bold("Commands") }),
3063
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Dispatched through the same routing as `noir` at the prompt.") }),
3064
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(
2304
3065
  " /status /sync /doctor /context search &lt;q&gt; /task next /memory recall &lt;q&gt;"
2305
3066
  ) }),
2306
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" Commands that need their own interactive prompts (e.g. a /sync with a") }),
2307
- /* @__PURE__ */ jsx6(Text6, { children: c.dim(" conflict) are best run directly \u2014 exit first with q.") }),
2308
- /* @__PURE__ */ jsx6(Text6, { children: " " }),
2309
- /* @__PURE__ */ jsx6(Text6, { children: c.dim("press ? / Esc / q to close this help") })
3067
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" Commands that need their own interactive prompts (e.g. a /sync with a") }),
3068
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim(" conflict) are best run directly \u2014 exit first with q.") }),
3069
+ /* @__PURE__ */ jsx8(Text8, { children: " " }),
3070
+ /* @__PURE__ */ jsx8(Text8, { children: c.dim("press ? / Esc / q to close this help") })
2310
3071
  ] });
2311
3072
  }
2312
3073
 
3074
+ // src/tui/palette/history.ts
3075
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync } from "fs";
3076
+ import { homedir } from "os";
3077
+ import { join as join2 } from "path";
3078
+ import { atomicWriteFile } from "@noir-ai/core";
3079
+ var MAX_ENTRIES = 50;
3080
+ var TUI_HISTORY_DISABLE_ENV = "NOIR_DISABLE_TUI_HISTORY";
3081
+ var testHome = null;
3082
+ function homeDir() {
3083
+ return testHome ?? join2(homedir(), ".noir");
3084
+ }
3085
+ function historyFile(projectId) {
3086
+ return join2(homeDir(), projectId, "tui-history.json");
3087
+ }
3088
+ function isHistoryEnabled() {
3089
+ const v = process.env[TUI_HISTORY_DISABLE_ENV];
3090
+ return v === void 0 || v === "";
3091
+ }
3092
+ function loadRecent(projectId) {
3093
+ if (!isHistoryEnabled()) return [];
3094
+ const file = historyFile(projectId);
3095
+ try {
3096
+ if (!existsSync(file)) return [];
3097
+ const raw = readFileSync(file, "utf8");
3098
+ const parsed = JSON.parse(raw);
3099
+ if (!Array.isArray(parsed)) return [];
3100
+ const entries = [];
3101
+ for (const item of parsed) {
3102
+ if (typeof item !== "object" || item === null) continue;
3103
+ const argv = item.argv;
3104
+ const id = item.id;
3105
+ if (!Array.isArray(argv) || typeof id !== "string") continue;
3106
+ if (!argv.every((tok) => typeof tok === "string")) continue;
3107
+ entries.push({ argv, id });
3108
+ }
3109
+ return entries;
3110
+ } catch {
3111
+ return [];
3112
+ }
3113
+ }
3114
+ function recordRecent(projectId, argv) {
3115
+ if (argv.length === 0) return;
3116
+ if (!isHistoryEnabled()) return;
3117
+ const entry = { argv, id: argv.join(" ") };
3118
+ const current = loadRecent(projectId).filter((e) => e.id !== entry.id);
3119
+ const next = [entry, ...current].slice(0, MAX_ENTRIES);
3120
+ try {
3121
+ const dir = join2(homeDir(), projectId);
3122
+ mkdirSync2(dir, { recursive: true });
3123
+ atomicWriteFile(historyFile(projectId), `${JSON.stringify(next, null, 2)}
3124
+ `);
3125
+ } catch {
3126
+ }
3127
+ }
3128
+
2313
3129
  // src/tui/index.tsx
2314
- import { jsx as jsx7 } from "react/jsx-runtime";
3130
+ import { jsx as jsx9 } from "react/jsx-runtime";
2315
3131
  function defaultFetchStatus(opts) {
2316
3132
  return async () => {
2317
3133
  try {
@@ -2322,11 +3138,30 @@ function defaultFetchStatus(opts) {
2322
3138
  };
2323
3139
  }
2324
3140
  async function runTui(opts, dispatch) {
3141
+ let projectId = null;
3142
+ try {
3143
+ projectId = loadProjectInfo8(process.cwd()).id;
3144
+ } catch {
3145
+ projectId = null;
3146
+ }
2325
3147
  const deps = {
2326
3148
  dispatch,
2327
- fetchStatus: defaultFetchStatus(opts)
3149
+ fetchStatus: defaultFetchStatus(opts),
3150
+ // The palette source, derived from a fresh commander program at launch (B3).
3151
+ commands: buildPaletteCommandsForTui(),
3152
+ // C3 — persistent recent commands (projectId-keyed). recordRecent is async
3153
+ // only to match the TuiDeps seam; it never rejects.
3154
+ record: (argv) => {
3155
+ if (projectId) recordRecent(projectId, argv);
3156
+ return Promise.resolve();
3157
+ },
3158
+ loadRecent: async () => {
3159
+ if (!projectId) return [];
3160
+ const byId = new Map(deps.commands?.map((c2) => [c2.id, c2]) ?? []);
3161
+ return loadRecent(projectId).map((e) => byId.get(e.id)).filter((c2) => c2 !== void 0);
3162
+ }
2328
3163
  };
2329
- const instance = render(/* @__PURE__ */ jsx7(App, { deps }));
3164
+ const instance = render(/* @__PURE__ */ jsx9(App, { deps }));
2330
3165
  await instance.waitUntilExit();
2331
3166
  }
2332
3167
  export {