one-code-extension 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +189 -94
- package/docs/guide/README.md +3 -2
- package/docs/guide/bring-your-claude-code-setup.md +7 -5
- package/docs/guide/configuration.md +7 -5
- package/docs/guide/differences-from-claude-code.md +25 -14
- package/docs/guide/doctor.md +5 -3
- package/docs/guide/hooks.md +20 -6
- package/docs/guide/installation.md +62 -12
- package/docs/guide/permissions-modes-and-auto-mode.md +46 -28
- package/docs/guide/providers-and-models.md +12 -10
- package/docs/guide/reference.md +9 -4
- package/docs/guide/sessions-and-context.md +23 -3
- package/docs/guide/skills-plugins-and-mcp.md +7 -5
- package/docs/guide/subagents-and-workflows.md +13 -11
- package/docs/guide/tasks-and-background-work.md +10 -8
- package/docs/guide/terminal-interface.md +7 -5
- package/docs/guide/tools.md +29 -5
- package/docs/guide/troubleshooting.md +26 -14
- package/docs/guide/windows.md +184 -0
- package/extensions/auto-mode/actions.ts +3 -1
- package/extensions/auto-mode/paths.ts +21 -23
- package/extensions/auto-mode/safety-floor.ts +44 -6
- package/extensions/auto-mode/shell-analysis.ts +25 -10
- package/extensions/auto-mode/transcript.ts +15 -5
- package/extensions/background/index.ts +83 -34
- package/extensions/background/monitor-batch.ts +7 -5
- package/extensions/background/wakeup.ts +18 -24
- package/extensions/bash/background.ts +19 -10
- package/extensions/bash/guards.ts +36 -16
- package/extensions/bash/index.ts +36 -166
- package/extensions/branding/index.ts +35 -4
- package/extensions/btw/index.ts +207 -0
- package/extensions/btw/panel.ts +179 -0
- package/extensions/btw/prompt.ts +45 -0
- package/extensions/clear/index.ts +15 -0
- package/extensions/compaction/fit.ts +9 -6
- package/extensions/doctor/build.ts +22 -2
- package/extensions/doctor/dependencies.ts +63 -31
- package/extensions/doctor/fix-prompt.ts +12 -4
- package/extensions/doctor/index.ts +1 -0
- package/extensions/doctor/models.ts +2 -1
- package/extensions/doctor/report.ts +2 -3
- package/extensions/doctor/update-lookup.ts +19 -11
- package/extensions/effort/index.ts +2 -1
- package/extensions/footer/footer-line.ts +19 -0
- package/extensions/footer/index.ts +25 -5
- package/extensions/hooks/executor.ts +84 -18
- package/extensions/hooks/index.ts +1 -0
- package/extensions/hooks/matcher.ts +23 -9
- package/extensions/hooks/settings.ts +15 -0
- package/extensions/lib/branch-restore.ts +12 -0
- package/extensions/lib/claude-context.ts +2 -3
- package/extensions/lib/claude-settings.ts +19 -0
- package/extensions/lib/command-launch.ts +71 -0
- package/extensions/lib/deferred.ts +173 -33
- package/extensions/lib/git.ts +16 -0
- package/extensions/lib/keys.ts +38 -0
- package/extensions/lib/local-command.ts +112 -0
- package/extensions/lib/model-facts.generated.json +263 -68
- package/extensions/lib/model-policy.ts +13 -0
- package/extensions/lib/model-tier.ts +16 -5
- package/extensions/lib/notifications.ts +547 -53
- package/extensions/lib/paths.ts +130 -2
- package/extensions/lib/pi-ai-estimate.ts +57 -26
- package/extensions/lib/pi-version.ts +1 -1
- package/extensions/lib/process-tree.ts +131 -5
- package/extensions/lib/reminders.ts +80 -27
- package/extensions/lib/scratchpad.ts +11 -10
- package/extensions/lib/shell-spawn.ts +298 -0
- package/extensions/lib/shell-tool.ts +252 -0
- package/extensions/lib/shell-tools.ts +13 -0
- package/extensions/lib/side-call.ts +76 -0
- package/extensions/lib/tui-render.ts +62 -11
- package/extensions/lib/update-check.mjs +155 -0
- package/extensions/lib/usage-bus.ts +1 -1
- package/extensions/lib/which.ts +62 -0
- package/extensions/lib/worktree-channel.ts +18 -0
- package/extensions/lib/worktree-isolation.ts +2 -1
- package/extensions/lsp/client.ts +14 -3
- package/extensions/lsp/index.ts +12 -5
- package/extensions/lsp/install-hints.ts +33 -3
- package/extensions/lsp/plugin-servers.ts +2 -2
- package/extensions/lsp/servers.ts +29 -7
- package/extensions/mcp/index.ts +3 -2
- package/extensions/memory/entries.ts +9 -6
- package/extensions/memory/index.ts +3 -2
- package/extensions/memory/open-external.ts +4 -1
- package/extensions/model-default/index.ts +21 -2
- package/extensions/permissions/index.ts +57 -28
- package/extensions/permissions/matcher.ts +120 -33
- package/extensions/permissions/modes.ts +14 -6
- package/extensions/permissions/powershell-rules.ts +549 -0
- package/extensions/permissions/session-grant.ts +9 -9
- package/extensions/plugins/index.ts +3 -2
- package/extensions/plugins/install/install.ts +1 -1
- package/extensions/powershell/description.ts +139 -0
- package/extensions/powershell/guards.ts +91 -0
- package/extensions/powershell/index.ts +89 -0
- package/extensions/powershell/policy.ts +91 -0
- package/extensions/recap/index.ts +10 -24
- package/extensions/recap/prompt.ts +3 -3
- package/extensions/session-title/index.ts +120 -0
- package/extensions/session-title/title.ts +111 -0
- package/extensions/skill/index.ts +40 -18
- package/extensions/skill/invoke.ts +1 -0
- package/extensions/subagents/hand-back-review.ts +10 -12
- package/extensions/subagents/index.ts +221 -78
- package/extensions/subagents/model-select.ts +75 -7
- package/extensions/system-prompt/environment.ts +14 -1
- package/extensions/system-reminder/index.ts +9 -0
- package/extensions/tasks/index.ts +2 -1
- package/extensions/tool-search/index.ts +41 -20
- package/extensions/workflow/agent-session.ts +6 -4
- package/extensions/workflow/index.ts +28 -5
- package/extensions/worktree/guards.ts +5 -4
- package/extensions/worktree/index.ts +15 -2
- package/extensions/worktree/rewrite.ts +17 -0
- package/package.json +7 -4
- package/types/app.d.ts +18 -2
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* btw extension — Claude Code's `/btw` ("by the way") side question.
|
|
3
|
+
*
|
|
4
|
+
* `/btw <question>` asks a one-off question that is answered from the current
|
|
5
|
+
* conversation's context while the main agent keeps working. CC frames the
|
|
6
|
+
* answering model as a separate, lightweight, tool-less instance (the verbatim
|
|
7
|
+
* reminder in prompt.ts, captured from CC 2.1.278) and shows the answer in a
|
|
8
|
+
* focused overlay panel, not in the transcript — so the exchange never enters
|
|
9
|
+
* the main agent's context and cannot steer its work.
|
|
10
|
+
*
|
|
11
|
+
* The call reuses recap's machinery: the exact messages the session last put on
|
|
12
|
+
* the wire (captured from the `context` event), name-only tool stubs so a
|
|
13
|
+
* history carrying tool_use blocks stays valid on strict providers, and
|
|
14
|
+
* withReasoningFallback for models that cannot disable thinking. Unlike recap it
|
|
15
|
+
* uses the SESSION model (not an economical one) — a side question deserves the
|
|
16
|
+
* same quality as the main conversation — and sends the full captured history,
|
|
17
|
+
* not just the recent window.
|
|
18
|
+
*
|
|
19
|
+
* Deviations from CC, logged in docs/decisions/btw.md: the exchange is stateless
|
|
20
|
+
* (each `/btw` sees only the shared main context, never a prior `/btw` — CC
|
|
21
|
+
* keeps a side-session; the "one-off, no follow-up" framing holds either way);
|
|
22
|
+
* `f to fork` is omitted (pi's `ctx.fork` branches from a session entry, not
|
|
23
|
+
* from an injected side exchange); and the call runs with an empty system prompt
|
|
24
|
+
* (the `context` event carries no system prompt to reuse), relying on the shared
|
|
25
|
+
* history plus the reminder.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
29
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
30
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
31
|
+
import { convertToLlm, copyToClipboard, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { withReasoningFallback } from "../lib/model-policy.ts";
|
|
33
|
+
import { answerText, stripImageBlocks, toolStubs, trimToTurnBoundary } from "../lib/side-call.ts";
|
|
34
|
+
import { boundedDockHeight, truncateLine } from "../lib/tui-render.ts";
|
|
35
|
+
import { recordUsage } from "../lib/usage-bus.ts";
|
|
36
|
+
import { applyBtwKey, type BtwBody, BTW_MAX_HEIGHT, decodeBtwKey, initialBtwState, renderBtwPanel } from "./panel.ts";
|
|
37
|
+
import { sideQuestionMessage } from "./prompt.ts";
|
|
38
|
+
|
|
39
|
+
const BTW_MAX_TOKENS = 8192;
|
|
40
|
+
const BTW_TIMEOUT_MS = 120_000;
|
|
41
|
+
/** Description on the name-only tool stubs sent with the side question. */
|
|
42
|
+
const STUB_REASON = "Unavailable during this side question; answer in text.";
|
|
43
|
+
|
|
44
|
+
export default function btwExtension(pi: ExtensionAPI) {
|
|
45
|
+
// The exact request messages the session last sent (after every extension's
|
|
46
|
+
// mutations), captured like recap and the compaction extension do.
|
|
47
|
+
let capturedMessages: AgentMessage[] | undefined;
|
|
48
|
+
pi.on("context", (event) => {
|
|
49
|
+
capturedMessages = event.messages;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The controller for an open panel's in-flight call. Aborted when the session
|
|
53
|
+
// is replaced or torn down, so a late resolve cannot repaint a disposed `tui`
|
|
54
|
+
// or record usage on a stale `pi` (the crash the subagents panel once hit —
|
|
55
|
+
// docs/decisions/tools.md lifecycle review).
|
|
56
|
+
let inFlight: AbortController | undefined;
|
|
57
|
+
|
|
58
|
+
// A new session (including after /clear) shares no context with the old one,
|
|
59
|
+
// and any in-flight side question is abandoned.
|
|
60
|
+
pi.on("session_start", () => {
|
|
61
|
+
capturedMessages = undefined;
|
|
62
|
+
inFlight?.abort();
|
|
63
|
+
inFlight = undefined;
|
|
64
|
+
});
|
|
65
|
+
pi.on("session_shutdown", () => {
|
|
66
|
+
inFlight?.abort();
|
|
67
|
+
inFlight = undefined;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Run the side question against the session model; returns the answer text. */
|
|
71
|
+
async function ask(ctx: ExtensionCommandContext, question: string, signal: AbortSignal): Promise<string> {
|
|
72
|
+
const model = ctx.model;
|
|
73
|
+
if (!model) throw new Error("No model is configured for this session.");
|
|
74
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
75
|
+
if (!auth.ok) throw new Error("No API key is available for the current model.");
|
|
76
|
+
const baseUrl = (auth as { baseUrl?: string }).baseUrl;
|
|
77
|
+
|
|
78
|
+
const tools = toolStubs(pi.getActiveTools(), STUB_REASON);
|
|
79
|
+
const context = trimToTurnBoundary(capturedMessages ?? []);
|
|
80
|
+
// btw runs on a cheap, possibly text-only reader and answers a text question,
|
|
81
|
+
// so images the conversation carried are stripped before the call
|
|
82
|
+
// (docs/decisions/model-policy.md).
|
|
83
|
+
const messages = [...stripImageBlocks(convertToLlm(context)), { role: "user" as const, content: sideQuestionMessage(question), timestamp: Date.now() }];
|
|
84
|
+
|
|
85
|
+
const result = await withReasoningFallback(
|
|
86
|
+
model as Model<Api>,
|
|
87
|
+
(reasoning) => {
|
|
88
|
+
const timeout = AbortSignal.timeout(BTW_TIMEOUT_MS);
|
|
89
|
+
return completeSimple(
|
|
90
|
+
baseUrl ? ({ ...model, baseUrl } as Model<Api>) : (model as Model<Api>),
|
|
91
|
+
{ systemPrompt: "", messages, tools },
|
|
92
|
+
{
|
|
93
|
+
apiKey: auth.apiKey,
|
|
94
|
+
headers: auth.headers,
|
|
95
|
+
env: auth.env,
|
|
96
|
+
signal: AbortSignal.any([signal, timeout]),
|
|
97
|
+
maxTokens: BTW_MAX_TOKENS,
|
|
98
|
+
...(reasoning ? { reasoning } : {}),
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
},
|
|
102
|
+
undefined,
|
|
103
|
+
(usage) => recordUsage(pi, "btw", usage),
|
|
104
|
+
);
|
|
105
|
+
return answerText(result.content);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
pi.registerCommand("btw", {
|
|
109
|
+
description: "Ask a one-off side question, answered from the current context while the main agent keeps working",
|
|
110
|
+
handler: async (args, ctx) => {
|
|
111
|
+
const question = args.trim();
|
|
112
|
+
if (!question) {
|
|
113
|
+
ctx.ui.notify("Ask a side question: /btw <question>", "info");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Headless (-p / --mode json): no panel — answer and print it. The
|
|
118
|
+
// handler blocks to completion, so the process does not exit early.
|
|
119
|
+
if (!ctx.hasUI) {
|
|
120
|
+
try {
|
|
121
|
+
const answer = await ask(ctx, question, new AbortController().signal);
|
|
122
|
+
ctx.ui.notify(answer || "(no answer)", "info");
|
|
123
|
+
} catch (error) {
|
|
124
|
+
ctx.ui.notify(`Side question failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
125
|
+
}
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Show the panel at once with a loading line; run the call concurrently
|
|
130
|
+
// and repaint when it lands. Closing the panel — or a session
|
|
131
|
+
// replacement/shutdown — aborts an in-flight call.
|
|
132
|
+
inFlight?.abort();
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
inFlight = controller;
|
|
135
|
+
const view: { answer?: string; error?: string } = {};
|
|
136
|
+
const bodyState = (): BtwBody =>
|
|
137
|
+
view.error !== undefined ? { kind: "error", message: view.error } : view.answer !== undefined ? { kind: "answer", text: view.answer } : { kind: "loading" };
|
|
138
|
+
|
|
139
|
+
await ctx.ui.custom<null>((tui, theme, _keybindings, done) => {
|
|
140
|
+
const state = initialBtwState();
|
|
141
|
+
let cache: { width: number; lines: string[] } | undefined;
|
|
142
|
+
const repaint = () => {
|
|
143
|
+
cache = undefined;
|
|
144
|
+
// A repaint can be requested from an async callback that raced a
|
|
145
|
+
// session teardown; a stale `tui` throws, so never let it propagate.
|
|
146
|
+
try {
|
|
147
|
+
tui.requestRender();
|
|
148
|
+
} catch {}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Kick off the model call once, at mount, so `repaint` is bound.
|
|
152
|
+
void (async () => {
|
|
153
|
+
try {
|
|
154
|
+
const answer = await ask(ctx, question, controller.signal);
|
|
155
|
+
if (controller.signal.aborted) return;
|
|
156
|
+
view.answer = answer;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (controller.signal.aborted) return;
|
|
159
|
+
view.error = error instanceof Error ? error.message : String(error);
|
|
160
|
+
}
|
|
161
|
+
repaint();
|
|
162
|
+
})();
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
render: (width: number) => {
|
|
166
|
+
if (cache?.width === width) return cache.lines;
|
|
167
|
+
const termRows = (tui as { terminal: { rows: number } }).terminal.rows;
|
|
168
|
+
const height = boundedDockHeight(termRows, BTW_MAX_HEIGHT);
|
|
169
|
+
// Belt-and-suspenders truncate on top of renderBtwPanel's own,
|
|
170
|
+
// matching every sibling dock panel: pi-tui crashes the app on
|
|
171
|
+
// a line wider than the terminal.
|
|
172
|
+
const lines = renderBtwPanel({ state, question, body: bodyState(), width, height }, theme).map((line) => truncateLine(line, width));
|
|
173
|
+
cache = { width, lines };
|
|
174
|
+
return lines;
|
|
175
|
+
},
|
|
176
|
+
handleInput: (data: string) => {
|
|
177
|
+
const key = decodeBtwKey(data);
|
|
178
|
+
if (!key) return;
|
|
179
|
+
const effect = applyBtwKey(state, key);
|
|
180
|
+
switch (effect?.kind) {
|
|
181
|
+
case "close":
|
|
182
|
+
done(null);
|
|
183
|
+
return;
|
|
184
|
+
case "copy":
|
|
185
|
+
// Only offer copy once there is a non-empty answer.
|
|
186
|
+
if (view.answer) {
|
|
187
|
+
void copyToClipboard(view.answer).then(() => {
|
|
188
|
+
state.copied = true;
|
|
189
|
+
repaint();
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
default:
|
|
194
|
+
repaint();
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
invalidate: () => {
|
|
198
|
+
cache = undefined;
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
controller.abort();
|
|
204
|
+
if (inFlight === controller) inFlight = undefined;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state, key decoding, and rendering for the `/btw` answer overlay — Claude
|
|
3
|
+
* Code's side-question panel: a focused, scrollable view of the answer with the
|
|
4
|
+
* question dim above it. `↑/↓` (and PgUp/PgDn) scroll, `c` copies the answer,
|
|
5
|
+
* `Esc` closes.
|
|
6
|
+
*
|
|
7
|
+
* Kept free of pi imports so the scroll maths and layout are unit-tested; the
|
|
8
|
+
* extension owns the overlay, repaint, and the actual clipboard write. CC's
|
|
9
|
+
* panel also offers `f to fork`; One Code omits it (pi exposes no primitive to
|
|
10
|
+
* branch a session with an injected exchange) — docs/decisions/btw.md.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { panelTopRule, safeThemePaint, truncateLine, wrapPlainText } from "../lib/tui-render.ts";
|
|
14
|
+
|
|
15
|
+
export const BTW_MAX_HEIGHT = 24;
|
|
16
|
+
|
|
17
|
+
/** Key hint shown at the foot of the panel. Every key here is discoverable. */
|
|
18
|
+
export const BTW_HINT = "↑/↓ to scroll · c to copy · Esc to close";
|
|
19
|
+
|
|
20
|
+
/** Most echoed question lines shown in the header, so it can't starve the body. */
|
|
21
|
+
export const MAX_QUESTION_LINES = 4;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The panel's body state. The pure module owns the text for each phase so every
|
|
25
|
+
* appearance (loading, error, answer) is rendered — and tested — here rather
|
|
26
|
+
* than assembled by the extension wiring.
|
|
27
|
+
*/
|
|
28
|
+
export type BtwBody = { kind: "loading" } | { kind: "error"; message: string } | { kind: "answer"; text: string };
|
|
29
|
+
|
|
30
|
+
/** The text shown in the body for a given phase. */
|
|
31
|
+
function bodyText(body: BtwBody): string {
|
|
32
|
+
switch (body.kind) {
|
|
33
|
+
case "loading":
|
|
34
|
+
return "Thinking…";
|
|
35
|
+
case "error":
|
|
36
|
+
return `Side question failed: ${body.message}`;
|
|
37
|
+
case "answer":
|
|
38
|
+
return body.text || "(no answer)";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BtwPanelState {
|
|
43
|
+
/** First body line shown (0 = top). */
|
|
44
|
+
offset: number;
|
|
45
|
+
/** Largest valid offset for the current viewport; render keeps it current. */
|
|
46
|
+
maxOffset: number;
|
|
47
|
+
/** Visible body lines in the current viewport; render keeps it current. */
|
|
48
|
+
viewport: number;
|
|
49
|
+
/** Set for one frame after a copy so the footer can confirm it. */
|
|
50
|
+
copied: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function initialBtwState(): BtwPanelState {
|
|
54
|
+
return { offset: 0, maxOffset: 0, viewport: 1, copied: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type BtwKey = { kind: "up" | "down" | "pageUp" | "pageDown" | "top" | "bottom" | "copy" | "close" };
|
|
58
|
+
|
|
59
|
+
export function decodeBtwKey(data: string): BtwKey | undefined {
|
|
60
|
+
switch (data) {
|
|
61
|
+
case "\x1b[A":
|
|
62
|
+
case "\x1bOA":
|
|
63
|
+
case "\x10": // ctrl+p
|
|
64
|
+
return { kind: "up" };
|
|
65
|
+
case "\x1b[B":
|
|
66
|
+
case "\x1bOB":
|
|
67
|
+
case "\x0e": // ctrl+n
|
|
68
|
+
return { kind: "down" };
|
|
69
|
+
case "\x1b[5~":
|
|
70
|
+
return { kind: "pageUp" };
|
|
71
|
+
case "\x1b[6~":
|
|
72
|
+
return { kind: "pageDown" };
|
|
73
|
+
case "\x1b[H":
|
|
74
|
+
case "\x1bOH":
|
|
75
|
+
return { kind: "top" };
|
|
76
|
+
case "\x1b[F":
|
|
77
|
+
case "\x1bOF":
|
|
78
|
+
return { kind: "bottom" };
|
|
79
|
+
case "c":
|
|
80
|
+
case "C":
|
|
81
|
+
return { kind: "copy" };
|
|
82
|
+
case "\x1b":
|
|
83
|
+
case "\x03": // ctrl+c
|
|
84
|
+
case "q":
|
|
85
|
+
return { kind: "close" };
|
|
86
|
+
default:
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type BtwEffect = { kind: "copy" } | { kind: "close" };
|
|
92
|
+
|
|
93
|
+
/** Page size for PgUp/PgDn: nearly a full viewport, one line of overlap for orientation. */
|
|
94
|
+
function pageStep(viewport: number): number {
|
|
95
|
+
return Math.max(1, viewport - 1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply a key to the scroll state. Returns an effect for the extension to run
|
|
100
|
+
* (copy, close) or undefined for a scroll (repaint only). Offsets are clamped to
|
|
101
|
+
* `state.maxOffset`, and pages by `state.viewport` — both set by the most recent
|
|
102
|
+
* render from the real viewport.
|
|
103
|
+
*/
|
|
104
|
+
export function applyBtwKey(state: BtwPanelState, key: BtwKey): BtwEffect | undefined {
|
|
105
|
+
state.copied = false;
|
|
106
|
+
const step = pageStep(state.viewport);
|
|
107
|
+
switch (key.kind) {
|
|
108
|
+
case "up":
|
|
109
|
+
state.offset = Math.max(0, state.offset - 1);
|
|
110
|
+
return undefined;
|
|
111
|
+
case "down":
|
|
112
|
+
state.offset = Math.min(state.maxOffset, state.offset + 1);
|
|
113
|
+
return undefined;
|
|
114
|
+
case "pageUp":
|
|
115
|
+
state.offset = Math.max(0, state.offset - step);
|
|
116
|
+
return undefined;
|
|
117
|
+
case "pageDown":
|
|
118
|
+
state.offset = Math.min(state.maxOffset, state.offset + step);
|
|
119
|
+
return undefined;
|
|
120
|
+
case "top":
|
|
121
|
+
state.offset = 0;
|
|
122
|
+
return undefined;
|
|
123
|
+
case "bottom":
|
|
124
|
+
state.offset = state.maxOffset;
|
|
125
|
+
return undefined;
|
|
126
|
+
case "copy":
|
|
127
|
+
return { kind: "copy" };
|
|
128
|
+
case "close":
|
|
129
|
+
return { kind: "close" };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Render the panel to at most `height` lines. The header (the dim question) and
|
|
135
|
+
* footer (rule + key hint, with a scroll indicator and a copy confirmation) are
|
|
136
|
+
* fixed; the answer scrolls within what remains. Clamps and writes back
|
|
137
|
+
* `state.offset`/`state.maxOffset` so key handling scrolls against the real
|
|
138
|
+
* body length. Every line is width-truncated (pi-tui crashes on an overwide
|
|
139
|
+
* line).
|
|
140
|
+
*/
|
|
141
|
+
export function renderBtwPanel(
|
|
142
|
+
input: { state: BtwPanelState; question: string; body: BtwBody; width: number; height: number },
|
|
143
|
+
theme?: unknown,
|
|
144
|
+
): string[] {
|
|
145
|
+
const { state, question, body, width, height } = input;
|
|
146
|
+
const paint = safeThemePaint(theme);
|
|
147
|
+
const inner = Math.max(1, width);
|
|
148
|
+
|
|
149
|
+
// Cap the echoed question so a long one (a pasted paragraph or URL) cannot
|
|
150
|
+
// grow the header past the panel height and starve the answer of rows; a
|
|
151
|
+
// truncated last line gets an ellipsis. The answer is what scrolls, not the
|
|
152
|
+
// question.
|
|
153
|
+
const questionLines = wrapPlainText(question.trim(), inner);
|
|
154
|
+
const shownQuestion =
|
|
155
|
+
questionLines.length > MAX_QUESTION_LINES
|
|
156
|
+
? [...questionLines.slice(0, MAX_QUESTION_LINES - 1), truncateLine(`${questionLines[MAX_QUESTION_LINES - 1]}…`, inner)]
|
|
157
|
+
: questionLines;
|
|
158
|
+
const header = [
|
|
159
|
+
paint("accent", truncateLine("Side question", inner)),
|
|
160
|
+
...shownQuestion.map((line) => paint("muted", truncateLine(line, inner))),
|
|
161
|
+
panelTopRule(paint, inner),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const bodyAll = wrapPlainText(bodyText(body), inner);
|
|
165
|
+
const capacity = Math.max(1, height - header.length - 2); // 2 = footer rule + hint
|
|
166
|
+
state.viewport = capacity;
|
|
167
|
+
state.maxOffset = Math.max(0, bodyAll.length - capacity);
|
|
168
|
+
state.offset = Math.min(Math.max(0, state.offset), state.maxOffset);
|
|
169
|
+
|
|
170
|
+
const visible = bodyAll.slice(state.offset, state.offset + capacity).map((line) => truncateLine(line, inner));
|
|
171
|
+
// Pad a short answer so the footer sits at the panel's foot, not mid-screen.
|
|
172
|
+
while (visible.length < capacity) visible.push("");
|
|
173
|
+
|
|
174
|
+
const scrollNote = state.maxOffset > 0 ? ` (${state.offset + 1}-${Math.min(state.offset + capacity, bodyAll.length)} of ${bodyAll.length})` : "";
|
|
175
|
+
const hint = state.copied ? "Copied to clipboard" : `${BTW_HINT}${scrollNote}`;
|
|
176
|
+
const footer = [panelTopRule(paint, inner), paint("muted", truncateLine(hint, inner))];
|
|
177
|
+
|
|
178
|
+
return [...header, ...visible, ...footer];
|
|
179
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure pieces of Claude Code's `/btw` ("by the way") side-question feature: a
|
|
3
|
+
* lightweight, one-off question answered against the current conversation's
|
|
4
|
+
* context while the main agent keeps working. Prompt text is verbatim from CC's
|
|
5
|
+
* capture (captures/btw.json, CC 2.1.278): a `<system-reminder>` that frames the
|
|
6
|
+
* answering model as a separate, tool-less instance, prepended to the user's
|
|
7
|
+
* question as the final user message.
|
|
8
|
+
*
|
|
9
|
+
* Kept free of pi imports so the message assembly and text extraction are
|
|
10
|
+
* unit-tested; the extension owns the model call, the spinner, and the panel.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* CC's verbatim side-question reminder (captures/btw.json message 20). It frames
|
|
15
|
+
* the answering instance as a separate, lightweight, tool-less agent so the
|
|
16
|
+
* model answers in one shot from context instead of trying to act. One Code
|
|
17
|
+
* backs this framing for real: it sends name-only tool stubs (never live tools)
|
|
18
|
+
* and does not persist the exchange, so there genuinely is no follow-up turn.
|
|
19
|
+
*/
|
|
20
|
+
export const SIDE_QUESTION_REMINDER = `<system-reminder>This is a side question from the user. You must answer this question directly in a single response.
|
|
21
|
+
|
|
22
|
+
IMPORTANT CONTEXT:
|
|
23
|
+
- You are a separate, lightweight agent spawned to answer this one question
|
|
24
|
+
- The main agent is NOT interrupted - it continues working independently in the background
|
|
25
|
+
- You share the conversation context but are a completely separate instance
|
|
26
|
+
- Do NOT reference being interrupted or what you were "previously doing" - that framing is incorrect
|
|
27
|
+
|
|
28
|
+
CRITICAL CONSTRAINTS:
|
|
29
|
+
- You have NO tools available - you cannot read files, run commands, search, or take any actions
|
|
30
|
+
- Do NOT write tool calls or tool output as text (for example invoke or function_calls XML blocks) - nothing you write here is executed; if answering would need reading files, running commands, or searching, say that can't be checked from a side question and suggest asking in the main conversation
|
|
31
|
+
- This is a one-off response - there will be no follow-up turns
|
|
32
|
+
- You can ONLY provide information based on what you already know from the conversation context
|
|
33
|
+
- NEVER say things like "Let me try...", "I'll now...", "Let me check...", or promise to take any action
|
|
34
|
+
- If you don't know the answer, say so - do not offer to look it up or investigate
|
|
35
|
+
|
|
36
|
+
Simply answer the question with the information you have.</system-reminder>`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The final user message a `/btw` call sends: CC's reminder, a blank line, then
|
|
40
|
+
* the user's question verbatim (captures/btw.json message 20 is exactly this
|
|
41
|
+
* string). The question is trimmed of surrounding whitespace only.
|
|
42
|
+
*/
|
|
43
|
+
export function sideQuestionMessage(question: string): string {
|
|
44
|
+
return `${SIDE_QUESTION_REMINDER}\n\n${question.trim()}`;
|
|
45
|
+
}
|
|
@@ -13,9 +13,20 @@
|
|
|
13
13
|
* a clean context — which is what a `/clear` habit wants. Like `/exit`→`/quit`,
|
|
14
14
|
* we cannot fold this into pi's `/new` palette entry (pi has no command
|
|
15
15
|
* aliases), so `/clear` and `/new` are two entries that both reset.
|
|
16
|
+
*
|
|
17
|
+
* Claude Code tells the model the conversation was cleared: the new
|
|
18
|
+
* conversation opens with the `/clear` breadcrumb (caveat, command, empty
|
|
19
|
+
* stdout — lib/local-command.ts). It cannot be queued from the handler: pi's
|
|
20
|
+
* `newSession()` replaces the whole runtime (a `session_shutdown` with reason
|
|
21
|
+
* "new" on the old extension runner, every factory run again — findings §8),
|
|
22
|
+
* so a reminder queued before the call dies with the old queue. The NEW
|
|
23
|
+
* instance announces it instead, from its `session_start` with reason "new" —
|
|
24
|
+
* which is also what pi's own `/new` and an RPC `new_session` produce, and
|
|
25
|
+
* they are the same user action as far as the model is concerned.
|
|
16
26
|
*/
|
|
17
27
|
|
|
18
28
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import { announceLocalCommand } from "../lib/local-command.ts";
|
|
19
30
|
|
|
20
31
|
export default function clearExtension(pi: ExtensionAPI) {
|
|
21
32
|
pi.registerCommand("clear", {
|
|
@@ -24,4 +35,8 @@ export default function clearExtension(pi: ExtensionAPI) {
|
|
|
24
35
|
await ctx.newSession();
|
|
25
36
|
},
|
|
26
37
|
});
|
|
38
|
+
|
|
39
|
+
pi.on("session_start", (event) => {
|
|
40
|
+
if ((event as { reason?: string }).reason === "new") announceLocalCommand(pi, { name: "clear" });
|
|
41
|
+
});
|
|
27
42
|
}
|
|
@@ -27,16 +27,19 @@
|
|
|
27
27
|
*
|
|
28
28
|
* Both decisions ask pi-ai's `clampMaxTokensToContext` — the very function
|
|
29
29
|
* `completeSimple` applies to the request — rather than re-deriving its
|
|
30
|
-
* arithmetic, so they can never disagree with the clamp they predict.
|
|
31
|
-
*
|
|
32
|
-
*
|
|
30
|
+
* arithmetic, so they can never disagree with the clamp they predict. They
|
|
31
|
+
* `normalizeContext` the request first, exactly as `completeSimple` does, so the
|
|
32
|
+
* prompt and tools are counted (pi 0.86.1 carries them in a leading system
|
|
33
|
+
* message, not separate `Context` fields). One trap in that estimator: it
|
|
34
|
+
* anchors on the last assistant message's `usage` total when there is one
|
|
35
|
+
* (`withoutUsage`).
|
|
33
36
|
*/
|
|
34
37
|
|
|
35
38
|
import { type Api, type Context, contentText, type Message, type Model } from "@earendil-works/pi-ai";
|
|
36
39
|
import { PREVIEW_BYTES } from "../lib/persisted-output.ts";
|
|
37
40
|
// Vendored from pi-ai: importing its deep subpaths directly breaks under the
|
|
38
41
|
// bundled app's library loader (distribution review 2026-09-09, H1).
|
|
39
|
-
import { clampMaxTokensToContext, estimateMessageTokens } from "../lib/pi-ai-estimate.ts";
|
|
42
|
+
import { clampMaxTokensToContext, estimateMessageTokens, normalizeContext } from "../lib/pi-ai-estimate.ts";
|
|
40
43
|
|
|
41
44
|
/**
|
|
42
45
|
* The least output room a replay must leave for the summary. Below it the
|
|
@@ -60,7 +63,7 @@ export const CLEARED_RESULT_HEAD_CHARS = PREVIEW_BYTES;
|
|
|
60
63
|
*/
|
|
61
64
|
export function replayFits(reason: "manual" | "threshold" | "overflow", model: Model<Api>, request: Context, maxTokens: number): boolean {
|
|
62
65
|
if (reason === "overflow") return false;
|
|
63
|
-
return clampMaxTokensToContext(model, request, maxTokens) >= MIN_REPLAY_SUMMARY_TOKENS;
|
|
66
|
+
return clampMaxTokensToContext(model, normalizeContext(request), maxTokens) >= MIN_REPLAY_SUMMARY_TOKENS;
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
/**
|
|
@@ -80,7 +83,7 @@ export function fitToBudget(model: Model<Api>, request: Context, maxTokens: numb
|
|
|
80
83
|
|
|
81
84
|
let cleared = 0;
|
|
82
85
|
for (const { index } of candidates) {
|
|
83
|
-
if (clampMaxTokensToContext(model, fitted, maxTokens) >= STANDALONE_SUMMARY_TOKENS) break;
|
|
86
|
+
if (clampMaxTokensToContext(model, normalizeContext(fitted), maxTokens) >= STANDALONE_SUMMARY_TOKENS) break;
|
|
84
87
|
messages[index] = clearToolResult(messages[index]);
|
|
85
88
|
cleared++;
|
|
86
89
|
}
|
|
@@ -17,7 +17,10 @@ import { readJsonFile } from "../lib/atomic-write.ts";
|
|
|
17
17
|
import { oneCodeSettingsPath } from "../lib/one-code-settings.ts";
|
|
18
18
|
import { webSearchSettingsFrom } from "../web/backends.ts";
|
|
19
19
|
import { collectCompat, importedConfigSection, mcpSection } from "./compat.ts";
|
|
20
|
-
import { checkDependencies, dependenciesSection } from "./dependencies.ts";
|
|
20
|
+
import { checkDependencies, dependenciesSection, type ShellsInput } from "./dependencies.ts";
|
|
21
|
+
import { readSettingsEnv } from "../lib/claude-settings.ts";
|
|
22
|
+
import { resolveBashSpawn, resolvePowerShellSpawn } from "../lib/shell-spawn.ts";
|
|
23
|
+
import { shellToolPolicy } from "../powershell/policy.ts";
|
|
21
24
|
import { collectModelFacts, modelsSection } from "./models.ts";
|
|
22
25
|
import { computePresets, presetsSection } from "./presets.ts";
|
|
23
26
|
import {
|
|
@@ -181,7 +184,7 @@ export function buildDoctorReport({ env, registry, session }: BuildInput): Docto
|
|
|
181
184
|
sections.push(mcpSection(compat, session, env.home));
|
|
182
185
|
|
|
183
186
|
const webSearchSettings = webSearchSettingsFrom(readJsonFile<{ webSearch?: unknown }>(oneCodeSettingsPath(env.home, env.env))?.webSearch);
|
|
184
|
-
const deps = checkDependencies({ cwd: env.cwd, env: env.env, platform: env.platform, mcpServers: compat.mcp.servers, sessionModel: session.model, webSearchSettings });
|
|
187
|
+
const deps = checkDependencies({ cwd: env.cwd, env: env.env, platform: env.platform, agentDir: env.agentDir, mcpServers: compat.mcp.servers, sessionModel: session.model, webSearchSettings, shells: resolveShells(env.env, env.home, env.platform) });
|
|
185
188
|
findings.push(...deps.findings);
|
|
186
189
|
sections.push(dependenciesSection(deps));
|
|
187
190
|
|
|
@@ -224,3 +227,20 @@ function summaryText(input: {
|
|
|
224
227
|
export function oneCodeVersion(env: NodeJS.ProcessEnv = process.env): string {
|
|
225
228
|
return env.CC_VERSION ?? extensionVersion();
|
|
226
229
|
}
|
|
230
|
+
|
|
231
|
+
/** The shell tools exactly as a session resolves them (bash/index.ts, powershell/index.ts). */
|
|
232
|
+
export function resolveShells(procEnv: NodeJS.ProcessEnv, home: string, platform: string): ShellsInput {
|
|
233
|
+
const plat = platform as NodeJS.Platform;
|
|
234
|
+
const bash = resolveBashSpawn({ env: procEnv, settingsEnv: readSettingsEnv(home) });
|
|
235
|
+
const powershell = resolvePowerShellSpawn({ platform: plat, env: procEnv });
|
|
236
|
+
const policy = shellToolPolicy({ platform: plat, env: procEnv, bash: !!bash.spawn, powershell: !!powershell });
|
|
237
|
+
return {
|
|
238
|
+
bash: bash.spawn?.shell,
|
|
239
|
+
bashWarning: bash.warning,
|
|
240
|
+
powershell: powershell?.shell,
|
|
241
|
+
powershellWanted: policy.powershellWanted,
|
|
242
|
+
bashExpected: policy.bashExpected,
|
|
243
|
+
primary: policy.primary,
|
|
244
|
+
notices: policy.notices,
|
|
245
|
+
};
|
|
246
|
+
}
|