@cruxy/cli 1.2.0 → 1.3.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.
Files changed (77) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +41 -2
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +185 -72
  7. package/dist/approval/classify.js +204 -0
  8. package/dist/approval/policy.js +41 -3
  9. package/dist/approval/prompt.js +49 -22
  10. package/dist/checkpoint/gate.js +12 -0
  11. package/dist/cli/commands/run.js +374 -227
  12. package/dist/cli/commands/usage.js +45 -45
  13. package/dist/cli/onboard.js +2 -1
  14. package/dist/cli/program.js +60 -18
  15. package/dist/cli/repl.js +67 -249
  16. package/dist/cli/session-commands.js +755 -0
  17. package/dist/cli/session-factory.js +198 -76
  18. package/dist/cli/suggest.js +77 -0
  19. package/dist/components/fuzzy.js +3 -3
  20. package/dist/components/input.js +17 -2
  21. package/dist/components/keys.js +27 -3
  22. package/dist/components/select.js +3 -3
  23. package/dist/config/project.js +53 -1
  24. package/dist/config/schema.js +49 -16
  25. package/dist/jobs/log-renderer.js +47 -0
  26. package/dist/onboarding/steps.js +13 -22
  27. package/dist/plan/approve.js +36 -24
  28. package/dist/plan/execute.js +9 -7
  29. package/dist/plan/render.js +10 -23
  30. package/dist/plan/service.js +4 -1
  31. package/dist/render/capabilities.js +30 -1
  32. package/dist/render/context-view.js +106 -0
  33. package/dist/render/diff.js +198 -12
  34. package/dist/render/index.js +31 -5
  35. package/dist/render/plain-renderer.js +38 -2
  36. package/dist/render/plan-view.js +108 -0
  37. package/dist/render/resize.js +7 -2
  38. package/dist/render/status-view.js +66 -0
  39. package/dist/render/test-view.js +89 -0
  40. package/dist/render/tty-renderer.js +40 -0
  41. package/dist/routing/index.js +1 -0
  42. package/dist/routing/router.js +13 -4
  43. package/dist/routing/session-model.js +109 -0
  44. package/dist/routing/types.js +14 -0
  45. package/dist/session/export.js +88 -0
  46. package/dist/session/index.js +20 -0
  47. package/dist/session/list.js +137 -0
  48. package/dist/session/log.js +137 -0
  49. package/dist/session/paths.js +73 -0
  50. package/dist/session/replay.js +169 -0
  51. package/dist/session/resume.js +128 -0
  52. package/dist/session/types.js +223 -0
  53. package/dist/subagent/orchestrator.js +23 -0
  54. package/dist/testing/run-tests-tool.js +8 -0
  55. package/dist/tools/registry.js +3 -3
  56. package/dist/tui/app.js +385 -0
  57. package/dist/tui/approval-overlay.js +160 -0
  58. package/dist/tui/context-gauge.js +48 -0
  59. package/dist/tui/git-status.js +63 -0
  60. package/dist/tui/index.js +10 -0
  61. package/dist/tui/layout.js +269 -0
  62. package/dist/tui/overlay.js +105 -0
  63. package/dist/tui/palette.js +73 -0
  64. package/dist/tui/panels.js +235 -0
  65. package/dist/tui/renderer.js +776 -0
  66. package/dist/tui/supports.js +20 -0
  67. package/dist/tui/tool-versions.js +129 -0
  68. package/dist/usage/collect.js +21 -3
  69. package/dist/usage/index.js +10 -2
  70. package/dist/usage/report.js +76 -0
  71. package/dist/usage/store.js +7 -1
  72. package/dist/usage/summary.js +106 -17
  73. package/dist/usage/types.js +73 -4
  74. package/dist/usage/weighted.js +77 -0
  75. package/dist/utils/git.js +50 -4
  76. package/package.json +2 -2
  77. package/dist/usage/cost.js +0 -29
@@ -10,16 +10,39 @@ import { withCheckpointGate } from "../checkpoint/index.js";
10
10
  // session factory keep working.
11
11
  export { withCheckpointGate };
12
12
  import { shouldUseColor } from "../errors/index.js";
