pi-jar 0.1.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/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-jar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ygrip/pi-jar.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/ygrip/pi-jar#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/ygrip/pi-jar/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"pi-package",
|
|
17
|
+
"pi-theme",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"terminal",
|
|
20
|
+
"multi-agent",
|
|
21
|
+
"plan-mode",
|
|
22
|
+
"goal",
|
|
23
|
+
"model-roles"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"extensions",
|
|
27
|
+
"src",
|
|
28
|
+
"themes",
|
|
29
|
+
"docs/*.md",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./extensions"
|
|
36
|
+
],
|
|
37
|
+
"themes": [
|
|
38
|
+
"./themes"
|
|
39
|
+
],
|
|
40
|
+
"image": "https://raw.githubusercontent.com/ygrip/pi-jar/main/docs/assets/pi-jar-demo.gif"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"check": "tsc --noEmit",
|
|
44
|
+
"typecheck": "tsc --noEmit",
|
|
45
|
+
"themes:build": "node --experimental-strip-types scripts/build-accents.mjs",
|
|
46
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.6"
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
53
|
+
"@earendil-works/pi-tui": "*",
|
|
54
|
+
"typebox": "*"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
58
|
+
"@earendil-works/pi-tui": "^0.87.1",
|
|
59
|
+
"typebox": "^1.3.34",
|
|
60
|
+
"typescript": "5.9.2"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/accent.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** Sharpened pi-jar dark preset values; keep these in sync with bundled theme vars. */
|
|
4
|
+
export const ACCENTS = {
|
|
5
|
+
gray: { accent: "#D0D9E5", selected: "#252B35" },
|
|
6
|
+
pink: { accent: "#FF88C6", selected: "#3B1D2D" },
|
|
7
|
+
teal: { accent: "#45DDD1", selected: "#123433" },
|
|
8
|
+
azure: { accent: "#75BCFF", selected: "#132A41" },
|
|
9
|
+
violet: { accent: "#BCA0FF", selected: "#2C2350" },
|
|
10
|
+
amber: { accent: "#F7B955", selected: "#3A2B0F" }
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type AccentName = keyof typeof ACCENTS;
|
|
14
|
+
export const ACCENT_NAMES = Object.keys(ACCENTS) as AccentName[];
|
|
15
|
+
|
|
16
|
+
/** A loaded extension is not proof that Pi also discovered its package themes. */
|
|
17
|
+
export function loadedAccents(ctx: ExtensionContext): string[] {
|
|
18
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return [];
|
|
19
|
+
try {
|
|
20
|
+
return ["default", ...ACCENT_NAMES].filter((name) =>
|
|
21
|
+
Boolean(ctx.ui.getTheme(name === "default" ? "pi-jar-dark" : `pi-jar-dark-${name}`)));
|
|
22
|
+
} catch { return []; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only switch among complete bundled themes; never mutate semantic status colors. */
|
|
26
|
+
export function selectAccent(ctx: ExtensionContext, value: string): boolean {
|
|
27
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return false;
|
|
28
|
+
const themeName = value === "default" ? "pi-jar-dark"
|
|
29
|
+
: Object.hasOwn(ACCENTS, value) ? `pi-jar-dark-${value}` : undefined;
|
|
30
|
+
if (!themeName) return false;
|
|
31
|
+
try {
|
|
32
|
+
if (!ctx.ui.getTheme(themeName)) return false;
|
|
33
|
+
// Switching by name persists Pi's theme setting; passing a Theme instance does not.
|
|
34
|
+
return ctx.ui.setTheme(themeName).success;
|
|
35
|
+
} catch { return false; }
|
|
36
|
+
}
|
package/src/advisor.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { ModelRoleManager } from "./model-roles.ts";
|
|
4
|
+
import { askRole, type SideUsage } from "./side-model.ts";
|
|
5
|
+
import { ROLE_PREFIX } from "./status.ts";
|
|
6
|
+
|
|
7
|
+
export const ADVISOR_TOOL = "jar_advisor";
|
|
8
|
+
export const ADVISOR_MESSAGE = "pi-jar.advisor";
|
|
9
|
+
const TRANSCRIPT_LIMIT = 24_000;
|
|
10
|
+
const GIT_LIMIT = 4_000;
|
|
11
|
+
/** Identical tool calls (same tool and input) within the recent window that count as a loop. */
|
|
12
|
+
export const LOOP_REPEATS = 3;
|
|
13
|
+
/** Consecutive failing tool results that count as stuck. */
|
|
14
|
+
export const FAILURE_STREAK = 3;
|
|
15
|
+
/** Automatic consultations allowed per user prompt. */
|
|
16
|
+
export const GATES_PER_PROMPT = 2;
|
|
17
|
+
const WINDOW = 8;
|
|
18
|
+
|
|
19
|
+
export const ADVISOR_SYSTEM = [
|
|
20
|
+
"You are the advisor: a senior engineer giving a second opinion to a coding agent (the executor) mid-task.",
|
|
21
|
+
"You see a transcript excerpt and the repository state; you cannot run tools.",
|
|
22
|
+
"Be direct and specific. Lead with your verdict (proceed / revise / stop and ask the user), then the reasons,",
|
|
23
|
+
"then concrete next steps. Point out wrong assumptions, missed edge cases, simpler approaches and risks.",
|
|
24
|
+
"Keep it under 250 words. Do not restate the transcript."
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
type Entry = { type: string; message?: { role?: string; content?: unknown; toolName?: string; isError?: boolean; customType?: string } };
|
|
28
|
+
|
|
29
|
+
const textOf = (content: unknown): string => typeof content === "string" ? content
|
|
30
|
+
: Array.isArray(content) ? content.map((part: { type?: string; text?: string; name?: string; arguments?: unknown }) =>
|
|
31
|
+
part.type === "text" ? part.text ?? "" : part.type === "toolCall" ? `[tool ${part.name} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}]` : "").filter(Boolean).join("\n")
|
|
32
|
+
: "";
|
|
33
|
+
|
|
34
|
+
/** The most recent conversation, newest last, capped to `limit` characters. */
|
|
35
|
+
export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT): string {
|
|
36
|
+
const parts: string[] = [];
|
|
37
|
+
let size = 0;
|
|
38
|
+
for (let index = entries.length - 1; index >= 0 && size < limit; index--) {
|
|
39
|
+
const message = entries[index]!.type === "message" ? entries[index]!.message : undefined;
|
|
40
|
+
if (!message?.role) continue;
|
|
41
|
+
let body = textOf(message.content).trim();
|
|
42
|
+
if (!body) continue;
|
|
43
|
+
if (message.role === "toolResult" && body.length > 1200) body = body.slice(0, 1200) + " …";
|
|
44
|
+
const label = message.role === "toolResult" ? `tool result (${message.toolName ?? "?"}${message.isError ? ", error" : ""})` : message.role;
|
|
45
|
+
const part = `### ${label}\n${body}`;
|
|
46
|
+
parts.unshift(part.slice(0, limit - size));
|
|
47
|
+
size += part.length;
|
|
48
|
+
}
|
|
49
|
+
return parts.join("\n\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AdvisorRequest { question?: string; draft?: string; trigger?: string }
|
|
53
|
+
|
|
54
|
+
export function advisorPrompt(request: AdvisorRequest, conversation: string, git: string): string {
|
|
55
|
+
return [
|
|
56
|
+
request.trigger ? `Automatic consultation: ${request.trigger}` : "",
|
|
57
|
+
request.question ? `Executor's question: ${request.question}` : "Executor asks for a general review of its current direction.",
|
|
58
|
+
request.draft ? `Executor's draft / candidate approach:\n${request.draft}` : "",
|
|
59
|
+
git ? `Repository state:\n${git}` : "",
|
|
60
|
+
`Recent conversation:\n${conversation || "(empty)"}`
|
|
61
|
+
].filter(Boolean).join("\n\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Stable key for loop detection. */
|
|
65
|
+
export const callKey = (tool: string, input: unknown) => tool + " " + JSON.stringify(input ?? {});
|
|
66
|
+
|
|
67
|
+
/** Tracks repeated calls and failure streaks within one user prompt. */
|
|
68
|
+
export class StuckDetector {
|
|
69
|
+
private recent: string[] = [];
|
|
70
|
+
private failures = 0;
|
|
71
|
+
private gates = 0;
|
|
72
|
+
reset(): void { this.recent = []; this.failures = 0; this.gates = 0; }
|
|
73
|
+
/** Returns a trigger description when this call completes a loop. */
|
|
74
|
+
call(key: string): string | undefined {
|
|
75
|
+
this.recent.push(key);
|
|
76
|
+
if (this.recent.length > WINDOW) this.recent.shift();
|
|
77
|
+
const count = this.recent.filter((item) => item === key).length;
|
|
78
|
+
if (count < LOOP_REPEATS || !this.take()) return undefined;
|
|
79
|
+
this.recent = [];
|
|
80
|
+
return `the executor has made the same tool call ${count} times: ${key.slice(0, 200)}`;
|
|
81
|
+
}
|
|
82
|
+
/** Returns a trigger description when a failure streak is reached. */
|
|
83
|
+
result(isError: boolean, tool: string): string | undefined {
|
|
84
|
+
this.failures = isError ? this.failures + 1 : 0;
|
|
85
|
+
if (this.failures < FAILURE_STREAK || !this.take()) return undefined;
|
|
86
|
+
this.failures = 0;
|
|
87
|
+
return `${FAILURE_STREAK} tool calls in a row failed (last: ${tool})`;
|
|
88
|
+
}
|
|
89
|
+
private take(): boolean { if (this.gates >= GATES_PER_PROMPT) return false; this.gates++; return true; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AdvisorOptions { enabled: () => boolean; gates: () => boolean; usage: SideUsage }
|
|
93
|
+
|
|
94
|
+
/** First-class advisor: the jar_advisor tool, /advisor, and automatic gates for loops and failure streaks. */
|
|
95
|
+
export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, options: AdvisorOptions) {
|
|
96
|
+
const stuck = new StuckDetector();
|
|
97
|
+
let busy = 0;
|
|
98
|
+
|
|
99
|
+
const status = (ctx: ExtensionContext, task?: string) => {
|
|
100
|
+
if (!ctx.hasUI) return;
|
|
101
|
+
try {
|
|
102
|
+
ctx.ui.setStatus(ROLE_PREFIX + "advisor", task ? JSON.stringify({ name: "Advisor", label: "advisor", state: "working", task, expiresAt: Date.now() + 30_000 }) : undefined);
|
|
103
|
+
} catch { /* status is decoration */ }
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const gitState = async (ctx: ExtensionContext): Promise<string> => {
|
|
107
|
+
try {
|
|
108
|
+
const [st, stat] = await Promise.all([
|
|
109
|
+
pi.exec("git", ["status", "--short", "--branch"], { cwd: ctx.cwd, timeout: 5_000 }),
|
|
110
|
+
pi.exec("git", ["diff", "--stat", "HEAD"], { cwd: ctx.cwd, timeout: 5_000 })
|
|
111
|
+
]);
|
|
112
|
+
if (st.code !== 0) return "";
|
|
113
|
+
return (st.stdout.trim() + (stat.code === 0 && stat.stdout.trim() ? "\n" + stat.stdout.trim() : "")).slice(0, GIT_LIMIT);
|
|
114
|
+
} catch { return ""; } // repository state is optional context; not a git repo or git missing
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const consult = async (ctx: ExtensionContext, request: AdvisorRequest, signal?: AbortSignal) => {
|
|
118
|
+
busy++;
|
|
119
|
+
status(ctx, request.trigger ?? request.question ?? "reviewing the current direction");
|
|
120
|
+
try {
|
|
121
|
+
const conversation = transcript(ctx.sessionManager.getBranch() as readonly Entry[]);
|
|
122
|
+
return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx)), signal);
|
|
123
|
+
} finally { if (--busy === 0) status(ctx); }
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const Parameters = Type.Object({
|
|
127
|
+
question: Type.Optional(Type.String({ description: "A focused question or decision for the advisor" })),
|
|
128
|
+
draft: Type.Optional(Type.String({ description: "Your candidate plan, answer or fix for the advisor to critique" }))
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool === "function") pi.registerTool({
|
|
132
|
+
name: ADVISOR_TOOL,
|
|
133
|
+
label: "advisor",
|
|
134
|
+
description: "Ask the advisor (a stronger reviewer model with a fresh view of this conversation and the repo state) for a second opinion. Returns its advice.",
|
|
135
|
+
promptSnippet: "Use jar_advisor for a second opinion on consequential decisions, when stuck, or before declaring hard work done.",
|
|
136
|
+
promptGuidelines: [
|
|
137
|
+
"Call jar_advisor before committing to a risky or hard-to-reverse approach, after two failed attempts at the same problem, and before declaring a complex task complete.",
|
|
138
|
+
"Form your own candidate first and pass it as draft; ask a specific question. Do not call it for routine steps."
|
|
139
|
+
],
|
|
140
|
+
parameters: Parameters,
|
|
141
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
142
|
+
if (!options.enabled()) throw new Error("the advisor is turned off in /jar settings");
|
|
143
|
+
const answer = await consult(ctx, { ...(params.question ? { question: params.question } : {}), ...(params.draft ? { draft: params.draft } : {}) }, signal);
|
|
144
|
+
return { content: [{ type: "text", text: answer.text }], details: { model: answer.model } };
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const deliver = (ctx: ExtensionContext, heading: string, text: string) => {
|
|
149
|
+
pi.sendMessage({ customType: ADVISOR_MESSAGE, content: `${heading}\n\n${text}`, display: true },
|
|
150
|
+
ctx.isIdle() ? { deliverAs: "nextTurn" } : { deliverAs: "steer" });
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
pi.registerCommand("advisor", {
|
|
154
|
+
description: "Ask the advisor for a second opinion on the current work: /advisor [focus]",
|
|
155
|
+
handler: async (args, ctx) => {
|
|
156
|
+
if (!options.enabled()) { ctx.ui.notify("The advisor is off; turn it on in /jar settings → Pi", "warning"); return; }
|
|
157
|
+
const focus = args.trim();
|
|
158
|
+
ctx.ui.notify("Consulting the advisor…", "info");
|
|
159
|
+
try {
|
|
160
|
+
const answer = await consult(ctx, focus ? { question: focus } : {});
|
|
161
|
+
deliver(ctx, `◆ Advisor · ${answer.model}${focus ? " · " + focus : ""}`, answer.text);
|
|
162
|
+
} catch (error) { ctx.ui.notify("Advisor failed: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
pi.on("input", (event) => { if (event.source === "interactive") stuck.reset(); });
|
|
167
|
+
|
|
168
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
169
|
+
if (event.toolName === ADVISOR_TOOL || !options.enabled() || !options.gates()) return;
|
|
170
|
+
const trigger = stuck.call(callKey(event.toolName, event.input));
|
|
171
|
+
if (!trigger) return;
|
|
172
|
+
try {
|
|
173
|
+
const answer = await consult(ctx, { trigger }, ctx.signal);
|
|
174
|
+
return { block: true, reason: `Loop detected: ${trigger}. The advisor (${answer.model}) reviewed the situation:\n\n${answer.text}` };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
ctx.ui.notify("Advisor gate failed: " + (error instanceof Error ? error.message : String(error)), "warning");
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
182
|
+
if (event.toolName === ADVISOR_TOOL || !options.enabled() || !options.gates()) return;
|
|
183
|
+
const trigger = stuck.result(event.isError, event.toolName);
|
|
184
|
+
if (!trigger) return;
|
|
185
|
+
try {
|
|
186
|
+
const answer = await consult(ctx, { trigger }, ctx.signal);
|
|
187
|
+
deliver(ctx, `◆ Advisor · ${answer.model} · ${trigger}`, answer.text);
|
|
188
|
+
} catch (error) {
|
|
189
|
+
ctx.ui.notify("Advisor gate failed: " + (error instanceof Error ? error.message : String(error)), "warning");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return { consult, stuck };
|
|
194
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RoleState } from "./roles.ts";
|
|
2
|
+
|
|
3
|
+
const FRAMES: Record<RoleState, readonly string[]> = {
|
|
4
|
+
idle: ["◇"],
|
|
5
|
+
thinking: ["◈", "◆"],
|
|
6
|
+
working: ["◐", "◓", "◑", "◒"],
|
|
7
|
+
waiting: ["◇"],
|
|
8
|
+
reviewing: ["◔", "◑", "◕", "●"],
|
|
9
|
+
done: ["✓"],
|
|
10
|
+
failed: ["×"]
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function roleFrame(state: RoleState, frame: number, animations = true): string {
|
|
14
|
+
const frames = FRAMES[state];
|
|
15
|
+
if (!animations || frames.length === 1) {
|
|
16
|
+
return frames[0] ?? "◇";
|
|
17
|
+
}
|
|
18
|
+
return frames[frame % frames.length] ?? frames[0] ?? "◇";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const WORKING_FRAMES = ["◇", "◆", "◇", "◈"] as const;
|
|
22
|
+
/** Pixel-fire tick for the welcome flame; frozen when motion is disabled. */
|
|
23
|
+
export const WELCOME_INTERVAL_MS = 90;
|
package/src/ask-tool.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { cleanText } from "./status.ts";
|
|
5
|
+
|
|
6
|
+
const OptionSchema = Type.Object({
|
|
7
|
+
label: Type.String({ maxLength: 160 }),
|
|
8
|
+
description: Type.Optional(Type.String({ maxLength: 320 }))
|
|
9
|
+
});
|
|
10
|
+
const QuestionSchema = Type.Object({
|
|
11
|
+
id: Type.Optional(Type.String({ maxLength: 64 })),
|
|
12
|
+
header: Type.Optional(Type.String({ maxLength: 48 })),
|
|
13
|
+
question: Type.String({ maxLength: 1000 }),
|
|
14
|
+
multi: Type.Optional(Type.Boolean({ description: "Show checkboxes so the user can select several options." })),
|
|
15
|
+
allowCustom: Type.Optional(Type.Boolean()),
|
|
16
|
+
options: Type.Optional(Type.Array(OptionSchema, { maxItems: 12 }))
|
|
17
|
+
});
|
|
18
|
+
const Parameters = Type.Object({
|
|
19
|
+
questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: 8 })
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
type AskOption = { label: string; description?: string };
|
|
23
|
+
type AskQuestion = { id?: string; header?: string; question: string; multi?: boolean; allowCustom?: boolean; options?: AskOption[] };
|
|
24
|
+
type AskAnswer = { id: string; answer?: string; selected?: string[]; chat?: string; cancelled?: boolean };
|
|
25
|
+
type PickResult = { kind: "answer"; selected: string[] } | { kind: "custom" } | { kind: "chat" } | { kind: "cancel" };
|
|
26
|
+
|
|
27
|
+
const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
|
|
28
|
+
|
|
29
|
+
async function pickQuestion(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<PickResult> {
|
|
30
|
+
const options = (question.options ?? []).map((option) => ({
|
|
31
|
+
label: cleanText(option.label, 120),
|
|
32
|
+
description: option.description ? cleanText(option.description, 240) : undefined
|
|
33
|
+
})).filter((option) => !!option.label);
|
|
34
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return { kind: "cancel" };
|
|
35
|
+
return ctx.ui.custom<PickResult>((tui, theme, _keys, done) => {
|
|
36
|
+
const multi = !!question.multi;
|
|
37
|
+
const allowCustom = question.allowCustom !== false;
|
|
38
|
+
const actions = [
|
|
39
|
+
...(allowCustom ? [{ kind: "custom" as const, label: "Type your own answer", icon: "✎" }] : []),
|
|
40
|
+
{ kind: "chat" as const, label: "Chat about this", icon: "◇" },
|
|
41
|
+
...(multi ? [{ kind: "submit" as const, label: "Continue with selection", icon: "✓" }] : [])
|
|
42
|
+
];
|
|
43
|
+
let selected = 0;
|
|
44
|
+
const checked = new Set<number>();
|
|
45
|
+
let hitRows: number[] = [];
|
|
46
|
+
let scroll = 0;
|
|
47
|
+
let lastSelected = selected;
|
|
48
|
+
const totalRows = () => options.length + actions.length;
|
|
49
|
+
const choose = () => {
|
|
50
|
+
if (selected < options.length) {
|
|
51
|
+
if (multi) {
|
|
52
|
+
if (checked.has(selected)) checked.delete(selected); else checked.add(selected);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
return done({ kind: "answer", selected: [options[selected]!.label] });
|
|
56
|
+
}
|
|
57
|
+
const action = actions[selected - options.length];
|
|
58
|
+
if (!action) return;
|
|
59
|
+
if (action.kind === "custom") return done({ kind: "custom" });
|
|
60
|
+
if (action.kind === "chat") return done({ kind: "chat" });
|
|
61
|
+
return done({ kind: "answer", selected: [...checked].sort((a, b) => a - b).map((at) => options[at]!.label) });
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
invalidate() {},
|
|
65
|
+
handleInput(data: string) {
|
|
66
|
+
if (matchesKey(data, Key.escape)) return done({ kind: "cancel" });
|
|
67
|
+
if (matchesKey(data, Key.up)) selected = (selected + totalRows() - 1) % totalRows();
|
|
68
|
+
else if (matchesKey(data, Key.down)) selected = (selected + 1) % totalRows();
|
|
69
|
+
else if (multi && data === "a") {
|
|
70
|
+
// Select all, or clear when everything is already checked.
|
|
71
|
+
if (checked.size === options.length) checked.clear(); else options.forEach((_option, at) => checked.add(at));
|
|
72
|
+
} else if (/^[1-9]$/.test(data)) {
|
|
73
|
+
const at = Number(data) - 1;
|
|
74
|
+
if (at < options.length) { selected = at; choose(); }
|
|
75
|
+
} else if (matchesKey(data, Key.enter) || data === " ") choose();
|
|
76
|
+
tui.requestRender();
|
|
77
|
+
},
|
|
78
|
+
handleMouse(event: TuiMouseEvent) {
|
|
79
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
80
|
+
scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3);
|
|
81
|
+
tui.requestRender(); return { handled: true };
|
|
82
|
+
}
|
|
83
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
84
|
+
const index = hitRows[event.y];
|
|
85
|
+
if (index === undefined) return;
|
|
86
|
+
selected = index;
|
|
87
|
+
choose();
|
|
88
|
+
tui.requestRender();
|
|
89
|
+
return { handled: true };
|
|
90
|
+
},
|
|
91
|
+
render(width: number): string[] {
|
|
92
|
+
hitRows = [];
|
|
93
|
+
const inner = Math.max(1, width - 4);
|
|
94
|
+
const badge = "◆ QUESTION " + String(index + 1) + "/" + String(total) + (multi ? " · MULTI SELECT" : "");
|
|
95
|
+
const title = question.header ? cleanText(question.header, 48) : "Clarification";
|
|
96
|
+
const lines: string[] = [
|
|
97
|
+
fit(theme.fg("accent", "╭─ " + badge + " ─"), width),
|
|
98
|
+
fit(theme.fg("muted", "│ " + title), width)
|
|
99
|
+
];
|
|
100
|
+
const content: { line: string; target?: number }[] = [];
|
|
101
|
+
for (const row of wrapTextWithAnsi(cleanText(question.question, 900), inner)) content.push({ line: fit("│ " + row, width) });
|
|
102
|
+
content.push({ line: fit(theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 1))), width) });
|
|
103
|
+
options.forEach((option, at) => {
|
|
104
|
+
const active = selected === at;
|
|
105
|
+
const marker = multi ? (checked.has(at) ? "☑" : "☐") : (active ? "●" : "○");
|
|
106
|
+
const number = String(at + 1).padStart(2, " ") + ".";
|
|
107
|
+
content.push({ target: at, line: fit(theme.fg(active ? "accent" : "muted", "│ " + (active ? "❯ " : " ") + marker + " " + number + " " + option.label), width) });
|
|
108
|
+
if (option.description) content.push({ target: at, line: fit(theme.fg("dim", "│ " + option.description), width) });
|
|
109
|
+
});
|
|
110
|
+
actions.forEach((action, at) => {
|
|
111
|
+
const row = options.length + at;
|
|
112
|
+
const active = row === selected;
|
|
113
|
+
const label = action.kind === "submit" ? `Continue with ${checked.size ? checked.size + " selected" : "none selected"}` : action.label;
|
|
114
|
+
const chip = "[ " + action.icon + " " + label + " ]";
|
|
115
|
+
content.push({ target: row, line: fit(theme.fg(active ? "accent" : "dim", "│ " + (active ? "❯ " : " ") + chip), width) });
|
|
116
|
+
});
|
|
117
|
+
const page = Math.max(3, Math.min(16, (process.stdout.rows ?? 24) - 5));
|
|
118
|
+
if (selected !== lastSelected) {
|
|
119
|
+
const target = content.findIndex((row) => row.target === selected);
|
|
120
|
+
if (target < scroll) scroll = target;
|
|
121
|
+
else if (target >= scroll + page) scroll = target - page + 1;
|
|
122
|
+
lastSelected = selected;
|
|
123
|
+
}
|
|
124
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, content.length - page)));
|
|
125
|
+
content.slice(scroll, scroll + page).forEach((row) => {
|
|
126
|
+
if (row.target !== undefined) hitRows[lines.length] = row.target;
|
|
127
|
+
lines.push(row.line);
|
|
128
|
+
});
|
|
129
|
+
const hint = multi ? `${checked.size}/${options.length} checked · ↑↓ move · Space/Enter toggle · a all · Esc cancel` : "↑↓ move · Enter choose · wheel scroll · Esc cancel";
|
|
130
|
+
lines.push(fit(theme.fg("dim", `╰─ ${scroll + 1}–${Math.min(scroll + page, content.length)}/${content.length} · ${hint}`), width));
|
|
131
|
+
return lines.map((line) => visibleWidth(line) <= width ? line : fit(line, width));
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function askOne(ctx: ExtensionContext, question: AskQuestion, index: number, total: number): Promise<AskAnswer> {
|
|
138
|
+
const id = cleanText(question.id ?? "q" + String(index + 1), 64) || "q" + String(index + 1);
|
|
139
|
+
const picked = await pickQuestion(ctx, question, index, total);
|
|
140
|
+
if (!picked || picked.kind === "cancel") return { id, cancelled: true };
|
|
141
|
+
if (picked.kind === "custom") {
|
|
142
|
+
const text = await ctx.ui.editor("✎ Your answer · " + cleanText(question.header ?? "Question", 48), "");
|
|
143
|
+
return text?.trim() ? { id, answer: text.trim() } : { id, cancelled: true };
|
|
144
|
+
}
|
|
145
|
+
if (picked.kind === "chat") {
|
|
146
|
+
const text = await ctx.ui.editor("◇ Chat about this · " + cleanText(question.header ?? "Question", 48), "I want to discuss this before I answer. ");
|
|
147
|
+
return text?.trim() ? { id, chat: text.trim() } : { id, cancelled: true };
|
|
148
|
+
}
|
|
149
|
+
return question.multi ? { id, selected: picked.selected } : { id, answer: picked.selected[0] ?? "" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function registerAskTool(pi: ExtensionAPI): void {
|
|
153
|
+
if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
154
|
+
pi.registerTool({
|
|
155
|
+
name: "jar_ask",
|
|
156
|
+
label: "ask user",
|
|
157
|
+
description: "Ask the user one or more structured clarification questions with described choices, multi-select checkboxes, custom text, or a chat-about-this path.",
|
|
158
|
+
promptSnippet: "Use jar_ask when a decision or clarification is needed; prefer concise options with useful descriptions.",
|
|
159
|
+
promptGuidelines: [
|
|
160
|
+
"Use jar_ask instead of writing ad-hoc numbered questions when the user needs to choose between options or provide clarification.",
|
|
161
|
+
"Use multi=true only when several answers may be selected. Include short descriptions when labels alone are ambiguous.",
|
|
162
|
+
"If a jar_ask result contains chat, respond to that discussion before asking for a final choice again."
|
|
163
|
+
],
|
|
164
|
+
parameters: Parameters,
|
|
165
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
166
|
+
const questions = params.questions as AskQuestion[];
|
|
167
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
168
|
+
return { content: [{ type: "text", text: "Structured questions require Pi's interactive TUI." }], details: { answers: [] } };
|
|
169
|
+
}
|
|
170
|
+
const answers: AskAnswer[] = [];
|
|
171
|
+
for (let index = 0; index < questions.length; index++) {
|
|
172
|
+
const answer = await askOne(ctx, questions[index]!, index, questions.length);
|
|
173
|
+
answers.push(answer);
|
|
174
|
+
if (answer.cancelled || answer.chat) break;
|
|
175
|
+
}
|
|
176
|
+
const summary = answers.map((answer) => {
|
|
177
|
+
if (answer.cancelled) return answer.id + ": cancelled";
|
|
178
|
+
if (answer.chat) return answer.id + ": CHAT: " + answer.chat;
|
|
179
|
+
if (answer.selected) return answer.id + ": " + (answer.selected.length ? answer.selected.join(", ") : "(none selected)");
|
|
180
|
+
return answer.id + ": " + (answer.answer ?? "");
|
|
181
|
+
}).join("\n");
|
|
182
|
+
return { content: [{ type: "text", text: summary || "No answers." }], details: { answers } };
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ImageInfo { path: string; name: string; bytes: number; width?: number; height?: number; format: string }
|
|
6
|
+
|
|
7
|
+
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
|
8
|
+
const HEADER_BYTES = 64 * 1024;
|
|
9
|
+
const cache = new Map<string, { mtime: number; info: ImageInfo }>();
|
|
10
|
+
|
|
11
|
+
/** Width × height from an image header (PNG, GIF, JPEG, WebP), or undefined. */
|
|
12
|
+
export function imageSize(header: Buffer): { width: number; height: number; format: string } | undefined {
|
|
13
|
+
if (header.length >= 24 && header.readUInt32BE(0) === 0x89504e47 && header.toString("ascii", 12, 16) === "IHDR") {
|
|
14
|
+
return { width: header.readUInt32BE(16), height: header.readUInt32BE(20), format: "PNG" };
|
|
15
|
+
}
|
|
16
|
+
if (header.length >= 10 && header.toString("ascii", 0, 3) === "GIF") return { width: header.readUInt16LE(6), height: header.readUInt16LE(8), format: "GIF" };
|
|
17
|
+
if (header.length >= 30 && header.toString("ascii", 0, 4) === "RIFF" && header.toString("ascii", 8, 12) === "WEBP") {
|
|
18
|
+
const chunk = header.toString("ascii", 12, 16);
|
|
19
|
+
if (chunk === "VP8X") return { width: 1 + header.readUIntLE(24, 3), height: 1 + header.readUIntLE(27, 3), format: "WebP" };
|
|
20
|
+
if (chunk === "VP8 ") return { width: header.readUInt16LE(26) & 0x3fff, height: header.readUInt16LE(28) & 0x3fff, format: "WebP" };
|
|
21
|
+
if (chunk === "VP8L") {
|
|
22
|
+
const bits = header.readUInt32LE(21);
|
|
23
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1, format: "WebP" };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (header.length >= 4 && header[0] === 0xff && header[1] === 0xd8) {
|
|
27
|
+
let at = 2;
|
|
28
|
+
while (at + 9 < header.length) {
|
|
29
|
+
if (header[at] !== 0xff) { at++; continue; }
|
|
30
|
+
const marker = header[at + 1]!;
|
|
31
|
+
// Start-of-frame markers carry the dimensions (C4, C8 and CC are not frames).
|
|
32
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
33
|
+
return { width: header.readUInt16BE(at + 7), height: header.readUInt16BE(at + 5), format: "JPEG" };
|
|
34
|
+
}
|
|
35
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) { at += 2; continue; }
|
|
36
|
+
at += 2 + header.readUInt16BE(at + 2);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Image file paths mentioned in a draft (Pi pastes clipboard images as temp file paths). */
|
|
43
|
+
export function imagePaths(text: string, cwd = process.cwd()): string[] {
|
|
44
|
+
const found = new Set<string>();
|
|
45
|
+
const tokens = text.match(/"[^"\n]+"|'[^'\n]+'|(?:\\ |[^\s"'])+/g) ?? [];
|
|
46
|
+
for (const raw of tokens) {
|
|
47
|
+
const token = raw.replace(/^["']|["']$/g, "").replace(/\\ /g, " ").replace(/^@/, "");
|
|
48
|
+
if (!IMAGE.test(token)) continue;
|
|
49
|
+
const path = token.startsWith("~/") ? resolve(homedir(), token.slice(2)) : isAbsolute(token) ? token : resolve(cwd, token);
|
|
50
|
+
if (existsSync(path)) found.add(path);
|
|
51
|
+
}
|
|
52
|
+
return [...found].slice(0, 8);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function imageInfo(path: string): ImageInfo | undefined {
|
|
56
|
+
try {
|
|
57
|
+
const stat = statSync(path);
|
|
58
|
+
if (!stat.isFile()) return undefined;
|
|
59
|
+
const cached = cache.get(path);
|
|
60
|
+
if (cached && cached.mtime === stat.mtimeMs) return cached.info;
|
|
61
|
+
const header = Buffer.alloc(Math.min(HEADER_BYTES, stat.size));
|
|
62
|
+
const fd = openSync(path, "r");
|
|
63
|
+
try { readSync(fd, header, 0, header.length, 0); } finally { closeSync(fd); }
|
|
64
|
+
const size = imageSize(header);
|
|
65
|
+
const info: ImageInfo = { path, name: basename(path), bytes: stat.size, format: size?.format ?? (path.split(".").pop() ?? "").toUpperCase(),
|
|
66
|
+
...(size ? { width: size.width, height: size.height } : {}) };
|
|
67
|
+
cache.set(path, { mtime: stat.mtimeMs, info });
|
|
68
|
+
if (cache.size > 64) cache.delete(cache.keys().next().value!);
|
|
69
|
+
return info;
|
|
70
|
+
} catch { return undefined; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const formatBytes = (bytes: number) => bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
74
|
+
|
|
75
|
+
/** Short chip text: "▣ shot.png · 1280×720 · 240 KB". Temp clipboard names are shortened. */
|
|
76
|
+
export function imageChip(info: ImageInfo): string {
|
|
77
|
+
const name = /^pi-clipboard-/.test(info.name) ? `pasted ${info.format.toLowerCase()}` : info.name;
|
|
78
|
+
return `▣ ${name}${info.width ? ` · ${info.width}×${info.height}` : ""} · ${formatBytes(info.bytes)}`;
|
|
79
|
+
}
|