@cruxy/cli 1.2.1 → 1.4.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 (85) hide show
  1. package/dist/agent/context.js +178 -0
  2. package/dist/agent/index.js +1 -0
  3. package/dist/agent/loop.js +20 -1
  4. package/dist/agent/mode.js +103 -0
  5. package/dist/agent/prompts.js +1 -1
  6. package/dist/agent/session.js +171 -69
  7. package/dist/agent/status.js +56 -0
  8. package/dist/approval/classify.js +204 -0
  9. package/dist/approval/policy.js +41 -3
  10. package/dist/approval/prompt.js +49 -22
  11. package/dist/checkpoint/gate.js +12 -0
  12. package/dist/cli/commands/run.js +401 -227
  13. package/dist/cli/commands/usage.js +45 -45
  14. package/dist/cli/onboard.js +2 -1
  15. package/dist/cli/program.js +60 -18
  16. package/dist/cli/repl.js +67 -249
  17. package/dist/cli/session-commands.js +717 -0
  18. package/dist/cli/session-factory.js +198 -76
  19. package/dist/cli/suggest.js +77 -0
  20. package/dist/components/fuzzy.js +3 -3
  21. package/dist/components/input.js +17 -2
  22. package/dist/components/keys.js +65 -3
  23. package/dist/components/select.js +3 -3
  24. package/dist/config/effective.js +225 -0
  25. package/dist/config/index.js +1 -0
  26. package/dist/config/manager.js +50 -20
  27. package/dist/config/project.js +53 -1
  28. package/dist/config/schema.js +49 -16
  29. package/dist/jobs/log-renderer.js +47 -0
  30. package/dist/onboarding/steps.js +13 -22
  31. package/dist/plan/approve.js +36 -24
  32. package/dist/plan/execute.js +9 -7
  33. package/dist/plan/render.js +10 -23
  34. package/dist/plan/service.js +4 -1
  35. package/dist/render/capabilities.js +30 -1
  36. package/dist/render/context-view.js +106 -0
  37. package/dist/render/diff.js +204 -12
  38. package/dist/render/index.js +31 -5
  39. package/dist/render/plain-renderer.js +38 -2
  40. package/dist/render/plan-view.js +108 -0
  41. package/dist/render/resize.js +7 -2
  42. package/dist/render/status-view.js +66 -0
  43. package/dist/render/test-view.js +89 -0
  44. package/dist/render/tty-renderer.js +40 -0
  45. package/dist/routing/index.js +1 -0
  46. package/dist/routing/router.js +13 -4
  47. package/dist/routing/session-model.js +109 -0
  48. package/dist/routing/types.js +14 -0
  49. package/dist/session/export.js +88 -0
  50. package/dist/session/index.js +20 -0
  51. package/dist/session/list.js +137 -0
  52. package/dist/session/log.js +137 -0
  53. package/dist/session/paths.js +73 -0
  54. package/dist/session/replay.js +169 -0
  55. package/dist/session/resume.js +128 -0
  56. package/dist/session/types.js +223 -0
  57. package/dist/subagent/orchestrator.js +23 -0
  58. package/dist/testing/run-tests-tool.js +8 -0
  59. package/dist/tools/registry.js +3 -3
  60. package/dist/tui/app.js +508 -0
  61. package/dist/tui/approval-overlay.js +160 -0
  62. package/dist/tui/context-gauge.js +48 -0
  63. package/dist/tui/git-status.js +108 -0
  64. package/dist/tui/git-view.js +121 -0
  65. package/dist/tui/index.js +15 -0
  66. package/dist/tui/layout.js +314 -0
  67. package/dist/tui/overlay.js +105 -0
  68. package/dist/tui/overview.js +49 -0
  69. package/dist/tui/palette.js +73 -0
  70. package/dist/tui/panels.js +235 -0
  71. package/dist/tui/renderer.js +1121 -0
  72. package/dist/tui/settings-view.js +282 -0
  73. package/dist/tui/supports.js +20 -0
  74. package/dist/tui/tasks-view.js +215 -0
  75. package/dist/tui/tool-versions.js +129 -0
  76. package/dist/tui/views.js +66 -0
  77. package/dist/usage/collect.js +6 -6
  78. package/dist/usage/index.js +10 -2
  79. package/dist/usage/report.js +76 -0
  80. package/dist/usage/summary.js +106 -17
  81. package/dist/usage/types.js +5 -2
  82. package/dist/usage/weighted.js +77 -0
  83. package/dist/utils/git.js +163 -4
  84. package/package.json +1 -1
  85. package/dist/usage/cost.js +0 -29
package/dist/utils/git.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { execFile, spawnSync } from "node:child_process";
2
2
  /** Hard ceiling on a git invocation; a hung git must never stall startup. */
3
3
  const GIT_TIMEOUT_MS = 5000;