13
+ // The concrete TUI modules, not `tui/index.js`: `render/index.js` already
14
+ // imports `tui/renderer.js`, and routing this through the barrel would pull the
15
+ // whole TUI surface into a module the one-shot path also loads.
16
+ import { createOverlayPromptIO } from "../tui/approval-overlay.js";
17
+ import { canOverlay } from "../tui/overlay.js";
18
+ import { TuiRenderer } from "../tui/renderer.js";
13
19
  import { buildDefaultRegistry, } from "../tools/index.js";
14
- import { Session, } from "../agent/index.js";
20
+ import { DEFAULT_MODE, Session, } from "../agent/index.js";
15
21
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
16
- import { routerForConfig } from "../routing/index.js";
22
+ import { parseModelChoice, routerForConfig, SessionModel, } from "../routing/index.js";
17
23
  import { MemoryService, buildMultiRootRecallBlock, rememberTool, } from "../memory/index.js";
18
24
  import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
19
25
  import { createWebSearchTool, createWebFetchTool } from "../web/index.js";
20
26
  import { appendRun } from "../usage/index.js";
21
27
  import { Semaphore, SubagentOrchestrator, makeSpawnSubagentTool, makeSpawnSubagentsTool, } from "../subagent/index.js";
22
28
  import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/index.js";
29
+ /**
30
+ * Where this session's prompts are drawn (P5 track 2).
31
+ *
32
+ * The TUI gets an in-viewport modal — but only with a lease in hand. Both
33
+ * conditions are load-bearing and neither implies the other: without the TUI
34
+ * there is no drawer to paint into, and without the lease the modal would open
35
+ * a second raw-mode reader on the stdin the input loop is already holding,
36
+ * which is the contention the seam exists to prevent. Either missing, and the
37
+ * unchanged stderr prompt is the correct answer rather than a degraded one.
38
+ */
39
+ function promptIOFor(renderer, lease) {
40
+ const color = shouldUseColor();
41
+ if (renderer instanceof TuiRenderer && lease && canOverlay(renderer)) {
42
+ return createOverlayPromptIO(renderer, lease, color);
43
+ }
44
+ return defaultPromptIO(color);
45
+ }
23
46
  /**
24
47
  * Wrap a PromptIO so the live region yields before any prompt text lands
25
48
  * (U.2/U.4): the prompt writes to stderr while the status line owns the last
@@ -27,25 +50,51 @@ import { ApprovalQueue, JobManager, makeRunInBackgroundTool, } from "../jobs/ind
27
50
  * hides the live line (the prompt IS the visible state) while keeping the
28
51
  * step-progress register intact, so the line comes back with full context on
29
52
  * the next transition after the user decides.
53
+ *
54
+ * Under the TUI (P5 track 2) the phase no longer hides anything — the prompt is
55
+ * an overlay drawer composed into the paint — but it still displaces the live
56
+ * state and stops the clock, so this wrapper is unchanged and still the one
57
+ * place the phase is entered.
58
+ *
59
+ * The trigger is `beginPrompt`, NOT `write`. This io is shared by every prompt
60
+ * in the session AND by write-only callers that never read — the plan
61
+ * executor's per-step trail, and its "plan aborted" line. Yielding on `write`
62
+ * treated those as pending approvals, which under the full-viewport TUI meant
63
+ * `TuiRenderer.paintNow` cleared the entire frame on every plan step: plan mode
64
+ * and the TUI together blanked the screen and never repainted until the next
65
+ * phase change. Only a caller that is actually about to read may release the
66
+ * screen, and it says so.
67
+ *
68
+ * `endPrompt` is the paired closer, and it is not optional in practice: the
69
+ * plan prompts had nothing that gave the screen back, so aborting a plan left
70
+ * the TUI painting an empty frame until some later turn happened to change the
71
+ * phase. Every prompt now brackets itself in a `finally`, so even a default-deny
72
+ * on EOF restores the live region.
30
73
  */
