@gavin-hu/my-pi-agent 0.14.0 → 0.15.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.
- package/README.md +55 -33
- package/README.zh-CN.md +44 -27
- package/extensions/bytefly/README.md +193 -140
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +3 -2
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +6 -11
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +21 -1
- package/extensions/bytefly/panel/format.ts +5 -2
- package/extensions/bytefly/panel/settings-runtime.ts +26 -6
- package/extensions/bytefly/panel/settings.ts +41 -4
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
- package/extensions/bytefly/panel/tui.ts +94 -152
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +14 -4
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +94 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +61 -6
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +4 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +91 -2
- package/extensions/subagent/render.ts +13 -0
- package/extensions/subagent/run.ts +17 -16
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/types.ts +11 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +1 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +50 -3
- package/lib/errors.ts +12 -0
- package/lib/format.ts +50 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +22 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/report.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pending-report coordinator: how a live feature tells the model what
|
|
3
|
+
* happened without interrupting it.
|
|
4
|
+
*
|
|
5
|
+
* A watcher match and a finished job follow one rule — report at the next turn,
|
|
6
|
+
* and raise at most one triggered turn when the work opted into `wake`. The two
|
|
7
|
+
* traps are subtle enough to have been re-derived once per extension:
|
|
8
|
+
*
|
|
9
|
+
* - a wake is only safe while the agent is idle, so an outcome that arrives
|
|
10
|
+
* mid-turn must be queued rather than sent;
|
|
11
|
+
* - between the triggered send and the `agent_start` it causes, the agent still
|
|
12
|
+
* looks idle, so a second outcome in that window must not trigger a second
|
|
13
|
+
* turn. `waking` closes that window.
|
|
14
|
+
*
|
|
15
|
+
* Delivery is one shared transcript notice per burst, and a burst is marked
|
|
16
|
+
* reported only after the send succeeded, so a throw leaves it for the next
|
|
17
|
+
* turn. Value-only: every flag lives in the returned closure, never in a module
|
|
18
|
+
* variable, because each extension gets its own module instance.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
22
|
+
import { pruneStaleNotices, type NoticeMessage } from "./notice.ts";
|
|
23
|
+
|
|
24
|
+
export interface PendingReporterOptions<T> {
|
|
25
|
+
/** Send a notice and ask the host to start one turn for it. */
|
|
26
|
+
wake(notice: NoticeMessage): void;
|
|
27
|
+
/** The outcomes not yet reported, oldest first. Does not mark them. */
|
|
28
|
+
pending(): T[];
|
|
29
|
+
/** Mark outcomes as reported, once the send has succeeded. */
|
|
30
|
+
markReported(items: T[]): void;
|
|
31
|
+
/** Build the notice for one burst: model text plus renderer details. */
|
|
32
|
+
buildNotice(items: T[]): NoticeMessage;
|
|
33
|
+
/** The notice's source, so only its own stale entries are pruned. */
|
|
34
|
+
source: string;
|
|
35
|
+
/** Runs after a wake turn was successfully requested, for an extra announcement. */
|
|
36
|
+
onWoke?(items: T[]): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The handlers an extension wires into its lifecycle hooks. */
|
|
40
|
+
export interface PendingReporter {
|
|
41
|
+
/** An outcome arrived: wake a turn if the agent is idle, else leave it queued. */
|
|
42
|
+
report(): void;
|
|
43
|
+
/** A new session starts idle. */
|
|
44
|
+
sessionStart(): void;
|
|
45
|
+
/** A turn began: outcomes queue up, and none of them triggers a turn. */
|
|
46
|
+
agentStart(): void;
|
|
47
|
+
/** The turn settled: a later outcome may wake again. */
|
|
48
|
+
agentSettled(): void;
|
|
49
|
+
/** Report what is queued at the start of a turn, without triggering one. */
|
|
50
|
+
beforeAgentStart(): { message: NoticeMessage } | undefined;
|
|
51
|
+
/** Drop this source's older notices from the outgoing context. */
|
|
52
|
+
filterContext(messages: readonly AgentMessage[]): { messages: AgentMessage[] } | undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
|
|
56
|
+
const { wake, pending, markReported, buildNotice, source, onWoke } = options;
|
|
57
|
+
let busy = false;
|
|
58
|
+
let waking = false;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
report: () => {
|
|
62
|
+
if (busy || waking) return;
|
|
63
|
+
const items = pending();
|
|
64
|
+
if (items.length === 0) return;
|
|
65
|
+
try {
|
|
66
|
+
wake(buildNotice(items));
|
|
67
|
+
} catch {
|
|
68
|
+
// Reporting is best-effort; the next turn reports the same items.
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
// Mark only after the send succeeded, so a throw changes nothing.
|
|
72
|
+
markReported(items);
|
|
73
|
+
waking = true;
|
|
74
|
+
onWoke?.(items);
|
|
75
|
+
},
|
|
76
|
+
sessionStart: () => {
|
|
77
|
+
busy = false;
|
|
78
|
+
waking = false;
|
|
79
|
+
},
|
|
80
|
+
agentStart: () => {
|
|
81
|
+
busy = true;
|
|
82
|
+
},
|
|
83
|
+
agentSettled: () => {
|
|
84
|
+
busy = false;
|
|
85
|
+
waking = false;
|
|
86
|
+
},
|
|
87
|
+
beforeAgentStart: () => {
|
|
88
|
+
const items = pending();
|
|
89
|
+
if (items.length === 0) return undefined;
|
|
90
|
+
const notice = buildNotice(items);
|
|
91
|
+
markReported(items);
|
|
92
|
+
return { message: notice };
|
|
93
|
+
},
|
|
94
|
+
filterContext: (messages) => {
|
|
95
|
+
const pruned = pruneStaleNotices(messages, source);
|
|
96
|
+
return pruned ? { messages: pruned } : undefined;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/lib/requirements.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
import { configSection, configValueAt, type JsonReadResult, SHARED_CONFIG_FILE } from "./config.ts";
|
|
19
19
|
import type { WebSearchProviderEvent } from "./events.ts";
|
|
20
|
+
import { pdfGuidance } from "./pdf.ts";
|
|
20
21
|
import { getPiInvocation, type PiInvocation } from "./pi-invocation.ts";
|
|
21
22
|
|
|
22
23
|
/** How a check came out. Only `missing` is worth interrupting a session for. */
|
|
@@ -167,19 +168,6 @@ export interface ExtensionReport {
|
|
|
167
168
|
findings: Finding[];
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
// --- Install guidance -------------------------------------------------------
|
|
171
|
-
//
|
|
172
|
-
// Moved here from the extensions that raise it, so `doctor` and the tool error
|
|
173
|
-
// say the same thing. Formatting only: no extension-specific logic.
|
|
174
|
-
|
|
175
|
-
/** Install command for the optional PDF extractor package. */
|
|
176
|
-
const UNPDF_INSTALL = "npm i unpdf";
|
|
177
|
-
|
|
178
|
-
/** Model-facing guidance when the optional `unpdf` package is absent. */
|
|
179
|
-
export function pdfGuidance(): string {
|
|
180
|
-
return `PDF extraction requires the optional 'unpdf' package (${UNPDF_INSTALL}).`;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
171
|
// --- The table --------------------------------------------------------------
|
|
184
172
|
|
|
185
173
|
/**
|
|
@@ -224,6 +212,9 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
|
|
|
224
212
|
},
|
|
225
213
|
todo: {},
|
|
226
214
|
goal: {},
|
|
215
|
+
check: {
|
|
216
|
+
notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
|
|
217
|
+
},
|
|
227
218
|
plan: {
|
|
228
219
|
optional: [gitCheck("project-local `.pi/plans` (otherwise plans land in the agent directory)")],
|
|
229
220
|
notes: ["Needs an interactive UI to enter plan mode."],
|
|
@@ -769,19 +760,34 @@ export async function probeExtension(extension: string, seams: ProbeSeams): Prom
|
|
|
769
760
|
return { extension, enabled, findings };
|
|
770
761
|
}
|
|
771
762
|
|
|
763
|
+
/** Probes in flight at once: enough to hide spawn latency, not a burst. */
|
|
764
|
+
export const PROBE_CONCURRENCY = 4;
|
|
765
|
+
|
|
772
766
|
/**
|
|
773
|
-
* Probe every declared extension, or just `names`,
|
|
767
|
+
* Probe every declared extension, or just `names`, keeping declaration order.
|
|
774
768
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
*
|
|
769
|
+
* The caller is a user-facing report (`run_doctor`) or the panel's refresh, so
|
|
770
|
+
* the probes run a few at a time: a serial pass pays the sum of every spawn and
|
|
771
|
+
* read, while one `Promise.all` over the whole table starts a burst of child
|
|
772
|
+
* processes. The startup check does not come through here; it probes only the
|
|
773
|
+
* hard requirements, all at once (see `probeRequired`).
|
|
778
774
|
*/
|
|
779
775
|
export async function probeAll(
|
|
780
776
|
seams: ProbeSeams,
|
|
781
777
|
names: readonly string[] = Object.keys(REQUIREMENTS),
|
|
782
778
|
): Promise<ExtensionReport[]> {
|
|
783
|
-
const reports
|
|
784
|
-
|
|
779
|
+
const reports = new Array<ExtensionReport>(names.length);
|
|
780
|
+
let cursor = 0;
|
|
781
|
+
/** Take the next name until the queue is empty; each worker keeps its slot. */
|
|
782
|
+
const drain = async (): Promise<void> => {
|
|
783
|
+
while (cursor < names.length) {
|
|
784
|
+
const index = cursor;
|
|
785
|
+
cursor += 1;
|
|
786
|
+
reports[index] = await probeExtension(names[index], seams);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
const workers = Array.from({ length: Math.min(PROBE_CONCURRENCY, names.length) }, () => drain());
|
|
790
|
+
await Promise.all(workers);
|
|
785
791
|
return reports;
|
|
786
792
|
}
|
|
787
793
|
|
package/lib/spawn.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process seam shared by the extensions that run a shell command.
|
|
3
|
+
*
|
|
4
|
+
* `job`, `watch`, and `check` all start a command in its own process group,
|
|
5
|
+
* stream or discard its output, and kill the whole tree — a signal to the POSIX
|
|
6
|
+
* group, or `taskkill /T` on Windows. Only the policy on top differs (durable
|
|
7
|
+
* jobs, a tailed watcher, a bounded check), so the seam lives here.
|
|
8
|
+
*
|
|
9
|
+
* IO is split from the decision: {@link spawnOptions} and {@link killPlan} are
|
|
10
|
+
* pure and testable on every platform, while {@link defaultSpawn} and
|
|
11
|
+
* {@link defaultKillTree} execute them. Value-only, like every `lib/` module:
|
|
12
|
+
* no module state, and each caller keeps its own children.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn as nodeSpawn, spawnSync } from "node:child_process";
|
|
16
|
+
import type { Readable } from "node:stream";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The writable end of a child's stdin pipe.
|
|
20
|
+
*
|
|
21
|
+
* Only the operations a caller needs, so tests can hand the runtime a recording
|
|
22
|
+
* double instead of a real socket.
|
|
23
|
+
*/
|
|
24
|
+
export interface StdinSink {
|
|
25
|
+
write(chunk: string): boolean;
|
|
26
|
+
end(): void;
|
|
27
|
+
destroy?(): void;
|
|
28
|
+
on(event: "error" | "close", listener: (error?: Error) => void): unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The subset of a spawned child the extensions use. */
|
|
32
|
+
export interface SpawnedProcess {
|
|
33
|
+
pid?: number;
|
|
34
|
+
stdout: Readable | null;
|
|
35
|
+
stderr: Readable | null;
|
|
36
|
+
/** Present only when the child was spawned with `stdin: true`. */
|
|
37
|
+
stdin?: StdinSink | null;
|
|
38
|
+
on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
39
|
+
on(event: "error", listener: (error: Error) => void): unknown;
|
|
40
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
41
|
+
/** Detach from the parent's event loop, so a child cannot hold Pi open. */
|
|
42
|
+
unref?(): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SpawnOptions {
|
|
46
|
+
cwd: string;
|
|
47
|
+
env?: NodeJS.ProcessEnv;
|
|
48
|
+
/** Give the child a stdin pipe instead of a closed stdin (no immediate EOF). */
|
|
49
|
+
stdin?: boolean;
|
|
50
|
+
/** The command writes its own output to a log; keep no parent pipe. */
|
|
51
|
+
detachOutput?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type SpawnFn = (command: string, options: SpawnOptions) => SpawnedProcess;
|
|
55
|
+
|
|
56
|
+
/** Send `signal` to a pid's process tree, escalating to SIGKILL when `force`. */
|
|
57
|
+
export type KillTreeFn = (pid: number, signal: NodeJS.Signals, force?: boolean) => void;
|
|
58
|
+
|
|
59
|
+
/** The `stdio` triple a spawn needs: stdin, stdout, stderr. */
|
|
60
|
+
export type SpawnStdio = ["pipe" | "ignore", "pipe" | "ignore", "pipe" | "ignore"];
|
|
61
|
+
|
|
62
|
+
/** The `child_process.spawn` options this seam produces. */
|
|
63
|
+
export interface NodeSpawnOptions {
|
|
64
|
+
cwd: string;
|
|
65
|
+
shell: boolean;
|
|
66
|
+
detached: boolean;
|
|
67
|
+
stdio: SpawnStdio;
|
|
68
|
+
env?: NodeJS.ProcessEnv;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The spawn options for one command: a shell in its own process group, so a
|
|
73
|
+
* signal can reach the shell and its descendants together.
|
|
74
|
+
*
|
|
75
|
+
* Stdin stays closed unless asked for, because a closed stdin gives the child
|
|
76
|
+
* EOF — a `read` exits instead of blocking forever. `detachOutput` keeps no
|
|
77
|
+
* parent pipe, for a child that writes its own log file.
|
|
78
|
+
*/
|
|
79
|
+
export function spawnOptions(options: SpawnOptions, platform: NodeJS.Platform = process.platform): NodeSpawnOptions {
|
|
80
|
+
return {
|
|
81
|
+
cwd: options.cwd,
|
|
82
|
+
shell: true,
|
|
83
|
+
detached: platform !== "win32",
|
|
84
|
+
stdio: [
|
|
85
|
+
options.stdin ? "pipe" : "ignore",
|
|
86
|
+
options.detachOutput ? "ignore" : "pipe",
|
|
87
|
+
options.detachOutput ? "ignore" : "pipe",
|
|
88
|
+
],
|
|
89
|
+
env: options.env,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What killing a pid's tree means on this platform. */
|
|
94
|
+
export interface KillPlan {
|
|
95
|
+
/** A program to run instead of signalling (Windows has no process group). */
|
|
96
|
+
command?: { file: string; args: string[] };
|
|
97
|
+
/**
|
|
98
|
+
* Pids to signal in order: the process group first, then the pid itself for a
|
|
99
|
+
* child that is not a group leader (a reattached pid, for example).
|
|
100
|
+
*/
|
|
101
|
+
targets: number[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The kill plan for `pid`: `taskkill /T` on Windows, group-then-pid otherwise. */
|
|
105
|
+
export function killPlan(pid: number, force: boolean, platform: NodeJS.Platform = process.platform): KillPlan {
|
|
106
|
+
if (platform === "win32") {
|
|
107
|
+
const args = ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])];
|
|
108
|
+
return { command: { file: "taskkill", args }, targets: [] };
|
|
109
|
+
}
|
|
110
|
+
return { targets: [-pid, pid] };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A signal-0 probe; injectable so the EPERM branch is testable. */
|
|
114
|
+
export type KillProbe = (pid: number, signal: number) => unknown;
|
|
115
|
+
|
|
116
|
+
/** Whether a pid is alive on this host. EPERM means it exists but is not ours. */
|
|
117
|
+
export function isPidAlive(pid: number, probe: KillProbe = process.kill): boolean {
|
|
118
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
119
|
+
try {
|
|
120
|
+
probe(pid, 0);
|
|
121
|
+
return true;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The host seams {@link applyKillPlan} runs through. */
|
|
128
|
+
export interface KillSeams {
|
|
129
|
+
/** Signal one pid; throws when the pid is gone or not ours. */
|
|
130
|
+
kill(pid: number, signal: NodeJS.Signals): unknown;
|
|
131
|
+
/** Run a program (the Windows `taskkill`). */
|
|
132
|
+
run(file: string, args: string[]): unknown;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Execute a {@link KillPlan}: run the command, or signal each target in order.
|
|
137
|
+
*
|
|
138
|
+
* The group attempt may answer `ESRCH` for a pid that is not a group leader, and
|
|
139
|
+
* the final attempt is the fallback, so its failure means "already gone" — a
|
|
140
|
+
* kill of a dead process is not an error a caller can act on.
|
|
141
|
+
*/
|
|
142
|
+
export function applyKillPlan(plan: KillPlan, signal: NodeJS.Signals, seams: KillSeams): void {
|
|
143
|
+
if (plan.command) {
|
|
144
|
+
seams.run(plan.command.file, plan.command.args);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const [index, target] of plan.targets.entries()) {
|
|
148
|
+
const isFallback = index === plan.targets.length - 1;
|
|
149
|
+
try {
|
|
150
|
+
seams.kill(target, signal);
|
|
151
|
+
return;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (!isFallback && (error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The real seams, so a caller needs no adapter. */
|
|
159
|
+
const processSeams: KillSeams = {
|
|
160
|
+
kill: (pid, signal) => process.kill(pid, signal),
|
|
161
|
+
run: (file, args) => spawnSync(file, args, { stdio: "ignore" }),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** Default spawn: a shell command in its own process group, output piped. */
|
|
165
|
+
export const defaultSpawn: SpawnFn = (command, options) => nodeSpawn(command, spawnOptions(options));
|
|
166
|
+
|
|
167
|
+
/** Default tree kill: process group on POSIX, `taskkill /T` on Windows. */
|
|
168
|
+
export const defaultKillTree: KillTreeFn = (pid, signal, force = false) => {
|
|
169
|
+
applyKillPlan(killPlan(pid, force), signal, processSeams);
|
|
170
|
+
};
|
package/lib/todo-state.ts
CHANGED
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The todo item contract, its parser, and the published state replayed from the
|
|
3
|
+
* session branch.
|
|
3
4
|
*
|
|
4
|
-
* The
|
|
5
|
-
* `details`, so replaying the branch is how
|
|
6
|
-
* importing the todo extension. `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* The list is a whole-list replacement stored in each `todo` tool result's
|
|
6
|
+
* `details`, so replaying the branch is how an extension reads it without
|
|
7
|
+
* importing the todo extension. `todo` reads the branch through the same
|
|
8
|
+
* {@link lastTodos} replay, so the tool, `plan`, and `goal` cannot disagree
|
|
9
|
+
* about which write is a state write: a write that fails validation is ignored
|
|
10
|
+
* and the last valid list stays, exactly as the tool itself keeps the previous
|
|
11
|
+
* list on a rejected call.
|
|
12
|
+
*
|
|
13
|
+
* Stored values are re-validated and re-sanitized here. Branch data can be
|
|
14
|
+
* tampered with, migrated, or written by an older version, and a reader must not
|
|
15
|
+
* pass escapes or unbounded text onward; anything that fails validation is
|
|
16
|
+
* ignored rather than partially applied.
|
|
17
|
+
*
|
|
18
|
+
* Value-only: no module state, no host access.
|
|
9
19
|
*/
|
|
10
20
|
|
|
21
|
+
import { sanitizeLine } from "./format.ts";
|
|
11
22
|
import { TODO_TOOL } from "./tool-names.ts";
|
|
12
23
|
|
|
13
|
-
/**
|
|
24
|
+
/** Statuses a todo item may carry. */
|
|
25
|
+
export const TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
|
|
26
|
+
|
|
27
|
+
/** One item's status. */
|
|
28
|
+
export type TodoStatus = (typeof TODO_STATUSES)[number];
|
|
29
|
+
|
|
30
|
+
/** Maximum number of items in one list. */
|
|
31
|
+
export const MAX_TODOS = 50;
|
|
32
|
+
/** Maximum length of a single item's text. */
|
|
33
|
+
export const MAX_CONTENT = 500;
|
|
34
|
+
|
|
35
|
+
/** One item in a `todo` list, as the tool stores it. */
|
|
14
36
|
export interface ExistingTodo {
|
|
37
|
+
/** Imperative description of the task. */
|
|
15
38
|
content: string;
|
|
16
|
-
|
|
39
|
+
/** Where the item sits in the list. */
|
|
40
|
+
status: TodoStatus;
|
|
17
41
|
/** Present-continuous label the todo tool requires for an in-progress item. */
|
|
18
42
|
activeForm?: string;
|
|
19
43
|
}
|
|
@@ -28,14 +52,82 @@ interface BranchEntryLike {
|
|
|
28
52
|
};
|
|
29
53
|
}
|
|
30
54
|
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
const STATUS_SET: ReadonlySet<string> = new Set(TODO_STATUSES);
|
|
56
|
+
|
|
57
|
+
function normalizeTodoStatus(raw: unknown, index: number): TodoStatus {
|
|
58
|
+
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
|
|
59
|
+
if (STATUS_SET.has(value)) return value as TodoStatus;
|
|
60
|
+
throw new Error(`Todo ${index + 1}: status must be one of ${TODO_STATUSES.join(", ")}.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Options for {@link normalizeTodos}. */
|
|
64
|
+
export interface NormalizeOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Require a non-blank `activeForm` on `in_progress` items. Live tool input
|
|
67
|
+
* enforces this (the default); branch replay passes `false` so lists written
|
|
68
|
+
* before the rule was added still load.
|
|
69
|
+
*/
|
|
70
|
+
requireActiveForm?: boolean;
|
|
33
71
|
}
|
|
34
72
|
|
|
35
73
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
74
|
+
* Validate and normalize a todo list.
|
|
75
|
+
*
|
|
76
|
+
* Content and `activeForm` are sanitized to a single safe line (see
|
|
77
|
+
* `sanitizeLine`). Rejects an empty description, an unknown status, duplicate
|
|
78
|
+
* content, an over-long field, more than one `in_progress` item, and — unless
|
|
79
|
+
* `options.requireActiveForm` is `false` — an `in_progress` item without a
|
|
80
|
+
* non-blank `activeForm`. `activeForm` is dropped when blank.
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeTodos(raw: unknown, options: NormalizeOptions = {}): ExistingTodo[] {
|
|
83
|
+
const requireActiveForm = options.requireActiveForm ?? true;
|
|
84
|
+
if (raw === undefined || raw === null) return [];
|
|
85
|
+
if (!Array.isArray(raw)) throw new Error("todos must be an array.");
|
|
86
|
+
if (raw.length > MAX_TODOS) throw new Error(`At most ${MAX_TODOS} todos are allowed.`);
|
|
87
|
+
|
|
88
|
+
const todos: ExistingTodo[] = [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
let inProgress = 0;
|
|
91
|
+
|
|
92
|
+
for (let i = 0; i < raw.length; i++) {
|
|
93
|
+
const item = (raw[i] ?? {}) as Partial<ExistingTodo>;
|
|
94
|
+
const content = typeof item.content === "string" ? sanitizeLine(item.content) : "";
|
|
95
|
+
if (!content) throw new Error(`Todo ${i + 1}: content is required.`);
|
|
96
|
+
if (content.length > MAX_CONTENT) {
|
|
97
|
+
throw new Error(`Todo ${i + 1}: content is longer than ${MAX_CONTENT} characters.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const key = content.toLowerCase();
|
|
101
|
+
if (seen.has(key)) throw new Error(`Todo ${i + 1}: duplicate content "${content}".`);
|
|
102
|
+
seen.add(key);
|
|
103
|
+
|
|
104
|
+
const status = normalizeTodoStatus(item.status, i);
|
|
105
|
+
if (status === "in_progress") inProgress++;
|
|
106
|
+
|
|
107
|
+
const activeForm = typeof item.activeForm === "string" ? sanitizeLine(item.activeForm) : "";
|
|
108
|
+
if (activeForm.length > MAX_CONTENT) {
|
|
109
|
+
throw new Error(`Todo ${i + 1}: activeForm is longer than ${MAX_CONTENT} characters.`);
|
|
110
|
+
}
|
|
111
|
+
if (status === "in_progress" && requireActiveForm && !activeForm) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Todo ${i + 1}: activeForm is required for an in_progress item (present-continuous, e.g. "Running tests").`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
todos.push(activeForm ? { content, status, activeForm } : { content, status });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (inProgress > 1) {
|
|
120
|
+
throw new Error("At most one todo may be in_progress; mark the rest pending or completed.");
|
|
121
|
+
}
|
|
122
|
+
return todos;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The last valid todo list written on the branch, or an empty list.
|
|
127
|
+
*
|
|
128
|
+
* A rejected call carries the unchanged list and is not a state write, so it is
|
|
129
|
+
* skipped. A write that fails validation is ignored whole, so a corrupt or
|
|
130
|
+
* tampered entry cannot wipe or partially replace a valid list.
|
|
39
131
|
*/
|
|
40
132
|
export function lastTodos(entries: Iterable<unknown>): ExistingTodo[] {
|
|
41
133
|
let todos: ExistingTodo[] = [];
|
|
@@ -47,16 +139,11 @@ export function lastTodos(entries: Iterable<unknown>): ExistingTodo[] {
|
|
|
47
139
|
const details = message.details as { todos?: unknown; error?: unknown } | undefined;
|
|
48
140
|
if (!details || details.error) continue;
|
|
49
141
|
if (!Array.isArray(details.todos)) continue;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (!content) return [];
|
|
56
|
-
const activeForm =
|
|
57
|
-
typeof value.activeForm === "string" && value.activeForm.trim() ? value.activeForm.trim() : undefined;
|
|
58
|
-
return [activeForm ? { content, status: value.status, activeForm } : { content, status: value.status }];
|
|
59
|
-
});
|
|
142
|
+
try {
|
|
143
|
+
todos = normalizeTodos(details.todos, { requireActiveForm: false });
|
|
144
|
+
} catch {
|
|
145
|
+
// Keep the previous list.
|
|
146
|
+
}
|
|
60
147
|
}
|
|
61
148
|
return todos;
|
|
62
149
|
}
|
package/lib/tool-names.ts
CHANGED
|
@@ -15,6 +15,13 @@ export const TODO_TOOL = "todo";
|
|
|
15
15
|
/** The `goal` tool, allowlisted as a reader by plan and rewind. */
|
|
16
16
|
export const GOAL_TOOL = "goal";
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The `enter_plan_mode` tool. `plan` registers it, and `goal` offers the same
|
|
20
|
+
* phase to the user, so the name is shared: the request channel and the
|
|
21
|
+
* registration probe both look it up.
|
|
22
|
+
*/
|
|
23
|
+
export const PLAN_ENTER_TOOL = "enter_plan_mode";
|
|
24
|
+
|
|
18
25
|
/** The `subagent` tool, which plan mode pins to read-only delegation. */
|
|
19
26
|
export const SUBAGENT_TOOL = "subagent";
|
|
20
27
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared shape of a rejected tool call.
|
|
3
|
+
*
|
|
4
|
+
* Extension tools return an `isError: true` result carrying their own `details`
|
|
5
|
+
* type. `toolError` builds the content and the flag so the extensions that
|
|
6
|
+
* reject a call (`job` and `watch`, plus any future tool) agree on the shape.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One text block in a tool result. */
|
|
10
|
+
export interface ToolErrorContent {
|
|
11
|
+
type: "text";
|
|
12
|
+
text: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A rejected tool call: the message as content, the extension's details, and the error flag. */
|
|
16
|
+
export function toolError<Details extends { action: string; error: string }>(
|
|
17
|
+
message: string,
|
|
18
|
+
details: Details,
|
|
19
|
+
): { content: ToolErrorContent[]; details: Details; isError: true } {
|
|
20
|
+
return {
|
|
21
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
22
|
+
details,
|
|
23
|
+
isError: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A rejected tool call whose details are only the action and the message. */
|
|
28
|
+
export function errorResult<Action extends string>(
|
|
29
|
+
action: Action,
|
|
30
|
+
message: string,
|
|
31
|
+
): { content: ToolErrorContent[]; details: { action: Action; error: string }; isError: true } {
|
|
32
|
+
return toolError(message, { action, error: message });
|
|
33
|
+
}
|
package/lib/tui/README.md
CHANGED
|
@@ -30,9 +30,61 @@ build a second terminal renderer.
|
|
|
30
30
|
## Primitives
|
|
31
31
|
|
|
32
32
|
Reach for a `lib/tui/` primitive before composing your own: `plainRule` /
|
|
33
|
-
`titleRule` for a heading, `listRow` / `checkbox` for a list,
|
|
34
|
-
meter, `emptyState` for an empty screen, `splitRow` /
|
|
35
|
-
`LinePane` for a scrollable log,
|
|
33
|
+
`titleRule` for a heading, `listRow` / `statusRow` / `checkbox` for a list,
|
|
34
|
+
`gaugeBar` for a meter, `emptyState` for an empty screen, `splitRow` /
|
|
35
|
+
`fitCells` for two columns, `LinePane` for a scrollable log, `ListCursor` /
|
|
36
|
+
`IdCursor` for selection, `byRunningFirst` for a live list's order,
|
|
37
|
+
`DockListScreen` for a list that opens a pane, `TailResult` for a tool result
|
|
38
|
+
that lists rows or tails output, and `ConfirmState` before a destructive action.
|
|
39
|
+
|
|
40
|
+
## Docked list screens
|
|
41
|
+
|
|
42
|
+
Every list screen is `dockListFrame`: the header rule, an optional summary and
|
|
43
|
+
its blank line, the body, an optional range row, an optional detail block, and
|
|
44
|
+
the blank-padded hint. The frame owns that spacing, so a screen never hand-rolls
|
|
45
|
+
it — including the blank line above the detail block, which keeps a footnote
|
|
46
|
+
from reading as one more row. A screen that sizes its body through `fitRows`
|
|
47
|
+
counts the block with `detailBlockRows`, or the hint row is pushed off the
|
|
48
|
+
bottom of a short terminal.
|
|
49
|
+
|
|
50
|
+
A list that **opens a pane over one row** — `/jobs`, `/watch`, `/agents`,
|
|
51
|
+
`/check` — subclasses [`dock-screen.ts`](./dock-screen.ts) instead of
|
|
52
|
+
re-assembling the frame. `DockListScreen` owns the two modes, the `IdCursor` and
|
|
53
|
+
`LinePane`, the navigation and wheel keys, the y/N confirmation, the one-line
|
|
54
|
+
feedback notice, and the range rows; the subclass supplies `rows`, `idOf`,
|
|
55
|
+
`rowLine`, `detailBlock`, `summaryCells`, `listHints(rows)`, `detailHints(row)`,
|
|
56
|
+
`detailFor`, and `handleVerb`. Keep the ordering in `rows()` (the base re-reads
|
|
57
|
+
it every render) and return it by stable id, so a live re-sort cannot move the
|
|
58
|
+
cursor or swap the open pane out from under the reader.
|
|
59
|
+
|
|
60
|
+
Four optional members cover what a richer screen needs, and each defaults to
|
|
61
|
+
what a plain list wants — do not hand-roll the frame to get one of them:
|
|
62
|
+
|
|
63
|
+
- `DetailContents.facts(width)` — rows *above* the scrollable body that depend on
|
|
64
|
+
the width (key/value facts, a legend). The body's lines are built once at open,
|
|
65
|
+
so anything width-dependent belongs here, and the base counts the rows against
|
|
66
|
+
the pane's height.
|
|
67
|
+
- `liveDetail()` — set it when the pane's source changes between frames on its
|
|
68
|
+
own (a child's output) rather than on a poll. The base then re-reads
|
|
69
|
+
`detailById` while rendering, without asking for another frame, and closes the
|
|
70
|
+
pane if the row is gone.
|
|
71
|
+
- `detailById(id)` — the poll source job and watch read every 500 ms; omit it
|
|
72
|
+
when nothing polls the pane.
|
|
73
|
+
- `handleDetailVerb(key, row)` — a verb that also works while reading the pane
|
|
74
|
+
(`c` collect, `x` cancel). Return `true` for the keys it claimed; everything
|
|
75
|
+
else falls through to the pane's scroll keys.
|
|
76
|
+
|
|
77
|
+
A destructive action asks through [`confirm.ts`](./confirm.ts):
|
|
78
|
+
`ConfirmState.ask(prompt, accept)` holds the question, `resolve(data)` accepts on
|
|
79
|
+
`y`/`Y`/Enter, dismisses on `n`/`N`/Esc/`q`, and returns `undefined` for any
|
|
80
|
+
other key so a stray arrow cannot cancel the question. The prompt ends in
|
|
81
|
+
`? y/N`.
|
|
82
|
+
|
|
83
|
+
The block carries footnotes about the focused row: what the row does not already
|
|
84
|
+
say. Never repeat its label, verdict, or duration — the row is one line above,
|
|
85
|
+
and a second copy is noise the reader has to compare. Prose that the hint row
|
|
86
|
+
already says with keys does not belong there either; a sentence that long is
|
|
87
|
+
clipped mid-word at the widths the screen runs at.
|
|
36
88
|
|
|
37
89
|
## Color is a role
|
|
38
90
|
|
|
@@ -42,6 +94,13 @@ token named inline; a theme swap must re-skin every surface. Use the shared
|
|
|
42
94
|
`selectionMarker`, `GLYPHS`, `SEPARATORS`, and `STATUS_KEYS` instead of retyping
|
|
43
95
|
their literals, and `screenHint` / Pi's `keyHint` for the hint row.
|
|
44
96
|
|
|
97
|
+
A list accents the focused row's label (`labelRole: selected ? "accent" :
|
|
98
|
+
"label"`); the `❯` marker alone is not enough to tell which row is live. The
|
|
99
|
+
hint row colors by role, not by taste: `accent` for the screen's primary action,
|
|
100
|
+
`warning` for one that discards state (a revert, a kill, a stop, a clear, a
|
|
101
|
+
forget), and `error` for one that ends the session. Keys that navigate, scroll,
|
|
102
|
+
or select stay dim, so a colored key is one worth a second thought.
|
|
103
|
+
|
|
45
104
|
## Width is columns
|
|
46
105
|
|
|
47
106
|
Width is terminal columns, not string length. Use `visibleWidth`,
|
|
@@ -59,6 +118,12 @@ widget or the terminal: it neutralizes control characters and then collapses the
|
|
|
59
118
|
text to one line. `stripControlChars` alone is for multi-line text that must keep
|
|
60
119
|
its newlines.
|
|
61
120
|
|
|
121
|
+
Program output — a log file, a child's stdout, a tailed file — goes through
|
|
122
|
+
`sanitizeUntrustedLine` instead. It removes OSC, CSI, and single-character
|
|
123
|
+
escapes whole (so the printable tail of an escape never reaches the terminal),
|
|
124
|
+
then resolves `\r` the way a terminal would, squeezes whitespace, and clips.
|
|
125
|
+
`sanitizeLine` would leave `[31m` behind, because it turns ESC into a space.
|
|
126
|
+
|
|
62
127
|
## Keyboard first
|
|
63
128
|
|
|
64
129
|
Use `matchesKey` / `Key`, give every mouse interaction a keyboard path, and keep
|