@macintacos/caret 1.8.1 → 1.9.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/.claude-plugin/plugin.json +1 -1
- package/README.md +34 -7
- package/bin/caret-launcher +15 -10
- package/{opencode → bridge}/review-bridge.ts +153 -12
- package/commands/doctor.md +4 -0
- package/dist/cli.js +23980 -23370
- package/opencode/caret.core.ts +26 -113
- package/opencode/caret.plugin.ts +11 -5
- package/opencode/caret.plugin.v2.ts +12 -4
- package/opencode/caret.tui.ts +6 -18
- package/opencode/commands/demo.md +1 -1
- package/opencode/commands/doctor.md +4 -0
- package/package.json +18 -3
- package/pi/caret.ts +145 -0
- package/pi/demo.ts +79 -0
- package/pi/doctor.ts +149 -0
- package/pi/follow-up.ts +19 -0
- package/pi/gate.ts +49 -0
- package/pi/notice.ts +127 -0
- package/pi/plan.ts +182 -0
- package/pi/render.ts +51 -0
- package/pi/state.ts +74 -0
- package/pi/tool.ts +150 -0
- package/ui/dist/assets/{angular-html-BHGQgfyp.js → angular-html-DTAmLsI1.js} +1 -1
- package/ui/dist/assets/{angular-ts-CjJOXiTk.js → angular-ts-CnmIqVSv.js} +1 -1
- package/ui/dist/assets/{apl-BKCXbKuk.js → apl-BRrI8o6u.js} +1 -1
- package/ui/dist/assets/{astro-CbIGh4kW.js → astro-ws1SFlqZ.js} +1 -1
- package/ui/dist/assets/{blade-Dlz3TW50.js → blade-BAfome-F.js} +1 -1
- package/ui/dist/assets/{c-CNaIUpZJ.js → c-Cb8iBkqY.js} +1 -1
- package/ui/dist/assets/{chapel-BSTn-HqM.js → chapel-CwNxsGmt.js} +1 -1
- package/ui/dist/assets/{cobol-Ch_1nBvr.js → cobol-C6senUsW.js} +1 -1
- package/ui/dist/assets/{coffee-DlKg6sYw.js → coffee-BNmOwO3K.js} +1 -1
- package/ui/dist/assets/{cpp-CPx3-f9p.js → cpp-BBfuAVc8.js} +1 -1
- package/ui/dist/assets/{crystal-Cgvix66v.js → crystal-BIenSyu_.js} +1 -1
- package/ui/dist/assets/{css-CwPT7Xr2.js → css-BM7X2qQt.js} +1 -1
- package/ui/dist/assets/{dist-DH3vs96X.js → dist-8Qj1RsyX.js} +1 -1
- package/ui/dist/assets/{dist-DFQEDf6Q.js → dist-BBj5r4JZ.js} +1 -1
- package/ui/dist/assets/{dist-D2BIUS34.js → dist-B_l6M6p7.js} +1 -1
- package/ui/dist/assets/{dist-BdIbTHZ2.js → dist-Bsjq6LzO.js} +1 -1
- package/ui/dist/assets/{dist-BqPfD9yL.js → dist-Bxb_HUlD.js} +1 -1
- package/ui/dist/assets/{dist-Dze-hH7D.js → dist-C7V4XWMN.js} +1 -1
- package/ui/dist/assets/{dist-Cq6qfECE.js → dist-Cq8hWpOn.js} +1 -1
- package/ui/dist/assets/{dist-C6pK9dBg.js → dist-fytLbOcF.js} +1 -1
- package/ui/dist/assets/{dist-B7Ila1Oa.js → dist-zbP7SV6c.js} +1 -1
- package/ui/dist/assets/{dist-nsVj3Kzw.js → dist-zezPBQmO.js} +1 -1
- package/ui/dist/assets/{edge-C6856TSk.js → edge-BlclzlRw.js} +1 -1
- package/ui/dist/assets/{elixir-nUsO5D1t.js → elixir-BYumuPVp.js} +1 -1
- package/ui/dist/assets/{elm-BWmeidnG.js → elm-DNBykypS.js} +1 -1
- package/ui/dist/assets/{erb-DgYxypnH.js → erb-Cg-GLw-3.js} +1 -1
- package/ui/dist/assets/{git-rebase-Bu77Zji8.js → git-rebase-DcwetDCn.js} +1 -1
- package/ui/dist/assets/{glimmer-js-C-Er2CH_.js → glimmer-js-DwUQB02R.js} +1 -1
- package/ui/dist/assets/{glimmer-ts-DHJp3ZV-.js → glimmer-ts-DNxDwzad.js} +1 -1
- package/ui/dist/assets/{glsl-CkN2-uXa.js → glsl-CTQvGLl-.js} +1 -1
- package/ui/dist/assets/{graphql-DM_zS2Al.js → graphql-6N6VWnCL.js} +1 -1
- package/ui/dist/assets/{hack-BjOgct5l.js → hack-BIWJ2t_-.js} +1 -1
- package/ui/dist/assets/{haml-CY4ICINw.js → haml-CTaug-6I.js} +1 -1
- package/ui/dist/assets/{handlebars-DLsdnsgP.js → handlebars-EciX6Xtm.js} +1 -1
- package/ui/dist/assets/{html-Cw-p-zYu.js → html-5ZVMwxqg.js} +1 -1
- package/ui/dist/assets/{html-derivative-CDmHYwVW.js → html-derivative-Bo7PACv6.js} +1 -1
- package/ui/dist/assets/{http-Bqbe5jqD.js → http-D14aglwt.js} +1 -1
- package/ui/dist/assets/{hurl-Ca-chcCT.js → hurl-DGa-Lc1_.js} +1 -1
- package/ui/dist/assets/index-BocyxbG2.css +1 -0
- package/ui/dist/assets/{index-DLUt2ehA.js → index-Do48mTUl.js} +226 -225
- package/ui/dist/assets/{java-0P1dqfZ8.js → java-BUpF6706.js} +1 -1
- package/ui/dist/assets/{javascript-VI2-fvZG.js → javascript-CU7AY0Mj.js} +1 -1
- package/ui/dist/assets/{jinja-Dz73I98G.js → jinja-OREkRLMJ.js} +1 -1
- package/ui/dist/assets/{jison-BtT1z3MS.js → jison-Zf3gmndI.js} +1 -1
- package/ui/dist/assets/{json-C4tTi3F5.js → json-C8E_SzMr.js} +1 -1
- package/ui/dist/assets/{jsx-tTJjMA8e.js → jsx-CHMjfgSc.js} +1 -1
- package/ui/dist/assets/{julia-rokLSeEC.js → julia-DnO66Wng.js} +1 -1
- package/ui/dist/assets/{just-qeFPHb0X.js → just-BUkZWNEk.js} +1 -1
- package/ui/dist/assets/{latex-Beecofx6.js → latex-eFDA2ndR.js} +1 -1
- package/ui/dist/assets/{liquid-DAA5lUdB.js → liquid-BqLuL6hU.js} +1 -1
- package/ui/dist/assets/{lua-BsloMLJI.js → lua-CKNxXI-W.js} +1 -1
- package/ui/dist/assets/{marko-BohJpFVB.js → marko-CM7Qyeyg.js} +1 -1
- package/ui/dist/assets/{mdc-DcoaHcos.js → mdc-DQnmyYku.js} +1 -1
- package/ui/dist/assets/{nginx-rrqwgIhx.js → nginx-B7PJAw6W.js} +1 -1
- package/ui/dist/assets/{nim-bmLsCyNQ.js → nim-CmtAHkVL.js} +1 -1
- package/ui/dist/assets/{org-DBqD3zir.js → org-DXNJ0F8G.js} +1 -1
- package/ui/dist/assets/{perl-DTcxBbd6.js → perl-DCIWxaEc.js} +1 -1
- package/ui/dist/assets/{php-CtncUr2-.js → php-Dc66Nx9S.js} +1 -1
- package/ui/dist/assets/{pug-CIvCXkQY.js → pug-C_S0MlW-.js} +1 -1
- package/ui/dist/assets/{qml-NvdQJP41.js → qml-VqOrS5Wl.js} +1 -1
- package/ui/dist/assets/{r-CTxQa3JJ.js → r-Dl8VhfSg.js} +1 -1
- package/ui/dist/assets/{razor-6oObkjRl.js → razor-BEpGMKEn.js} +1 -1
- package/ui/dist/assets/{regexp-BoEgdKzz.js → regexp-X9VkDePO.js} +1 -1
- package/ui/dist/assets/{rst-C1BRN5ux.js → rst-CQz_65m4.js} +1 -1
- package/ui/dist/assets/{ruby-ll_ilmOa.js → ruby-DcUs-Hjd.js} +1 -1
- package/ui/dist/assets/{sas-nmRD3ilr.js → sas-0Aya4BIJ.js} +1 -1
- package/ui/dist/assets/{scss-DaWVZ0iP.js → scss-BiYXJFG-.js} +1 -1
- package/ui/dist/assets/{shellscript-CT8Jobwl.js → shellscript-D_GiG2j2.js} +1 -1
- package/ui/dist/assets/{shellsession-CxpwtalA.js → shellsession-CJaG9Dka.js} +1 -1
- package/ui/dist/assets/{soy-DEBXlOYH.js → soy-qeuP0wdW.js} +1 -1
- package/ui/dist/assets/{sql-3mfonMZP.js → sql-DjtVrjTT.js} +1 -1
- package/ui/dist/assets/{stata-DaVf6BI8.js → stata-CTtxT0Lq.js} +1 -1
- package/ui/dist/assets/{surrealql-BTM-6O5Q.js → surrealql-CLiRfZ3-.js} +1 -1
- package/ui/dist/assets/{svelte-CDPuyZop.js → svelte-DojibMCX.js} +1 -1
- package/ui/dist/assets/{templ-CbDJ7JLK.js → templ-BccZaOLv.js} +1 -1
- package/ui/dist/assets/{tex-DsKYCwQw.js → tex-DvrCGTlw.js} +1 -1
- package/ui/dist/assets/{ts-tags-BYhIdk0p.js → ts-tags-Dv3BB8TA.js} +1 -1
- package/ui/dist/assets/{tsx-BjLM2BRB.js → tsx-DogZPQbY.js} +1 -1
- package/ui/dist/assets/{twig-DqinkzV-.js → twig-DY6_W6um.js} +1 -1
- package/ui/dist/assets/{typescript-DH1pT4ZK.js → typescript-DwP-qhUH.js} +1 -1
- package/ui/dist/assets/{typst-BBE0SKCu.js → typst-DW555l0v.js} +1 -1
- package/ui/dist/assets/{vue-DIIUy5X_.js → vue-OZGLIqo2.js} +1 -1
- package/ui/dist/assets/{vue-html-BLERoY9d.js → vue-html-CjmVpLjs.js} +1 -1
- package/ui/dist/assets/{vue-vine-CGIG_xts.js → vue-vine-Cye_YqnL.js} +1 -1
- package/ui/dist/assets/{xml-BqbRoduq.js → xml-eBWMgcbX.js} +1 -1
- package/ui/dist/assets/{xsl-DZMfS9NA.js → xsl-BdSZmEF6.js} +1 -1
- package/ui/dist/assets/{yaml-BM5LLWf8.js → yaml-CbCy6UZM.js} +1 -1
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-P_fbn8LU.css +0 -1
package/pi/doctor.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// `/caret:doctor` for pi: runs `caret doctor` and shows its report in pi's transcript as
|
|
2
|
+
// a custom entry, which never reaches the model.
|
|
3
|
+
|
|
4
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
import { errorMessage, type SpawnRunner } from "../bridge/review-bridge.ts";
|
|
7
|
+
import type { PiTheme } from "./render.ts";
|
|
8
|
+
|
|
9
|
+
/** The customType of the transcript entry, and the key of the footer status. */
|
|
10
|
+
export const DOCTOR_ENTRY = "caret-doctor";
|
|
11
|
+
export const DOCTOR_TIMEOUT_MS = 60_000;
|
|
12
|
+
/** Verdict lines without their glyph; doctorText adds it. */
|
|
13
|
+
export const PASSED_VERDICT = "caret doctor: every check passed";
|
|
14
|
+
export const FAILED_VERDICT = "caret doctor: at least one check failed";
|
|
15
|
+
export const NO_REPORT_VERDICT = "caret doctor produced no report";
|
|
16
|
+
|
|
17
|
+
/** What one `caret doctor` run came to; persisted as the `caret-doctor` entry's data. */
|
|
18
|
+
export type DoctorOutcome =
|
|
19
|
+
| { kind: "report"; exitCode: 0 | 1; report: string }
|
|
20
|
+
| { kind: "no-report"; exitCode: number | null; reason: string };
|
|
21
|
+
|
|
22
|
+
export type DoctorCommandContext = {
|
|
23
|
+
mode: "tui" | "rpc" | "json" | "print";
|
|
24
|
+
ui: { setStatus(key: string, text: string | undefined): void };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The slice of pi's extension API `/caret:doctor` calls; pinned by
|
|
28
|
+
* test/pi/sdk-conformance.ts. It has no send method: the report stays out of model
|
|
29
|
+
* context. */
|
|
30
|
+
export type DoctorApi = {
|
|
31
|
+
registerCommand(
|
|
32
|
+
name: string,
|
|
33
|
+
options: {
|
|
34
|
+
description: string;
|
|
35
|
+
handler: (args: string, ctx: DoctorCommandContext) => Promise<void>;
|
|
36
|
+
},
|
|
37
|
+
): void;
|
|
38
|
+
appendEntry(customType: string, data: DoctorOutcome): void;
|
|
39
|
+
registerEntryRenderer(customType: string, renderer: typeof renderDoctorEntry): void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type DoctorDeps = {
|
|
43
|
+
bin: string;
|
|
44
|
+
run: SpawnRunner;
|
|
45
|
+
env: Record<string, string | undefined>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Runs `caret doctor` and describes the outcome; never throws. */
|
|
49
|
+
export async function runDoctor({ bin, run, env }: DoctorDeps): Promise<DoctorOutcome> {
|
|
50
|
+
let stderr = "";
|
|
51
|
+
try {
|
|
52
|
+
const { stdout, exitCode } = await run(
|
|
53
|
+
[bin, "doctor", "--no-color"],
|
|
54
|
+
{ ...env, CARET_AGENT: "pi" },
|
|
55
|
+
"",
|
|
56
|
+
(chunk) => {
|
|
57
|
+
stderr += chunk;
|
|
58
|
+
},
|
|
59
|
+
AbortSignal.timeout(DOCTOR_TIMEOUT_MS),
|
|
60
|
+
);
|
|
61
|
+
const report = stdout.trim();
|
|
62
|
+
if ((exitCode === 0 || exitCode === 1) && report) return { kind: "report", exitCode, report };
|
|
63
|
+
return {
|
|
64
|
+
kind: "no-report",
|
|
65
|
+
exitCode,
|
|
66
|
+
reason: stderr.trim() || `caret doctor exited ${exitCode} without a report.`,
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// The timeout is the only abort source.
|
|
70
|
+
if (err instanceof Error && err.name === "AbortError")
|
|
71
|
+
return {
|
|
72
|
+
kind: "no-report",
|
|
73
|
+
exitCode: null,
|
|
74
|
+
reason: `caret doctor didn't finish within ${DOCTOR_TIMEOUT_MS / 1000} seconds.`,
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
kind: "no-report",
|
|
78
|
+
exitCode: null,
|
|
79
|
+
reason: `Couldn't start caret at ${bin}: ${errorMessage(err)}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Paint = (tone: "success" | "error", text: string) => string;
|
|
85
|
+
|
|
86
|
+
/** The verdict line, a blank line, then the report or the reason. */
|
|
87
|
+
export function doctorText(outcome: DoctorOutcome, paint: Paint = (_tone, s) => s): string {
|
|
88
|
+
if (outcome.kind === "no-report")
|
|
89
|
+
return `${paint("error", `✕ ${NO_REPORT_VERDICT}`)}\n\n${outcome.reason}`;
|
|
90
|
+
const verdict =
|
|
91
|
+
outcome.exitCode === 0
|
|
92
|
+
? paint("success", `✓ ${PASSED_VERDICT}`)
|
|
93
|
+
: paint("error", `✕ ${FAILED_VERDICT}`);
|
|
94
|
+
return `${verdict}\n\n${outcome.report}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isDoctorOutcome(data: unknown): data is DoctorOutcome {
|
|
98
|
+
if (typeof data !== "object" || data === null || !("kind" in data)) return false;
|
|
99
|
+
if (data.kind === "report")
|
|
100
|
+
return (
|
|
101
|
+
"exitCode" in data &&
|
|
102
|
+
(data.exitCode === 0 || data.exitCode === 1) &&
|
|
103
|
+
"report" in data &&
|
|
104
|
+
typeof data.report === "string"
|
|
105
|
+
);
|
|
106
|
+
if (data.kind === "no-report")
|
|
107
|
+
return (
|
|
108
|
+
"exitCode" in data &&
|
|
109
|
+
(data.exitCode === null || typeof data.exitCode === "number") &&
|
|
110
|
+
"reason" in data &&
|
|
111
|
+
typeof data.reason === "string"
|
|
112
|
+
);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The entry's row. Draws from the entry's data alone, since pi re-renders persisted
|
|
117
|
+
* entries on resume. */
|
|
118
|
+
export function renderDoctorEntry(
|
|
119
|
+
entry: { data?: unknown },
|
|
120
|
+
_options: unknown,
|
|
121
|
+
theme: PiTheme,
|
|
122
|
+
): Text | undefined {
|
|
123
|
+
if (!isDoctorOutcome(entry.data)) return undefined;
|
|
124
|
+
return new Text(
|
|
125
|
+
doctorText(entry.data, (tone, s) => theme.fg(tone, s)),
|
|
126
|
+
0,
|
|
127
|
+
0,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Registers `/caret:doctor` and the renderer for its transcript entry. */
|
|
132
|
+
export function installDoctor(
|
|
133
|
+
pi: DoctorApi,
|
|
134
|
+
deps: DoctorDeps & { stdout: (text: string) => void },
|
|
135
|
+
): void {
|
|
136
|
+
pi.registerEntryRenderer(DOCTOR_ENTRY, renderDoctorEntry);
|
|
137
|
+
pi.registerCommand("caret:doctor", {
|
|
138
|
+
description: "Check this caret install and show the report",
|
|
139
|
+
handler: async (_args, ctx) => {
|
|
140
|
+
ctx.ui.setStatus(DOCTOR_ENTRY, "running caret doctor…");
|
|
141
|
+
const outcome = await runDoctor(deps);
|
|
142
|
+
ctx.ui.setStatus(DOCTOR_ENTRY, undefined);
|
|
143
|
+
pi.appendEntry(DOCTOR_ENTRY, outcome);
|
|
144
|
+
// json mode already streams the entry as an `entry_appended` event; raw text there
|
|
145
|
+
// would corrupt the JSONL stream.
|
|
146
|
+
if (ctx.mode === "print") deps.stdout(`${doctorText(outcome)}\n`);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
package/pi/follow-up.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Sends a slash command's prompt as a follow-up and waits for the run it starts, so print
|
|
2
|
+
// mode stays up until the agent has answered.
|
|
3
|
+
|
|
4
|
+
type FollowUpApi = { sendUserMessage(content: string, options: { deliverAs: "followUp" }): void };
|
|
5
|
+
|
|
6
|
+
/** Queues `content` as a follow-up and resolves once the run it starts has finished. */
|
|
7
|
+
export async function sendAndWait(
|
|
8
|
+
pi: FollowUpApi,
|
|
9
|
+
ctx: { waitForIdle(): Promise<void> },
|
|
10
|
+
content: string,
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
pi.sendUserMessage(content, { deliverAs: "followUp" });
|
|
13
|
+
// ponytail: one macrotask lets the queued run start before waitForIdle; a run whose
|
|
14
|
+
// preflight awaits I/O (an extension's `input` handler, the auth check) starts later, so
|
|
15
|
+
// a print-mode run of a command that sends a follow-up may exit early. Fix once pi exposes
|
|
16
|
+
// the queued run's promise.
|
|
17
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
18
|
+
await ctx.waitForIdle();
|
|
19
|
+
}
|
package/pi/gate.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The planning gate's path containment. Imports node: builtins only and uses erasable
|
|
2
|
+
// syntax only: test/pi/gate.test.ts loads it under plain Node, where pi runs.
|
|
3
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
// pi's UNICODE_SPACES (utils/paths.js), which its edit and write tools turn into " ".
|
|
8
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
9
|
+
|
|
10
|
+
/** Whether a write to `path`, resolved against `cwd` as pi's edit and write tools resolve
|
|
11
|
+
* it, lands inside `dir`. Fails closed: false whenever that can't be told, and for a
|
|
12
|
+
* `file:` URL, the one pi normalisation it does not mirror. */
|
|
13
|
+
export function landsInside(path: string, cwd: string, dir: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
if (/^file:\/\//.test(normalize(path))) return false;
|
|
16
|
+
return realPathOf(resolveLikePi(path, cwd)).startsWith(realPathOf(dir) + sep);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** `path` resolved lexically against `cwd`, as pi's tools resolve it. */
|
|
23
|
+
export function resolveLikePi(path: string, cwd: string): string {
|
|
24
|
+
return resolve(cwd, expandHome(normalize(path)));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalize(path: string): string {
|
|
28
|
+
return path.replace(UNICODE_SPACES, " ").replace(/^@/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function expandHome(path: string): string {
|
|
32
|
+
if (path === "~") return homedir();
|
|
33
|
+
return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The nearest existing ancestor's real path, plus the segments below it. Throws on an
|
|
37
|
+
* existing entry that won't resolve (a dangling symlink, a loop) or a missing root. */
|
|
38
|
+
function realPathOf(path: string): string {
|
|
39
|
+
const missingSegments: string[] = [];
|
|
40
|
+
let at = path;
|
|
41
|
+
while (!lstatSync(at, { throwIfNoEntry: false })) {
|
|
42
|
+
// An absent win32 root (an unmapped drive) is its own dirname.
|
|
43
|
+
if (dirname(at) === at) throw new Error(`no such root: ${at}`);
|
|
44
|
+
missingSegments.unshift(basename(at));
|
|
45
|
+
at = dirname(at);
|
|
46
|
+
}
|
|
47
|
+
// .native: Node's JS realpathSync keeps the input's case on macOS.
|
|
48
|
+
return join(realpathSync.native(at), ...missingSegments);
|
|
49
|
+
}
|
package/pi/notice.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The pending-plan notice above pi's editor, the `caret` footer status, and the `/caret`
|
|
2
|
+
// command and ctrl+alt+o shortcut that open the waiting review. Colours come only from
|
|
3
|
+
// pi's theme.
|
|
4
|
+
|
|
5
|
+
import { type Component, MouseRegion, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
import type { DetachedSpawn } from "../bridge/review-bridge.ts";
|
|
8
|
+
import { footerText, type ReviewStore, type SessionReview } from "./state.ts";
|
|
9
|
+
|
|
10
|
+
export const OPEN_KEY = "ctrl+alt+o";
|
|
11
|
+
const NEWS = "plan waiting for your review";
|
|
12
|
+
const VERB = "open in browser";
|
|
13
|
+
const RPC_LINE = `^ caret ${NEWS} /caret ${VERB}`;
|
|
14
|
+
|
|
15
|
+
/** The slice of pi's theme the notice reads; pinned by test/pi/sdk-conformance.ts. */
|
|
16
|
+
export type NoticeTheme = {
|
|
17
|
+
fg(color: "warning" | "text" | "muted" | "dim", text: string): string;
|
|
18
|
+
bold(text: string): string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type WidgetFactory = (tui: unknown, theme: NoticeTheme) => Component;
|
|
22
|
+
|
|
23
|
+
/** The slice of pi's session context the notice calls; pinned by test/pi/sdk-conformance.ts. */
|
|
24
|
+
export type NoticeContext = {
|
|
25
|
+
mode: "tui" | "rpc" | "json" | "print";
|
|
26
|
+
hasUI: boolean;
|
|
27
|
+
ui: {
|
|
28
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
29
|
+
setStatus(key: string, text: string | undefined): void;
|
|
30
|
+
setWidget(key: string, content: string[] | WidgetFactory | undefined): void;
|
|
31
|
+
};
|
|
32
|
+
sessionManager: { getSessionId(): string };
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The slice of pi's extension API the notice calls; pinned by test/pi/sdk-conformance.ts. */
|
|
36
|
+
export type NoticeApi = {
|
|
37
|
+
on(event: "session_start", handler: (event: unknown, ctx: NoticeContext) => void): void;
|
|
38
|
+
on(event: "session_shutdown", handler: () => void): void;
|
|
39
|
+
registerCommand(
|
|
40
|
+
name: string,
|
|
41
|
+
options: { description: string; handler: (args: string, ctx: NoticeContext) => Promise<void> },
|
|
42
|
+
): void;
|
|
43
|
+
registerShortcut(
|
|
44
|
+
shortcut: string,
|
|
45
|
+
options: { description: string; handler: (ctx: NoticeContext) => void },
|
|
46
|
+
): void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The row at `width`, with a one-column inset each side as pi's own string widgets have. */
|
|
50
|
+
export function noticeLine(theme: NoticeTheme, width: number): string {
|
|
51
|
+
const innerWidth = width - 2;
|
|
52
|
+
const left = `${theme.fg("warning", "^")} ${theme.fg("muted", "caret")} ${theme.bold(theme.fg("text", NEWS))}`;
|
|
53
|
+
const right = `${theme.fg("dim", OPEN_KEY)} ${theme.fg("muted", VERB)}`;
|
|
54
|
+
const gap = innerWidth - visibleWidth(`^ caret ${NEWS}`) - visibleWidth(`${OPEN_KEY} ${VERB}`);
|
|
55
|
+
if (gap >= 2) return ` ${left}${" ".repeat(gap)}${right} `;
|
|
56
|
+
return ` ${truncateToWidth(left, Math.max(innerWidth, 0))}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The clickable row. pi runs a widget factory once, so `render()` must call the theme each
|
|
60
|
+
* time: a cached string would keep the old theme's colours. */
|
|
61
|
+
export function noticeWidget(theme: NoticeTheme, open: () => void): Component {
|
|
62
|
+
// No OSC 8 link on the row: a link under the cell takes the click before the row does.
|
|
63
|
+
const row: Component = { render: (width) => [noticeLine(theme, width)], invalidate: () => {} };
|
|
64
|
+
// Only `click` opens: pi synthesises it on a still release, and handling `press` would
|
|
65
|
+
// steal the gesture from text selection.
|
|
66
|
+
return new MouseRegion(row, (event) => {
|
|
67
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
68
|
+
open();
|
|
69
|
+
return { handled: true };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Paints the footer status and the row for `state` in the session's UI mode. */
|
|
74
|
+
export function paint(ctx: NoticeContext, state: SessionReview, open: () => void): void {
|
|
75
|
+
if (!ctx.hasUI) return;
|
|
76
|
+
ctx.ui.setStatus("caret", footerText(state));
|
|
77
|
+
if (!state.pending) ctx.ui.setWidget("caret", undefined);
|
|
78
|
+
else if (ctx.mode === "tui")
|
|
79
|
+
ctx.ui.setWidget("caret", (_tui, theme) => noticeWidget(theme, open));
|
|
80
|
+
else ctx.ui.setWidget("caret", [RPC_LINE]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Paints the notice from the store for each session and registers `/caret` and the
|
|
84
|
+
* shortcut. Call once per pi instance. */
|
|
85
|
+
export function installNotice(
|
|
86
|
+
pi: NoticeApi,
|
|
87
|
+
deps: { store: ReviewStore; bin: string; spawn: DetachedSpawn },
|
|
88
|
+
): void {
|
|
89
|
+
const open = (ctx: NoticeContext) =>
|
|
90
|
+
deps.spawn(
|
|
91
|
+
deps.bin,
|
|
92
|
+
["band-link", "--session", ctx.sessionManager.getSessionId(), "--open"],
|
|
93
|
+
(err) => {
|
|
94
|
+
try {
|
|
95
|
+
ctx.ui.notify(
|
|
96
|
+
`caret: open failed: ${err.message}. Run caret doctor to see why.`,
|
|
97
|
+
"error",
|
|
98
|
+
);
|
|
99
|
+
} catch {
|
|
100
|
+
// The session was replaced before the child reported; there is no UI left to tell.
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
let unsubscribe: (() => void) | undefined;
|
|
106
|
+
pi.on("session_start", (_event, ctx) => {
|
|
107
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
108
|
+
const openHere = () => open(ctx);
|
|
109
|
+
unsubscribe = deps.store.subscribe((id, state) => {
|
|
110
|
+
if (id === sessionId) paint(ctx, state, openHere);
|
|
111
|
+
});
|
|
112
|
+
paint(ctx, deps.store.get(sessionId), openHere);
|
|
113
|
+
});
|
|
114
|
+
pi.on("session_shutdown", () => {
|
|
115
|
+
unsubscribe?.();
|
|
116
|
+
unsubscribe = undefined;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.registerCommand("caret", {
|
|
120
|
+
description: "Open the waiting caret review in the browser",
|
|
121
|
+
handler: async (_args, ctx) => open(ctx),
|
|
122
|
+
});
|
|
123
|
+
pi.registerShortcut(OPEN_KEY, {
|
|
124
|
+
description: "Open the waiting caret review in the browser",
|
|
125
|
+
handler: (ctx) => open(ctx),
|
|
126
|
+
});
|
|
127
|
+
}
|
package/pi/plan.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// `/caret:plan`, caret's planning mode for pi: a hidden steer to write and submit a plan,
|
|
2
|
+
// and a gate on edit/write outside the plans folder until a review of a plan in that
|
|
3
|
+
// folder, submitted while planning, is approved. State lives in the review store (which
|
|
4
|
+
// outlives `/reload`) and in session entries, rebuilt from the current branch so a fork or
|
|
5
|
+
// `/tree` jump to before planning began is not planning.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
import { PLAN_TITLE_INSTRUCTION, REVIEW_TOOL } from "../bridge/review-bridge.ts";
|
|
10
|
+
import { sendAndWait } from "./follow-up.ts";
|
|
11
|
+
import { landsInside } from "./gate.ts";
|
|
12
|
+
import type { ReviewStore } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
/** The customType of both the hidden steer message and the session entry. */
|
|
15
|
+
export const PLANNING = "caret-planning";
|
|
16
|
+
|
|
17
|
+
const LEAVE_NOTICE = "caret planning is off; edits are no longer blocked.";
|
|
18
|
+
const EARLIER_TASK = "Plan what I asked for earlier in this session.";
|
|
19
|
+
|
|
20
|
+
type SessionCtx = { sessionManager: { getSessionId(): string } };
|
|
21
|
+
export type PlanningEntry = { type: string; customType?: string; data?: unknown };
|
|
22
|
+
export type PlanningContext = SessionCtx & {
|
|
23
|
+
cwd: string;
|
|
24
|
+
sessionManager: { getBranch(): PlanningEntry[] };
|
|
25
|
+
};
|
|
26
|
+
export type PlanningCommandContext = PlanningContext & {
|
|
27
|
+
ui: { notify(message: string, type?: "info" | "warning" | "error"): void };
|
|
28
|
+
waitForIdle(): Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
type SteerResult = { message: { customType: string; content: string; display: boolean } };
|
|
31
|
+
type ToolCall = { toolName: string; toolCallId: string; input: Record<string, unknown> };
|
|
32
|
+
type ToolResult = { toolName: string; toolCallId: string; details?: unknown };
|
|
33
|
+
|
|
34
|
+
/** The slice of pi's extension API the planning mode calls; pinned by
|
|
35
|
+
* test/pi/sdk-conformance.ts. */
|
|
36
|
+
export type PlanningApi = {
|
|
37
|
+
registerCommand(
|
|
38
|
+
name: string,
|
|
39
|
+
options: {
|
|
40
|
+
description: string;
|
|
41
|
+
handler: (args: string, ctx: PlanningCommandContext) => Promise<void>;
|
|
42
|
+
},
|
|
43
|
+
): void;
|
|
44
|
+
sendUserMessage(content: string, options: { deliverAs: "followUp" }): void;
|
|
45
|
+
appendEntry(customType: string, data: { active: boolean }): void;
|
|
46
|
+
on(
|
|
47
|
+
event: "before_agent_start",
|
|
48
|
+
handler: (
|
|
49
|
+
event: { messageType: "user" | "custom" },
|
|
50
|
+
ctx: SessionCtx,
|
|
51
|
+
) => SteerResult | undefined,
|
|
52
|
+
): void;
|
|
53
|
+
on(
|
|
54
|
+
event: "context",
|
|
55
|
+
handler: <M>(event: { messages: M[] }, ctx: SessionCtx) => { messages: M[] } | undefined,
|
|
56
|
+
): void;
|
|
57
|
+
on(
|
|
58
|
+
event: "tool_call",
|
|
59
|
+
handler: (event: ToolCall, ctx: PlanningContext) => { block: true; reason: string } | undefined,
|
|
60
|
+
): void;
|
|
61
|
+
on(event: "tool_result", handler: (event: ToolResult, ctx: SessionCtx) => void): void;
|
|
62
|
+
on(
|
|
63
|
+
event: "session_start" | "session_tree",
|
|
64
|
+
handler: (event: unknown, ctx: PlanningContext) => void,
|
|
65
|
+
): void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export interface PlanningDeps {
|
|
69
|
+
store: ReviewStore;
|
|
70
|
+
plansDir: string;
|
|
71
|
+
/** Stands the caret daemon up ahead of the review; best-effort. */
|
|
72
|
+
warm: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The session id's last 8 characters: pi's ids are UUIDv7, whose leading characters are a
|
|
76
|
+
* timestamp shared by sessions started close together. */
|
|
77
|
+
export function sessionTag(sessionId: string): string {
|
|
78
|
+
return sessionId.slice(-8);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The hidden message that tells the model to write its plan under `plansDir` and
|
|
82
|
+
* submit it. */
|
|
83
|
+
export function planningSteer(plansDir: string, sessionId: string): string {
|
|
84
|
+
const file = `${plansDir}/<short-name>-${sessionTag(sessionId)}.md`;
|
|
85
|
+
return [
|
|
86
|
+
"## Planning mode (caret)",
|
|
87
|
+
"",
|
|
88
|
+
`You are in caret's planning mode. Research the change — read the relevant code and trace the real flow — but do not modify any file outside \`${plansDir}/\`: edit and write calls elsewhere are blocked until the plan is approved, and do not change files with shell commands either.`,
|
|
89
|
+
`Write the plan as markdown to \`${file}\`, replacing \`<short-name>\` with a short kebab-case name, then call the \`${REVIEW_TOOL}\` tool with that file as the \`path\` argument.`,
|
|
90
|
+
"It opens caret's visual review UI in the browser; the user approves or requests changes. A change request comes back as the tool result: re-read the file, revise it with targeted edits rather than rewriting it, and call the tool again with the same `path` until it is approved. Once it is approved, planning ends and you can implement the plan.",
|
|
91
|
+
PLAN_TITLE_INSTRUCTION,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether the branch's last planning entry turned planning on. */
|
|
96
|
+
export function planningFromBranch(entries: readonly PlanningEntry[]): boolean {
|
|
97
|
+
const lastPlanningEntry = entries.findLast(
|
|
98
|
+
(e) =>
|
|
99
|
+
e.type === "custom" &&
|
|
100
|
+
e.customType === PLANNING &&
|
|
101
|
+
typeof (e.data as { active?: unknown } | undefined)?.active === "boolean",
|
|
102
|
+
);
|
|
103
|
+
return (lastPlanningEntry?.data as { active: boolean } | undefined)?.active === true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Registers `/caret:plan` and its handlers on `pi`. Call once per pi instance: the set
|
|
107
|
+
* of in-flight review calls is per install. */
|
|
108
|
+
export function installPlanning(pi: PlanningApi, deps: PlanningDeps): void {
|
|
109
|
+
const { store, plansDir, warm } = deps;
|
|
110
|
+
const reviewsWhilePlanning = new Set<string>();
|
|
111
|
+
const sid = (ctx: SessionCtx) => ctx.sessionManager.getSessionId();
|
|
112
|
+
const isPlanning = (ctx: SessionCtx) => store.get(sid(ctx)).planning;
|
|
113
|
+
const setPlanning = (ctx: SessionCtx, active: boolean) => {
|
|
114
|
+
if (active) mkdirSync(plansDir, { recursive: true });
|
|
115
|
+
pi.appendEntry(PLANNING, { active });
|
|
116
|
+
store.update(sid(ctx), { planning: active });
|
|
117
|
+
};
|
|
118
|
+
const restorePlanningFromBranch = (_event: unknown, ctx: PlanningContext) =>
|
|
119
|
+
store.update(sid(ctx), { planning: planningFromBranch(ctx.sessionManager.getBranch()) });
|
|
120
|
+
const pathInPlansDir = (call: ToolCall, ctx: PlanningContext) =>
|
|
121
|
+
typeof call.input.path === "string" && landsInside(call.input.path, ctx.cwd, plansDir);
|
|
122
|
+
|
|
123
|
+
pi.registerCommand("caret:plan", {
|
|
124
|
+
description: "Plan a change and review it in caret before any file is edited",
|
|
125
|
+
handler: async (args, ctx) => {
|
|
126
|
+
const task = args.trim();
|
|
127
|
+
if (!task && isPlanning(ctx)) {
|
|
128
|
+
setPlanning(ctx, false);
|
|
129
|
+
ctx.ui.notify(LEAVE_NOTICE, "info");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (!isPlanning(ctx)) setPlanning(ctx, true);
|
|
133
|
+
await sendAndWait(pi, ctx, task || EARLIER_TASK);
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
138
|
+
if (event.messageType !== "user" || !isPlanning(ctx)) return undefined;
|
|
139
|
+
try {
|
|
140
|
+
warm();
|
|
141
|
+
} catch {
|
|
142
|
+
/* best-effort */
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
message: { customType: PLANNING, content: planningSteer(plansDir, sid(ctx)), display: false },
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.on("context", (event, ctx) => {
|
|
150
|
+
if (isPlanning(ctx)) return undefined;
|
|
151
|
+
return {
|
|
152
|
+
messages: event.messages.filter(
|
|
153
|
+
(m) => (m as { customType?: unknown }).customType !== PLANNING,
|
|
154
|
+
),
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Must not throw: pi does not catch a tool_call handler's throw.
|
|
159
|
+
pi.on("tool_call", (event, ctx) => {
|
|
160
|
+
if (!isPlanning(ctx)) return undefined;
|
|
161
|
+
if (event.toolName === REVIEW_TOOL) {
|
|
162
|
+
if (pathInPlansDir(event, ctx)) reviewsWhilePlanning.add(event.toolCallId);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return undefined;
|
|
166
|
+
if (pathInPlansDir(event, ctx)) return undefined;
|
|
167
|
+
return {
|
|
168
|
+
block: true,
|
|
169
|
+
reason: `Planning mode: only files in \`${plansDir}/\` can change until the plan is approved. Finish the plan there and submit it with \`${REVIEW_TOOL}\`.`,
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
pi.on("tool_result", (event, ctx) => {
|
|
174
|
+
if (event.toolName !== REVIEW_TOOL || !reviewsWhilePlanning.delete(event.toolCallId)) return;
|
|
175
|
+
if ((event.details as { state?: unknown } | undefined)?.state === "approved") {
|
|
176
|
+
setPlanning(ctx, false);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("session_start", restorePlanningFromBranch);
|
|
181
|
+
pi.on("session_tree", restorePlanningFromBranch);
|
|
182
|
+
}
|
package/pi/render.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The caret_review_plan row in pi's transcript. Colours come only from pi's theme.
|
|
2
|
+
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { hyperlink } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
import type { ReviewOutcome } from "./state.ts";
|
|
8
|
+
|
|
9
|
+
/** The slice of pi's theme the review row and the doctor entry read. */
|
|
10
|
+
export type PiTheme = {
|
|
11
|
+
fg(color: "toolTitle" | "muted" | "dim" | "warning" | "success" | "error", text: string): string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ReviewDetails = { state: "waiting"; url: string } | { state: ReviewOutcome };
|
|
15
|
+
|
|
16
|
+
/** The plan's first `# ` heading, else the plan file's name, else "plan". */
|
|
17
|
+
export function planTitle(args: { plan?: string; path?: string }): string {
|
|
18
|
+
return args.plan?.match(/^# (.+)$/m)?.[1] ?? (args.path ? basename(args.path) : "plan");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function callLine(args: { plan?: string; path?: string }, theme: PiTheme): string {
|
|
22
|
+
return `${theme.fg("toolTitle", "caret")} ${theme.fg("muted", planTitle(args))}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A result as pi hands it back. A call that never reached `execute` (bad schema, a
|
|
26
|
+
* blocking hook, an abort) carries `details: {}` and pi's reason in `content`, and so
|
|
27
|
+
* does a session written by a caret with another state set. */
|
|
28
|
+
export type RenderedResult = {
|
|
29
|
+
content?: { type: string; text?: string }[];
|
|
30
|
+
details?: Partial<ReviewDetails>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function resultLine({ content, details }: RenderedResult, theme: PiTheme): string {
|
|
34
|
+
switch (details?.state) {
|
|
35
|
+
case "waiting": {
|
|
36
|
+
const url = details.url ?? "";
|
|
37
|
+
return `${theme.fg("warning", "^")} ${theme.fg("muted", "waiting for review")} ${theme.fg("dim", hyperlink(url, url))}`;
|
|
38
|
+
}
|
|
39
|
+
case "approved":
|
|
40
|
+
return theme.fg("success", "✓ approved");
|
|
41
|
+
case "changes":
|
|
42
|
+
return theme.fg("warning", "↺ changes requested");
|
|
43
|
+
case "cancelled":
|
|
44
|
+
return theme.fg("muted", "cancelled");
|
|
45
|
+
case "failed":
|
|
46
|
+
return theme.fg("error", "✕ review failed");
|
|
47
|
+
default:
|
|
48
|
+
details?.state satisfies undefined;
|
|
49
|
+
return theme.fg("error", `✕ ${content?.[0]?.text ?? "review failed"}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
package/pi/state.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Per-session review state for the pi extension. The notice (EXC-2316) and `/caret:plan`
|
|
2
|
+
// (EXC-2317) consume this store and never redefine it.
|
|
3
|
+
//
|
|
4
|
+
// The data lives on `globalThis` because `/reload` loads a fresh module instance; pi
|
|
5
|
+
// refuses `/reload` while a tool runs, so a pending review never straddles one. Only data
|
|
6
|
+
// is kept there, so each instance runs its own methods over it. That stored shape is a
|
|
7
|
+
// cross-version contract: bump the key when it changes. The listener set outlives
|
|
8
|
+
// `/reload` too, so subscribers must unsubscribe on `session_shutdown`.
|
|
9
|
+
|
|
10
|
+
export type ReviewOutcome = "approved" | "changes" | "cancelled" | "failed";
|
|
11
|
+
|
|
12
|
+
export interface SessionReview {
|
|
13
|
+
/** The waiting review's URL; at most one per session, since the tool runs sequentially. */
|
|
14
|
+
pending?: string;
|
|
15
|
+
/** Whether the session is in caret's planning mode. */
|
|
16
|
+
planning: boolean;
|
|
17
|
+
/** How the session's most recent review ended. */
|
|
18
|
+
lastOutcome?: ReviewOutcome;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ReviewListener = (sessionId: string, state: SessionReview) => void;
|
|
22
|
+
|
|
23
|
+
export interface ReviewStore {
|
|
24
|
+
get(sessionId: string): SessionReview;
|
|
25
|
+
update(sessionId: string, patch: Partial<SessionReview>): void;
|
|
26
|
+
/** Returns the unsubscribe function. */
|
|
27
|
+
subscribe(listener: ReviewListener): () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type ReviewData = { sessions: Map<string, SessionReview>; listeners: Set<ReviewListener> };
|
|
31
|
+
|
|
32
|
+
const newData = (): ReviewData => ({ sessions: new Map(), listeners: new Set() });
|
|
33
|
+
|
|
34
|
+
function storeOver({ sessions, listeners }: ReviewData): ReviewStore {
|
|
35
|
+
const get = (sessionId: string): SessionReview => sessions.get(sessionId) ?? { planning: false };
|
|
36
|
+
return {
|
|
37
|
+
get,
|
|
38
|
+
update(sessionId, patch) {
|
|
39
|
+
const next = { ...get(sessionId), ...patch };
|
|
40
|
+
sessions.set(sessionId, next);
|
|
41
|
+
for (const listener of listeners) {
|
|
42
|
+
try {
|
|
43
|
+
listener(sessionId, next);
|
|
44
|
+
} catch {
|
|
45
|
+
// A footer must never cost the review its decision.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
subscribe(listener) {
|
|
50
|
+
listeners.add(listener);
|
|
51
|
+
return () => listeners.delete(listener);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createReviewStore(): ReviewStore {
|
|
57
|
+
return storeOver(newData());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DATA_KEY = Symbol.for("caret.pi.reviews.v1");
|
|
61
|
+
|
|
62
|
+
/** The process-wide store over data kept on `holder` (globalThis in production); every
|
|
63
|
+
* call on one holder shares sessions and listeners. */
|
|
64
|
+
export function reviewStore(holder: Record<symbol, unknown>): ReviewStore {
|
|
65
|
+
holder[DATA_KEY] ??= newData();
|
|
66
|
+
return storeOver(holder[DATA_KEY] as ReviewData);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The text of the `caret` footer status key; undefined clears it. */
|
|
70
|
+
export function footerText(state: SessionReview): string | undefined {
|
|
71
|
+
if (state.pending) return "^ plan waiting";
|
|
72
|
+
if (state.planning) return "^ planning";
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|