pi-qtools 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.
@@ -0,0 +1,1528 @@
1
+ /**
2
+ * pi-qtools — bridge the Qwen Token Plan into pi: built-in server-side tools plus
3
+ * the image / video / audio generation models that are not chat models.
4
+ *
5
+ * Mapped empirically against the live endpoint:
6
+ * https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
7
+ *
8
+ * TWO API surfaces, different capabilities:
9
+ *
10
+ * A) /chat/completions (what pi itself uses)
11
+ * enable_search + search_options.search_strategy: "turbo"|"max" coexist with
12
+ * pi's function tools -> injected into the main loop.
13
+ * search_strategy "agent" and enable_code_interpreter flip Alibaba "Agent
14
+ * mode", which REJECTS any `tools` array -> cannot run in pi's loop.
15
+ *
16
+ * B) /responses — built-in tools DO coexist with function tools here, and it
17
+ * returns structured traces: web_search_call (query+sources),
18
+ * code_interpreter_call (code+container_id+logs), web_extractor_call
19
+ * (urls+goal+output; requires web_search alongside).
20
+ *
21
+ * C) native /api/v1/services/... — image + video generation. Video submits async
22
+ * and returns 200 even for a malformed request, so truth comes from polling.
23
+ *
24
+ * Config persists to <agentDir>/qtools.json. UI: /qtools-config
25
+ */
26
+
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
30
+ import { getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
31
+ import { Container, type SelectItem, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
32
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
33
+ import { Type } from "typebox";
34
+
35
+ // ------------------------------------------------------------------ capability
36
+
37
+ const PROVIDER_PREFIX = "qwen-token-plan";
38
+
39
+ const BASE_URLS: Record<string, string> = {
40
+ "qwen-token-plan": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
41
+ "qwen-token-plan-individual": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1",
42
+ "qwen-token-plan-cn": "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
43
+ };
44
+
45
+ /**
46
+ * The two surfaces have DIFFERENT support. Measured per model:
47
+ * chat = /chat/completions params (enable_search, agent strategy, ci)
48
+ * responses = /responses built-in tools (web_search, code_interpreter, web_extractor)
49
+ * Notably glm-5.2 rejects chat enable_search outright yet works on /responses,
50
+ * and qwen3.8-* lack chat agent/code_interpreter but work on /responses.
51
+ */
52
+ interface Caps {
53
+ chat: { search: boolean; agent: boolean; ci: boolean };
54
+ responses: {
55
+ web_search: boolean;
56
+ code_interpreter: boolean;
57
+ web_extractor: boolean;
58
+ web_search_image: boolean;
59
+ image_search: boolean;
60
+ };
61
+ }
62
+
63
+ const chat = (search: boolean, agent: boolean, ci: boolean) => ({ search, agent, ci });
64
+ const c = (v: boolean) => v;
65
+ const res = (ws: boolean, ci: boolean, we: boolean, wsi: boolean, is: boolean) => ({
66
+ web_search: ws,
67
+ code_interpreter: ci,
68
+ web_extractor: we,
69
+ web_search_image: wsi,
70
+ image_search: is,
71
+ });
72
+ const ALL_RES = res(c(true), c(true), c(true), c(true), c(true));
73
+ /** Every text model does search/code/extract; only some do the image tools. */
74
+ const IMG = (wsi: boolean, is: boolean) => res(true, true, true, wsi, is);
75
+ const NO_RES = res(false, false, false, false, false);
76
+
77
+ /**
78
+ * Measured per model. The two image tools are gated separately from each other:
79
+ * `web_search_image` is qwen3.8-max only, while `image_search` also works on
80
+ * qwen3.8-flash / 3.7-plus / 3.6-flash. Untested cells are left false.
81
+ */
82
+ const CAPS: Record<string, Caps> = {
83
+ "qwen3.8-max": { chat: chat(true, false, false), responses: ALL_RES },
84
+ "qwen3.8-flash": { chat: chat(true, false, false), responses: IMG(false, true) },
85
+ "qwen3.7-max": { chat: chat(true, true, true), responses: IMG(false, false) },
86
+ "qwen3.7-plus": { chat: chat(true, true, true), responses: IMG(false, true) },
87
+ "qwen3.6-flash": { chat: chat(true, true, true), responses: IMG(false, true) },
88
+ "deepseek-v4-pro": { chat: chat(true, true, true), responses: IMG(false, false) },
89
+ "deepseek-v4-pro-0813": { chat: chat(true, true, true), responses: IMG(false, false) },
90
+ "deepseek-v4-flash": { chat: chat(true, true, true), responses: IMG(false, false) },
91
+ "deepseek-v4-flash-0731": { chat: chat(true, true, true), responses: IMG(false, false) },
92
+ "glm-5.2": { chat: chat(false, false, false), responses: IMG(false, false) },
93
+ "wan2.7-image": { chat: chat(false, false, false), responses: NO_RES },
94
+ "wan2.7-image-pro": { chat: chat(false, false, false), responses: NO_RES },
95
+ "qwen-audio-3.0-tts-plus": { chat: chat(false, false, false), responses: NO_RES },
96
+ "qwen-audio-3.0-realtime-plus": { chat: chat(false, false, false), responses: NO_RES },
97
+ };
98
+
99
+ /** Which models actually run each /responses tool, for error messages. */
100
+ export function modelsSupporting(tool: keyof Caps["responses"]): string[] {
101
+ return Object.entries(CAPS)
102
+ .filter(([, k]) => k.responses[tool])
103
+ .map(([id]) => id);
104
+ }
105
+
106
+ /** Unknown models: assume the common three, assume not the image tools. */
107
+ const UNKNOWN_CAPS: Caps = { chat: chat(true, false, false), responses: IMG(false, false) };
108
+
109
+ function capsFor(modelId: string): Caps {
110
+ return CAPS[modelId] ?? UNKNOWN_CAPS;
111
+ }
112
+
113
+ /**
114
+ * glm-5.2 returns "This model does not support enable_search", which would break
115
+ * EVERY turn rather than just searches, so the main-loop injection is gated.
116
+ */
117
+ function searchCapable(modelId: string): boolean {
118
+ return capsFor(modelId).chat.search;
119
+ }
120
+
121
+ // --------------------------------------------------------------------- config
122
+
123
+ type SearchMode = "off" | "turbo" | "max";
124
+
125
+ interface QwenToolsConfig {
126
+ searchMode: SearchMode;
127
+ /** Model the /responses tools fall back to when the active one cannot. */
128
+ fallbackModel: string;
129
+ showReasoning: boolean;
130
+ /** Output cap for qwen_deep_search, which can otherwise burn ~340k tokens. */
131
+ deepSearchMaxTokens: number;
132
+ /** Defaults for the generation models (native endpoints, not chat models). */
133
+ imageModel: string;
134
+ videoModel: string;
135
+ }
136
+
137
+ /** Verified working on this plan via the native multimodal-generation endpoint. */
138
+ const IMAGE_MODELS = ["wan2.7-image", "wan2.7-image-pro", "qwen-image-3.0-pro"];
139
+ /** Verified working via async submit + task polling. i2v/r2v need extra input. */
140
+ const VIDEO_MODELS = ["happyhorse-1.1-t2v", "happyhorse-1.1-i2v", "happyhorse-1.1-r2v"];
141
+ const VIDEO_SIZES = ["1280*720", "1920*1080", "832*480"];
142
+
143
+ const DEFAULTS: QwenToolsConfig = {
144
+ searchMode: "turbo",
145
+ fallbackModel: "qwen3.7-max",
146
+ showReasoning: false,
147
+ deepSearchMaxTokens: 16384,
148
+ imageModel: "wan2.7-image",
149
+ videoModel: "happyhorse-1.1-t2v",
150
+ };
151
+
152
+ const MODE_NOTES: Record<SearchMode, string> = {
153
+ off: "Model answers from memory. Cheapest; no fresh facts.",
154
+ turbo: "~330 extra prompt tokens per turn. Searches, returns a fresh answer.",
155
+ max: "~420 extra prompt tokens per turn. Wider search plus inline [n] citations.",
156
+ };
157
+
158
+ const SEARCH_MODES: SearchMode[] = ["off", "turbo", "max"];
159
+ const TOKEN_CAPS = [4096, 8192, 16384, 32768, 65536];
160
+
161
+ let config: QwenToolsConfig = { ...DEFAULTS };
162
+
163
+ const CONFIG_FILE = "qtools.json";
164
+ /** Pre-rename filename, migrated on first load. */
165
+ const LEGACY_CONFIG_FILE = "qwen-tools.json";
166
+
167
+ function configPath(): string {
168
+ return join(getAgentDir(), CONFIG_FILE);
169
+ }
170
+
171
+ function loadConfig(): void {
172
+ try {
173
+ const p = configPath();
174
+ let source = p;
175
+ if (!existsSync(p)) {
176
+ const legacy = join(getAgentDir(), LEGACY_CONFIG_FILE);
177
+ if (!existsSync(legacy)) return;
178
+ source = legacy;
179
+ }
180
+ const raw = JSON.parse(readFileSync(source, "utf8")) as Partial<QwenToolsConfig>;
181
+ config = {
182
+ ...DEFAULTS,
183
+ ...raw,
184
+ searchMode: SEARCH_MODES.includes(raw.searchMode as SearchMode)
185
+ ? (raw.searchMode as SearchMode)
186
+ : DEFAULTS.searchMode,
187
+ deepSearchMaxTokens:
188
+ typeof raw.deepSearchMaxTokens === "number" && raw.deepSearchMaxTokens > 0
189
+ ? raw.deepSearchMaxTokens
190
+ : DEFAULTS.deepSearchMaxTokens,
191
+ showReasoning: typeof raw.showReasoning === "boolean" ? raw.showReasoning : DEFAULTS.showReasoning,
192
+ fallbackModel: typeof raw.fallbackModel === "string" && raw.fallbackModel ? raw.fallbackModel : DEFAULTS.fallbackModel,
193
+ imageModel: IMAGE_MODELS.includes(raw.imageModel ?? "") ? raw.imageModel! : DEFAULTS.imageModel,
194
+ videoModel: VIDEO_MODELS.includes(raw.videoModel ?? "") ? raw.videoModel! : DEFAULTS.videoModel,
195
+ };
196
+ // Migrate the old filename once so the new one is authoritative.
197
+ if (source !== p) saveConfig();
198
+ } catch {
199
+ config = { ...DEFAULTS };
200
+ }
201
+ }
202
+
203
+ function saveConfig(): void {
204
+ try {
205
+ const dir = getAgentDir();
206
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
207
+ writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, "utf8");
208
+ } catch (err) {
209
+ lastSaveError = err instanceof Error ? err.message : String(err);
210
+ }
211
+ }
212
+
213
+ let lastSaveError: string | undefined;
214
+
215
+ // -------------------------------------------------------------------- helpers
216
+
217
+ function isQwen(ctx: ExtensionContext): boolean {
218
+ return (ctx.model?.provider ?? "").startsWith(PROVIDER_PREFIX);
219
+ }
220
+
221
+ function resolveProvider(ctx: ExtensionContext): string {
222
+ const current = ctx.model?.provider ?? "";
223
+ if (current.startsWith(PROVIDER_PREFIX)) return current;
224
+ const ids = ctx.modelRegistry.getRegisteredProviderIds();
225
+ return ids.find((id) => id.startsWith(PROVIDER_PREFIX)) ?? "qwen-token-plan-individual";
226
+ }
227
+
228
+ async function resolveEndpoint(ctx: ExtensionContext): Promise<{ baseUrl: string; apiKey: string }> {
229
+ const provider = resolveProvider(ctx);
230
+ const registered = ctx.modelRegistry.getProvider(provider);
231
+ const baseUrl =
232
+ (registered as { baseUrl?: string } | undefined)?.baseUrl ?? BASE_URLS[provider] ?? BASE_URLS["qwen-token-plan"];
233
+ const apiKey = (await ctx.modelRegistry.getApiKeyForProvider(provider)) ?? "";
234
+ if (!apiKey) throw new Error(`No API key resolved for ${provider}. Run /login.`);
235
+ return { baseUrl, apiKey };
236
+ }
237
+
238
+ /** Chat-completions agent-mode side call (deep search): needs chat.agent support. */
239
+ function pickChatCapable(ctx: ExtensionContext, requested?: string): string {
240
+ const active = ctx.model?.id ?? "";
241
+ for (const c of [requested, active, config.fallbackModel]) {
242
+ if (c && capsFor(c).chat.agent) return c;
243
+ }
244
+ return "qwen3.7-max";
245
+ }
246
+
247
+ /**
248
+ * /responses tools: keep the ACTIVE model. Every chat model on this plan runs all
249
+ * three built-in tools there, so downgrading to the fallback would silently bill a
250
+ * different model than the one the user selected.
251
+ */
252
+ function pickResponsesModel(ctx: ExtensionContext, tool: keyof Caps["responses"], requested?: string): string {
253
+ const active = ctx.model?.id ?? "";
254
+ for (const c of [requested, active, config.fallbackModel]) {
255
+ if (c && capsFor(c).responses[tool]) return c;
256
+ }
257
+ return "qwen3.7-max";
258
+ }
259
+
260
+ // --------------------------------------------------------------- /responses
261
+
262
+ type ResponsesContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string };
263
+ /**
264
+ * Bare content arrays are rejected for image input ("the role in last message must
265
+ * be in [user, function, tool]") — images must arrive wrapped in a user message.
266
+ */
267
+ type ResponsesInput = string | ResponsesContent[] | Array<{ role: string; content: ResponsesContent[] }>;
268
+
269
+ interface ResponsesResult {
270
+ text: string;
271
+ reasoning: string;
272
+ traces: string[];
273
+ /** Image URLs returned by web_search_image / image_search. */
274
+ imageUrls: string[];
275
+ /** Which built-in tools actually fired; the model may decline to use them. */
276
+ callTypes: string[];
277
+ status: string;
278
+ usage?: { input_tokens?: number; output_tokens?: number };
279
+ }
280
+
281
+ /** `arguments` and `output` arrive as JSON-encoded strings; be defensive. */
282
+ function parseImageCall(o: any): { text: string; urls: string[] } {
283
+ let args: unknown = o?.arguments ?? null;
284
+ if (typeof args === "string") {
285
+ try {
286
+ args = JSON.parse(args);
287
+ } catch {
288
+ /* keep raw */
289
+ }
290
+ }
291
+ let out: unknown = o?.output ?? [];
292
+ if (typeof out === "string") {
293
+ try {
294
+ out = JSON.parse(out);
295
+ } catch {
296
+ out = [];
297
+ }
298
+ }
299
+ const rows = Array.isArray(out) ? out : [];
300
+ const urls = rows
301
+ .map((x: any) => (typeof x === "string" ? x : x?.url))
302
+ .filter((u: unknown): u is string => typeof u === "string" && u.startsWith("http"));
303
+ const listed = rows
304
+ .slice(0, 12)
305
+ .map((x: any, i: number) => ` ${i + 1}. ${x?.title ? String(x.title).slice(0, 100) + "\n " : ""}${x?.url ?? x}`)
306
+ .join("\n");
307
+ return {
308
+ text: `[${o?.type ?? "image_call"}] args: ${JSON.stringify(args)}\n${listed || " (no results parsed)"}`,
309
+ urls,
310
+ };
311
+ }
312
+
313
+ /** Accept an http(s)/data URL directly, or inline a local file as a data URL. */
314
+ /**
315
+ * Resolve an image reference to something the Qwen APIs accept: an http(s) URL or
316
+ * data: URL is passed through, a local path is inlined as a base64 data URL.
317
+ *
318
+ * Measured bound: a 5.0 MB PNG (6.7 MB of base64) submitted to happyhorse-1.1-i2v
319
+ * reached SUCCEEDED. The ceiling above that is unknown, so this limit is a guard
320
+ * rail with headroom rather than a verified maximum; no local transcoder is assumed
321
+ * available, so the message tells the user to host the file instead.
322
+ */
323
+ const MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024;
324
+
325
+ function toImageUrl(image: string, maxBytes = MAX_INLINE_IMAGE_BYTES): string {
326
+ if (/^(https?:|data:)/i.test(image)) return image;
327
+ const path = image.replace(/^file:\/\//, "");
328
+ if (!existsSync(path)) {
329
+ throw new Error(`Image not found: ${image}. Pass an http(s) URL, a data: URL, or a local file path.`);
330
+ }
331
+ const buf = readFileSync(path);
332
+ if (buf.length > maxBytes) {
333
+ throw new Error(
334
+ `Local image is ${(buf.length / (1024 * 1024)).toFixed(1)} MB, above the ${(maxBytes / (1024 * 1024)).toFixed(0)} MB inline limit (verified to 5.0 MB). Upload it and pass the URL, or point -i at a smaller file.`,
335
+ );
336
+ }
337
+ const ext = (path.split(".").pop() ?? "png").toLowerCase();
338
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : `image/${ext}`;
339
+ return `data:${mime};base64,${buf.toString("base64")}`;
340
+ }
341
+
342
+ function toImagePart(image: string): { type: "input_image"; image_url: string } {
343
+ return { type: "input_image", image_url: toImageUrl(image) };
344
+ }
345
+
346
+ async function responsesCall(
347
+ ctx: ExtensionContext,
348
+ options: { model: string; input: ResponsesInput; tools: string[]; maxTokens?: number; signal?: AbortSignal },
349
+ ): Promise<ResponsesResult> {
350
+ const { baseUrl, apiKey } = await resolveEndpoint(ctx);
351
+
352
+ const res = await fetch(`${baseUrl}/responses`, {
353
+ method: "POST",
354
+ signal: options.signal,
355
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
356
+ body: JSON.stringify({
357
+ model: options.model,
358
+ input: options.input,
359
+ max_output_tokens: options.maxTokens ?? 4096,
360
+ tools: options.tools.map((type) => ({ type })),
361
+ }),
362
+ });
363
+
364
+ const raw = await res.text();
365
+ let r: any;
366
+ try {
367
+ r = JSON.parse(raw);
368
+ } catch {
369
+ throw new Error(`Qwen /responses returned non-JSON (${res.status}): ${raw.slice(0, 400)}`);
370
+ }
371
+ if (!res.ok || r?.error) {
372
+ throw new Error(`Qwen /responses failed (${res.status}): ${r?.error?.message ?? String(raw).slice(0, 400)}`);
373
+ }
374
+
375
+ const traces: string[] = [];
376
+ const callTypes: string[] = [];
377
+ const imageUrls: string[] = [];
378
+ let text = "";
379
+ let reasoning = "";
380
+
381
+ for (const o of r?.output ?? []) {
382
+ switch (o?.type) {
383
+ case "message":
384
+ text += (o.content ?? []).map((c: any) => c?.text ?? "").join("");
385
+ break;
386
+ case "reasoning":
387
+ reasoning += (o.summary ?? []).map((s: any) => s?.text ?? "").join("");
388
+ break;
389
+ case "web_search_call": {
390
+ callTypes.push("web_search_call");
391
+ const a = o.action ?? {};
392
+ const sources = (a.sources ?? []).map((s: any) => s?.url).filter(Boolean);
393
+ traces.push(
394
+ `[web_search] query: ${a.query ?? "?"}` +
395
+ (sources.length ? `\n sources:\n${sources.map((s: string) => ` - ${s}`).join("\n")}` : ""),
396
+ );
397
+ break;
398
+ }
399
+ case "code_interpreter_call": {
400
+ callTypes.push("code_interpreter_call");
401
+ const logs = (o.outputs ?? []).map((x: any) => x?.logs ?? "").filter(Boolean).join("\n");
402
+ traces.push(`[code_interpreter] code:\n${o.code ?? "?"}\n container: ${o.container_id ?? "?"}\n output:\n${logs || "(none)"}`);
403
+ break;
404
+ }
405
+ case "web_extractor_call":
406
+ callTypes.push("web_extractor_call");
407
+ traces.push(`[web_extractor] urls: ${(o.urls ?? []).join(", ")}\n goal: ${o.goal ?? "?"}\n output:\n${o.output ?? "(none)"}`);
408
+ break;
409
+ case "web_search_image_call":
410
+ case "image_search_call": {
411
+ callTypes.push(String(o.type));
412
+ const img = parseImageCall(o);
413
+ traces.push(img.text);
414
+ imageUrls.push(...img.urls);
415
+ break;
416
+ }
417
+ default:
418
+ if (o?.type) {
419
+ if (String(o.type).endsWith("_call")) callTypes.push(String(o.type));
420
+ traces.push(`[${o.type}] ${JSON.stringify(o).slice(0, 400)}`);
421
+ }
422
+ }
423
+ }
424
+
425
+ return {
426
+ text: text.trim() || "(no message content)",
427
+ reasoning: reasoning.trim(),
428
+ traces,
429
+ imageUrls: [...new Set(imageUrls)],
430
+ callTypes,
431
+ status: r?.status ?? "?",
432
+ usage: r?.usage,
433
+ };
434
+ }
435
+
436
+ function formatResponses(t: ResponsesResult, expectCall?: string): string {
437
+ const parts: string[] = [];
438
+ if (t.traces.length) parts.push(`=== tool trace ===\n${t.traces.join("\n\n")}`);
439
+ if (config.showReasoning && t.reasoning) parts.push(`=== reasoning ===\n${t.reasoning}`);
440
+ parts.push(`=== result ===\n${t.text}`);
441
+ // Tool use is discretionary: the model can answer from memory and look confident.
442
+ // Say so loudly instead of returning an unlabelled guess.
443
+ if (expectCall && !t.callTypes.includes(expectCall)) {
444
+ parts.push(
445
+ `[WARNING: ${expectCall} never fired — this answer may be from model memory, NOT from a real search/execution. Retry with a more explicit ask or another model.]`,
446
+ );
447
+ }
448
+ if (t.imageUrls.length) {
449
+ parts.push(`=== image urls (${t.imageUrls.length}) ===\n${t.imageUrls.map((u, i) => ` ${i + 1}. ${u}`).join("\n")}`);
450
+ }
451
+ if (t.status === "incomplete") parts.push("[status: incomplete — raised max_output_tokens; narrow the ask or retry]");
452
+ if (t.usage) parts.push(`[tokens: in=${t.usage.input_tokens ?? "?"} out=${t.usage.output_tokens ?? "?"}]`);
453
+ return parts.join("\n\n");
454
+ }
455
+
456
+ // ------------------------------------------------ /chat/completions agent mode
457
+
458
+ interface AgentResult {
459
+ text: string;
460
+ reasoning: string;
461
+ usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number };
462
+ }
463
+
464
+ /**
465
+ * Agent mode rejects any `tools` array, so this is a side call: streaming, with
466
+ * `enable_code_interpreter` or `search_strategy:"agent"` rather than pi's tools.
467
+ */
468
+ async function agentModeCall(
469
+ ctx: ExtensionContext,
470
+ options: {
471
+ model: string;
472
+ prompt: string;
473
+ extra: Record<string, unknown>;
474
+ signal?: AbortSignal;
475
+ maxTokens?: number;
476
+ onUpdate?: (text: string) => void;
477
+ },
478
+ ): Promise<AgentResult> {
479
+ const { baseUrl, apiKey } = await resolveEndpoint(ctx);
480
+
481
+ const res = await fetch(`${baseUrl}/chat/completions`, {
482
+ method: "POST",
483
+ signal: options.signal,
484
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
485
+ body: JSON.stringify({
486
+ model: options.model,
487
+ stream: true,
488
+ stream_options: { include_usage: true },
489
+ enable_thinking: true,
490
+ max_tokens: options.maxTokens ?? 8192,
491
+ messages: [{ role: "user", content: options.prompt }],
492
+ ...options.extra,
493
+ }),
494
+ });
495
+
496
+ if (!res.ok || !res.body) {
497
+ const detail = (await res.text().catch(() => "")).slice(0, 500);
498
+ throw new Error(`Qwen agent-mode call failed (${res.status}): ${detail}`);
499
+ }
500
+
501
+ const reader = res.body.getReader();
502
+ const decoder = new TextDecoder();
503
+ let buffer = "";
504
+ let content = "";
505
+ let reasoning = "";
506
+ let usage: AgentResult["usage"];
507
+
508
+ while (true) {
509
+ const { done, value } = await reader.read();
510
+ if (done) break;
511
+ buffer += decoder.decode(value, { stream: true });
512
+
513
+ let nl: number;
514
+ while ((nl = buffer.indexOf("\n")) !== -1) {
515
+ const line = buffer.slice(0, nl).trim();
516
+ buffer = buffer.slice(nl + 1);
517
+ if (!line.startsWith("data:")) continue;
518
+ const chunk = line.slice(5).trim();
519
+ if (!chunk || chunk === "[DONE]") continue;
520
+
521
+ let parsed: any;
522
+ try {
523
+ parsed = JSON.parse(chunk);
524
+ } catch {
525
+ continue;
526
+ }
527
+ if (parsed?.error) throw new Error(parsed.error.message ?? JSON.stringify(parsed.error));
528
+ if (parsed?.usage) usage = parsed.usage;
529
+ for (const choice of parsed?.choices ?? []) {
530
+ const delta = choice?.delta ?? {};
531
+ if (typeof delta.content === "string") {
532
+ content += delta.content;
533
+ options.onUpdate?.(content);
534
+ }
535
+ if (typeof delta.reasoning_content === "string") reasoning += delta.reasoning_content;
536
+ }
537
+ }
538
+ }
539
+
540
+ return { text: content.trim() || "(empty response)", reasoning: reasoning.trim(), usage };
541
+ }
542
+
543
+ // ------------------------------------------------------- native generation API
544
+
545
+ const MG_PATH = "/api/v1/services/aigc/multimodal-generation/generation";
546
+ const VIDEO_PATH = "/api/v1/services/aigc/video-generation/video-synthesis";
547
+
548
+ /**
549
+ * Native endpoints hang off the host root, while the configured baseUrl ends in
550
+ * /compatible-mode/v1. Fall back to the known plan host if a provider ever hands
551
+ * us something unparseable rather than throwing out of every generation call.
552
+ */
553
+ function originOf(baseUrl: string, provider: string): string {
554
+ try {
555
+ return new URL(baseUrl).origin;
556
+ } catch {
557
+ const fallback = BASE_URLS[provider];
558
+ if (fallback) {
559
+ try {
560
+ return new URL(fallback).origin;
561
+ } catch {
562
+ /* fall through */
563
+ }
564
+ }
565
+ return "https://token-plan.ap-southeast-1.maas.aliyuncs.com";
566
+ }
567
+ }
568
+
569
+ interface NativeResponse {
570
+ status: number;
571
+ body: any;
572
+ }
573
+
574
+ async function nativeCall(
575
+ ctx: ExtensionContext,
576
+ path: string,
577
+ body: unknown,
578
+ options?: { async?: boolean; signal?: AbortSignal; timeoutMs?: number },
579
+ ): Promise<NativeResponse> {
580
+ const { baseUrl, apiKey } = await resolveEndpoint(ctx);
581
+ const origin = originOf(baseUrl, resolveProvider(ctx));
582
+ const res = await fetch(`${origin}${path}`, {
583
+ method: "POST",
584
+ signal: options?.signal,
585
+ headers: {
586
+ Authorization: `Bearer ${apiKey}`,
587
+ "Content-Type": "application/json",
588
+ ...(options?.async ? { "X-DashScope-Async": "enable" } : {}),
589
+ },
590
+ body: JSON.stringify(body),
591
+ });
592
+ const text = await res.text();
593
+ let parsed: any = text;
594
+ try {
595
+ parsed = JSON.parse(text);
596
+ } catch {
597
+ /* keep text */
598
+ }
599
+ if (!res.ok) {
600
+ const msg = parsed?.message ?? parsed?.code ?? String(text).slice(0, 200);
601
+ throw new Error(`Qwen native ${path.split("/").pop()} failed (${res.status}): ${msg}`);
602
+ }
603
+ return { status: res.status, body: parsed };
604
+ }
605
+
606
+ async function pollTask(
607
+ ctx: ExtensionContext,
608
+ taskId: string,
609
+ options?: { signal?: AbortSignal; onTick?: (status: string, elapsedS: number) => void; timeoutMs?: number },
610
+ ): Promise<any> {
611
+ const { baseUrl, apiKey } = await resolveEndpoint(ctx);
612
+ const origin = originOf(baseUrl, resolveProvider(ctx));
613
+ const deadline = Date.now() + (options?.timeoutMs ?? 10 * 60 * 1000);
614
+ let attempt = 0;
615
+ while (Date.now() < deadline) {
616
+ await new Promise((r) => setTimeout(r, Math.min(3000 + attempt * 1500, 15000)));
617
+ attempt++;
618
+ const res = await fetch(`${origin}/api/v1/tasks/${taskId}`, {
619
+ signal: options?.signal,
620
+ headers: { Authorization: `Bearer ${apiKey}` },
621
+ });
622
+ const body = await res.json().catch(() => ({}));
623
+ const status = body?.output?.task_status ?? "UNKNOWN";
624
+ options?.onTick?.(status, Math.round((Date.now() - (deadline - (options?.timeoutMs ?? 600000))) / 1000));
625
+ if (status === "SUCCEEDED" || status === "FAILED" || status === "UNKNOWN") return body?.output ?? {};
626
+ }
627
+ throw new Error(`Task ${taskId} did not finish within the timeout; poll /api/v1/tasks/${taskId} later.`);
628
+ }
629
+
630
+ /** Extract every image URL from a multimodal-generation response. */
631
+ function extractImageUrls(body: any): string[] {
632
+ const urls: string[] = [];
633
+ for (const choice of body?.output?.choices ?? []) {
634
+ for (const part of choice?.message?.content ?? []) {
635
+ if (typeof part?.image === "string") urls.push(part.image);
636
+ if (typeof part?.image_url?.url === "string") urls.push(part.image_url.url);
637
+ }
638
+ }
639
+ if (typeof body?.output?.image_url === "string") urls.push(body.output.image_url);
640
+ return [...new Set(urls)];
641
+ }
642
+
643
+ /** Shared core so the tool and the /qimage command behave identically. */
644
+ async function generateImage(
645
+ ctx: ExtensionContext,
646
+ opts: { prompt: string; model?: string; negativePrompt?: string; size?: string; signal?: AbortSignal },
647
+ ): Promise<{ model: string; urls: string[]; raw: unknown }> {
648
+ const model = opts.model && IMAGE_MODELS.includes(opts.model) ? opts.model : config.imageModel;
649
+ const parameters: Record<string, unknown> = { result_format: "message" };
650
+ if (opts.size) parameters.size = opts.size;
651
+ if (opts.negativePrompt) parameters.negative_prompt = opts.negativePrompt;
652
+ const r = await nativeCall(ctx, MG_PATH, {
653
+ model,
654
+ input: { messages: [{ role: "user", content: [{ text: opts.prompt }] }] },
655
+ parameters,
656
+ }, { signal: opts.signal });
657
+ return { model, urls: extractImageUrls(r.body), raw: r.body };
658
+ }
659
+
660
+ /**
661
+ * Which `input.media[].type` each video model requires. Measured by reading the
662
+ * validation errors: i2v wants exactly "first_frame", r2v exactly "reference_image",
663
+ * and t2v takes no image at all. Anything else fails with
664
+ * "Input should be 'first_frame': input.media.0.type".
665
+ */
666
+ const VIDEO_MEDIA_TYPE: Record<string, string | undefined> = {
667
+ "happyhorse-1.1-t2v": undefined,
668
+ "happyhorse-1.1-i2v": "first_frame",
669
+ "happyhorse-1.1-r2v": "reference_image",
670
+ };
671
+
672
+ interface VideoResult {
673
+ model: string;
674
+ taskId: string;
675
+ status: string;
676
+ url?: string;
677
+ /** Task-level failure text. Submits always return 200, so this is the only place a bad request shows up. */
678
+ detail?: string;
679
+ usedImage?: string;
680
+ }
681
+
682
+ /** Shared core for /qvideo and qwen_generate_video. Blocks on task polling. */
683
+ async function generateVideo(
684
+ ctx: ExtensionContext,
685
+ opts: {
686
+ prompt: string;
687
+ model?: string;
688
+ size?: string;
689
+ image?: string;
690
+ signal?: AbortSignal;
691
+ onTick?: (s: string, sec: number) => void;
692
+ },
693
+ ): Promise<VideoResult> {
694
+ const hasImage = Boolean(opts.image?.trim());
695
+ let model = opts.model && VIDEO_MODELS.includes(opts.model) ? opts.model : config.videoModel;
696
+ // Passing an image to the text-only model is intent, not a mistake: switch to i2v.
697
+ if (hasImage && !VIDEO_MEDIA_TYPE[model]) model = "happyhorse-1.1-i2v";
698
+ const mediaType = VIDEO_MEDIA_TYPE[model];
699
+ if (!hasImage && mediaType) {
700
+ throw new Error(
701
+ `${model} requires an image (tool parameter "image", or /qvideo -i <path|url>). Use happyhorse-1.1-t2v for text-only video.`,
702
+ );
703
+ }
704
+
705
+ const input: Record<string, unknown> = { prompt: opts.prompt };
706
+ let usedImage: string | undefined;
707
+ if (hasImage && mediaType) {
708
+ const url = toImageUrl(opts.image!.trim());
709
+ input.media = [{ type: mediaType, url }];
710
+ usedImage = url.startsWith("data:") ? `inline image (${Math.round(url.length / 1024)} KB base64)` : url;
711
+ }
712
+
713
+ const submit = await nativeCall(
714
+ ctx,
715
+ VIDEO_PATH,
716
+ {
717
+ model,
718
+ input,
719
+ parameters: { size: opts.size && VIDEO_SIZES.includes(opts.size) ? opts.size : VIDEO_SIZES[0] },
720
+ },
721
+ { async: true, signal: opts.signal },
722
+ );
723
+ const taskId = submit.body?.output?.task_id;
724
+ if (!taskId) throw new Error(`No task_id returned: ${JSON.stringify(submit.body).slice(0, 300)}`);
725
+ const out = await pollTask(ctx, taskId, { signal: opts.signal, onTick: opts.onTick });
726
+ return {
727
+ model,
728
+ taskId,
729
+ status: out?.task_status ?? "UNKNOWN",
730
+ url: out?.video_url,
731
+ detail: out?.message ?? out?.code,
732
+ usedImage,
733
+ };
734
+ }
735
+
736
+ /**
737
+ * Download a hosted media URL into <cwd>/out/qtools/. The commands do this because
738
+ * these signed OSS URLs are long enough to wrap across terminal rows, so copying one
739
+ * out of the notify panel reliably yields a truncated (failing) link.
740
+ */
741
+ async function saveMedia(ctx: ExtensionContext, url: string, model: string, signal?: AbortSignal): Promise<string> {
742
+ const dir = join(ctx.cwd, "out", "qtools");
743
+ mkdirSync(dir, { recursive: true });
744
+ const ext = (url.split("?")[0].match(/\.(png|jpe?g|webp|mp4|wav|mp3)$/i) ?? [])[0]?.toLowerCase() || ".bin";
745
+ const stamp = new Date().toISOString().replace(/[-:TZ.]/g, "").slice(0, 14);
746
+ const dest = join(dir, `${model}-${stamp}${ext}`);
747
+ const res = await fetch(url, { signal });
748
+ if (!res.ok) throw new Error(`download failed (${res.status}) for ${url.slice(0, 80)}`);
749
+ const buf = Buffer.from(await res.arrayBuffer());
750
+ writeFileSync(dest, buf);
751
+ return `${dest} (${(buf.length / 1024).toFixed(0)} KB)`;
752
+ }
753
+
754
+ /**
755
+ * Full parameter help for the generation commands. pi exposes no argumentHint for
756
+ * extension commands, so discoverability comes from three places: the signature in
757
+ * the command description (shown in the `/` picker), `--help`/no-args printing this
758
+ * block, and argument completions.
759
+ */
760
+ function genUsage(kind: "image" | "video", cwd: string): string {
761
+ const outDir = join(cwd, "out", "qtools");
762
+ if (kind === "image") {
763
+ return [
764
+ "/qimage <prompt> [-m model] [-s WxH]",
765
+ "",
766
+ " prompt what to draw. No quotes needed: flags are stripped, the rest is the prompt.",
767
+ ` -m model: ${IMAGE_MODELS.join(", ")} (default: ${config.imageModel})`,
768
+ " -s e.g. 1024x1024. Must be 589824-16777216 total pixels (768x768 .. 4096x4096).",
769
+ " --help this text",
770
+ "",
771
+ `Writes one image to ${outDir}/<model>-<timestamp>.png`,
772
+ "Agent equivalent: qwen_generate_image (adds negativePrompt and save).",
773
+ ].join("\n");
774
+ }
775
+ return [
776
+ "/qvideo <prompt> [-i <path|url>] [-m model] [-s WxH]",
777
+ "",
778
+ " prompt scene and motion.",
779
+ " -i source image to animate (min 300x300). Local paths are inlined as data URLs.",
780
+ ` -m model: ${VIDEO_MODELS.join(", ")} (default: ${config.videoModel})`,
781
+ " t2v = text only, i2v = -i as first frame, r2v = -i as reference.",
782
+ " Passing -i while -m is t2v switches to i2v automatically.",
783
+ ` -s ${VIDEO_SIZES.join(", ")}. Advisory for i2v/r2v: output follows the source aspect ratio.`,
784
+ " --help this text",
785
+ "",
786
+ `Async task, ~90-120s. Writes to ${outDir}/<model>-<timestamp>.mp4`,
787
+ "Agent equivalent: qwen_generate_video (adds save).",
788
+ ].join("\n");
789
+ }
790
+
791
+ const FLAG_MODEL = ["-m", "--model"];
792
+ const FLAG_SIZE = ["-s", "--size"];
793
+ const FLAG_IMAGE = ["-i", "--image", "--img"];
794
+
795
+ function flagKind(tok: string | undefined): "model" | "size" | "image" | undefined {
796
+ if (!tok) return undefined;
797
+ if (FLAG_MODEL.includes(tok)) return "model";
798
+ if (FLAG_SIZE.includes(tok)) return "size";
799
+ if (FLAG_IMAGE.includes(tok)) return "image";
800
+ return undefined;
801
+ }
802
+
803
+ function wantsHelp(args: string): boolean {
804
+ const wrapped = ` ${args.trim()} `;
805
+ return wrapped.includes(" -h ") || wrapped.includes(" --help ") || args.trim() === "help";
806
+ }
807
+
808
+ /**
809
+ * Completions for /qimage and /qvideo. pi hands over the whole argument text and
810
+ * replaces that entire region with the chosen value, so every candidate is built as
811
+ * "<already typed> <completion>". Returns null while the user is typing a prompt,
812
+ * otherwise picking a suggestion would silently delete what they wrote.
813
+ */
814
+ function genCompletions(prefix: string, opts: { models: string[]; sizes: string[]; image: boolean }): AutocompleteItem[] | null {
815
+ const trimmed = prefix.replace(/\s+$/, "");
816
+ const toks = trimmed.length ? trimmed.split(/\s+/) : [];
817
+ const last = toks[toks.length - 1];
818
+ const lastIsFlag = flagKind(last);
819
+ const kind = lastIsFlag ?? flagKind(toks[toks.length - 2]);
820
+ const head = lastIsFlag ? toks.slice(0, -1) : flagKind(toks[toks.length - 2]) ? toks.slice(0, -2) : null;
821
+
822
+ if (kind && head) {
823
+ if (kind === "image") return null; // filesystem paths: nothing safe to suggest
824
+ const partial = lastIsFlag ? "" : (last ?? "");
825
+ const pool = kind === "model" ? opts.models : opts.sizes;
826
+ const items = pool
827
+ .filter((v) => v.startsWith(partial))
828
+ .map((v) => ({
829
+ value: [...head, v].join(" ") + " ",
830
+ label: v,
831
+ description: kind === "model" ? "model" : "resolution",
832
+ }));
833
+ return items.length ? items : null;
834
+ }
835
+
836
+ if (toks.length === 0 || (last && last.startsWith("-"))) {
837
+ const flagPrefix = last && last.startsWith("-") ? last : "";
838
+ const keep = last && last.startsWith("-") ? toks.slice(0, -1) : toks;
839
+ const cands: AutocompleteItem[] = [];
840
+ if (opts.image) cands.push({ value: "-i ", label: "-i", description: "source image path/url -> image-to-video" });
841
+ cands.push(
842
+ { value: "-m ", label: "-m", description: "model" },
843
+ { value: "-s ", label: "-s", description: "size" },
844
+ { value: "--help", label: "--help", description: "show all parameters" },
845
+ );
846
+ const items = cands
847
+ .filter((c) => c.label.startsWith(flagPrefix))
848
+ .map((c) => ({ ...c, value: `${keep.length ? keep.join(" ") + " " : ""}${c.value}`.replace(/\s+/g, " ") }));
849
+ return items.length ? items : null;
850
+ }
851
+
852
+ return null;
853
+ }
854
+
855
+ function parseGenArgs(
856
+ args: string,
857
+ models: string[],
858
+ ): { prompt: string; model?: string; size?: string; image?: string } {
859
+ const tokens = args.trim().split(/\s+/);
860
+ const parts: string[] = [];
861
+ let model: string | undefined;
862
+ let size: string | undefined;
863
+ let image: string | undefined;
864
+ for (let i = 0; i < tokens.length; i++) {
865
+ const t = tokens[i];
866
+ if ((t === "-m" || t === "--model") && tokens[i + 1]) {
867
+ const m = tokens[++i];
868
+ model = models.includes(m) ? m : undefined;
869
+ if (!model) parts.push(m); // not a known model: treat as prompt text
870
+ } else if ((t === "-s" || t === "--size") && tokens[i + 1]) {
871
+ size = tokens[++i].replace(/[x×]/g, "*");
872
+ } else if ((t === "-i" || t === "--image" || t === "--img") && tokens[i + 1]) {
873
+ image = tokens[++i];
874
+ } else {
875
+ parts.push(t);
876
+ }
877
+ }
878
+ return { prompt: parts.join(" ").trim(), model, size, image };
879
+ }
880
+
881
+ function formatUrls(label: string, model: string, urls: string[]): string {
882
+ if (!urls.length) return `${label}: no URL in the response from ${model}.`;
883
+ return `${label} from ${model} (${urls.length}):\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join("\n")}\n\nHosted URLs expire; download if you need to keep them.`;
884
+ }
885
+
886
+ // -------------------------------------------------------------- settings TUI
887
+
888
+ /**
889
+ * `theme` is only a *type* export from pi-coding-agent, not a runtime value, so the
890
+ * theme object has to come from the ctx.ui.custom callback argument.
891
+ */
892
+ interface ThemeLike {
893
+ fg: (color: string, text: string) => string;
894
+ bold: (text: string) => string;
895
+ }
896
+
897
+ function makeSubmenu(
898
+ th: ThemeLike,
899
+ title: string,
900
+ items: SelectItem[],
901
+ current: string,
902
+ done: (value?: string) => void,
903
+ ): { render: (w: number) => string[]; invalidate: () => void; handleInput: (d: string) => void } {
904
+ const container = new Container();
905
+ container.addChild(new Text(`${th.fg("accent", th.bold(title))}`, 0, 0));
906
+ container.addChild(new Spacer(1));
907
+
908
+ const list = new SelectList(items, Math.min(items.length + 2, 12), getSelectListTheme());
909
+ const idx = items.findIndex((i) => i.value === current);
910
+ if (idx >= 0) list.setSelectedIndex(idx);
911
+ list.onSelect = (item) => done(item.value);
912
+ list.onCancel = () => done(undefined);
913
+ container.addChild(list);
914
+
915
+ return {
916
+ render: (width: number) => container.render(width),
917
+ invalidate: () => container.invalidate(),
918
+ handleInput: (data: string) => list.handleInput(data),
919
+ };
920
+ }
921
+
922
+ function capabilitySummary(caps: Caps): string {
923
+ const y = (b: boolean) => (b ? "yes" : "NO");
924
+ return (
925
+ `chat: search ${y(caps.chat.search)}, agent ${y(caps.chat.agent)}, code ${y(caps.chat.ci)}` +
926
+ ` | /responses: search ${y(caps.responses.web_search)}, code ${y(caps.responses.code_interpreter)},` +
927
+ ` extract ${y(caps.responses.web_extractor)},` +
928
+ ` t2i-img ${y(caps.responses.web_search_image)}, i2i-img ${y(caps.responses.image_search)}`
929
+ );
930
+ }
931
+
932
+ /**
933
+ * Console capability label -> the `tools[].type` string the API actually wants.
934
+ * This is the trap that made me mis-report both image tools as dead: the Qwen UI
935
+ * says t2i_search / i2i_search, but sending those as a type is silently accepted
936
+ * and ignored, exactly like a made-up name. Real names are below.
937
+ */
938
+ const TYPE_MAP: Array<{ label: string; type: string; tool: string }> = [
939
+ { label: "web_search", type: "web_search", tool: "qwen_web_search" },
940
+ { label: "web_extractor", type: "web_extractor", tool: "qwen_web_extractor (needs web_search)" },
941
+ { label: "code_interpreter", type: "code_interpreter", tool: "qwen_code_interpreter" },
942
+ { label: "t2i_search", type: "web_search_image", tool: "qwen_image_search" },
943
+ { label: "i2i_search", type: "image_search", tool: "qwen_reverse_image_search" },
944
+ ];
945
+
946
+ function capabilityItems(): SelectItem[] {
947
+ return Object.entries(CAPS).map(([id, caps]) => ({
948
+ value: id,
949
+ label: id,
950
+ description: capabilitySummary(caps),
951
+ }));
952
+ }
953
+
954
+ function typeMapItems(): SelectItem[] {
955
+ return TYPE_MAP.map((t) => ({
956
+ value: t.type,
957
+ label: `${t.label} -> "${t.type}"`,
958
+ description: `${t.tool} | works on: ${modelsSupporting(t.type as keyof Caps["responses"]).join(", ") || "none"}`,
959
+ }));
960
+ }
961
+
962
+ /** Descriptions are recomputed after every change so they never go stale. */
963
+ function descriptionFor(id: string, ctx: ExtensionContext): string {
964
+ const model = ctx.model?.id ?? "?";
965
+ const caps = capsFor(model);
966
+ switch (id) {
967
+ case "searchMode":
968
+ return (
969
+ MODE_NOTES[config.searchMode] +
970
+ (caps.chat.search ? "" : ` — WARNING: ${model} rejects enable_search, so injection is skipped`)
971
+ );
972
+ case "fallbackModel":
973
+ return `Model the qwen_* tools run on only when the active model cannot. Currently ${config.fallbackModel}. (/responses tools use the active model — every chat model here supports them.)`;
974
+ case "showReasoning":
975
+ return "Adds the model's own plan/rationale to tool results. More context, more tokens.";
976
+ case "deepSearchMaxTokens":
977
+ return `max_tokens cap for qwen_deep_search. It burned ~340k prompt tokens uncapped; limit is ${config.deepSearchMaxTokens} now.`;
978
+ case "capabilities":
979
+ return "Measured against the live endpoint. Note the two image tools are gated differently.";
980
+ case "imageModel":
981
+ return `Default for qwen_generate_image and /qimage. All three verified working via the native multimodal-generation endpoint.`;
982
+ case "videoModel":
983
+ return `Default for qwen_generate_video and /qvideo. t2v animates text; i2v animates a starting frame (media type first_frame); r2v takes a reference image (reference_image). All video is async and takes ~90-120s.`;
984
+ case "typemap":
985
+ return "Console capability labels differ from the API tools[].type strings. Sending a label is silently ignored.";
986
+ case "provider":
987
+ return "Where the API key and base URL come from, plus whether the active model is a Qwen plan model.";
988
+ }
989
+ return "";
990
+ }
991
+
992
+ function buildItems(ctx: ExtensionContext, th: ThemeLike): SettingItem[] {
993
+ const model = ctx.model?.id ?? "?";
994
+ const caps = capsFor(model);
995
+ const y = (b: boolean) => (b ? "y" : "-");
996
+ const capLine =
997
+ `${model}: chat ${y(caps.chat.search)}${y(caps.chat.agent)}${y(caps.chat.ci)}` +
998
+ ` | res ${y(caps.responses.web_search)}${y(caps.responses.code_interpreter)}${y(caps.responses.web_extractor)}` +
999
+ `${y(caps.responses.web_search_image)}${y(caps.responses.image_search)}`;
1000
+ return [
1001
+ {
1002
+ id: "searchMode",
1003
+ label: "Main-loop web search",
1004
+ description: descriptionFor("searchMode", ctx),
1005
+ currentValue: config.searchMode,
1006
+ values: [...SEARCH_MODES],
1007
+ },
1008
+ {
1009
+ id: "fallbackModel",
1010
+ label: "Built-in tool model",
1011
+ description: descriptionFor("fallbackModel", ctx),
1012
+ currentValue: config.fallbackModel,
1013
+ // NOTE: do NOT pass { navigateTo } to done(). Verified against pi-tui 0.87.1:
1014
+ // closeSubmenu() leaves submenuComponent set, so the submenu never closes
1015
+ // (onChange still fires, so it looks like a render hang). The cursor already
1016
+ // returns to this row, so navigateTo buys nothing here.
1017
+ submenu: (current, done) => makeSubmenu(th, "Built-in tool model", capabilityItems(), current, (v) => done(v)),
1018
+ },
1019
+ {
1020
+ id: "showReasoning",
1021
+ label: "Include model reasoning",
1022
+ description: descriptionFor("showReasoning", ctx),
1023
+ currentValue: config.showReasoning ? "enabled" : "disabled",
1024
+ values: ["disabled", "enabled"],
1025
+ },
1026
+ {
1027
+ id: "deepSearchMaxTokens",
1028
+ label: "Deep search output cap",
1029
+ description: descriptionFor("deepSearchMaxTokens", ctx),
1030
+ currentValue: String(config.deepSearchMaxTokens),
1031
+ submenu: (current, done) =>
1032
+ makeSubmenu(
1033
+ th,
1034
+ "Deep search output cap",
1035
+ TOKEN_CAPS.map((n) => ({ value: String(n), label: String(n) })),
1036
+ current,
1037
+ (v) => done(v),
1038
+ ),
1039
+ },
1040
+ {
1041
+ id: "imageModel",
1042
+ label: "Default image model",
1043
+ description: descriptionFor("imageModel", ctx),
1044
+ currentValue: config.imageModel,
1045
+ submenu: (current, done) =>
1046
+ makeSubmenu(th, "Default image model", IMAGE_MODELS.map((m) => ({ value: m, label: m })), current, (v) => done(v)),
1047
+ },
1048
+ {
1049
+ id: "videoModel",
1050
+ label: "Default video model",
1051
+ description: descriptionFor("videoModel", ctx),
1052
+ currentValue: config.videoModel,
1053
+ submenu: (current, done) =>
1054
+ makeSubmenu(
1055
+ th,
1056
+ "Default video model",
1057
+ VIDEO_MODELS.map((m) => ({
1058
+ value: m,
1059
+ label: m,
1060
+ description: VIDEO_MEDIA_TYPE[m]
1061
+ ? `needs -i / "image"; media type "${VIDEO_MEDIA_TYPE[m]}" (min 300x300)`
1062
+ : "text -> video, no image needed",
1063
+ })),
1064
+ current,
1065
+ (v) => done(v),
1066
+ ),
1067
+ },
1068
+ {
1069
+ id: "typemap",
1070
+ label: "Tool names (label vs API)",
1071
+ description: descriptionFor("typemap", ctx),
1072
+ currentValue: `${TYPE_MAP.length} mapped`,
1073
+ submenu: (_current, done) => makeSubmenu(th, "Console label -> API tools[].type", typeMapItems(), "", () => done(undefined)),
1074
+ },
1075
+ {
1076
+ id: "capabilities",
1077
+ label: "Per-model capabilities",
1078
+ description: descriptionFor("capabilities", ctx),
1079
+ currentValue: capLine,
1080
+ submenu: (_current, done) => makeSubmenu(th, "Per-model capabilities", capabilityItems(), "", () => done(undefined)),
1081
+ },
1082
+ {
1083
+ id: "provider",
1084
+ label: "Provider",
1085
+ description: descriptionFor("provider", ctx),
1086
+ currentValue: `${resolveProvider(ctx)}${isQwen(ctx) ? " (active)" : " (not active)"}`,
1087
+ },
1088
+ ];
1089
+ }
1090
+
1091
+ function applyChange(id: string, value: string): void {
1092
+ switch (id) {
1093
+ case "searchMode":
1094
+ if (SEARCH_MODES.includes(value as SearchMode)) config.searchMode = value as SearchMode;
1095
+ break;
1096
+ case "fallbackModel":
1097
+ if (value) config.fallbackModel = value;
1098
+ break;
1099
+ case "showReasoning":
1100
+ config.showReasoning = value === "enabled";
1101
+ break;
1102
+ case "imageModel":
1103
+ if (IMAGE_MODELS.includes(value)) config.imageModel = value;
1104
+ break;
1105
+ case "videoModel":
1106
+ if (VIDEO_MODELS.includes(value)) config.videoModel = value;
1107
+ break;
1108
+ case "deepSearchMaxTokens": {
1109
+ const n = Number.parseInt(value, 10);
1110
+ if (Number.isFinite(n) && n > 0) config.deepSearchMaxTokens = n;
1111
+ break;
1112
+ }
1113
+ }
1114
+ saveConfig();
1115
+ }
1116
+
1117
+ // ------------------------------------------------------------------ extension
1118
+
1119
+ loadConfig();
1120
+
1121
+ export default function (pi: ExtensionAPI): void {
1122
+ // ---- main-loop web_search injection (chat/completions) ----
1123
+ pi.on("before_provider_request", (event, ctx) => {
1124
+ if (config.searchMode === "off" || !isQwen(ctx)) return undefined;
1125
+
1126
+ const payload = event.payload;
1127
+ if (!payload || typeof payload !== "object") return undefined;
1128
+ const body = payload as Record<string, unknown>;
1129
+ if (typeof body.model !== "string") return undefined;
1130
+ if (!searchCapable(body.model)) return undefined;
1131
+
1132
+ return {
1133
+ ...body,
1134
+ enable_search: true,
1135
+ search_options: {
1136
+ // Never "agent": that is Agent mode and rejects pi's tools.
1137
+ search_strategy: config.searchMode,
1138
+ enable_source: true,
1139
+ enable_citation: config.searchMode === "max",
1140
+ },
1141
+ };
1142
+ });
1143
+
1144
+ // ------------------------------------------------------------------- tools
1145
+ pi.registerTool({
1146
+ name: "qwen_web_search",
1147
+ label: "Qwen Web Search",
1148
+ description:
1149
+ "Server-side web search via Qwen's Responses API. Returns the answer plus the actual queries " +
1150
+ "issued and the source URLs consulted. Use when you need current facts with citations.",
1151
+ promptSnippet: "Search the web and get back answers with source URLs",
1152
+ promptGuidelines: [
1153
+ "Use qwen_web_search when you need current or verifiable facts with source URLs, and want the search performed server-side rather than via curl.",
1154
+ ],
1155
+ parameters: Type.Object({
1156
+ query: Type.String({ description: "The search question or query." }),
1157
+ model: Type.Optional(Type.String({ description: "Override model id." })),
1158
+ }),
1159
+ async execute(_id, params, signal, onUpdate, ctx) {
1160
+ onUpdate?.({ content: [{ type: "text", text: `searching: ${params.query}` }] });
1161
+ const model = pickResponsesModel(ctx, "web_search", params.model);
1162
+ const t = await responsesCall(ctx, { model, input: params.query, tools: ["web_search"], signal });
1163
+ return {
1164
+ content: [{ type: "text", text: formatResponses(t, "web_search_call") }],
1165
+ details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes },
1166
+ };
1167
+ },
1168
+ });
1169
+
1170
+ pi.registerTool({
1171
+ name: "qwen_web_extractor",
1172
+ label: "Qwen Web Extractor",
1173
+ description:
1174
+ "Scrape and extract content from specific URLs via Qwen's Responses API web_extractor tool. " +
1175
+ "Returns the page text relevant to your stated goal. web_extractor requires web_search, so both are enabled.",
1176
+ promptSnippet: "Extract content from specific URLs toward a stated goal",
1177
+ promptGuidelines: [
1178
+ "Use qwen_web_extractor to read the content of a known URL when local curl is blocked or the page needs rendering/extraction, stating a clear goal.",
1179
+ ],
1180
+ parameters: Type.Object({
1181
+ urls: Type.Array(Type.String(), { description: "One or more URLs to extract from." }),
1182
+ goal: Type.String({ description: "What information to pull out of those pages." }),
1183
+ model: Type.Optional(Type.String({ description: "Override model id." })),
1184
+ }),
1185
+ async execute(_id, params, signal, onUpdate, ctx) {
1186
+ onUpdate?.({ content: [{ type: "text", text: `extracting ${params.urls.length} url(s)` }] });
1187
+ const model = pickResponsesModel(ctx, "web_extractor", params.model);
1188
+ const t = await responsesCall(ctx, {
1189
+ model,
1190
+ input: `Extract from these URLs: ${params.urls.join(", ")}\nGoal: ${params.goal}`,
1191
+ tools: ["web_search", "web_extractor"],
1192
+ maxTokens: 6144,
1193
+ signal,
1194
+ });
1195
+ return {
1196
+ content: [{ type: "text", text: formatResponses(t, "web_extractor_call") }],
1197
+ details: { status: t.status, model, urls: params.urls, usage: t.usage, callTypes: t.callTypes },
1198
+ };
1199
+ },
1200
+ });
1201
+
1202
+ pi.registerTool({
1203
+ name: "qwen_code_interpreter",
1204
+ label: "Qwen Code Interpreter",
1205
+ description:
1206
+ "Execute Python in Alibaba's sandboxed server-side code interpreter. Returns the code it ran, " +
1207
+ "the container id, and the real stdout/stderr. Use for exact numeric, crypto, hash, or math results.",
1208
+ promptSnippet: "Run Python in Alibaba's sandboxed server-side code interpreter",
1209
+ promptGuidelines: [
1210
+ "Use qwen_code_interpreter when an answer needs real code execution rather than recall, such as SHA/MD5 digests, big-integer math, statistics over a pasted dataset, or confirming an algorithm's exact output.",
1211
+ "qwen_code_interpreter runs in an isolated Alibaba sandbox with no access to the local filesystem or network, so use bash for local work.",
1212
+ ],
1213
+ parameters: Type.Object({
1214
+ task: Type.String({ description: "Self-contained instruction, including any input data inline." }),
1215
+ model: Type.Optional(Type.String({ description: "Override model id." })),
1216
+ }),
1217
+ async execute(_id, params, signal, onUpdate, ctx) {
1218
+ onUpdate?.({ content: [{ type: "text", text: "running in sandbox..." }] });
1219
+ const model = pickResponsesModel(ctx, "code_interpreter", params.model);
1220
+ const t = await responsesCall(ctx, { model, input: params.task, tools: ["code_interpreter"], maxTokens: 6144, signal });
1221
+ return {
1222
+ content: [{ type: "text", text: formatResponses(t, "code_interpreter_call") }],
1223
+ details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes },
1224
+ };
1225
+ },
1226
+ });
1227
+
1228
+ // --------------------------------------------------------- qwen_image_search
1229
+ // Console label "t2i_search"; the API type string is web_search_image.
1230
+ // Sending "t2i_search" is silently ignored, like any unknown type.
1231
+ pi.registerTool({
1232
+ name: "qwen_image_search",
1233
+ label: "Qwen Image Search (text -> images)",
1234
+ description:
1235
+ "Search the web for images matching a text description, using Qwen's server-side web_search_image " +
1236
+ "tool. Returns the image URLs it found plus the queries it issued. Qwen runs the retrieval; results " +
1237
+ "are real URLs, not model guesses. Supported by qwen3.8-max only on this plan.",
1238
+ promptSnippet: "Find images on the web from a text description",
1239
+ promptGuidelines: [
1240
+ "Use qwen_image_search when the user wants pictures of something found on the web, as opposed to qwen_web_search which retrieves text sources.",
1241
+ "Report qwen_image_search results as links; the sandbox cannot render images inline.",
1242
+ ],
1243
+ parameters: Type.Object({
1244
+ query: Type.String({ description: "What to find images of, as a short descriptive phrase." }),
1245
+ model: Type.Optional(Type.String({ description: "Override model id (only qwen3.8-max supports this tool)." })),
1246
+ }),
1247
+ async execute(_id, params, signal, onUpdate, ctx) {
1248
+ onUpdate?.({ content: [{ type: "text", text: `image search: ${params.query}` }] });
1249
+ const model = pickResponsesModel(ctx, "web_search_image", params.model);
1250
+ const t = await responsesCall(ctx, { model, input: params.query, tools: ["web_search_image"], maxTokens: 4096, signal });
1251
+ return {
1252
+ content: [{ type: "text", text: formatResponses(t, "web_search_image_call") }],
1253
+ details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes, imageUrls: t.imageUrls },
1254
+ };
1255
+ },
1256
+ });
1257
+
1258
+ // ------------------------------------------------- qwen_reverse_image_search
1259
+ // Console label "i2i_search"; the API type string is image_search. The service
1260
+ // fetches the image itself, so even models that reject image *input* can drive it.
1261
+ pi.registerTool({
1262
+ name: "qwen_reverse_image_search",
1263
+ label: "Qwen Reverse Image Search (image -> images)",
1264
+ description:
1265
+ "Find visually similar images on the web for a given image, using Qwen's server-side image_search " +
1266
+ "tool. Accepts an http(s) URL, a data: URL, or a local file path (inlined as base64, 4 MB cap). " +
1267
+ "Useful for identifying what is in an image, finding sources, or locating higher-resolution versions.",
1268
+ promptSnippet: "Reverse image search: find similar images from a URL or local file",
1269
+ promptGuidelines: [
1270
+ "Use qwen_reverse_image_search when the user supplies an image and asks where it is from, what it depicts, or wants similar images.",
1271
+ "qwen_reverse_image_search takes a local path or URL; prefer a URL for files larger than 4 MB.",
1272
+ ],
1273
+ parameters: Type.Object({
1274
+ image: Type.String({ description: "Image to search by: http(s) URL, data: URL, or local file path." }),
1275
+ question: Type.Optional(
1276
+ Type.String({ description: "Optional instruction shaping the search, e.g. 'find the original photo'." }),
1277
+ ),
1278
+ model: Type.Optional(Type.String({ description: "Override model id." })),
1279
+ }),
1280
+ async execute(_id, params, signal, onUpdate, ctx) {
1281
+ onUpdate?.({ content: [{ type: "text", text: "reverse image search..." }] });
1282
+ const model = pickResponsesModel(ctx, "image_search", params.model);
1283
+ const input: ResponsesInput = [
1284
+ {
1285
+ role: "user",
1286
+ content: [
1287
+ toImagePart(params.image),
1288
+ {
1289
+ type: "input_text",
1290
+ text: params.question?.trim() || "Find visually similar images and list their URLs.",
1291
+ },
1292
+ ],
1293
+ },
1294
+ ];
1295
+ const t = await responsesCall(ctx, { model, input, tools: ["image_search"], maxTokens: 4096, signal });
1296
+ return {
1297
+ content: [{ type: "text", text: formatResponses(t, "image_search_call") }],
1298
+ details: { status: t.status, model, usage: t.usage, callTypes: t.callTypes, imageUrls: t.imageUrls },
1299
+ };
1300
+ },
1301
+ });
1302
+
1303
+ pi.registerTool({
1304
+ name: "qwen_deep_search",
1305
+ label: "Qwen Deep Search",
1306
+ description:
1307
+ "Qwen's multi-step 'agent' web search strategy: plans, issues several queries, and synthesises a " +
1308
+ "sourced answer. Much deeper than qwen_web_search but far more expensive (~300k prompt tokens " +
1309
+ "observed). Runs via chat/completions Agent mode, which cannot be combined with other tools.",
1310
+ promptSnippet: "Multi-step agentic web research with sources (expensive)",
1311
+ promptGuidelines: [
1312
+ "Use qwen_deep_search only for research needing several searches or cross-checking sources, such as tracing a breaking change or resolving conflicting documentation; prefer qwen_web_search for single-fact lookups because qwen_deep_search is very token-expensive.",
1313
+ ],
1314
+ parameters: Type.Object({
1315
+ query: Type.String({ description: "The research question." }),
1316
+ model: Type.Optional(Type.String({ description: "Override model id." })),
1317
+ }),
1318
+ async execute(_id, params, signal, onUpdate, ctx) {
1319
+ onUpdate?.({ content: [{ type: "text", text: "deep searching (agent strategy)..." }] });
1320
+ const model = pickChatCapable(ctx, params.model);
1321
+ const r = await agentModeCall(ctx, {
1322
+ model,
1323
+ prompt: params.query,
1324
+ maxTokens: config.deepSearchMaxTokens,
1325
+ signal,
1326
+ extra: {
1327
+ enable_search: true,
1328
+ search_options: { search_strategy: "agent", enable_source: true, enable_citation: true },
1329
+ },
1330
+ onUpdate: (t) => onUpdate?.({ content: [{ type: "text", text: t.slice(-2000) }] }),
1331
+ });
1332
+ const body = config.showReasoning && r.reasoning ? `\n\n<reasoning>\n${r.reasoning}\n</reasoning>` : "";
1333
+ const meta = r.usage ? `\n\n[tokens: prompt=${r.usage.prompt_tokens ?? "?"} total=${r.usage.total_tokens ?? "?"}]` : "";
1334
+ return { content: [{ type: "text", text: `${r.text}${body}${meta}` }], details: { model, usage: r.usage } };
1335
+ },
1336
+ });
1337
+
1338
+ // ------------------------------------------------------ qwen_generate_image
1339
+ pi.registerTool({
1340
+ name: "qwen_generate_image",
1341
+ label: "Qwen Image Generation",
1342
+ description:
1343
+ "Generate images with the Qwen plan's image models (wan2.7-image, wan2.7-image-pro, qwen-image-3.0-pro) " +
1344
+ "through the native multimodal-generation endpoint. These are not chat models, so they are unreachable " +
1345
+ "from pi's normal model list. Returns temporary hosted image URLs.",
1346
+ promptSnippet: "Generate images with Qwen/Wan image models",
1347
+ promptGuidelines: [
1348
+ "Use qwen_generate_image when the user asks to create or draw an image; report the returned URLs as links and note they are temporary hosted files.",
1349
+ "qwen_generate_image cannot render images in the terminal, so hand the user the URL rather than describing pixels you cannot see.",
1350
+ ],
1351
+ parameters: Type.Object({
1352
+ prompt: Type.String({ description: "What to draw." }),
1353
+ model: Type.Optional(Type.String({ description: `Override model. One of: ${IMAGE_MODELS.join(", ")}.` })),
1354
+ negativePrompt: Type.Optional(Type.String({ description: "What to avoid." })),
1355
+ size: Type.Optional(Type.String({ description: "Pixel size, e.g. 1024*1024. Must be 589824-16777216 total pixels (768² to 4096²)." })),
1356
+ save: Type.Optional(Type.Boolean({ description: "Also write the images into <cwd>/out/qtools/ instead of only returning expiring URLs." })),
1357
+ }),
1358
+ async execute(_id, params, signal, onUpdate, ctx) {
1359
+ onUpdate?.({ content: [{ type: "text", text: "generating image..." }] });
1360
+ const r = await generateImage(ctx, { ...params, signal });
1361
+ let text = r.urls.length
1362
+ ? formatUrls("Generated images", r.model, r.urls)
1363
+ : `No image URL returned by ${r.model}:\n${JSON.stringify(r.raw).slice(0, 600)}`;
1364
+ let files: string[] = [];
1365
+ if (params.save && r.urls.length) {
1366
+ files = await Promise.all(r.urls.map((u) => saveMedia(ctx, u, r.model, signal)));
1367
+ text += `\n\nSaved:\n${files.map((f) => ` ${f}`).join("\n")}`;
1368
+ }
1369
+ return { content: [{ type: "text", text }], details: { model: r.model, urls: r.urls, files } };
1370
+ },
1371
+ });
1372
+
1373
+ // ------------------------------------------------------ qwen_generate_video
1374
+ pi.registerTool({
1375
+ name: "qwen_generate_video",
1376
+ label: "Qwen Video Generation",
1377
+ description:
1378
+ "Generate a short video with the plan's HappyHorse models using the async task API (submit then poll). " +
1379
+ "Blocks until the task finishes, which took ~90-120s in testing. t2v animates from a text prompt; " +
1380
+ "i2v animates from a starting frame and r2v from a reference image, so pass the `image` parameter for those " +
1381
+ "(http(s) URL, data: URL, or local file path). Passing an image to t2v switches it to i2v automatically. " +
1382
+ "Note the endpoint returns HTTP 200 on submit even for a malformed request, so failures only appear in the " +
1383
+ "polled result; read the returned status rather than assuming success.",
1384
+ promptSnippet: "Generate a short video from text or a source image (slow)",
1385
+ promptGuidelines: [
1386
+ "Use qwen_generate_video only when the user explicitly asks for generated video, and warn them it takes a couple of minutes since it blocks.",
1387
+ "For animating an existing picture, pass qwen_generate_video the image parameter; happyhorse-1.1-i2v needs at least a 300x300 source image.",
1388
+ ],
1389
+ parameters: Type.Object({
1390
+ prompt: Type.String({ description: "Scene and motion description." }),
1391
+ image: Type.Optional(
1392
+ Type.String({
1393
+ description:
1394
+ "Source image for i2v (first frame) or r2v (reference). http(s) URL, data: URL, or local file path. Min 300x300.",
1395
+ }),
1396
+ ),
1397
+ model: Type.Optional(Type.String({ description: `Override model. One of: ${VIDEO_MODELS.join(", ")}.` })),
1398
+ size: Type.Optional(Type.String({ description: `One of: ${VIDEO_SIZES.join(", ")}. Advisory only for i2v/r2v: output follows the source image aspect ratio (a square frame returned 1440x1440).` })),
1399
+ save: Type.Optional(Type.Boolean({ description: "Also write the clip into <cwd>/out/qtools/ instead of only returning an expiring URL." })),
1400
+ }),
1401
+ async execute(_id, params, signal, onUpdate, ctx) {
1402
+ onUpdate?.({ content: [{ type: "text", text: params.image ? "submitting image-to-video task..." : "submitting video task..." }] });
1403
+ const r = await generateVideo(ctx, {
1404
+ ...params,
1405
+ signal,
1406
+ onTick: (st, sec) => onUpdate?.({ content: [{ type: "text", text: `video ${st} (${sec}s)` }] }),
1407
+ });
1408
+ const text =
1409
+ r.status === "SUCCEEDED"
1410
+ ? formatUrls("Generated video", r.model, r.url ? [r.url] : []) + `\n(task ${r.taskId})`
1411
+ : `Video task ${r.status} (task ${r.taskId})${r.detail ? `\n ${r.detail}` : ""}`;
1412
+ let file: string | undefined;
1413
+ if (params.save && r.status === "SUCCEEDED" && r.url) {
1414
+ file = await saveMedia(ctx, r.url, r.model, signal).catch((e) => `save failed: ${e.message}`);
1415
+ }
1416
+ return {
1417
+ content: [{ type: "text", text: file ? `${text}\n\nSaved:\n ${file}` : text }],
1418
+ details: { ...r, file },
1419
+ };
1420
+ },
1421
+ });
1422
+
1423
+ // ---------------------------------------------------------------- commands
1424
+ pi.registerCommand("qimage", {
1425
+ description: `Generate an image — /qimage <prompt> [-m model] [-s WxH] (default: ${config.imageModel})`,
1426
+ getArgumentCompletions: (prefix) => genCompletions(prefix, { models: IMAGE_MODELS, sizes: [], image: false }),
1427
+ handler: async (args, ctx) => {
1428
+ if (wantsHelp(args)) {
1429
+ ctx.ui.notify(genUsage("image", ctx.cwd), "info");
1430
+ return;
1431
+ }
1432
+ const { prompt, model, size } = parseGenArgs(args, IMAGE_MODELS);
1433
+ if (!prompt) {
1434
+ ctx.ui.notify(genUsage("image", ctx.cwd), "error");
1435
+ return;
1436
+ }
1437
+ ctx.ui.notify(`Generating image with ${model || config.imageModel}...`, "info");
1438
+ const r = await generateImage(ctx, { prompt, model, size });
1439
+ if (!r.urls.length) {
1440
+ ctx.ui.notify(`No image URL from ${r.model}`, "error");
1441
+ return;
1442
+ }
1443
+ // Save locally: the signed URLs wrap across terminal rows and are unreliable
1444
+ // to copy out of the panel.
1445
+ const saved = await Promise.all(r.urls.map((u) => saveMedia(ctx, u, r.model)));
1446
+ ctx.ui.notify(`Image from ${r.model} saved:\n${saved.map((s) => " " + s).join("\n")}`, "success");
1447
+ },
1448
+ });
1449
+
1450
+ pi.registerCommand("qvideo", {
1451
+ description: `Generate a video — /qvideo <prompt> [-i img] [-m model] [-s WxH] (default: ${config.videoModel})`,
1452
+ getArgumentCompletions: (prefix) => genCompletions(prefix, { models: VIDEO_MODELS, sizes: VIDEO_SIZES, image: true }),
1453
+ handler: async (args, ctx) => {
1454
+ if (wantsHelp(args)) {
1455
+ ctx.ui.notify(genUsage("video", ctx.cwd), "info");
1456
+ return;
1457
+ }
1458
+ const { prompt, model, size, image } = parseGenArgs(args, VIDEO_MODELS);
1459
+ if (!prompt) {
1460
+ ctx.ui.notify(genUsage("video", ctx.cwd), "error");
1461
+ return;
1462
+ }
1463
+ const r = await generateVideo(ctx, {
1464
+ prompt,
1465
+ model,
1466
+ size,
1467
+ image,
1468
+ onTick: (st) => ctx.ui.notify(`video ${st}`, "info"),
1469
+ });
1470
+ if (r.status === "SUCCEEDED" && r.url) {
1471
+ const saved = await saveMedia(ctx, r.url, r.model).catch((e) => `save failed: ${e.message}`);
1472
+ ctx.ui.notify(`Video from ${r.model}:\n ${saved}`, "success");
1473
+ } else {
1474
+ // Submits always 200, so the polled detail is the only real error signal.
1475
+ ctx.ui.notify(
1476
+ `Video task ${r.status} (task ${r.taskId})${r.detail ? `\n ${r.detail}` : ""}`,
1477
+ "error",
1478
+ );
1479
+ }
1480
+ },
1481
+ });
1482
+
1483
+ pi.registerCommand("qtools-config", {
1484
+ description: "Configure Qwen web search, built-in tools, and model fallback",
1485
+ handler: async (_args, ctx) => {
1486
+ if (ctx.mode !== "tui") {
1487
+ ctx.ui.notify("/qtools-config requires TUI mode. Config file: " + configPath(), "info");
1488
+ return;
1489
+ }
1490
+ await ctx.ui.custom((_tui, th, _kb, done) => {
1491
+ const items = buildItems(ctx, th);
1492
+ const container = new Container();
1493
+ container.addChild(new Text(th.fg("accent", th.bold("Qwen Tools Configuration")), 0, 0));
1494
+ container.addChild(new Spacer(1));
1495
+
1496
+ const list = new SettingsList(
1497
+ items,
1498
+ Math.min(items.length + 2, 14),
1499
+ getSettingsListTheme(),
1500
+ (id, value) => {
1501
+ applyChange(id, value);
1502
+ for (const row of items) {
1503
+ row.description = descriptionFor(row.id, ctx);
1504
+ list.updateValue(row.id, row.currentValue);
1505
+ }
1506
+ _tui.requestRender();
1507
+ },
1508
+ () => {
1509
+ if (lastSaveError) ctx.ui.notify(`Could not save config: ${lastSaveError}`, "error");
1510
+ done(undefined);
1511
+ },
1512
+ { enableSearch: true },
1513
+ );
1514
+
1515
+ container.addChild(list);
1516
+
1517
+ return {
1518
+ render: (width: number) => container.render(width),
1519
+ invalidate: () => container.invalidate(),
1520
+ handleInput: (data: string) => {
1521
+ list.handleInput(data);
1522
+ _tui.requestRender();
1523
+ },
1524
+ };
1525
+ });
1526
+ },
1527
+ });
1528
+ }