@dungle-scrubs/tallow 0.8.27 → 0.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/README.md +42 -1
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -1
- package/dist/config.js.map +1 -1
- package/dist/install.d.ts.map +1 -1
- package/dist/install.js +2 -9
- package/dist/install.js.map +1 -1
- package/dist/interactive-mode-patch.d.ts.map +1 -1
- package/dist/interactive-mode-patch.js +20 -9
- package/dist/interactive-mode-patch.js.map +1 -1
- package/dist/model-metadata-overrides.d.ts +2 -5
- package/dist/model-metadata-overrides.d.ts.map +1 -1
- package/dist/model-metadata-overrides.js +23 -12
- package/dist/model-metadata-overrides.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +20 -9
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-interactive.d.ts.map +1 -1
- package/dist/workspace-transition-interactive.js +53 -3
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/dist/workspace-transition.d.ts +2 -1
- package/dist/workspace-transition.d.ts.map +1 -1
- package/dist/workspace-transition.js +16 -4
- package/dist/workspace-transition.js.map +1 -1
- package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
- package/extensions/__integration__/welcome-screen.test.ts +240 -0
- package/extensions/_icons/__tests__/icons.test.ts +0 -1
- package/extensions/_icons/index.ts +0 -2
- package/extensions/_shared/pid-registry.ts +5 -5
- package/extensions/background-task-tool/index.ts +1 -1
- package/extensions/cd-tool/index.ts +4 -1
- package/extensions/context-fork/__tests__/context-fork.test.ts +9 -0
- package/extensions/edit-tool-enhanced/index.ts +3 -1
- package/extensions/health/__tests__/diagnostics.test.ts +25 -0
- package/extensions/health/index.ts +62 -1
- package/extensions/loop/__tests__/loop.test.ts +365 -1
- package/extensions/loop/index.ts +213 -3
- package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
- package/extensions/prompt-suggestions/autocomplete.ts +23 -5
- package/extensions/prompt-suggestions/index.ts +62 -3
- package/extensions/read-tool-enhanced/index.ts +5 -1
- package/extensions/render-stabilizer/__tests__/render-stabilizer.test.ts +42 -0
- package/extensions/render-stabilizer/extension.json +5 -0
- package/extensions/render-stabilizer/index.ts +66 -0
- package/extensions/session-memory/index.ts +1 -1
- package/extensions/session-namer/index.ts +1 -1
- package/extensions/subagent-tool/__tests__/auto-cheap-model.test.ts +66 -6
- package/extensions/subagent-tool/__tests__/model-router-explicit-resolution.test.ts +79 -5
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +4 -4
- package/extensions/subagent-tool/index.ts +4 -2
- package/extensions/subagent-tool/process.ts +26 -8
- package/extensions/teams-tool/sessions/spawn.ts +2 -2
- package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
- package/extensions/welcome-screen/extension.json +20 -0
- package/extensions/welcome-screen/index.ts +189 -0
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +56 -0
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js +188 -5
- package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +1 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
- package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
- package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
- package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
- package/node_modules/@mariozechner/pi-tui/src/tui.ts +205 -5
- package/package.json +9 -9
- package/runtime/config.ts +7 -0
- package/runtime/model-metadata-overrides.ts +7 -0
- package/schemas/settings.schema.json +0 -5
- package/skills/tallow-expert/SKILL.md +6 -4
- package/extensions/plan-mode-tool/__tests__/e2e.mjs +0 -350
- package/extensions/plan-mode-tool/__tests__/index.test.ts +0 -213
- package/extensions/plan-mode-tool/__tests__/utils.test.ts +0 -381
- package/extensions/plan-mode-tool/extension.json +0 -22
- package/extensions/plan-mode-tool/index.ts +0 -583
- package/extensions/plan-mode-tool/utils.ts +0 -257
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
6
|
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
7
7
|
import {
|
|
8
|
+
AUTOCOMPLETE_COMMAND_PREFIXES,
|
|
8
9
|
AUTOCOMPLETE_FALLBACKS,
|
|
9
10
|
type AutocompleteConfig,
|
|
10
11
|
AutocompleteEngine,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
resolveAutocompleteModel,
|
|
16
17
|
tryResolveModel,
|
|
17
18
|
} from "../autocomplete.js";
|
|
19
|
+
import { buildLoopAutocompletePrompt, selectAutocompletePrompt } from "../index.js";
|
|
18
20
|
|
|
19
21
|
// ─── Test helpers ────────────────────────────────────────────────────────────
|
|
20
22
|
|
|
@@ -42,8 +44,9 @@ function createMockRegistry(
|
|
|
42
44
|
find(provider: string, modelId: string) {
|
|
43
45
|
return models.find((m) => m.provider === provider && m.id === modelId);
|
|
44
46
|
},
|
|
45
|
-
async
|
|
46
|
-
|
|
47
|
+
async getApiKeyForProvider(provider: string) {
|
|
48
|
+
const entry = [...keys.entries()].find(([k]) => k.startsWith(`${provider}/`));
|
|
49
|
+
return entry?.[1];
|
|
47
50
|
},
|
|
48
51
|
getAvailable() {
|
|
49
52
|
return models.filter((m) => keys.has(`${m.provider}/${m.id}`));
|
|
@@ -189,7 +192,7 @@ describe("resolveAutocompleteModel", () => {
|
|
|
189
192
|
findCalls.push(`${provider}/${modelId}`);
|
|
190
193
|
return undefined;
|
|
191
194
|
},
|
|
192
|
-
async
|
|
195
|
+
async getApiKeyForProvider() {
|
|
193
196
|
return undefined;
|
|
194
197
|
},
|
|
195
198
|
getAvailable() {
|
|
@@ -224,6 +227,24 @@ describe("AutocompleteEngine.shouldTrigger", () => {
|
|
|
224
227
|
test("rejects slash commands", () => {
|
|
225
228
|
const { engine } = createTestEngine({});
|
|
226
229
|
expect(engine.shouldTrigger("/help")).toBe(false);
|
|
230
|
+
expect(engine.shouldTrigger("/clear")).toBe(false);
|
|
231
|
+
expect(engine.shouldTrigger("/debug")).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("allows /loop with content after prefix", () => {
|
|
235
|
+
const { engine } = createTestEngine({});
|
|
236
|
+
expect(engine.shouldTrigger("/loop check ci")).toBe(true);
|
|
237
|
+
expect(engine.shouldTrigger("/loop run tests every 30s")).toBe(true);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("rejects bare /loop without trailing content", () => {
|
|
241
|
+
const { engine } = createTestEngine({});
|
|
242
|
+
expect(engine.shouldTrigger("/loop")).toBe(false);
|
|
243
|
+
expect(engine.shouldTrigger("/loop ")).toBe(false);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("AUTOCOMPLETE_COMMAND_PREFIXES includes /loop", () => {
|
|
247
|
+
expect(AUTOCOMPLETE_COMMAND_PREFIXES).toContain("/loop ");
|
|
227
248
|
});
|
|
228
249
|
|
|
229
250
|
test("rejects when disabled", () => {
|
|
@@ -504,3 +525,90 @@ describe("AutocompleteEngine conversation context", () => {
|
|
|
504
525
|
expect(receivedContext).toBeNull();
|
|
505
526
|
});
|
|
506
527
|
});
|
|
528
|
+
|
|
529
|
+
// ─── /loop autocomplete integration ─────────────────────────────────────────
|
|
530
|
+
|
|
531
|
+
describe("/loop autocomplete", () => {
|
|
532
|
+
let engine: AutocompleteEngine;
|
|
533
|
+
|
|
534
|
+
afterEach(() => {
|
|
535
|
+
engine?.dispose();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("triggers autocomplete for /loop commands", async () => {
|
|
539
|
+
const calls: string[] = [];
|
|
540
|
+
const result = createTestEngine({
|
|
541
|
+
config: { debounceMs: 5 },
|
|
542
|
+
completionFn: async (_m, _k, input) => {
|
|
543
|
+
calls.push(input);
|
|
544
|
+
return " every 2m until CI is green";
|
|
545
|
+
},
|
|
546
|
+
currentText: "/loop check ci",
|
|
547
|
+
});
|
|
548
|
+
engine = result.engine;
|
|
549
|
+
|
|
550
|
+
engine.trigger("/loop check ci");
|
|
551
|
+
await waitForDebounce();
|
|
552
|
+
expect(calls).toEqual(["/loop check ci"]);
|
|
553
|
+
expect(result.ghostTexts).toEqual([" every 2m until CI is green"]);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("does not trigger for non-allowlisted slash commands", async () => {
|
|
557
|
+
const calls: string[] = [];
|
|
558
|
+
const result = createTestEngine({
|
|
559
|
+
config: { debounceMs: 5 },
|
|
560
|
+
completionFn: async (_m, _k, input) => {
|
|
561
|
+
calls.push(input);
|
|
562
|
+
return "completion";
|
|
563
|
+
},
|
|
564
|
+
});
|
|
565
|
+
engine = result.engine;
|
|
566
|
+
|
|
567
|
+
engine.trigger("/help something");
|
|
568
|
+
await waitForDebounce();
|
|
569
|
+
expect(calls.length).toBe(0);
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ─── Prompt selection ────────────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
describe("selectAutocompletePrompt", () => {
|
|
576
|
+
test("returns loop prompt for /loop prefix", () => {
|
|
577
|
+
const prompt = selectAutocompletePrompt("/loop check ci", null);
|
|
578
|
+
expect(prompt).toContain("/loop");
|
|
579
|
+
expect(prompt).toContain("natural language");
|
|
580
|
+
expect(prompt).not.toContain("developer message");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("returns general prompt for non-loop input", () => {
|
|
584
|
+
const prompt = selectAutocompletePrompt("refactor the auth", null);
|
|
585
|
+
expect(prompt).toContain("developer");
|
|
586
|
+
expect(prompt).not.toContain("/loop");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("loop prompt includes conversation context when available", () => {
|
|
590
|
+
const ctx = { recentExchanges: "User: checking CI\n\nAssistant: it failed" };
|
|
591
|
+
const prompt = selectAutocompletePrompt("/loop check", ctx);
|
|
592
|
+
expect(prompt).toContain("checking CI");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("general prompt includes conversation context when available", () => {
|
|
596
|
+
const ctx = { recentExchanges: "User: fix auth\n\nAssistant: done" };
|
|
597
|
+
const prompt = selectAutocompletePrompt("now also", ctx);
|
|
598
|
+
expect(prompt).toContain("fix auth");
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe("buildLoopAutocompletePrompt", () => {
|
|
603
|
+
test("includes example loop patterns", () => {
|
|
604
|
+
const prompt = buildLoopAutocompletePrompt(null);
|
|
605
|
+
expect(prompt).toContain("every 2m");
|
|
606
|
+
expect(prompt).toContain("until");
|
|
607
|
+
expect(prompt).toContain("observable");
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("warns against vague conditions", () => {
|
|
611
|
+
const prompt = buildLoopAutocompletePrompt(null);
|
|
612
|
+
expect(prompt).toContain("vague");
|
|
613
|
+
});
|
|
614
|
+
});
|
|
@@ -14,8 +14,8 @@ import type { Api, Model } from "@mariozechner/pi-ai";
|
|
|
14
14
|
export interface ModelRegistryLike {
|
|
15
15
|
/** Find a model by provider and ID. */
|
|
16
16
|
find(provider: string, modelId: string): Model<Api> | undefined;
|
|
17
|
-
/** Get API key for a
|
|
18
|
-
|
|
17
|
+
/** Get API key for a provider. */
|
|
18
|
+
getApiKeyForProvider(provider: string): Promise<string | undefined>;
|
|
19
19
|
/** Get all models that have auth configured. */
|
|
20
20
|
getAvailable(): Model<Api>[];
|
|
21
21
|
/** Register a provider dynamically. */
|
|
@@ -71,6 +71,15 @@ export const AUTOCOMPLETE_FALLBACKS = [
|
|
|
71
71
|
/** Minimum characters before autocomplete triggers. */
|
|
72
72
|
export const MIN_CHARS = 4;
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Command prefixes that opt into autocomplete despite starting with `/`.
|
|
76
|
+
*
|
|
77
|
+
* Most slash commands are short and syntactically rigid — autocomplete adds
|
|
78
|
+
* noise. But commands with complex, hard-to-remember syntax (like `/loop`)
|
|
79
|
+
* benefit from inline suggestions.
|
|
80
|
+
*/
|
|
81
|
+
export const AUTOCOMPLETE_COMMAND_PREFIXES = ["/loop "] as const;
|
|
82
|
+
|
|
74
83
|
// ─── Model resolution ────────────────────────────────────────────────────────
|
|
75
84
|
|
|
76
85
|
/**
|
|
@@ -93,7 +102,7 @@ export async function tryResolveModel(
|
|
|
93
102
|
const model = registry.find(provider, modelId);
|
|
94
103
|
if (!model) return null;
|
|
95
104
|
|
|
96
|
-
const apiKey = await registry.
|
|
105
|
+
const apiKey = await registry.getApiKeyForProvider(model.provider);
|
|
97
106
|
if (!apiKey) return null;
|
|
98
107
|
|
|
99
108
|
return { model, apiKey };
|
|
@@ -126,7 +135,7 @@ export async function resolveAutocompleteModel(
|
|
|
126
135
|
const available = registry.getAvailable();
|
|
127
136
|
const sorted = [...available].sort((a, b) => (a.cost?.input ?? 0) - (b.cost?.input ?? 0));
|
|
128
137
|
for (const model of sorted) {
|
|
129
|
-
const apiKey = await registry.
|
|
138
|
+
const apiKey = await registry.getApiKeyForProvider(model.provider);
|
|
130
139
|
if (apiKey) return { model, apiKey };
|
|
131
140
|
}
|
|
132
141
|
|
|
@@ -196,6 +205,10 @@ export class AutocompleteEngine {
|
|
|
196
205
|
/**
|
|
197
206
|
* Determine whether a partial input should trigger autocomplete.
|
|
198
207
|
*
|
|
208
|
+
* Most `/` prefixed input is rejected (slash commands are short and rigid).
|
|
209
|
+
* Commands listed in {@link AUTOCOMPLETE_COMMAND_PREFIXES} are allowed
|
|
210
|
+
* because their syntax is complex enough to benefit from inline suggestions.
|
|
211
|
+
*
|
|
199
212
|
* @param input - Current editor text
|
|
200
213
|
* @returns true if autocomplete should be scheduled
|
|
201
214
|
*/
|
|
@@ -203,7 +216,12 @@ export class AutocompleteEngine {
|
|
|
203
216
|
if (!this.config.enabled) return false;
|
|
204
217
|
if (this._busy) return false;
|
|
205
218
|
if (this._callCount >= this.config.maxCalls) return false;
|
|
206
|
-
if (input.startsWith("/"))
|
|
219
|
+
if (input.startsWith("/")) {
|
|
220
|
+
const isAllowed = AUTOCOMPLETE_COMMAND_PREFIXES.some(
|
|
221
|
+
(prefix) => input.startsWith(prefix) && input.length > prefix.length
|
|
222
|
+
);
|
|
223
|
+
if (!isAllowed) return false;
|
|
224
|
+
}
|
|
207
225
|
if (input.trim().length < MIN_CHARS) return false;
|
|
208
226
|
return true;
|
|
209
227
|
}
|
|
@@ -154,7 +154,8 @@ export function buildConversationContext(
|
|
|
154
154
|
// ─── LLM completion ──────────────────────────────────────────────────────────
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
|
-
* Build the system prompt for autocomplete, optionally including
|
|
157
|
+
* Build the system prompt for general autocomplete, optionally including
|
|
158
|
+
* conversation context.
|
|
158
159
|
*
|
|
159
160
|
* @param context - Recent conversation context, or null
|
|
160
161
|
* @returns System prompt string
|
|
@@ -175,6 +176,60 @@ export function buildAutocompleteSystemPrompt(context: ConversationContext | nul
|
|
|
175
176
|
return `${base}\n\nHere is the recent conversation for context:\n\n${context.recentExchanges}`;
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Build a system prompt for `/loop` command autocomplete.
|
|
181
|
+
*
|
|
182
|
+
* The prompt teaches the model about natural-language loop syntax so it
|
|
183
|
+
* suggests good intervals, specific conditions, and actionable tasks.
|
|
184
|
+
* The user writes in natural language; a separate NL parser converts the
|
|
185
|
+
* final command into strict `/loop` syntax.
|
|
186
|
+
*
|
|
187
|
+
* @param context - Recent conversation context, or null
|
|
188
|
+
* @returns System prompt string
|
|
189
|
+
*/
|
|
190
|
+
export function buildLoopAutocompletePrompt(context: ConversationContext | null): string {
|
|
191
|
+
const base =
|
|
192
|
+
"You are completing a /loop command in a coding CLI. " +
|
|
193
|
+
"The developer writes loops in natural language. The system parses it automatically.\n\n" +
|
|
194
|
+
"A loop needs: a task to perform, how often, and optionally when to stop.\n\n" +
|
|
195
|
+
"Good patterns:\n" +
|
|
196
|
+
'- "check the latest GitHub Actions run every 2m until CI is green"\n' +
|
|
197
|
+
'- "run the test suite every 30s until all tests pass"\n' +
|
|
198
|
+
'- "check deploy health every 1m until all pods are healthy"\n' +
|
|
199
|
+
'- "monitor build progress every 5m until the build completes"\n' +
|
|
200
|
+
'- "check disk usage every 10m, max 20 times"\n\n' +
|
|
201
|
+
"Reply with ONLY the completion text — the part that comes after what they typed. " +
|
|
202
|
+
"Keep suggestions specific and actionable — avoid vague conditions like 'it works' or 'it's done'. " +
|
|
203
|
+
"Suggest observable facts the agent can check. " +
|
|
204
|
+
"If they wrote a task, suggest the interval and condition. " +
|
|
205
|
+
"If they wrote an interval, suggest a clear task. " +
|
|
206
|
+
"One line only. Do not repeat what they typed. Do not add formatting or explanations.";
|
|
207
|
+
|
|
208
|
+
if (!context) return base;
|
|
209
|
+
|
|
210
|
+
return `${base}\n\nRecent conversation for context:\n\n${context.recentExchanges}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Select the appropriate system prompt based on the user's partial input.
|
|
215
|
+
*
|
|
216
|
+
* Commands with complex syntax (like `/loop`) get specialized prompts that
|
|
217
|
+
* understand their structure and suggest valid, high-quality completions.
|
|
218
|
+
*
|
|
219
|
+
* @param partialInput - Current editor text
|
|
220
|
+
* @param context - Recent conversation context, or null
|
|
221
|
+
* @returns System prompt string
|
|
222
|
+
*/
|
|
223
|
+
export function selectAutocompletePrompt(
|
|
224
|
+
partialInput: string,
|
|
225
|
+
context: ConversationContext | null
|
|
226
|
+
): string {
|
|
227
|
+
if (partialInput.startsWith("/loop ")) {
|
|
228
|
+
return buildLoopAutocompletePrompt(context);
|
|
229
|
+
}
|
|
230
|
+
return buildAutocompleteSystemPrompt(context);
|
|
231
|
+
}
|
|
232
|
+
|
|
178
233
|
/**
|
|
179
234
|
* Call the autocomplete model with the user's partial input and conversation context.
|
|
180
235
|
*
|
|
@@ -193,14 +248,18 @@ async function getCompletion(
|
|
|
193
248
|
context: ConversationContext | null
|
|
194
249
|
): Promise<string | null> {
|
|
195
250
|
try {
|
|
251
|
+
const userMessage = partialInput.startsWith("/loop ")
|
|
252
|
+
? `Complete this /loop command:\n${partialInput}`
|
|
253
|
+
: `Complete this developer message:\n${partialInput}`;
|
|
254
|
+
|
|
196
255
|
const result = await completeSimple(
|
|
197
256
|
model,
|
|
198
257
|
{
|
|
199
|
-
systemPrompt:
|
|
258
|
+
systemPrompt: selectAutocompletePrompt(partialInput, context),
|
|
200
259
|
messages: [
|
|
201
260
|
{
|
|
202
261
|
role: "user",
|
|
203
|
-
content:
|
|
262
|
+
content: userMessage,
|
|
204
263
|
timestamp: Date.now(),
|
|
205
264
|
},
|
|
206
265
|
],
|
|
@@ -489,7 +489,11 @@ export default function readSummary(pi: ExtensionAPI): void {
|
|
|
489
489
|
const left =
|
|
490
490
|
formatPresentationText(theme, "identity", icon) +
|
|
491
491
|
` ${formatPresentationText(theme, "identity", `skill: ${skillName}`)}`;
|
|
492
|
-
const right = formatPresentationText(
|
|
492
|
+
const right = formatPresentationText(
|
|
493
|
+
theme,
|
|
494
|
+
"hint",
|
|
495
|
+
keyHint("app.tools.expand", "to expand")
|
|
496
|
+
);
|
|
493
497
|
|
|
494
498
|
return {
|
|
495
499
|
render(width: number): string[] {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import renderStabilizerExtension from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("render-stabilizer extension", () => {
|
|
5
|
+
it("registers session_start and session_before_switch handlers", () => {
|
|
6
|
+
const handlers = new Map<string, unknown[]>();
|
|
7
|
+
|
|
8
|
+
const mockPi = {
|
|
9
|
+
on(event: string, handler: unknown) {
|
|
10
|
+
if (!handlers.has(event)) handlers.set(event, []);
|
|
11
|
+
handlers.get(event)!.push(handler);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
renderStabilizerExtension(mockPi as never);
|
|
16
|
+
|
|
17
|
+
expect(handlers.has("session_start")).toBe(true);
|
|
18
|
+
expect(handlers.get("session_start")!.length).toBe(1);
|
|
19
|
+
expect(handlers.has("session_before_switch")).toBe(true);
|
|
20
|
+
expect(handlers.get("session_before_switch")!.length).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("does not register any commands or tools", () => {
|
|
24
|
+
let commandCount = 0;
|
|
25
|
+
let toolCount = 0;
|
|
26
|
+
|
|
27
|
+
const mockPi = {
|
|
28
|
+
on() {},
|
|
29
|
+
registerCommand() {
|
|
30
|
+
commandCount++;
|
|
31
|
+
},
|
|
32
|
+
registerTool() {
|
|
33
|
+
toolCount++;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
renderStabilizerExtension(mockPi as never);
|
|
38
|
+
|
|
39
|
+
expect(commandCount).toBe(0);
|
|
40
|
+
expect(toolCount).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render Stabilizer Extension
|
|
3
|
+
*
|
|
4
|
+
* Prevents the visual flicker that occurs when resuming a session.
|
|
5
|
+
* During session switches, the chat container is cleared and rebuilt,
|
|
6
|
+
* which causes rapid content height changes. The TUI's shrink-detection
|
|
7
|
+
* redraws use screen clears (`\x1b[2J`) that produce visible blank frames.
|
|
8
|
+
*
|
|
9
|
+
* This extension resets the TUI's render grace period at the start of
|
|
10
|
+
* each session switch so the gentler line-by-line redraw is used instead
|
|
11
|
+
* of the screen-clearing approach.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import type { TUI } from "@mariozechner/pi-tui";
|
|
16
|
+
|
|
17
|
+
/** Reference to the TUI instance, captured on first session_start. */
|
|
18
|
+
let tuiRef: TUI | null = null;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Capture the TUI reference by briefly setting a widget with a factory function.
|
|
22
|
+
* The factory receives the TUI instance as its first argument.
|
|
23
|
+
*
|
|
24
|
+
* @param ui - Extension UI context
|
|
25
|
+
*/
|
|
26
|
+
function captureTuiRef(ui: {
|
|
27
|
+
setWidget: (
|
|
28
|
+
key: string,
|
|
29
|
+
content:
|
|
30
|
+
| undefined
|
|
31
|
+
| ((tui: TUI, theme: unknown) => { render: (w: number) => string[]; invalidate: () => void })
|
|
32
|
+
) => void;
|
|
33
|
+
}): void {
|
|
34
|
+
if (tuiRef) return;
|
|
35
|
+
ui.setWidget("_render-stabilizer", (tui: TUI) => {
|
|
36
|
+
tuiRef = tui;
|
|
37
|
+
return { render: () => [], invalidate: () => {} };
|
|
38
|
+
});
|
|
39
|
+
// Remove the widget immediately — it was only needed to capture the ref
|
|
40
|
+
ui.setWidget("_render-stabilizer", undefined);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register render stabilization hooks.
|
|
45
|
+
*
|
|
46
|
+
* @param pi - Extension API
|
|
47
|
+
*/
|
|
48
|
+
export default function renderStabilizerExtension(pi: ExtensionAPI): void {
|
|
49
|
+
// Capture the TUI reference on first session_start
|
|
50
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
51
|
+
captureTuiRef(ctx.ui);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Reset the render grace period before a session switch so the
|
|
55
|
+
// chatContainer.clear() → renderInitialMessages() transition uses
|
|
56
|
+
// gentle line-by-line redraws instead of screen-clearing redraws.
|
|
57
|
+
pi.on("session_before_switch", async (_event, ctx) => {
|
|
58
|
+
captureTuiRef(ctx.ui);
|
|
59
|
+
if (
|
|
60
|
+
tuiRef &&
|
|
61
|
+
typeof (tuiRef as TUI & { resetRenderGrace?: () => void }).resetRenderGrace === "function"
|
|
62
|
+
) {
|
|
63
|
+
(tuiRef as TUI & { resetRenderGrace: () => void }).resetRenderGrace();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -381,7 +381,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
381
381
|
};
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
-
const apiKey = await ctx.modelRegistry.
|
|
384
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
|
|
385
385
|
if (!apiKey) {
|
|
386
386
|
ctx.ui.setWorkingMessage();
|
|
387
387
|
const sc = new Set(results.map((r) => r.sessionId)).size;
|
|
@@ -188,7 +188,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
188
188
|
const model = findNamingModel(ctx);
|
|
189
189
|
if (!model) return;
|
|
190
190
|
|
|
191
|
-
const apiKey = await ctx.modelRegistry.
|
|
191
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
|
|
192
192
|
if (!apiKey) return;
|
|
193
193
|
|
|
194
194
|
const prompt = buildNamingPrompt(userText, assistantText);
|
|
@@ -39,14 +39,74 @@ const mockModels = [
|
|
|
39
39
|
];
|
|
40
40
|
|
|
41
41
|
const mockScope = createMockScope(import.meta.url);
|
|
42
|
-
mockScope.module("@
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
mockScope.module("@dungle-scrubs/synapse", () => ({
|
|
43
|
+
listAvailableModels: () => mockModels.map((model) => `${model.provider}/${model.id}`),
|
|
44
|
+
parseModelMatrixOverrides: () => undefined,
|
|
45
|
+
resolveModelCandidates: (query: string) => {
|
|
46
|
+
const normalized = query.toLowerCase().trim();
|
|
47
|
+
return mockModels
|
|
48
|
+
.filter(
|
|
49
|
+
(model) =>
|
|
50
|
+
model.id.toLowerCase() === normalized ||
|
|
51
|
+
model.name.toLowerCase().includes(normalized) ||
|
|
52
|
+
`${model.provider}/${model.id}`.toLowerCase() === normalized
|
|
53
|
+
)
|
|
54
|
+
.map((model) => ({
|
|
55
|
+
displayName: `${model.provider}/${model.id}`,
|
|
56
|
+
id: model.id,
|
|
57
|
+
provider: model.provider,
|
|
58
|
+
}));
|
|
59
|
+
},
|
|
60
|
+
resolveModelFuzzy: (
|
|
61
|
+
query: string,
|
|
62
|
+
source?: () => Array<{ id: string; name: string; provider: string }>,
|
|
63
|
+
preferredProviders?: string[]
|
|
64
|
+
) => {
|
|
65
|
+
const normalized = query.toLowerCase().trim();
|
|
66
|
+
const candidates = source
|
|
67
|
+
? source().map((model) => ({
|
|
68
|
+
id: model.id,
|
|
69
|
+
name: model.name,
|
|
70
|
+
provider: model.provider,
|
|
71
|
+
}))
|
|
72
|
+
: mockModels;
|
|
73
|
+
const matches = candidates.filter(
|
|
74
|
+
(model) =>
|
|
75
|
+
model.id.toLowerCase() === normalized ||
|
|
76
|
+
model.name.toLowerCase().includes(normalized) ||
|
|
77
|
+
`${model.provider}/${model.id}`.toLowerCase() === normalized
|
|
78
|
+
);
|
|
79
|
+
const ordered = preferredProviders?.length
|
|
80
|
+
? [...matches].sort((left, right) => {
|
|
81
|
+
const leftIndex = preferredProviders.indexOf(left.provider);
|
|
82
|
+
const rightIndex = preferredProviders.indexOf(right.provider);
|
|
83
|
+
const safeLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
|
84
|
+
const safeRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
|
85
|
+
return safeLeft - safeRight;
|
|
86
|
+
})
|
|
87
|
+
: matches;
|
|
88
|
+
const selected = ordered[0];
|
|
89
|
+
return selected
|
|
90
|
+
? {
|
|
91
|
+
displayName: `${selected.provider}/${selected.id}`,
|
|
92
|
+
id: selected.id,
|
|
93
|
+
provider: selected.provider,
|
|
94
|
+
}
|
|
95
|
+
: undefined;
|
|
96
|
+
},
|
|
97
|
+
selectModels: (_classification: unknown, costPreference: string) => {
|
|
98
|
+
const ordered =
|
|
99
|
+
costPreference === "premium"
|
|
100
|
+
? [mockModels[0], mockModels[1], mockModels[2], mockModels[3]]
|
|
101
|
+
: [mockModels[3], mockModels[2], mockModels[1], mockModels[0]];
|
|
102
|
+
return ordered.map((model) => ({
|
|
103
|
+
displayName: `${model.provider}/${model.id}`,
|
|
104
|
+
id: model.id,
|
|
105
|
+
provider: model.provider,
|
|
106
|
+
}));
|
|
107
|
+
},
|
|
45
108
|
}));
|
|
46
109
|
|
|
47
|
-
// NOTE: Do NOT mock ../model-resolver.js — it leaks across test files in bun.
|
|
48
|
-
// The pi-ai mock above provides model data that resolveModelFuzzy uses.
|
|
49
|
-
|
|
50
110
|
mockScope.module("../task-classifier.js", () => ({
|
|
51
111
|
classifyTask: async (_task: string, primaryType: string) => ({
|
|
52
112
|
type: primaryType,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { createMockScope } from "../../../test-utils/mock-scope.js";
|
|
3
6
|
|
|
4
7
|
const mockModels = [
|
|
@@ -35,9 +38,62 @@ const mockModels = [
|
|
|
35
38
|
];
|
|
36
39
|
|
|
37
40
|
const mockScope = createMockScope(import.meta.url);
|
|
38
|
-
mockScope.module("@
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
mockScope.module("@dungle-scrubs/synapse", () => ({
|
|
42
|
+
listAvailableModels: () => mockModels.map((model) => `${model.provider}/${model.id}`),
|
|
43
|
+
parseModelMatrixOverrides: () => undefined,
|
|
44
|
+
resolveModelCandidates: (query: string) => {
|
|
45
|
+
const normalized = query.toLowerCase().trim();
|
|
46
|
+
return mockModels
|
|
47
|
+
.filter(
|
|
48
|
+
(model) =>
|
|
49
|
+
model.id.toLowerCase() === normalized ||
|
|
50
|
+
model.name.toLowerCase().includes(normalized) ||
|
|
51
|
+
`${model.provider}/${model.id}`.toLowerCase() === normalized
|
|
52
|
+
)
|
|
53
|
+
.map((model) => ({
|
|
54
|
+
displayName: `${model.provider}/${model.id}`,
|
|
55
|
+
id: model.id,
|
|
56
|
+
provider: model.provider,
|
|
57
|
+
}));
|
|
58
|
+
},
|
|
59
|
+
resolveModelFuzzy: (
|
|
60
|
+
query: string,
|
|
61
|
+
source?: () => Array<{ id: string; name: string; provider: string }>,
|
|
62
|
+
preferredProviders?: string[]
|
|
63
|
+
) => {
|
|
64
|
+
const normalized = query.toLowerCase().trim();
|
|
65
|
+
const candidates = source
|
|
66
|
+
? source().map((model) => ({
|
|
67
|
+
id: model.id,
|
|
68
|
+
name: model.name,
|
|
69
|
+
provider: model.provider,
|
|
70
|
+
}))
|
|
71
|
+
: mockModels;
|
|
72
|
+
const matches = candidates.filter(
|
|
73
|
+
(model) =>
|
|
74
|
+
model.id.toLowerCase() === normalized ||
|
|
75
|
+
model.name.toLowerCase().includes(normalized) ||
|
|
76
|
+
`${model.provider}/${model.id}`.toLowerCase() === normalized
|
|
77
|
+
);
|
|
78
|
+
const ordered = preferredProviders?.length
|
|
79
|
+
? [...matches].sort((left, right) => {
|
|
80
|
+
const leftIndex = preferredProviders.indexOf(left.provider);
|
|
81
|
+
const rightIndex = preferredProviders.indexOf(right.provider);
|
|
82
|
+
const safeLeft = leftIndex === -1 ? Number.MAX_SAFE_INTEGER : leftIndex;
|
|
83
|
+
const safeRight = rightIndex === -1 ? Number.MAX_SAFE_INTEGER : rightIndex;
|
|
84
|
+
return safeLeft - safeRight;
|
|
85
|
+
})
|
|
86
|
+
: matches;
|
|
87
|
+
const selected = ordered[0];
|
|
88
|
+
return selected
|
|
89
|
+
? {
|
|
90
|
+
displayName: `${selected.provider}/${selected.id}`,
|
|
91
|
+
id: selected.id,
|
|
92
|
+
provider: selected.provider,
|
|
93
|
+
}
|
|
94
|
+
: undefined;
|
|
95
|
+
},
|
|
96
|
+
selectModels: () => [],
|
|
41
97
|
}));
|
|
42
98
|
|
|
43
99
|
mockScope.module("../task-classifier.js", () => ({
|
|
@@ -48,7 +104,23 @@ mockScope.module("../task-classifier.js", () => ({
|
|
|
48
104
|
let routeModel!: typeof import("../model-router.js").routeModel;
|
|
49
105
|
|
|
50
106
|
const ORIGINAL_ENV: Record<string, string | undefined> = {};
|
|
51
|
-
const ENV_KEYS = [
|
|
107
|
+
const ENV_KEYS = [
|
|
108
|
+
"ANTHROPIC_API_KEY",
|
|
109
|
+
"GEMINI_API_KEY",
|
|
110
|
+
"GOOGLE_API_KEY",
|
|
111
|
+
"MINIMAX_API_KEY",
|
|
112
|
+
"MINIMAX_CN_API_KEY",
|
|
113
|
+
"OPENCODE_API_KEY",
|
|
114
|
+
"OPENAI_API_KEY",
|
|
115
|
+
"OPENROUTER_API_KEY",
|
|
116
|
+
"TALLOW_CODING_AGENT_DIR",
|
|
117
|
+
"VERCEL_AI_GATEWAY_API_KEY",
|
|
118
|
+
"VERCEL_API_KEY",
|
|
119
|
+
"XAI_API_KEY",
|
|
120
|
+
"ZAI_API_KEY",
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
let isolatedTallowHome: string;
|
|
52
124
|
|
|
53
125
|
beforeAll(async () => {
|
|
54
126
|
mockScope.install();
|
|
@@ -65,6 +137,8 @@ beforeEach(() => {
|
|
|
65
137
|
ORIGINAL_ENV[key] = process.env[key];
|
|
66
138
|
delete process.env[key];
|
|
67
139
|
}
|
|
140
|
+
isolatedTallowHome = mkdtempSync(join(tmpdir(), "subagent-router-explicit-"));
|
|
141
|
+
process.env.TALLOW_CODING_AGENT_DIR = isolatedTallowHome;
|
|
68
142
|
});
|
|
69
143
|
|
|
70
144
|
afterEach(() => {
|
|
@@ -72,8 +146,8 @@ afterEach(() => {
|
|
|
72
146
|
if (ORIGINAL_ENV[key] === undefined) delete process.env[key];
|
|
73
147
|
else process.env[key] = ORIGINAL_ENV[key];
|
|
74
148
|
}
|
|
149
|
+
rmSync(isolatedTallowHome, { force: true, recursive: true });
|
|
75
150
|
});
|
|
76
|
-
|
|
77
151
|
describe("routeModel explicit model resolution", () => {
|
|
78
152
|
it("honors explicit provider/model choices without auto-routing", async () => {
|
|
79
153
|
const result = await routeModel(
|
|
@@ -113,7 +113,7 @@ describe("subagent presentation rendering", () => {
|
|
|
113
113
|
else process.env.PI_IS_SUBAGENT = originalSubagentFlag;
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
it("renders single-call
|
|
116
|
+
it("renders single-call with task preview and metadata (no redundant header)", () => {
|
|
117
117
|
const component = tool.renderCall?.(
|
|
118
118
|
{
|
|
119
119
|
agent: "worker",
|
|
@@ -126,9 +126,9 @@ describe("subagent presentation rendering", () => {
|
|
|
126
126
|
if (!component) throw new Error("subagent.renderCall returned undefined");
|
|
127
127
|
|
|
128
128
|
const rendered = renderComponent(component);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
expect(rendered).toContain("
|
|
129
|
+
// Single mode no longer renders "subagent single <agent>" header —
|
|
130
|
+
// the result renderer already shows "subagent running <duration> <agent>"
|
|
131
|
+
expect(rendered).not.toContain("<accent>single</accent>");
|
|
132
132
|
expect(rendered).toContain("<muted>scope:both • model:claude-sonnet</muted>");
|
|
133
133
|
expect(rendered).toContain("<dim>Implement authentication flow with retry handling</dim>");
|
|
134
134
|
});
|