31
- function suspendStatusOnPrompt(io, renderer) {
74
+ export function suspendStatusOnPrompt(io, renderer) {
32
75
  if (!renderer)
33
76
  return io;
34
77
  return {
35
78
  ...io,
36
- write: (text) => {
79
+ beginPrompt: () => {
80
+ io.beginPrompt?.();
37
81
  renderer.setPhase({ kind: "awaiting-approval" });
38
- io.write(text);
82
+ },
83
+ endPrompt: () => {
84
+ io.endPrompt?.();
85
+ renderer.promptResolved();
39
86
  },
40
87
  };
41
88
  }
42
89
  /**
43
- * Restore the live line once an approval request fully settles (U.4). The
44
- * settle point must be the service call, not the prompt's key read: the
45
- * prompt writes a trailing newline AFTER the read, which re-enters
46
- * `awaiting-approval` resolving here is the first moment no more prompt
47
- * bytes can follow. Fires on every decision (prompted or not); the renderer
48
- * treats it as a no-op unless a prompt actually displaced the line.
90
+ * Restore the live line once an approval request settles (U.4).
91
+ *
92
+ * A BACKSTOP, not the primary path: `promptForApproval` now brackets itself
93
+ * with `beginPrompt`/`endPrompt`, so the prompted path has already restored the
94
+ * line by the time this runs. It stays because this wraps the whole approval
95
+ * SERVICE which can settle a request without ever prompting (a session grant,
96
+ * a policy auto-allow) — and because `promptResolved` is a no-op unless a
97
+ * prompt actually displaced the line, so a second call costs nothing.
49
98
  */
50
99
  function resumeLineAfterApproval(requestApproval, renderer) {
51
100
  if (!renderer)
@@ -59,6 +108,31 @@ function resumeLineAfterApproval(requestApproval, renderer) {
59
108
  }
60
109
  };
61
110
  }
111
+ /**
112
+ * Show the diff for an action that was allowed WITHOUT the user being shown it
113
+ * (P3) — a session grant the allowlist answered, or a read-tier pass.
114
+ *
115
+ * This is the missing caller for `StreamRenderer.preview`. The seam has been
116
+ * implemented on every renderer since U.2 and nothing ever originated a call:
117
+ * the approval prompt draws its own diff straight to the io, so a PROMPTED
118
+ * action is already on screen and calling `preview` there would show it twice.
119
+ * The gap is the other path — once the user picks "allow this session", every
120
+ * later edit was applied with nothing rendered at all. That is what this fills.
121
+ *
122
+ * Only fires on an allow that carries no preview of its own, and only when the
123
+ * action actually has one, so read-tier passes (which carry none) stay silent.
124
+ */
125
+ export function previewSilentApprovals(requestApproval, renderer) {
126
+ if (!renderer)
127
+ return requestApproval;
128
+ return async (action) => {
129
+ const decision = await requestApproval(action);
130
+ if (decision.allow && !decision.prompted && action.preview) {
131
+ renderer.preview(action.preview);
132
+ }
133
+ return decision;
134
+ };
135
+ }
62
136
  /**
63
137
  * Register every CONDITIONALLY-enabled runtime tool onto `registry`, in the fixed
64
138
  * order the model sees them: `remember` (memory), the four LSP tools, the two web
@@ -122,11 +196,16 @@ export function registerRuntimeTools(registry, config, opts = {}) {
122
196
  * shared by `cruxy run` and the onboarding first-win task (so they can't drift).
123
197
  * The approval gate's interactivity tracks the TTY, exactly as in `run`.
124
198
  *
125
- * When `planMode` is on (C.31), it additionally wires a {@link PlanExecutionPolicy}
126
- * over the shared U.3 allowlist and a `planRunner` so `session.send` proposes
127
- * approves executes. Plan mode is fully opt-in; the default path is unchanged.
199
+ * The {@link PlanExecutionPolicy} over the shared U.3 allowlist and the
200
+ * `planRunner` are wired UNCONDITIONALLY (P5 track 3), so a session can be
201
+ * switched into a planning mode at runtime. `mode` decides only whether they
202
+ * engage; a manual session behaves exactly as before.
128
203
  */
129
- export function buildAgentSession(config, apiKey, workspace, ttyInteractive, planMode = false, renderer, checkpoints, sandbox, hooks, mcpTools = []) {
204
+ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, mode = DEFAULT_MODE, renderer, checkpoints, sandbox, hooks, mcpTools = [],
205
+ // An options bag rather than two more positional parameters — this signature
206
+ // is already at ten, and P2's two additions are both optional and unrelated
207
+ // to each other.
208
+ opts = {}) {
130
209
  // The workspace is the single source of truth for "which roots" (C.26). Every
131
210
  // primary-scoped subsystem below (git context, project instructions, memory,
132
211
  // checkpoints, subagents, the approval gate) derives its cwd from the primary
@@ -142,10 +221,24 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
142
221
  temperature: config.model.temperature,
143
222
  gatewayUrl: config.cruxy.gatewayUrl,
144
223
  });
