@pithos-kit/squiggle 0.5.0 → 0.6.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 CHANGED
@@ -39,33 +39,40 @@ pi -e ./pithos.squiggle
39
39
  ```yaml
40
40
  pi:
41
41
  extensions:
42
- "@pithos-kit/squiggle": "npm:0.5.0"
42
+ "@pithos-kit/squiggle": "npm:0.6.0"
43
43
  ```
44
44
 
45
45
  ## Configuration
46
46
 
47
- Create `.pi/squiggle.json` in your project:
47
+ Run `/squiggle config` to choose an exact correction model from providers with configured authentication, just like Translate's model picker. If none are available, run `/login` first. The picker opens directly and saves to `<cwd>/.pi/squiggle.json` (honors Pi's configured project directory name). There is no session or user-level model configuration.
48
+
49
+ Model precedence is **`SQUIGGLE_MODEL` > project > default**. An environment override may still take precedence after saving, which the completion message shows. Cancelling the picker changes nothing. Existing file fields are preserved, and malformed files are not overwritten.
50
+
51
+ You can still edit `.pi/squiggle.json` directly:
48
52
 
49
53
  ```json
50
54
  {
51
55
  "mode": "on",
52
56
  "model": "openai-codex/gpt-5.4-mini",
53
- "maxInputChars": 500
57
+ "maxInputChars": 500,
58
+ "timeoutMs": 10000
54
59
  }
55
60
  ```
56
61
 
57
62
  Options:
58
63
 
59
64
  - `mode`: `"on"` or `"off"`
60
- - `model`: pi model spec in `provider/model` format
65
+ - `model`: exact pi model spec in `provider/model` format (model IDs may contain additional slashes)
61
66
  - `maxInputChars`: maximum input length to send to the correction model
67
+ - `timeoutMs`: correction deadline in milliseconds, from `1000` to `60000` (default: `10000`)
62
68
 
63
- Environment variables override the config file:
69
+ Environment variables override the project config (except the session on/off toggle):
64
70
 
65
71
  ```bash
66
72
  SQUIGGLE_MODE=off pi
67
73
  SQUIGGLE_MODEL=openai-codex/gpt-5.4-mini pi
68
74
  SQUIGGLE_MAX_CHARS=1000 pi
75
+ SQUIGGLE_TIMEOUT_MS=15000 pi
69
76
  ```
70
77
 
71
78
  ## Commands
@@ -74,7 +81,8 @@ Inside pi:
74
81
 
75
82
  ```text
76
83
  /squiggle toggle # switch between on/off
77
- /squiggle --help # show toggle usage
84
+ /squiggle config # choose and save the correction model
85
+ /squiggle --help # show toggle/config usage
78
86
  /squiggle-status # show status
79
87
  /squiggle-status --help # show status-command usage
80
88
  ```
@@ -83,6 +91,10 @@ Inside pi:
83
91
 
84
92
  The toggle state is saved in the current pi session and overrides `.pi/squiggle.json` and environment configuration for that session.
85
93
 
94
+ The default correction model is `openai-codex/gpt-5.4-mini`. Squiggle uses only the exact configured model, never silently substituting the active coding model. `/squiggle-status` shows its exact ID, configuration source, and whether it is missing from the registry. Missing models or failed authentication leave the original prompt unchanged.
95
+
96
+ If authentication or correction exceeds the configured deadline, Squiggle cancels the request, clears the spinner, and submits the original prompt unchanged. Session shutdown, reload, and an active Pi cancellation signal also cancel in-flight correction.
97
+
86
98
  ## Notes
87
99
 
88
100
  This package imports pi runtime packages as peer dependencies:
@@ -1,12 +1,21 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, renameSync, rmSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { dirname, join } from "node:path";
3
4
  import { complete, type UserMessage } from "@earendil-works/pi-ai";
5
+ import * as piRuntime from "@earendil-works/pi-coding-agent";
4
6
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
7
  import { createPithosLogger, errorMetadata, modelMetadata, usageMetadata, type PithosLogger } from "./logging.ts";
6
8
 
