howtofixcli 0.7.8 → 0.7.9
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/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-63ZCQDXH.js +6 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-ACOMNDUL.js +185 -0
- package/dist/chunk-AEVPET7X.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-BHVBO4SC.js +1 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GIUYIKH3.js +109 -0
- package/dist/chunk-GQCAJ56I.js +5 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-IGEY3AWF.js +1 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-MVLCGU3G.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-NBQFPCRT.js +1 -0
- package/dist/chunk-OIPSLIZE.js +5 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-PDNBGUYP.js +2 -0
- package/dist/chunk-S656RE4A.js +1 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-U6CI6KXN.js +34 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-ZKZ6WCI3.js +2 -0
- package/dist/main.js +94 -536
- package/package.json +15 -62
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -120
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -301
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -288
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -715
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -347
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -446
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4757
- package/dist/update.js +0 -533
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- package/dist/workflow.js +0 -349
package/dist/device-id.test.js
DELETED
|
@@ -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
|
-
}
|
package/dist/extensions.js
DELETED
|
@@ -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
|
-
}
|