145
- // Multi-model routing (C.30): null unless routing is configured on a cruxy
146
- // session, so the default path threads `undefined` and behaves exactly as
147
- // before. One router is shared by the main loop, subagents, and plan mode.
148
- const router = routerForConfig(config) ?? undefined;
224
+ // The session's model choice, and the router everything selects through
225
+ // (C.30 + P6 track 1). One object shared by the main loop, subagents, plan
226
+ // mode and background jobs which is what makes `/model` reach all of them
227
+ // without re-wiring any: they hold a reference, not a resolved tier.
228
+ //
229
+ // Built ONLY for the cruxy gateway, and the omission is the honest answer
230
+ // rather than a gap. Tiers are a gateway concept; a bring-your-own provider's
231
+ // `model.model` is an upstream id this CLI must not even name (U.8), so there
232
+ // is no menu to offer and `router` stays `undefined` exactly as before.
233
+ //
234
+ // The starting choice is `config.model.model` when it names one — so a user
235
+ // who pinned `kavi` in config opens on `kavi`, and `/model auto` moves them to
236
+ // server-side routing rather than back to that pin. Anything else (an unknown
237
+ // string) falls back to `auto`, which is also the schema default.
238
+ const sessionModel = config.model.provider === "cruxy"
239
+ ? new SessionModel(parseModelChoice(config.model.model) ?? "auto", routerForConfig(config))
240
+ : undefined;
241
+ const router = sessionModel;
149
242
  // Usage telemetry (C.22): when enabled, each run's usage record is persisted
150
243
  // to the LOCAL store. Best-effort and non-fatal — a corrupt/unwritable store
151
244
  // is downgraded to a warning (CRUXY_E_USAGE_READ) and NEVER takes a run down.
@@ -159,7 +252,26 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
159
252
  logger.warn(`${error.code}: ${error.title} — ${error.cause}`);
160
253
  }
161
254
  : undefined;
162
- const execRegistry = buildDefaultRegistry();
255
+ // The `run_tests` side channel (P3): the structured outcome the tool already
256
+ // built, handed to the renderer to draw. Every field it needs is copied
257
+ // across as-is — nothing is derived here, so an absent `total` stays absent
258
+ // rather than becoming a number the parsers refused to claim.
259
+ const execRegistry = buildDefaultRegistry({
260
+ onTestResult: renderer
261
+ ? (result, command) => renderer.testResult({
262
+ passed: result.passed,
263
+ command: command.command,
264
+ durationMs: result.durationMs,
265
+ ...(result.total !== undefined ? { total: result.total } : {}),
266
+ failures: result.failures.map((f) => ({
267
+ name: f.name,
268
+ ...(f.file !== undefined ? { file: f.file } : {}),
269
+ ...(f.line !== undefined ? { line: f.line } : {}),
270
+ })),
271
+ outputTruncated: result.outputTruncated,
272
+ })
273
+ : undefined,
274
+ });
163
275
  const git = getGitInfo(cwd);
164
276
  const projectInstructions = loadProjectInstructions(cwd);
165
277
  // Persistent memory (C.29): build the recall block ONCE at session start (the
@@ -233,7 +345,7 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
233
345
  // because subagents must get the *identical* stack over a FRESH service: same
234
346
  // prompt + same checkpoint hook, but a new (empty) session allowlist — a
235
347
  // grant in the parent never silently widens a child's authority.
236
- const io = suspendStatusOnPrompt(defaultPromptIO(shouldUseColor()), renderer);
348
+ const io = suspendStatusOnPrompt(promptIOFor(renderer, opts.keyLease), renderer);
237
349
  // The C.26 coupling: `checkpointsActive` is true exactly when a per-root gate is
238
350
  // wired, and it is set on the SAME ctx whose `requestApproval` IS that gate — so
239
351
  // lifting the non-primary-write refusal and capturing the write are one decision.
@@ -251,7 +363,10 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
251
363
  // here too, so foreground servicing and job production share it.
252
364
  const executionSemaphore = new Semaphore(config.subagent.maxConcurrency);
253
365
  const approvalQueue = new ApprovalQueue();
254
- const gate = (approval) => serializeGate(withCheckpointGate(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), checkpoints, workspace), approvalMutex, cwd);
366
+ const gate = (approval) => serializeGate(withCheckpointGate(
367
+ // Outside `resumeLineAfterApproval`, so the live region is restored
368
+ // before a preview block is committed into it.
369
+ previewSilentApprovals(resumeLineAfterApproval((action) => approval.requestApproval(action), renderer), renderer), checkpoints, workspace), approvalMutex, cwd);
255
370
  // Subagent orchestration (C.14): spawn_subagent goes on the main registry