9
+ // Older supported Pi releases do not export CONFIG_DIR_NAME.
10
+ const CONFIG_DIR_NAME = (piRuntime as { CONFIG_DIR_NAME?: string }).CONFIG_DIR_NAME ?? ".pi";
11
+
7
12
  const SQUIGGLE_HELP = `Usage: /squiggle toggle
13
+ /squiggle config
8
14
 
9
15
  Toggle Squiggle on or off for the current session.
16
+ Configure an authenticated exact correction model for the project.
17
+ Model precedence: SQUIGGLE_MODEL > project > default.
18
+ Cancelling configuration makes no changes.
10
19
 
11
20
  Options:
12
21
  --help, -h Show this help`;
@@ -18,35 +27,83 @@ Show whether Squiggle is enabled and which correction model it uses.
18
27
  Options:
19
28
  --help, -h Show this help`;
20
29
 
30
+ export type SquiggleConfig = {
31
+ mode: "on" | "off";
32
+ model: string;
33
+ modelScope?: "SQUIGGLE_MODEL" | "project" | "default";
34
+ maxInputChars: number;
35
+ timeoutMs: number;
36
+ };
37
+
38
+ export const DEFAULT_CORRECTION_TIMEOUT_MS = 10_000;
39
+ export const MIN_CORRECTION_TIMEOUT_MS = 1_000;
40
+ export const MAX_CORRECTION_TIMEOUT_MS = 60_000;
41
+
42
+ type CorrectPrompt = (
43
+ input: string,
44
+ ctx: ExtensionContext,
45
+ config: SquiggleConfig,
46
+ log?: PithosLogger,
47
+ signal?: AbortSignal,
48
+ ) => Promise<string | null>;
49
+
21
50
  export function registerSquiggle(
22
51
  pi: ExtensionAPI,
23
- correctPrompt: (input: string, ctx: ExtensionContext, config: SquiggleConfig, log?: PithosLogger) => Promise<string | null> = correctWithModel,
52
+ correctPrompt: CorrectPrompt = correctWithModel,
24
53
  ) {
25
54
  const log = createPithosLogger();
26
55
  log.info("extension.register");
27
56
  let runtimeMode: SquiggleConfig["mode"] | undefined;
57
+ let correctionScope = new AbortController();
58
+ const effectiveConfig = (cwd: string): SquiggleConfig => loadEffectiveConfig(cwd, runtimeMode);
28
59
 
29
60
  pi.on("session_start", async (event, ctx) => {
61
+ correctionScope.abort();
62
+ correctionScope = new AbortController();
30
63
  runtimeMode = restoreRuntimeMode(ctx);
31
64
  log.info("session.start", { reason: event.reason, sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), runtimeMode });
32
65
  });
33
66
 
67
+ pi.on("session_shutdown", async () => {
68
+ correctionScope.abort();
69
+ });
70
+
34
71
  pi.registerCommand("squiggle", {
35
- description: "Toggle squiggle on/off",
72
+ description: "Toggle squiggle on/off or configure its correction model",
73
+ getArgumentCompletions: (prefix) => ["toggle", "config", "--help"]
74
+ .filter((value) => value.startsWith(prefix)).map((value) => ({ value, label: value })),
36
75
  handler: async (args, ctx) => {
37
76
  log.info("command.squiggle", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), args: args.trim() });
38
77
  if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_HELP);
39
78
 
40
79
  const command = args.trim().toLowerCase();
80
+ if (command === "config") {
81
+ const model = await chooseModel(ctx);
82
+ if (!model) return;
83
+ try {
84
+ saveModel(join(ctx.cwd, CONFIG_DIR_NAME, "squiggle.json"), model);
85
+ } catch {
86
+ ctx.ui.notify("Could not save Squiggle configuration. Check the config file and its permissions.", "error");
87
+ return;
88
+ }
89
+ const effective = effectiveConfig(ctx.cwd);
90
+ ctx.ui.notify(`Saved ${model}. ${formatStatus(ctx, effective)}`,
91
+ effective.model === model ? "info" : "warning");
92
+ return;
93
+ }
41
94
  if (command !== "toggle") {
42
- ctx.ui.notify("Usage: /squiggle toggle", "warning");
95
+ ctx.ui.notify("Usage: /squiggle toggle | config", "warning");
43
96
  return;
44
97
  }
45
98
 
46
99
  const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
47
100
  runtimeMode = config.mode === "on" ? "off" : "on";
101
+ if (runtimeMode === "off") {
102
+ correctionScope.abort();
103
+ correctionScope = new AbortController();
104
+ }
48
105
  persistRuntimeMode(pi, runtimeMode);
49
- ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
106
+ ctx.ui.notify(formatStatus(ctx, effectiveConfig(ctx.cwd)), "info");
50
107
  },
51
108
  });
52
109
 
@@ -55,19 +112,22 @@ export function registerSquiggle(
55
112
  handler: async (args, ctx) => {
56
113
  log.info("command.squiggle-status", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.() });
57
114
  if (isHelpRequest(args)) return emitHelp(ctx, SQUIGGLE_STATUS_HELP);
58
- ctx.ui.notify(formatStatus(ctx, loadEffectiveConfig(ctx.cwd, runtimeMode)), "info");
115
+ ctx.ui.notify(formatStatus(ctx, effectiveConfig(ctx.cwd)), "info");
59
116
  },
60
117
  });
61
118
 
62
119
  pi.on("input", async (event, ctx) => {
63
120
  if (event.source === "extension") return { action: "continue" };
64
121
 
65
- const config = loadEffectiveConfig(ctx.cwd, runtimeMode);
122
+ const config = effectiveConfig(ctx.cwd);
66
123
  if (config.mode === "off") return { action: "continue" };
67
124
  if (!event.text.trim()) return { action: "continue" };
68
125
 
69
126
  const stopIndicator = startSquiggleIndicator(ctx);
70
- const corrected = await correctPrompt(event.text, ctx, config, log).finally(stopIndicator);
127
+ const cancellationSignal = ctx.signal
128
+ ? AbortSignal.any([ctx.signal, correctionScope.signal])
129
+ : correctionScope.signal;
130
+ const corrected = await correctPrompt(event.text, ctx, config, log, cancellationSignal).finally(stopIndicator);
71
131
  if (!corrected || corrected === event.text) return { action: "continue" };
72
132
 
73
133
  if (ctx.hasUI) ctx.ui.notify(formatColoredDiff(event.text, corrected), "info");
@@ -102,22 +162,67 @@ Task:
102
162
  const DEFAULT_CORRECTION_MODEL = "openai-codex/gpt-5.4-mini";
103
163
  const DEFAULT_MAX_LLM_INPUT_CHARS = 500;
104
164
 
105
- type SquiggleConfig = {
106
- mode: "on" | "off";
107
- model: string;
108
- maxInputChars: number;
109
- };
165
+ type CorrectionInterruption = "cancelled" | "timeout";
110
166
 
111
- async function correctWithModel(input: string, ctx: ExtensionContext, config: SquiggleConfig, log = createPithosLogger()): Promise<string | null> {
167
+ class CorrectionInterruptedError extends Error {
168
+ readonly kind: CorrectionInterruption;
169
+
170
+ constructor(kind: CorrectionInterruption, timeoutMs?: number) {
171
+ super(kind === "timeout" ? `Correction timed out after ${formatDuration(timeoutMs!)}.` : "Correction cancelled.");
172
+ this.kind = kind;
173
+ }
174
+ }
175
+
176
+ export async function correctWithModel(
177
+ input: string,
178
+ ctx: ExtensionContext,
179
+ config: SquiggleConfig,
180
+ log = createPithosLogger(),
181
+ signal?: AbortSignal,
182
+ completePrompt: typeof complete = complete,
183
+ ): Promise<string | null> {
112
184
  const model = selectCorrectionModel(ctx, config);
113
185
  if (!model) return null;
114
186
  if (input.length > config.maxInputChars) return null;
115
187
 
188
+ const started = Date.now();
189
+ const sessionId = (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.();
190
+ log.info("model.correct.start", { sessionId, inputChars: input.length, timeoutMs: config.timeoutMs, ...modelMetadata(model) });
191
+
192
+ if (signal?.aborted) {
193
+ log.warn("model.correct.cancelled", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model) });
194
+ return null;
195
+ }
196
+
197
+ const requestController = new AbortController();
198
+ let interruptionKind: CorrectionInterruption | undefined;
199
+ let timer: ReturnType<typeof setTimeout> | undefined;
200
+ let removeAbort: (() => void) | undefined;
201
+ const interruption = new Promise<never>((_resolve, reject) => {
202
+ timer = setTimeout(() => {
203
+ interruptionKind = "timeout";
204
+ requestController.abort();
205
+ reject(new CorrectionInterruptedError("timeout", config.timeoutMs));
206
+ }, config.timeoutMs);
207
+ const onAbort = () => {
208
+ if (interruptionKind) return;
209
+ interruptionKind = "cancelled";
210
+ requestController.abort();
211
+ reject(new CorrectionInterruptedError("cancelled"));
212
+ };
213
+ signal?.addEventListener("abort", onAbort, { once: true });
214
+ removeAbort = () => signal?.removeEventListener("abort", onAbort);
215
+ });
216
+
116
217
  try {
117
- const started = Date.now();
118
- log.info("model.correct.start", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), inputChars: input.length, ...modelMetadata(model) });
119
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
120
- if (!auth.ok || !auth.apiKey) return null;
218
+ const auth = await Promise.race([
219
+ ctx.modelRegistry.getApiKeyAndHeaders(model),
220
+ interruption,
221
+ ]);
222
+ if (!auth.ok || !auth.apiKey) {
223
+ log.warn("model.correct.unavailable", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model) });
224
+ return null;
225
+ }
121
226
 
122
227
  const userMessage: UserMessage = {
123
228
  role: "user",
@@ -125,14 +230,24 @@ async function correctWithModel(input: string, ctx: ExtensionContext, config: Sq
125
230
  timestamp: Date.now(),
126
231
  };
127
232
 
128
- const response = await complete(
129
- model,
130
- { systemPrompt: CORRECTION_PROMPT, messages: [userMessage] },
131
- { apiKey: auth.apiKey, headers: auth.headers },
132
- );
233
+ const response = await Promise.race([
234
+ completePrompt(
235
+ model,
236
+ { systemPrompt: CORRECTION_PROMPT, messages: [userMessage] },
237
+ { apiKey: auth.apiKey, headers: auth.headers, signal: requestController.signal },
238
+ ),
239
+ interruption,
240
+ ]);
133
241
 
134
242
  if (response.stopReason === "aborted") {
135
- log.warn("model.correct.aborted", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), durationMs: Date.now() - started, ...modelMetadata(model), usage: usageMetadata(response.usage) });
243
+ const metadata = { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model), usage: usageMetadata(response.usage) };
244
+ if (interruptionKind === "timeout") {
245
+ log.warn("model.correct.timeout", { ...metadata, timeoutMs: config.timeoutMs });
246
+ } else if (interruptionKind === "cancelled" || signal?.aborted) {
247
+ log.warn("model.correct.cancelled", metadata);
248
+ } else {
249
+ log.warn("model.correct.aborted", metadata);
250
+ }
136
251
  return null;
137
252
  }
138
253
 
@@ -141,20 +256,35 @@ async function correctWithModel(input: string, ctx: ExtensionContext, config: Sq
141
256
  .map((c) => c.text)
142
257
  .join("\n")
143
258
  .trim();
144
- log.info("model.correct.complete", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), durationMs: Date.now() - started, inputChars: input.length, changed: corrected !== input, ...modelMetadata(model), usage: usageMetadata(response.usage) });
259
+ log.info("model.correct.complete", { sessionId, durationMs: Date.now() - started, inputChars: input.length, changed: corrected !== input, ...modelMetadata(model), usage: usageMetadata(response.usage) });
145
260
  return corrected;
146
261
  } catch (error) {
147
- log.warn("model.correct.error", { sessionId: (ctx.sessionManager as { getSessionId?: () => string } | undefined)?.getSessionId?.(), inputChars: input.length, ...modelMetadata(model), error: errorMetadata(error) });
262
+ if (interruptionKind === "timeout" || (error instanceof CorrectionInterruptedError && error.kind === "timeout")) {
263
+ log.warn("model.correct.timeout", { sessionId, durationMs: Date.now() - started, inputChars: input.length, timeoutMs: config.timeoutMs, ...modelMetadata(model) });
264
+ } else if (interruptionKind === "cancelled" || signal?.aborted || error instanceof CorrectionInterruptedError || (error instanceof Error && error.name === "AbortError")) {
265
+ log.warn("model.correct.cancelled", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model) });
266
+ } else {
267
+ log.warn("model.correct.error", { sessionId, durationMs: Date.now() - started, inputChars: input.length, ...modelMetadata(model), error: errorMetadata(error) });
268
+ }
148
269
  return null;
270
+ } finally {
271
+ if (timer) clearTimeout(timer);
272
+ removeAbort?.();
149
273
  }
150
274
  }
151
275
 
276
+ function formatDuration(timeoutMs: number): string {
277
+ return timeoutMs % 1_000 === 0 ? `${timeoutMs / 1_000}s` : `${timeoutMs}ms`;
278
+ }
279
+
152
280
  function loadConfig(cwd: string): SquiggleConfig {
153
281
  const fileConfig = readConfigFile(cwd);
154
282
  return {
155
283
  mode: normalizeMode(process.env.SQUIGGLE_MODE ?? fileConfig.mode) ?? "on",
156
284
  model: process.env.SQUIGGLE_MODEL ?? fileConfig.model ?? DEFAULT_CORRECTION_MODEL,
285
+ modelScope: process.env.SQUIGGLE_MODEL !== undefined ? "SQUIGGLE_MODEL" : fileConfig.model !== undefined ? "project" : "default",
157
286
  maxInputChars: normalizePositiveInt(process.env.SQUIGGLE_MAX_CHARS ?? fileConfig.maxInputChars) ?? DEFAULT_MAX_LLM_INPUT_CHARS,
287
+ timeoutMs: normalizeTimeoutMs(process.env.SQUIGGLE_TIMEOUT_MS ?? fileConfig.timeoutMs) ?? DEFAULT_CORRECTION_TIMEOUT_MS,
158
288
  };
159
289
  }
160
290
 
@@ -172,16 +302,52 @@ function restoreRuntimeMode(ctx: ExtensionContext): SquiggleConfig["mode"] | und
172
302
  return undefined;
173
303
  }
174
304
 
305
+ function saveModel(path: string, model: string): void {
306
+ const current = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
307
+ if (!current || typeof current !== "object" || Array.isArray(current)) throw new Error("Invalid config");
308
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
309
+ mkdirSync(dirname(path), { recursive: true });
310
+ try {
311
+ writeFileSync(temporaryPath, `${JSON.stringify({ ...current, model }, null, 2)}\n`, { mode: 0o600 });
312
+ renameSync(temporaryPath, path);
313
+ } finally {
314
+ rmSync(temporaryPath, { force: true });
315
+ }
316
+ }
317
+
318
+ async function chooseModel(ctx: ExtensionCommandContext): Promise<string | undefined> {
319
+ if (!ctx.hasUI) {
320
+ emitHelp(ctx, "Squiggle configuration requires an interactive UI.");
321
+ return;
322
+ }
323
+ const models = ctx.modelRegistry.getAvailable()
324
+ .filter((model) => ctx.modelRegistry.hasConfiguredAuth(model))
325
+ .sort((a, b) => `${a.provider}/${a.id}`.localeCompare(`${b.provider}/${b.id}`));
326
+ if (!models.length) {
327
+ ctx.ui.notify("No authenticated correction models are available. Configure a provider with /login first.", "error");
328
+ return;
329
+ }
330
+ const choices = models.map((model) => `${model.provider}/${model.id} — ${model.name}`);
331
+ const selected = await ctx.ui.select("Exact correction model", choices);
332
+ const model = models[choices.indexOf(selected ?? "")];
333
+ return model ? `${model.provider}/${model.id}` : undefined;
334
+ }
335
+
175
336
  function persistRuntimeMode(pi: ExtensionAPI, mode: SquiggleConfig["mode"]): void {
176
337
  pi.appendEntry("squiggle-mode", { mode });
177
338
  }
178
339
 
179
340
  function formatStatus(ctx: ExtensionContext, config: SquiggleConfig): string {
180
- return `squiggle is ${config.mode} (${formatModel(selectCorrectionModel(ctx, config))}).`;
341
+ const unavailable = selectCorrectionModel(ctx, config) ? "" : "; unavailable — run /squiggle config";
342
+ const source = config.modelScope === "SQUIGGLE_MODEL" ? "; SQUIGGLE_MODEL" : "";
343
+ return `squiggle is ${config.mode} (${config.model}${source}${unavailable}).`;
181
344
  }
182
345
 
183
346
  function readConfigFile(cwd: string): Partial<SquiggleConfig> {
184
- const path = join(cwd, ".pi", "squiggle.json");
347
+ return readConfigPath(join(cwd, CONFIG_DIR_NAME, "squiggle.json"));
348
+ }
349
+
350
+ function readConfigPath(path: string): Partial<SquiggleConfig> {
185
351
  if (!existsSync(path)) return {};
186
352
  try {
187
353
  const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
@@ -189,6 +355,7 @@ function readConfigFile(cwd: string): Partial<SquiggleConfig> {
189
355
  mode: typeof parsed.mode === "string" ? normalizeMode(parsed.mode) : undefined,
190
356
  model: typeof parsed.model === "string" ? parsed.model : undefined,
191
357
  maxInputChars: normalizePositiveInt(parsed.maxInputChars),
358
+ timeoutMs: normalizeTimeoutMs(parsed.timeoutMs),
192
359
  };
193
360
  } catch {
194
361
  return {};
@@ -204,13 +371,20 @@ function normalizePositiveInt(value: unknown): number | undefined {
204
371
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
205
372
  }
206
373
 
374
+ export function normalizeTimeoutMs(value: unknown): number | undefined {
375
+ const parsed = normalizePositiveInt(value);
376
+ return parsed !== undefined && parsed >= MIN_CORRECTION_TIMEOUT_MS && parsed <= MAX_CORRECTION_TIMEOUT_MS
377
+ ? parsed
378
+ : undefined;
379
+ }
380
+
207
381
  function selectCorrectionModel(ctx: ExtensionContext, config: SquiggleConfig) {
208
382
  const configured = parseModelSpec(config.model);
209
383
  if (configured) {
210
384
  const model = ctx.modelRegistry.find(configured.provider, configured.model);
211
385
  if (model) return model;
212
386
  }
213
- return ctx.model;
387
+ return undefined;
214
388
  }
215
389
 
216
390
  function parseModelSpec(spec: string): { provider: string; model: string } | null {
@@ -219,10 +393,6 @@ function parseModelSpec(spec: string): { provider: string; model: string } | nul
219
393
  return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
220
394
  }
221
395
 
222
- function formatModel(model: ReturnType<typeof selectCorrectionModel>): string {
223
- return model ? `${model.provider}/${model.id}` : "no model";
224
- }
225
-
226
396
  function startSquiggleIndicator(ctx: ExtensionContext): () => void {
227
397
  if (!ctx.hasUI) return () => {};
228
398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithos-kit/squiggle",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Quietly polish grammar and spelling in your Pi prompts.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -31,7 +31,7 @@
31
31
  "summary": "Quietly polish grammar and spelling in user prompts.",
32
32
  "minimumPi": ">=0.83.0",
33
33
  "commands": [
34
- { "name": "squiggle", "usage": "/squiggle [toggle|--help]", "summary": "Toggle prompt correction." },
34
+ { "name": "squiggle", "usage": "/squiggle [toggle|config|--help]", "summary": "Toggle prompt correction or configure its model." },
35
35
  { "name": "squiggle-status", "usage": "/squiggle-status [--help]", "summary": "Show correction status and configuration." }
36
36
  ],
37
37
  "tools": [],
@@ -40,10 +40,11 @@
40
40
  "themes": [],
41
41
  "agents": [],
42
42
  "configuration": [
43
- { "kind": "file", "key": ".pi/squiggle.json", "summary": "Project correction mode, model, and input limit." },
43
+ { "kind": "file", "key": ".pi/squiggle.json", "summary": "Project correction mode, model, input limit, and timeout." },
44
44
  { "kind": "environment", "key": "SQUIGGLE_MODE", "summary": "Override correction mode." },
45
45
  { "kind": "environment", "key": "SQUIGGLE_MODEL", "summary": "Override the correction model." },
46
- { "kind": "environment", "key": "SQUIGGLE_MAX_CHARS", "summary": "Override the maximum corrected input length." }
46
+ { "kind": "environment", "key": "SQUIGGLE_MAX_CHARS", "summary": "Override the maximum corrected input length." },
47
+ { "kind": "environment", "key": "SQUIGGLE_TIMEOUT_MS", "summary": "Override the bounded correction timeout." }
47
48
  ]
48
49
  },
49
50
  "pi": {