4
4
  /**
@@ -17,6 +17,108 @@ function runGit(args, cwd) {
17
17
  }
18
18
  return res.stdout;
19
19
  }
20
+ /**
21
+ * {@link runGit} without blocking the event loop. Same contract in every other
22
+ * respect: `null` on any failure, never throws, same timeout.
23
+ *
24
+ * This exists because `spawnSync` is measurably expensive — around 45ms warm for
25
+ * the branch+status pair — and the TUI needs this data while a model response is
26
+ * streaming. Blocking the loop for 45ms mid-stream stutters the paint; blocking
27
+ * it from the paint path itself would do so on every frame.
28
+ */
29
+ function runGitAsync(args, cwd) {
30
+ return new Promise((resolve) => {
31
+ execFile("git", args, { cwd, encoding: "utf8", timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => resolve(err ? null : stdout));
32
+ });
33
+ }
34
+ /**
35
+ * Number of changed paths in `git status --porcelain` output.
36
+ *
37
+ * One line is one path, INCLUDING a rename (`R old -> new`), which is one
38
+ * change and not two. Blank lines are ignored so a trailing newline — always
39
+ * present on non-empty output — cannot inflate the count by one.
40
+ */
41
+ export function countChanges(status) {
42
+ return status.split("\n").filter((line) => line.trim() !== "").length;
43
+ }
44
+ /** Porcelain XY code → the status this reports. */
45
+ function statusFromCode(x, y) {
46
+ if (x === "?" || y === "?")
47
+ return "untracked";
48
+ // A `U` on either side, or the AA/DD pairs, are unmerged paths.
49
+ if (x === "U" ||
50
+ y === "U" ||
51
+ (x === "A" && y === "A") ||
52
+ (x === "D" && y === "D")) {
53
+ return "conflicted";
54
+ }
55
+ if (x === "R")
56
+ return "renamed";
57
+ if (x === "A")
58
+ return "added";
59
+ if (x === "D" || y === "D")
60
+ return "deleted";
61
+ return "modified";
62
+ }
63
+ /**
64
+ * Parse `git status --porcelain` into one entry per path.
65
+ *
66
+ * The format is `XY <path>`, with a rename written `R old -> new`. Renames
67
+ * take the NEW path because that is where the content lives now and what a
68
+ * follow-up diff or edit would name; the old one is not shown, which is a real
69
+ * (small) loss of information accepted to keep one row per change.
70
+ *
71
+ * Paths containing spaces are handled because the split is positional (columns
72
+ * 0-1 are the code, 3 onward is the path) rather than whitespace-delimited.
73
+ * Quoted paths — git quotes non-ASCII unless `core.quotePath=false` — are left
74
+ * exactly as git wrote them rather than half-unescaped into something that
75
+ * looks like a real path but is not.
76
+ */
77
+ export function parsePorcelain(text) {
78
+ const out = [];
79
+ for (const line of text.split("\n")) {
80
+ if (line.trim() === "")
81
+ continue;
82
+ const x = line[0] ?? " ";
83
+ const y = line[1] ?? " ";
84
+ let path = line.slice(3);
85
+ const status = statusFromCode(x, y);
86
+ if (status === "renamed") {
87
+ const arrow = path.indexOf(" -> ");
88
+ if (arrow >= 0)
89
+ path = path.slice(arrow + 4);
90
+ }
91
+ out.push({ path, status, added: null, removed: null, approximate: false });
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * Parse `git diff --numstat` into per-path line counts.
97
+ *
98
+ * A binary file is reported as `-\t-\t<path>`, which becomes `null` on both
99
+ * sides — the honest answer, and the one {@link GitFileChange} already models.
100
+ */
101
+ export function parseNumstat(text) {
102
+ const out = new Map();
103
+ for (const line of text.split("\n")) {
104
+ if (line.trim() === "")
105
+ continue;
106
+ const [addedRaw, removedRaw, ...rest] = line.split("\t");
107
+ const path = rest.join("\t");
108
+ if (path === "")
109
+ continue;
110
+ out.set(path, {
111
+ added: addedRaw === "-" ? null : Number(addedRaw),
112
+ removed: removedRaw === "-" ? null : Number(removedRaw),
113
+ });
114
+ }
115
+ return out;
116
+ }
117
+ /** Shared shaping so the sync and async paths cannot disagree. */
118
+ function toGitInfo(branch, status) {
119
+ const changed = countChanges(status);
120
+ return { branch: branch.trim(), dirty: changed > 0, changed };
121
+ }
20
122
  /**
21
123
  * Branch name plus the raw `git status --porcelain` text for `cwd`, or `null`
22
124
  * when it isn't a git repository (or git is unavailable). Backs the `git_status`
@@ -32,12 +134,69 @@ export function getGitStatus(cwd) {
32
134
  return { branch: branch.trim(), status };
33
135
  }
34
136
  /**
35
- * Compact git context for the system prompt: current branch and whether the
36
- * working tree has uncommitted changes. `null` when not a repo / git missing.
137
+ * Compact git context for the system prompt: current branch, whether the working
138
+ * tree has uncommitted changes, and how many paths changed. `null` when not a
139
+ * repo / git missing.
140
+ *
141
+ * The change count costs no extra subprocess — it is derived from the porcelain
142
+ * output {@link getGitStatus} already fetched.
37
143
  */
38
144
  export function getGitInfo(cwd) {
39
145
  const info = getGitStatus(cwd);
40
146
  if (info === null)
41
147
  return null;
42
- return { branch: info.branch, dirty: info.status.trim().length > 0 };
148
+ return toGitInfo(info.branch, info.status);
149
+ }
150
+ /**
151
+ * {@link getGitInfo} without blocking the event loop — what the TUI rail uses.
152
+ * Identical result, identical `null` semantics.
153
+ */
154
+ export async function getGitInfoAsync(cwd) {
155
+ const branch = await runGitAsync(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
156
+ if (branch === null)
157
+ return null;
158
+ const status = await runGitAsync(["status", "--porcelain"], cwd);
159
+ if (status === null)
160
+ return null;
161
+ return toGitInfo(branch, status);
162
+ }
163
+ /**
164
+ * {@link getGitInfoAsync} plus the per-path detail the Git view needs (P7
165
+ * track 4): the same branch and count, and a `files` list.
166
+ *
167
+ * A SEPARATE ENTRY POINT rather than an option on the old one, because the
168
+ * extra `--numstat` is a third subprocess and the rail must not start paying
169
+ * for a list it has one line to show. Consumers pick the probe that matches
170
+ * what they render, and `files` being absent on the cheap one is what makes
171
+ * that choice visible in the type.
172
+ *
173
+ * The numstat is diffed against HEAD so staged and unstaged work both count —
174
+ * the question a Git view answers is "what has this session done to my files",
175
+ * and whether a change happens to be staged is not part of it. Untracked files
176
+ * appear in the porcelain but in no diff, so their counts stay `null` rather
177
+ * than being invented from a file read.
178
+ */
179
+ export async function getGitTreeAsync(cwd) {
180
+ const branch = await runGitAsync(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
181
+ if (branch === null)
182
+ return null;
183
+ const status = await runGitAsync(["status", "--porcelain"], cwd);
184
+ if (status === null)
185
+ return null;
186
+ const info = toGitInfo(branch, status);
187
+ const files = parsePorcelain(status);
188
+ // Best-effort: a repo with no commits yet has no HEAD to diff against, and
189
+ // that must degrade to "counts unknown", not to "not a repo".
190
+ const numstat = await runGitAsync(["diff", "--numstat", "HEAD"], cwd);
191
+ if (numstat !== null) {
192
+ const counts = parseNumstat(numstat);
193
+ for (const file of files) {
194
+ const c = counts.get(file.path);
195
+ if (c) {
196
+ file.added = c.added;
197
+ file.removed = c.removed;
198
+ }
199
+ }
200
+ }
201
+ return { ...info, files };
43
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cruxy/cli",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "an agentic coding CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,29 +0,0 @@
1
- /**
2
- * Token → cost mapping (C.22). The whole discipline lives in one rule: a cost is
3
- * produced ONLY when the tier has a configured price AND at least one token side
4
- * is known. Otherwise the result is `undefined` — cost is omitted, tokens are
5
- * still shown, and NO dollar figure is ever fabricated. Prices are per MILLION
6
- * tokens (see {@link TierPrice}). Keyed by tier only (U.8 gag). No network.
7
- */
8
- /** The configured price for a tier, or `undefined` when the tier is unpriced. */
9
- export function priceForTier(tier, prices) {
10
- return prices[tier];
11
- }
12
- /**
13
- * Compute the cost of a tier's usage, or `undefined` when it cannot be stated
14
- * honestly:
15
- * - no configured price for the tier → `undefined` (cost omitted).
16
- * - both token counts unknown → `undefined` (nothing real to price).
17
- * A known side is priced; an unknown side contributes nothing (never a
18
- * fabricated 0-token charge). tokens/1e6 × price, summed.
19
- */
20
- export function costFor(tier, inputTokens, outputTokens, prices) {
21
- const price = priceForTier(tier, prices);
22
- if (!price)
23
- return undefined;
24
- if (inputTokens === undefined && outputTokens === undefined)
25
- return undefined;
26
- const inCost = inputTokens !== undefined ? (inputTokens / 1_000_000) * price.input : 0;
27
- const outCost = outputTokens !== undefined ? (outputTokens / 1_000_000) * price.output : 0;
28
- return inCost + outCost;
29
- }