256
371
  // only when depth allows (maxDepth 0 disables the feature structurally).
257
372
  // Registered before the plan wiring so plan-mode execution steps can
@@ -270,7 +385,18 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
270
385
  sandbox,
271
386
  checkpointsActive,
272
387
  executionSemaphore,
273
- makeChildApproval: () => gate(new ApprovalService({ cwd, interactive: ttyInteractive, io })),
388
+ // A child gets the parent's MODE but not its grants (P5 track 3). The two
389
+ // are different kinds of thing: a scoped session grant is consent to one
390
+ // command in one root, and widening it to a child would be authority the
391
+ // user never gave — whereas auto-approve is a standing instruction about
392
+ // how this session runs, and a subagent that stopped to ask would strand an
393
+ // unattended run on a prompt nobody is watching.
394
+ makeChildApproval: () => gate(new ApprovalService({
395
+ cwd,
396
+ interactive: ttyInteractive,
397
+ io,
398
+ policy: new InteractivePolicy(new SessionAllowlist(), io, autoApprove),
399
+ })),
274
400
  });
275
401
  // Background jobs (C.28): the session-scoped manager the `run_in_background`
276
402
  // tool dispatches onto. Built only when enabled. It shares the SAME execution
@@ -312,61 +438,33 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
312
438
  spawnManyTool,
313
439
  jobTool,
314
440
  });
315
- if (planMode) {
316
- // One allowlist shared by the plan-approval prompt and the per-action
317
- // gate, so a grant recorded during execution is honored by U.3's own check.
318
- const allowlist = new SessionAllowlist();
319
- const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io));
320
- const approval = new ApprovalService({
321
- cwd,
322
- interactive: ttyInteractive,
323
- policy: planPolicy,
324
- io,
325
- });
326
- const ctx = {
327
- cwd,
328
- workspace,
329
- config,
330
- logger,
331
- requestApproval: gate(approval),
332
- checkpointsActive,
333
- sandbox,
334
- };
335
- const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
336
- provider,
337
- config,
338
- ctx,
339
- execRegistry,
340
- planPolicy,
341
- io,
342
- interactive: ttyInteractive,
343
- messages,
344
- git,
345
- projectInstructions,
346
- recalledMemory: turnMemory,
347
- renderer: turnRenderer,
348
- router,
349
- onRequestUsage,
350
- });
351
- return new Session({
352
- provider,
353
- registry: execRegistry,
354
- config,
355
- ctx,
356
- git,
357
- projectInstructions,
358
- recalledMemory,
359
- planMode: true,
360
- planRunner,
361
- hooks,
362
- router,
363
- onRunUsage,
364
- jobs: jobManager,
365
- });
366
- }
441
+ // The plan wiring is built ALWAYS, not only when the session STARTS in a
442
+ // planning mode (P5 track 3). It used to be gated on `--plan`, and that had a
443
+ // consequence nobody had written down: `setPlanMode` refuses to engage without
444
+ // a wired runner, so `/plan` in a session launched without the flag silently
445
+ // did nothing at all. A mode the user can cycle into at runtime cannot have
446
+ // its machinery decided at construction.
447
+ //
448
+ // Building it unconditionally is behaviour-preserving for a manual session:
449
+ // `PlanExecutionPolicy` with no safe-step grants enabled is a pass-through to
450
+ // the interactive policy, and `planRunner` is only ever *called* by a
451
+ // planning mode.
452
+ //
453
+ // One allowlist shared by the plan-approval prompt and the per-action gate, so
454
+ // a grant recorded during execution is honored by U.3's own check.
455
+ const allowlist = new SessionAllowlist();
456
+ // Late-bound to the session constructed below. The policy has to read the LIVE
457
+ // mode — the user can leave auto-approve between two actions of a single turn
458
+ // — and the session cannot exist yet because it needs the ctx this policy is
459
+ // wired into. Before it exists there is nothing to approve, so the `false`
460
+ // fallback is a closed door rather than a gap.
461
+ const holder = {};
462
+ const autoApprove = () => holder.session?.getAutoApprove() ?? false;
463
+ const planPolicy = new PlanExecutionPolicy(allowlist, new InteractivePolicy(allowlist, io, autoApprove));
367
464
  const approval = new ApprovalService({
368
465
  cwd,
369
466
  interactive: ttyInteractive,
467
+ policy: planPolicy,
370
468
  io,
371
469
  });
