howtofixcli 0.7.13 → 0.7.15

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 (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
@@ -1,23 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { test } from "node:test";
3
- import { deviceId, hardwareIds } from "./device-id.js";
4
- test("the device id is 32 lowercase hex characters", () => {
5
- const { id } = deviceId();
6
- assert.match(id, /^[0-9a-f]{32}$/);
7
- });
8
- test("the device id is stable across calls", () => {
9
- assert.equal(deviceId().id, deviceId().id);
10
- });
11
- test("the id comes from a hardware identifier when the platform has one", () => {
12
- const ids = hardwareIds();
13
- if (process.platform !== "linux")
14
- return; // other platforms are exercised on their own machines
15
- assert.ok(ids.length > 0, "a Linux host should expose a DMI or machine id");
16
- assert.equal(deviceId().weak, false);
17
- });
18
- test("the raw hardware identifier is not part of the id", () => {
19
- const raw = hardwareIds()[0];
20
- if (!raw)
21
- return;
22
- assert.ok(!deviceId().id.includes(raw.replace(/-/g, "")));
23
- });
package/dist/errors.js DELETED
@@ -1,63 +0,0 @@
1
- /**
2
- * One way to tell a user that something failed.
3
- *
4
- * Every command used to phrase its own failure, which meant a missing file, a
5
- * refused connection and a typo in a flag could all arrive in the same shape:
6
- * a sentence that says what broke but not what to do. This module answers both
7
- * questions from the error itself, so the answer does not depend on which code
8
- * path happened to throw.
9
- */
10
- /** The action a user can take, inferred from the code Node attached. */
11
- function hintFor(code, message) {
12
- const evidence = `${code ?? ""} ${message}`;
13
- if (/EACCES|EPERM/.test(evidence))
14
- return "the file or directory is not writable; check permissions and try again";
15
- if (/ENOENT/.test(evidence))
16
- return "a file or directory this command needs is missing";
17
- if (/ENOSPC/.test(evidence))
18
- return "the disk is full";
19
- if (/ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|UND_ERR|fetch failed/i.test(evidence)) {
20
- return "the network request failed; check the connection and the gateway address";
21
- }
22
- if (/EADDRINUSE/.test(evidence))
23
- return "another process is already using that port";
24
- if (/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND/.test(evidence)) {
25
- return "a required file or package is missing; reinstall HowToFix";
26
- }
27
- if (/JSON/i.test(evidence) && /parse|unexpected token|end of input/i.test(evidence)) {
28
- return "a configuration file is not valid JSON";
29
- }
30
- if (/not a git repository/i.test(evidence))
31
- return "run the command inside a project, or pass --cwd";
32
- return undefined;
33
- }
34
- /** The message, and the action that follows from it. */
35
- export function describeError(error) {
36
- const raw = error instanceof Error ? error.message : String(error);
37
- const message = raw.trim() || "unknown error";
38
- const code = error?.code;
39
- return { message, hint: hintFor(code, message) };
40
- }
41
- /**
42
- * The line(s) a failure prints.
43
- *
44
- * `context` names the thing that failed ("doctor", "update"), because "not
45
- * permitted" alone does not say what was not permitted. The stack is evidence,
46
- * and evidence only appears when `HOWTOFIX_DEBUG` asks for it.
47
- */
48
- export function formatError(appTitle, context, error, debug = false) {
49
- const { message, hint } = describeError(error);
50
- const head = context ? `${appTitle}: ${context}: ${message}` : `${appTitle}: ${message}`;
51
- const lines = [head];
52
- if (hint)
53
- lines.push(` ${hint}`);
54
- if (debug && error instanceof Error && error.stack)
55
- lines.push(error.stack);
56
- return lines.join("\n");
57
- }
58
- /** Print a failure the same way everywhere. */
59
- export function reportError(appTitle, context, error, options) {
60
- const debug = options?.debug ?? process.env.HOWTOFIX_DEBUG === "1";
61
- const write = options?.write ?? ((text) => console.error(text));
62
- write(formatError(appTitle, context, error, debug));
63
- }
@@ -1,48 +0,0 @@
1
- /**
2
- * UI context for built-in extensions (MCP sign-in, codemode, prompts) that
3
- * routes select/confirm/input/notify into the workbench instead of the SDK's own TUI.
4
- */
5
- export function workbenchUI(dialogs) {
6
- const noop = () => { };
7
- return {
8
- select: (title, choices) => dialogs.select(title, choices),
9
- confirm: (title, message) => dialogs.confirm(title, message),
10
- input: (title) => dialogs.input(title),
11
- notify: (message, type) => dialogs.notify(message, type),
12
- onTerminalInput: () => noop,
13
- setStatus: (key, text) => dialogs.setStatus(key, text),
14
- setWorkingMessage: noop,
15
- setWorkingVisible: noop,
16
- setWorkingIndicator: noop,
17
- setHiddenThinkingLabel: noop,
18
- // The SDK also accepts a component factory here, which needs a TUI to
19
- // instantiate. The workbench hosts plain text lines, so a factory is
20
- // ignored rather than clearing a widget that is already pinned.
21
- setWidget: (key, content, placement) => {
22
- if (content !== undefined && !Array.isArray(content))
23
- return;
24
- dialogs.setWidget?.(key, content, placement?.placement);
25
- },
26
- setFooter: noop,
27
- setHeader: noop,
28
- setTitle: noop,
29
- // The workbench has no overlay component host; extensions fall back to
30
- // text listings (for example /mcp prints its server status).
31
- custom: async () => {
32
- throw new Error("this screen has no custom component host; use the text commands instead");
33
- },
34
- pasteToEditor: noop,
35
- setEditorText: noop,
36
- getEditorText: () => "",
37
- editor: async () => undefined,
38
- addAutocompleteProvider: noop,
39
- setEditorComponent: noop,
40
- getEditorComponent: () => undefined,
41
- theme: undefined,
42
- getAllThemes: () => [],
43
- getTheme: () => undefined,
44
- setTheme: () => ({ success: false, error: "themes are not available in the workbench" }),
45
- getToolsExpanded: () => false,
46
- setToolsExpanded: noop,
47
- };
48
- }
package/dist/fatal.js DELETED
@@ -1,35 +0,0 @@
1
- /**
2
- * What a crash says to the user.
3
- *
4
- * A CLI that prints a stack trace for a failed network call, a missing file or a
5
- * closed pipe looks broken, and the one line that explains it is buried under
6
- * frames the user cannot act on. The message is what matters; the stack is
7
- * evidence, and evidence belongs behind a flag.
8
- */
9
- import { describeError } from "./errors.js";
10
- /** The single line a failure deserves, with the stack only when asked for. */
11
- export function describeFatal(error, debug = false) {
12
- const { message } = describeError(error);
13
- if (!debug)
14
- return message;
15
- const stack = error instanceof Error ? error.stack : undefined;
16
- return stack && stack.includes("\n") ? `${message}\n${stack}` : message;
17
- }
18
- /**
19
- * Catch what escapes `main()`.
20
- *
21
- * `main().catch()` only covers the awaited path. Timers, event listeners and
22
- * fire-and-forget promises live outside it, and Node answers them with a raw
23
- * stack trace and a non-zero exit that says nothing about what happened. Both
24
- * handlers keep the exit code honest and the message readable.
25
- */
26
- export function installCrashHandlers(appTitle, options) {
27
- const debug = options?.debug ?? process.env.HOWTOFIX_DEBUG === "1";
28
- const exit = options?.exit ?? ((code) => process.exit(code));
29
- const report = (error) => {
30
- console.error(`${appTitle}: ${describeFatal(error, debug)}`);
31
- exit(1);
32
- };
33
- process.on("uncaughtException", report);
34
- process.on("unhandledRejection", report);
35
- }
package/dist/feed.js DELETED
@@ -1,498 +0,0 @@
1
- import { Markdown, wrapTextWithAnsi } from "@netrodev/hxf-tui";
2
- import { ENTRANCE_MS, entrance, entering, fade, revealedLines } from "./anim.js";
3
- import { fg, ink, mdTheme } from "./theme.js";
4
- import { EXPAND_MARKER, RESULT_MARKER, formatToolCall, formatToolResult, spinnerFrame } from "./tool-render.js";
5
- import { collapseGroups } from "./collapse.js";
6
- import { stripDcpMarkers } from "./dcp/ids.js";
7
- /**
8
- * Add a note, replacing its own predecessor when that predecessor is still the
9
- * last thing in the transcript.
10
- *
11
- * A slash command that reports state — `/dcp status`, `/usage` — pushes a feed
12
- * item every time it runs, so asking twice drew the identical line twice and a
13
- * third time drew it three times. The screen filled with copies of one answer.
14
- *
15
- * A repeated report supersedes itself: the newest reading of `/dcp status` is
16
- * the answer, and the previous one is not history worth scrolling back to.
17
- * Anything that landed in between — the user's message, a tool call — makes the
18
- * old note part of the conversation, and it stays. The rule is deliberately
19
- * narrow (same title, still last) because only the immediately-previous,
20
- * same-titled note is a stale copy of this one; a note further back was
21
- * superseded by real work and deleting it would erase the record of that work.
22
- */
23
- export function upsertNote(items, item) {
24
- const last = items[items.length - 1];
25
- if (last && last.kind === "note" && last.title === item.title) {
26
- return [...items.slice(0, -1), item];
27
- }
28
- return [...items, item];
29
- }
30
- function textOf(content) {
31
- // A ref marker the model wrote into its own answer is machinery, not prose:
32
- // it is stripped here so nothing painted or stored can show it. See
33
- // `stripDcpMarkers`.
34
- if (typeof content === "string")
35
- return stripDcpMarkers(content);
36
- return stripDcpMarkers(content
37
- .map((part) => {
38
- if (part.type === "text")
39
- return part.text;
40
- if (part.type === "thinking")
41
- return "";
42
- if (part.type === "image")
43
- return "[image]";
44
- if (part.type === "toolCall")
45
- return "";
46
- return "";
47
- })
48
- .filter(Boolean)
49
- .join(""));
50
- }
51
- /**
52
- * The body of a user message, trimmed — exactly what `itemsFromMessages` draws
53
- * for it.
54
- *
55
- * Exported so the workbench can recognise its own optimistic copy of a message
56
- * when the real turn arrives: the two are generated by different code and share
57
- * no id, so the text is the only thing they agree on.
58
- */
59
- export function userTextOf(message) {
60
- return message.role === "user" ? textOf(message.content).trim() : "";
61
- }
62
- /**
63
- * Bring the feed's copy of a user message in line with the session's.
64
- *
65
- * The feed draws its own copy the instant Enter is pressed — it has to, or the
66
- * message would not appear until the model had already answered. The session
67
- * later reports the same message with its own id, and pushing that report as a
68
- * new item drew the message twice: once as the optimistic row and once as the
69
- * replayed turn, with the queue state attached to the row that was about to be
70
- * scrolled away.
71
- *
72
- * So the report *replaces* the copy it matches. The match is on the trimmed body
73
- * because the two ids are generated by different code and share nothing; the
74
- * first match wins, which keeps a repeated message (the same text sent twice) as
75
- * two rows — the second send matches the row the first one left behind only if
76
- * that row is still unclaimed, and `replace` consumes it.
77
- */
78
- export function upsertUserTurn(items, text, id) {
79
- const body = text.trim();
80
- if (!body)
81
- return items;
82
- const at = items.findIndex((item) => item.kind === "user" && item.body.trim() === body);
83
- if (at === -1)
84
- return [...items, { id, kind: "user", title: "you", body }];
85
- const next = [...items];
86
- // `pending` is deliberately dropped: the session reporting this message is the
87
- // proof that the run has it, and a marker left on would claim otherwise.
88
- next[at] = { id, kind: "user", title: "you", body };
89
- return next;
90
- }
91
- function toolCallsOf(message) {
92
- return message.content.filter((part) => part.type === "toolCall").map((part) => ({ name: part.name, arguments: part.arguments }));
93
- }
94
- /** The tool-call ids of an assistant message, in order, to pair with results. */
95
- function callIdsOf(message) {
96
- return message.content.filter((part) => part.type === "toolCall").map((part) => part.id);
97
- }
98
- /**
99
- * Replace a run of consecutive read/search items with one summary item.
100
- *
101
- * Done at item level rather than at render, because "consecutive" is a property
102
- * of the transcript and the renderer only ever sees one item. A run is broken by
103
- * anything that is not a read/search call: assistant prose, an edit, a shell
104
- * command, a result whose call was not collapsible. The rules and their
105
- * provenance are in `collapse.ts`.
106
- *
107
- * The replacement keeps the id of the FIRST call in the run, so the item's
108
- * position in the feed does not move and the entrance animation does not
109
- * re-fire for the rest.
110
- */
111
- function collapseItems(items) {
112
- const calls = items.map((item) => ({
113
- toolName: item.kind === "result" || item.kind === "tool" ? item.title : "",
114
- line: item.call ?? item.body,
115
- }));
116
- const groups = collapseGroups(calls);
117
- if (groups.length === 0)
118
- return items;
119
- const byStart = new Map(groups.map((group) => [group.start, group]));
120
- const covered = new Set(groups.flatMap((group) => group.indices));
121
- const out = [];
122
- for (const [index, item] of items.entries()) {
123
- const group = byStart.get(index);
124
- if (group) {
125
- const first = items[group.start];
126
- out.push({
127
- id: first?.id ?? `g-${group.start}`,
128
- kind: "collapsed",
129
- title: "collapsed",
130
- body: group.summary,
131
- // The individual calls are kept whole under the summary, so `ctrl+e`
132
- // still reaches them. A collapse that discarded the detail would be a
133
- // lossy render, and this is a view, not a transform.
134
- full: group.indices.map((i) => items[i]?.call ?? items[i]?.body ?? "").filter(Boolean),
135
- });
136
- continue;
137
- }
138
- if (covered.has(index))
139
- continue;
140
- out.push(item);
141
- }
142
- return out;
143
- }
144
- export function itemsFromMessages(messages) {
145
- const items = [];
146
- for (const message of messages) {
147
- if (message.role === "user") {
148
- const body = textOf(message.content).trim();
149
- if (!body)
150
- continue;
151
- // `u-` and not `you-`: the feed's own optimistic copy of this message
152
- // carries `you-${Date.now()}`, and the two have to be told apart so the
153
- // copy can be retired when the real turn arrives. See `sendTask`.
154
- items.push({ id: `u-${message.timestamp}`, kind: "user", title: "you", body });
155
- }
156
- else if (message.role === "assistant") {
157
- const body = textOf(message.content).trim();
158
- const calls = toolCallsOf(message);
159
- const callIds = callIdsOf(message);
160
- if (body) {
161
- items.push({
162
- id: `a-${message.timestamp}`,
163
- kind: message.stopReason === "error" ? "error" : "assistant",
164
- title: message.errorMessage ? "failed" : "fix",
165
- body: message.errorMessage ? `${message.errorMessage}\n\n${body}` : body,
166
- });
167
- }
168
- else if (message.errorMessage) {
169
- items.push({
170
- id: `e-${message.timestamp}`,
171
- kind: "error",
172
- title: "failed",
173
- body: message.errorMessage,
174
- });
175
- }
176
- calls.forEach((call, index) => {
177
- const toolCallId = callIds[index];
178
- items.push({
179
- id: toolCallId ? `tool-${toolCallId}` : `c-${message.timestamp}-${index}`,
180
- kind: "tool",
181
- title: call.name,
182
- body: formatToolCall(call.name, call.arguments),
183
- // The id is remembered so the result that follows can settle THIS
184
- // item, the way the live path does. Replay used to push a separate
185
- // result row, which drew every call head first and every summary
186
- // under them - the same bug the live path had, still present here.
187
- ...(toolCallId ? { call: formatToolCall(call.name, call.arguments) } : {}),
188
- });
189
- });
190
- }
191
- else if (message.role === "toolResult") {
192
- const body = formatToolResult(message.toolName, { content: message.content }, message.isError);
193
- // Settle the call that produced this result, in place. One item per call,
194
- // head then summary, so a count cannot sit under a call it came from
195
- // somewhere else - and so a run of reads is a run of one kind of item.
196
- const settled = items.find((entry) => entry.id === `tool-${message.toolCallId}`);
197
- if (settled) {
198
- settled.kind = "result";
199
- settled.body = body;
200
- settled.title = message.toolName;
201
- }
202
- else {
203
- items.push({
204
- id: `r-${message.toolCallId}`,
205
- kind: "result",
206
- title: message.toolName,
207
- body,
208
- });
209
- }
210
- }
211
- }
212
- return collapseItems(items);
213
- }
214
- function linesOf(text, width, color) {
215
- return wrapTextWithAnsi(text, Math.max(8, width)).map((line) => fg(line, color));
216
- }
217
- /**
218
- * Markdown components, memoised per feed item.
219
- *
220
- * The SDK's `Markdown` caches its lexed output per instance on `(text, width)`,
221
- * so constructing one per render throws that cache away: the 90 ms spinner tick
222
- * re-lexes every message in the transcript while the agent streams, and a long
223
- * session pays for the whole scrollback on every frame. Reusing the instance
224
- * means a finalised item lexes once ever, and a streaming item lexes once per
225
- * content change instead of once per frame. The entry is dropped when the item
226
- * leaves the feed, so the map cannot outgrow the feed.
227
- */
228
- const markdownByItem = new Map();
229
- function markdownFor(item) {
230
- const body = item.body || " ";
231
- const cached = markdownByItem.get(item.id);
232
- if (cached) {
233
- if (cached.text !== body) {
234
- cached.component.setText(body);
235
- cached.text = body;
236
- }
237
- return cached.component;
238
- }
239
- const component = new Markdown(body, 0, 0, mdTheme);
240
- markdownByItem.set(item.id, { text: body, component });
241
- return component;
242
- }
243
- class Block {
244
- item;
245
- constructor(item) {
246
- this.item = item;
247
- }
248
- invalidate() { }
249
- /**
250
- * Fade a colour in with the item. Assistant bodies come from the markdown
251
- * renderer, which owns its own colours and its own memoised lex, so they are
252
- * left alone: re-painting them per frame is exactly the cost that memo exists
253
- * to avoid.
254
- */
255
- shade(color) {
256
- const age = Date.now() - (this.item.born ?? 0);
257
- return fade(ink.dim, color, entrance(age, ENTRANCE_MS));
258
- }
259
- render(width) {
260
- const body = this.item.body;
261
- if (this.item.kind === "user") {
262
- // The queue notice. The run is still working and has not read this yet:
263
- // without the line, the message sitting in the transcript above the
264
- // agent's old plan reads as the agent ignoring it.
265
- const head = this.item.pending ? [fg(" queued — the run reads this at its next step", ink.dim)] : [];
266
- return ["", ...linesOf(`> ${body}`, width, this.shade(ink.user)), ...head];
267
- }
268
- // Tool call and result follow Claude Code's shape: a `⏺` head with the
269
- // arguments, then an indented `⎿` line carrying the summary.
270
- // A call in flight is marked with a spinner frame, because the absence of a
271
- // result line is not a signal. Before this, a running call and a call whose
272
- // result had simply not been drawn yet looked the same - one line, no
273
- // marker - so a reader could not tell "working" from "nothing happened".
274
- //
275
- // The frame comes from the clock rather than from stored state, the same way
276
- // the HUD's spinner does, and the tick that already runs while a run is live
277
- // is what repaints it. No second timer.
278
- if (this.item.kind === "tool") {
279
- return [fg(`${spinnerFrame()} ${body}`, this.shade(ink.tool))];
280
- }
281
- // A collapsed run draws as one dim line with the expand marker, because the
282
- // point of collapsing is that the reader does not have to look at it. The
283
- // individual calls stay in `full`, so `ctrl+e` still reaches them.
284
- if (this.item.kind === "collapsed") {
285
- // The individual calls are kept in `full`, and ctrl+e has to reach them:
286
- // a collapse that discarded the detail would be a lossy render, and the
287
- // summary it leaves behind is a claim about calls the reader can no longer
288
- // check. `full` holds call lines rather than output, so they draw as the
289
- // calls themselves, dimmed - not under a `└` they did not come from.
290
- const collapsedLines = [fg(`${EXPAND_MARKER} ${body}`, this.shade(ink.mute))];
291
- const members = this.item.full ?? [];
292
- if (members.length > 0) {
293
- if (this.item.expanded === true) {
294
- for (const line of members)
295
- collapsedLines.push(fg(` ${line}`, this.shade(ink.dim)));
296
- }
297
- else {
298
- collapsedLines.push(fg(` ${members.length} call(s) · ctrl+e to expand`, ink.dim));
299
- }
300
- }
301
- return collapsedLines;
302
- }
303
- if (this.item.kind === "result") {
304
- const failed = body.startsWith("exit ") && body !== "exit 0";
305
- // A panel owns its own full block: the two-pane view draws its own header,
306
- // boxes and footer, so it must not be nested under a `└` summary line.
307
- if (this.item.panel) {
308
- return ["", ...this.item.panel(width), ...(body ? [fg(` ${RESULT_MARKER} ${body}`, failed ? ink.bad : ink.mute)] : [])];
309
- }
310
- // The call that produced this result is drawn first when the item kept it:
311
- // a settled call is one item with two lines (`● Read(x)` then `└ 120 lines`),
312
- // so a summary can never sit under a call it did not come from.
313
- const lines = [];
314
- if (this.item.call)
315
- lines.push(fg(this.item.call, this.shade(ink.tool)));
316
- const head = ` ${RESULT_MARKER} ${body}`;
317
- lines.push(fg(head, failed ? ink.bad : this.shade(ink.mute)));
318
- // Detail lines cascade under the summary (a diff, the tail of a shell
319
- // call) instead of landing in one slab: a line that has not had its turn
320
- // is not drawn, so the block grows into place. An expanded item skips the
321
- // cascade — the reader asked for the body, so making them wait line by line
322
- // would fight the request.
323
- const expanded = this.item.expanded === true && (this.item.full?.length ?? 0) > 0;
324
- if (expanded) {
325
- const hint = fg(` ${this.item.full.length} line(s) · ctrl+e to collapse`, ink.dim);
326
- lines.push(fg(` ${EXPAND_MARKER}`, ink.dim));
327
- for (const line of this.item.full) {
328
- const color = line.startsWith("+") ? ink.tool : line.startsWith("-") ? ink.bad : ink.dim;
329
- lines.push(fg(` ${line}`, color));
330
- }
331
- lines.push(hint);
332
- return lines;
333
- }
334
- const detail = this.item.detail ?? [];
335
- const visible = revealedLines(Date.now() - (this.item.born ?? 0), detail.length);
336
- for (const line of detail.slice(0, visible)) {
337
- const color = line.startsWith("+") ? ink.tool : line.startsWith("-") ? ink.bad : ink.dim;
338
- lines.push(fg(` ${line}`, this.shade(color)));
339
- }
340
- // Tell the reader the body exists at all: a summary with no hint reads as
341
- // the whole result.
342
- if ((this.item.full?.length ?? 0) > 0) {
343
- lines.push(fg(` ${EXPAND_MARKER} ${this.item.full.length} line(s) · ctrl+e to expand`, ink.dim));
344
- }
345
- return lines;
346
- }
347
- if (this.item.kind === "error" || this.item.kind === "note") {
348
- const color = this.item.kind === "error" ? ink.bad : ink.mute;
349
- return ["", ...linesOf(body, width, this.shade(color))];
350
- }
351
- const markdown = markdownFor(this.item);
352
- return ["", ...markdown.render(Math.max(8, width - 2))];
353
- }
354
- }
355
- export class FeedView {
356
- items = [];
357
- status = "";
358
- /**
359
- * How many rows have been scrolled back from the newest line. The viewport
360
- * hands the feed its height, so the feed can decide which slice of the
361
- * transcript to draw: the pane scrolls its own content instead of asking the
362
- * terminal to scroll the whole frame (which would take the footer with it).
363
- */
364
- scrollOffset = 0;
365
- /**
366
- * The transcript line the reader is parked on, or `undefined` when they are
367
- * following the newest line.
368
- *
369
- * `scrollOffset` alone is a distance from the end, and that distance moves
370
- * under the reader whenever output arrives: the same offset resolves to
371
- * different content, so a parked reader drifts forward with every new line.
372
- * Measured: parked 12 rows back, five new lines moved the window from
373
- * BODY49..BODY53 to BODY54..BODY58 with no keystroke.
374
- *
375
- * The anchor is the fix: the resolved offset is turned into the line it points
376
- * at, and that line is what the next frame keeps. It is cleared automatically
377
- * when `scrollOffset` changes from the value `render` last settled on, so a
378
- * deliberate scroll — from any caller, without needing to remember anything —
379
- * is honoured as an end-relative request.
380
- */
381
- scrollAnchor;
382
- /** The offset `render` last resolved, used to tell a new scroll from a settled one. */
383
- settledOffset = 0;
384
- /**
385
- * Rows the pane has been given. Set by the workbench each frame before the
386
- * render, because the feed is what decides how much of the transcript fits —
387
- * the layout hands a component a width, never a height.
388
- */
389
- viewportHeight = 0;
390
- invalidate() { }
391
- /** True while any item is still fading in or revealing its detail, so the clock can keep running. */
392
- entering() {
393
- const now = Date.now();
394
- return this.items.some((item) => {
395
- if (item.born === undefined)
396
- return false;
397
- const age = now - item.born;
398
- if (entering(age, ENTRANCE_MS))
399
- return true;
400
- // The cascade outlives the fade by design (450 ms of lines at worst), and
401
- // a clock that stopped at 260 ms would leave the block half-drawn.
402
- return (item.detail?.length ?? 0) > revealedLines(age, item.detail?.length ?? 0);
403
- });
404
- }
405
- render(width) {
406
- // Drop memoised renderers for items the feed no longer holds (the feed is
407
- // reset on /new and /resume, and the session's items are rebuilt from the
408
- // transcript), so the map tracks the feed rather than growing with it.
409
- if (markdownByItem.size > this.items.length) {
410
- const live = new Set(this.items.map((item) => item.id));
411
- for (const id of markdownByItem.keys())
412
- if (!live.has(id))
413
- markdownByItem.delete(id);
414
- }
415
- const lines = [];
416
- for (const item of this.items) {
417
- // First sight of an item is its birth: an item appended by the workbench
418
- // arrives the frame after it is pushed, and an item rebuilt from the
419
- // transcript is born fully opaque instead of replaying an entrance.
420
- item.born ??= Date.now();
421
- lines.push(...new Block(item).render(width));
422
- }
423
- if (this.status)
424
- lines.push(fg(this.status, ink.mute));
425
- // Draw only the window the viewport asked for.
426
- //
427
- // Rendering everything made the scroll view's content taller than its
428
- // viewport, so it scrolled the whole frame — taking the footer with it —
429
- // instead of the transcript.
430
- //
431
- // The window is anchored to *content*, not to the distance from the end.
432
- // `scrollOffset` alone ("N rows back from the newest line") is a moving
433
- // target: new output pushes the transcript's tail down, so the same offset
434
- // resolves to different content and the reader drifts forward while they are
435
- // reading. Measured: with the reader parked 12 rows back, five new lines
436
- // moved them from BODY49..BODY53 to BODY54..BODY58 without a keystroke.
437
- //
438
- // So the offset is resolved to the line it points at and *that* is stored.
439
- // While the reader is still, the anchored line is what they keep and
440
- // `scrollOffset` is recomputed from it every frame, so the two never
441
- // disagree; a scroll is detected by the offset moving and drops the anchor.
442
- // When the anchor cannot be resolved — the transcript shrank under it — the
443
- // anchor is kept and the offset is clamped for drawing, so the position is
444
- // restored when the content grows back instead of being lost for good.
445
- const height = this.viewportHeight;
446
- if (height > 0 && lines.length > height) {
447
- const maxOffset = lines.length - height;
448
- // A new request means the caller moved `scrollOffset` since the last
449
- // frame; that is a deliberate scroll, so the content anchor is dropped and
450
- // the request is read against the end of the transcript, as intended.
451
- if (this.scrollOffset !== this.settledOffset)
452
- this.scrollAnchor = undefined;
453
- const anchor = this.scrollAnchor;
454
- let offset;
455
- if (anchor !== undefined) {
456
- const fromAnchor = lines.length - anchor;
457
- if (fromAnchor >= 0 && fromAnchor <= maxOffset) {
458
- // The anchored line is still drawable: keep it exactly where it is.
459
- offset = fromAnchor;
460
- }
461
- else {
462
- // The anchored line is off the end for now: draw the nearest position
463
- // that is drawable but keep the anchor, so the reader's place is
464
- // restored when the transcript grows back rather than lost for good.
465
- offset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
466
- }
467
- }
468
- else {
469
- offset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
470
- }
471
- // The offset the reader asked for is kept when an anchor is held, so a
472
- // later frame can resolve the anchor again; only the drawn offset moves.
473
- if (anchor === undefined)
474
- this.scrollOffset = offset;
475
- this.settledOffset = this.scrollOffset;
476
- if (offset === 0) {
477
- // Following the tail is not a position to preserve.
478
- this.scrollAnchor = undefined;
479
- }
480
- else if (this.scrollAnchor === undefined) {
481
- this.scrollAnchor = lines.length - offset;
482
- }
483
- return lines.slice(maxOffset - offset, maxOffset - offset + height);
484
- }
485
- // Everything fits, so there is nothing to scroll and the whole transcript is
486
- // drawn. `scrollOffset` is deliberately NOT cleared here.
487
- //
488
- // It used to be, and that made the position a measurement instead of an
489
- // intent: one frame where the transcript was shorter than the viewport — a
490
- // compaction summarizing the older turns, a session swap, the moment before
491
- // a rebuild lands — zeroed it for good. When the transcript grew back the
492
- // reader was snapped to the newest line with no way to return, which is the
493
- // "it scrolls by itself after compaction" report a sibling harness carries
494
- // (#1755: "resetting to top on content load"). The clamp above is enough;
495
- // the intent survives it.
496
- return lines;
497
- }
498
- }