372
470
  const ctx = {
@@ -378,7 +476,23 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
378
476
  checkpointsActive,
379
477
  sandbox,
380
478
  };
381
- return new Session({
479
+ const planRunner = ({ messages, projectInstructions, recalledMemory: turnMemory, renderer: turnRenderer, onRequestUsage, }) => runPlanSession({
480
+ provider,
481
+ config,
482
+ ctx,
483
+ execRegistry,
484
+ planPolicy,
485
+ io,
486
+ interactive: ttyInteractive,
487
+ messages,
488
+ git,
489
+ projectInstructions,
490
+ recalledMemory: turnMemory,
491
+ renderer: turnRenderer,
492
+ router,
493
+ onRequestUsage,
494
+ });
495
+ holder.session = new Session({
382
496
  provider,
383
497
  registry: execRegistry,
384
498
  config,
@@ -386,9 +500,17 @@ export function buildAgentSession(config, apiKey, workspace, ttyInteractive, pla
386
500
  git,
387
501
  projectInstructions,
388
502
  recalledMemory,
503
+ mode,
504
+ planRunner,
389
505
  hooks,
390
506
  router,
507
+ // The same object as `router`, under the handle `/model` mutates. See the
508
+ // note on `SessionArgs.model`.
509
+ ...(sessionModel ? { model: sessionModel } : {}),
391
510
  onRunUsage,
392
511
  jobs: jobManager,
512
+ recorder: opts.recorder,
513
+ restore: opts.restore,
393
514
  });
515
+ return holder.session;
394
516
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * "Did you mean …?" for bare operands (P1 entry).
3
+ *
4
+ * Bare `cruxy <token>` is a message — `cruxy refactor`, `cruxy fix`. But a
5
+ * mistyped subcommand (`cruxy confgi`) arrives in exactly the same shape, and
6
+ * silently sending it to the model would spend a turn on nonsense. So the
7
+ * DEFAULT is "message", and only a token that is a near-miss for a real
8
+ * subcommand is refused.
9
+ *
10
+ * Edit distance, not the fuzzy subsequence scorer in `components/fuzzy.ts`:
11
+ * that scorer answers "does the query appear in order inside the label?", which
12
+ * is the wrong question here. `confgi` is not a subsequence of `config` (there
13
+ * is no `i` after the `g`), so the fuzzy path would let the commonest kind of
14
+ * typo — a transposition — straight through.
15
+ */
16
+ /**
17
+ * Levenshtein distance, bounded: returns `max + 1` as soon as the true distance
18
+ * is known to exceed `max`. The bound matters because the caller only ever asks
19
+ * "is this within 1 or 2?", and the early exit makes a far-apart pair cheap.
20
+ */
21
+ export function editDistance(a, b, max = Infinity) {
22
+ if (a === b)
23
+ return 0;
24
+ if (Math.abs(a.length - b.length) > max)
25
+ return max + 1;
26
+ // Single row, rolled forward — the full matrix is never needed.
27
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
28
+ for (let i = 1; i <= a.length; i++) {
29
+ const row = [i];
30
+ let best = i;
31
+ for (let j = 1; j <= b.length; j++) {
32
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
33
+ const value = Math.min(prev[j] + 1, // deletion
34
+ row[j - 1] + 1, // insertion
35
+ prev[j - 1] + cost);
36
+ row.push(value);
37
+ if (value < best)
38
+ best = value;
39
+ }
40
+ // Every remaining row can only grow the running minimum, so once the whole
41
+ // row is past the bound the answer is settled.
42
+ if (best > max)
43
+ return max + 1;
44
+ prev = row;
45
+ }
46
+ return prev[b.length];
47
+ }
48
+ /**
49
+ * How wrong a token may be and still read as a typo rather than a message.
50
+ *
51
+ * Short tokens get one edit: at four or five characters, two edits reaches too
52
+ * many unrelated words, and a short word is far more likely to be a real
53
+ * instruction (`fix`, `test`, `plan`) than a fumbled command. Six or more
54
+ * characters gets two, which is what catches a transposition (`confgi` →
55
+ * `config`, distance 2) without pulling in genuine tasks (`refactor` is 4+ edits
56
+ * from every command name).
57
+ */
58
+ export function typoThreshold(length) {
59
+ return length <= 5 ? 1 : 2;
60
+ }
61
+ /**
62
+ * The closest subcommand to `token`, or `null` when it is far enough from all of
63
+ * them to be a message. Ties break on the earlier command in `commands`, which
64
+ * is registration order — deterministic, never arbitrary.
65
+ */
66
+ export function suggestCommand(token, commands) {
67
+ const max = typoThreshold(token.length);
68
+ let best = null;
69
+ for (const name of commands) {
70
+ const distance = editDistance(token.toLowerCase(), name.toLowerCase(), max);
71
+ if (distance > max)
72
+ continue;
73
+ if (best === null || distance < best.distance)
74
+ best = { name, distance };
75
+ }
76
+ return best?.name ?? null;
77
+ }
@@ -1,7 +1,6 @@
1
1
  import { fitMiddle } from "../render/layout.js";
2
2
  import { resolveTheme } from "../theme/index.js";
3
- import { createFrame } from "./frame.js";
4
- import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
3
+ import { componentFrame, defaultComponentIO, resolveNonInteractive, } from "./input.js";
5
4
  /** Word-boundary characters that earn the boundary bonus for the NEXT char. */
6
5
  const SEPARATORS = new Set(["/", "-", "_", ".", " "]);
7
6
  /** Per-character score weights — the entire ranking formula. */
@@ -98,7 +97,8 @@ export async function fuzzyFind(items, opts, io = defaultComponentIO()) {
98
97
  const t = resolveTheme(io.caps);
99
98
  const g = t.glyph;
100
99
  const maxVisible = opts.maxVisible ?? 10;
101
- const frame = createFrame(io.write, io.caps);
100
+ // The host's region when there is one (the TUI overlay), else its own.
101
+ const frame = componentFrame(io);
102
102
  let query = "";
103
103
  let cursor = 0; // index into the ranked results
104
104
  const paint = (ranked) => {
@@ -1,5 +1,10 @@
1
1
  import { interactiveRequired } from "../errors/index.js";
2
- import { detectCapabilities } from "../render/index.js";
2
+ // The concrete modules, NOT `render/index.js`: that barrel pulls in
3
+ // `createRenderer`, which imports `tui/renderer.js`, which is where the overlay
4
+ // seam (P5 track 1) lives — and a component importing the TUI that hosts it is
5
+ // a cycle that leaves this module half-initialized at import time.
6
+ import { detectCapabilities } from "../render/capabilities.js";
7
+ import { createFrame } from "./frame.js";
3
8
  import { decodeKeys } from "./keys.js";
4
9
  /** Build the real reader over `stdin` (or an injected fake in tests). */
5
10
  export function createKeyReader(stdin = process.stdin) {
@@ -86,10 +91,12 @@ export async function readSingleKey(stdin = process.stdin) {
86
91
  }
87
92
  /** The real environment: frames to stderr, keys from stdin, caps from stderr. */
88
93
  export function defaultComponentIO() {
94
+ // Detection resolves both axes (stderr for output, stdin for input) and their
95
+ // conjunction — so `interactive` is read, not recomputed here.
89
96
  const caps = detectCapabilities(process.stderr);
90
97
  return {
91
98
  caps,
92
- interactive: Boolean(process.stdin.isTTY) && caps.cursor,
99
+ interactive: caps.interactive,
93
100
  write: (text) => void process.stderr.write(text),
94
101
  keys: createKeyReader(process.stdin),
95
102
  };
@@ -109,3 +116,11 @@ export function resolveNonInteractive(io, what, defaultValue, alternatives = [])
109
116
  }
110
117
  throw interactiveRequired(what, alternatives);
111
118
  }
119
+ /**
120
+ * The transient region a component should draw into: the host's, when one is
121
+ * offered (the TUI overlay), otherwise its own stderr frame. One line, shared,
122
+ * so no component has to know which world it is running in.
123
+ */
124
+ export function componentFrame(io) {
125
+ return io.makeFrame ? io.makeFrame() : createFrame(io.write, io.caps);
126
+ }
@@ -5,12 +5,19 @@
5
5
  */
6
6
  const CTRL_C = 0x03;
7
7
  const CTRL_D = 0x04;
8
+ const CTRL_K = 0x0b;
8
9
  const BACKSPACE = 0x08;
9
10
  const TAB = 0x09;
10
11
  const LF = 0x0a;
11
12
  const CR = 0x0d;
12
13
  const ESC = 0x1b;
13
14
  const DELETE = 0x7f;
15
+ /**
16
+ * CSI final byte `Z` — CBT, "cursor backward tabulation". Every terminal that
17
+ * distinguishes Shift+Tab at all sends this and sends it for nothing else, so
18
+ * unlike the arrows it is safe to accept with OR without parameters.
19
+ */
20
+ const CBT = 0x5a;
14
21
  /** CSI final byte → arrow key, for `ESC [ <final>` sequences. */
15
22
  const ARROWS = {
16
23
  0x41: "up", // A
@@ -23,9 +30,9 @@ const ARROWS = {
23
30
  * keys per chunk; all are returned in order.
24
31
  *
25
32
  * Escape handling is deliberately simple: `ESC [ A..D` decodes to an arrow,
26
- * any other CSI sequence (`ESC [ …final`) is swallowed whole (unmapped keys
27
- * must not leak garbage chars into a query), and a lone ESC decodes to
28
- * `escape`. Terminals send arrow sequences atomically in practice; a sequence
33
+ * `ESC [ … Z` decodes to Shift+Tab, any other CSI sequence (`ESC [ …final`) is
34
+ * swallowed whole (unmapped keys must not leak garbage chars into a query), and
35
+ * a lone ESC decodes to `escape`. Terminals send arrow sequences atomically in practice; a sequence
29
36
  * split across chunks degrades to `escape` + literal chars, which is safe
30
37
  * (escape cancels).
31
38
  */
@@ -42,6 +49,18 @@ export function decodeKeys(chunk) {
42
49
  while (j < buf.length && buf[j] >= 0x20 && buf[j] <= 0x3f)
43
50
  j++;
44
51
  if (j < buf.length) {
52
+ if (buf[j] === CBT) {
53
+ // Shift+Tab, in BOTH its forms: bare `ESC [ Z`, and the
54
+ // parameterized `ESC [ 1 ; 2 Z` some terminals send when a modifier
55
+ // map is active. The `j === i + 2` guard below deliberately rejects
56
+ // parameterized sequences — it exists so a modifier-arrow cannot act
57
+ // as a plain arrow — but `Z` has exactly one meaning whatever its
58
+ // parameters, so applying that guard here would silently swallow
59
+ // half the terminals that can send this key at all.
60
+ keys.push({ kind: "shift-tab" });
61
+ i = j + 1;
62
+ continue;
63
+ }
45
64
  // Only a bare `ESC [ <final>` maps to an arrow; parameterized
46
65
  // sequences (modifier arrows, Home/End variants) are swallowed —
47
66
  // an unmapped combo must do nothing, not act as a plain arrow.
@@ -88,6 +107,11 @@ export function decodeKeys(chunk) {
88
107
  i++;
89
108
  continue;
90
109
  }
110
+ if (byte === CTRL_K) {
111
+ keys.push({ kind: "ctrl-k" });
112
+ i++;
113
+ continue;
114
+ }
91
115
  if (byte < 0x20) {
92
116
  // Other control chars: ignore (same rule as the secret reader).
93
117
  i++;
@@ -1,7 +1,6 @@
1
1
  import { fitMiddle } from "../render/layout.js";
2
2
  import { resolveTheme } from "../theme/index.js";
3
- import { createFrame } from "./frame.js";
4
- import { defaultComponentIO, resolveNonInteractive, } from "./input.js";
3
+ import { componentFrame, defaultComponentIO, resolveNonInteractive, } from "./input.js";
5
4
  /**
6
5
  * Pick one item: ↑/↓ move (wrapping past either end), Enter selects the
7
6
  * highlighted item, Esc / Ctrl-C / EOF cancel. Renders a transient frame via
@@ -19,7 +18,8 @@ export async function selectList(items, opts = {}, io = defaultComponentIO()) {
19
18
  const t = resolveTheme(io.caps);
20
19
  const g = t.glyph;
21
20
  const maxVisible = opts.maxVisible ?? 10;
22
- const frame = createFrame(io.write, io.caps);
21
+ // The host's region when there is one (the TUI overlay), else its own.
22
+ const frame = componentFrame(io);
23
23
  let cursor = Math.min(Math.max(opts.initialIndex ?? 0, 0), items.length - 1);
24
24
  const paint = () => {
25
25
  const lines = [];