pi-ui-extend 1.0.14 → 1.0.16

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.
@@ -12,6 +12,7 @@ export declare const PiToolsSuiteConfigSchema: Type.TObject<{
12
12
  enabledModules: Type.TOptional<Type.TArray<Type.TString>>;
13
13
  modules: Type.TOptional<Type.TRecord<"^.*$", Type.TBoolean>>;
14
14
  todoThinking: Type.TOptional<Type.TBoolean>;
15
+ todoThinkingOverrides: Type.TOptional<Type.TRecord<"^.*$", Type.TUnion<[Type.TLiteral<"off">, Type.TLiteral<"minimal">, Type.TLiteral<"low">, Type.TLiteral<"medium">, Type.TLiteral<"high">, Type.TLiteral<"xhigh">, Type.TLiteral<"max">, Type.TNull]>>>;
15
16
  lookupModel: Type.TOptional<Type.TUnion<[Type.TString, Type.TNull]>>;
16
17
  terminalBell: Type.TOptional<Type.TObject<{
17
18
  sound: Type.TOptional<Type.TBoolean>;
@@ -221,6 +221,16 @@ export const PiToolsSuiteConfigSchema = Type.Object({
221
221
  enabledModules: Type.Optional(Type.Array(Type.String(), { description: "List of module names to explicitly enable, including modules that are disabled by default." })),
222
222
  modules: Type.Optional(Type.Record(Type.String(), Type.Boolean(), { description: "Per-module enable/disable map. credential-firewall is disabled by default and can be enabled here." })),
223
223
  todoThinking: Type.Optional(Type.Boolean({ description: "Enable per-todo thinking levels and automatic thinking switch/restore when tasks become in-progress/completed." })),
224
+ todoThinkingOverrides: Type.Optional(Type.Record(Type.String(), Type.Union([
225
+ Type.Literal("off"),
226
+ Type.Literal("minimal"),
227
+ Type.Literal("low"),
228
+ Type.Literal("medium"),
229
+ Type.Literal("high"),
230
+ Type.Literal("xhigh"),
231
+ Type.Literal("max"),
232
+ Type.Null(),
233
+ ]), { description: "Force per-todo thinking for matching provider/model or bare-model keys. Keys support * and ? wildcards; null removes an inherited override." })),
224
234
  lookupModel: Type.Optional(Type.Union([Type.String(), Type.Null()], { description: "Vision-capable provider/model used by GLM's lookup tool; unset or null disables lookup." })),
225
235
  terminalBell: Type.Optional(TerminalBellConfig),
226
236
  dcp: Type.Optional(DcpConfig),
@@ -62,6 +62,18 @@ Saved prompt slash commands are stored under `promptCommands`. Use `/prompt-comm
62
62
  }
63
63
  ```
64
64
 
65
+ Todo thinking can be enabled globally and forced to a fixed level for selected models. `todoThinkingOverrides` keys accept exact `provider/model` or bare-model names plus `*` and `?` wildcards. Full provider/model matches beat bare-model matches, exact matches beat wildcards, and the more specific wildcard wins. The override is applied at runtime to create/update and batch create/update mutations even when the model requests another level or omits `thinking`. Unsupported levels are normalized to the nearest level supported by the current model. Later config layers can remove an inherited entry with `null`.
66
+
67
+ ```jsonc
68
+ {
69
+ "todoThinking": true,
70
+ "todoThinkingOverrides": {
71
+ "zai/glm-5.3": "max",
72
+ "cheap-provider/*": "high"
73
+ }
74
+ }
75
+ ```
76
+
65
77
  DCP settings are stored only under `dcp` in the user shared config file `~/.config/pi/pi-tools-suite.jsonc`. Legacy standalone `dcp.jsonc`, `$PI_CONFIG_DIR`, and project-local `.pi/pi-tools-suite.jsonc` DCP settings are intentionally ignored by the ported headless DCP module.
66
78
 
67
79
  ```jsonc
@@ -1,11 +1,13 @@
1
1
  /**
2
- * WORKAROUND for @earendil-works/pi-ai bug: the Codex / OpenAI Responses API
3
- * rejects HTTP 400 `Unknown parameter: 'input[N].content'` when non-message
4
- * items (reasoning, function_call_output) carry a spurious `content` field.
2
+ * WORKAROUNDS for Codex / OpenAI Responses payload compatibility:
3
+ * - non-message items (reasoning, function_call_output) must not carry the
4
+ * spurious `content` field rejected with HTTP 400;
5
+ * - the Codex backend rejects the legacy `prompt_cache_retention` field for
6
+ * all current models, while direct OpenAI GPT-5.6+ uses the newer prompt
7
+ * cache options shape.
5
8
  *
6
- * Stray fields can come from replayed pi-ai items or from another extension
7
- * that modifies the provider payload. The sanitizer therefore must be the
8
- * LAST `before_provider_request` handler registered by pi-tools-suite.
9
+ * The sanitizer must be the LAST `before_provider_request` handler registered
10
+ * by pi-tools-suite so another payload hook cannot undo these guards.
9
11
  *
10
12
  * In pi-ai >= 0.80.6 the final `before_provider_request` payload feeds both the
11
13
  * WebSocket delta builder and the zstd-compressed SSE fallback body. Running the
@@ -27,6 +29,9 @@ type ProviderRequestContext = {
27
29
  model?: unknown;
28
30
  };
29
31
 
32
+ const OPENAI_CODEX_PROVIDER = "openai-codex";
33
+ const OPENAI_PROVIDER = "openai";
34
+
30
35
  /**
31
36
  * Strip spurious `content` from any object that carries an `input` or
32
37
  * `messages` array. Shared core for payload sanitization.
@@ -75,12 +80,21 @@ export default function codexReasoningFix(pi: ExtensionAPI): void {
75
80
  // src/index.ts deliberately registers this module last. A later payload
76
81
  // modifier could otherwise reintroduce invalid content after sanitization,
77
82
  // and transport encoding happens after this hook.
78
- pi.on("before_provider_request", async (event: ProviderRequestEvent, _ctx: ProviderRequestContext) => {
79
- const result = stripReasoningContentFromPayload(event.payload);
83
+ pi.on("before_provider_request", async (event: ProviderRequestEvent, ctx: ProviderRequestContext) => {
84
+ const result = sanitizeCodexProviderPayload(event.payload, ctx.model);
80
85
  return result === event.payload ? undefined : result;
81
86
  });
82
87
  }
83
88
 
89
+ /**
90
+ * Apply all final Codex payload compatibility guards. Returns the original
91
+ * reference when no guard changes the payload.
92
+ */
93
+ export function sanitizeCodexProviderPayload(payload: unknown, model: unknown): unknown {
94
+ const contentSanitized = stripReasoningContentFromPayload(payload);
95
+ return stripUnsupportedPromptCacheRetention(contentSanitized, model);
96
+ }
97
+
84
98
  /**
85
99
  * Strip spurious `content` from non-message items in a full payload. Returns the same reference
86
100
  * when nothing changed; exported for unit testing.
@@ -90,6 +104,72 @@ export function stripReasoningContentFromPayload(payload: unknown): unknown {
90
104
  return result ? result.obj : payload;
91
105
  }
92
106
 
107
+ /**
108
+ * Remove legacy prompt-cache retention only where it is known to be rejected:
109
+ * every current Codex model, and direct OpenAI GPT-5.6 or newer. Match the
110
+ * selected model rather than a bare payload id so other providers keep the
111
+ * field untouched.
112
+ */
113
+ export function stripUnsupportedPromptCacheRetention(payload: unknown, model: unknown): unknown {
114
+ if (!isRecord(payload) || !Object.prototype.hasOwnProperty.call(payload, "prompt_cache_retention")) {
115
+ return payload;
116
+ }
117
+ if (!rejectsLegacyPromptCacheRetention(model, payload.model)) return payload;
118
+
119
+ const { prompt_cache_retention: _drop, ...rest } = payload;
120
+ return rest;
121
+ }
122
+
123
+ function rejectsLegacyPromptCacheRetention(model: unknown, payloadModel: unknown): boolean {
124
+ const selected = modelIdentity(model);
125
+ if (selected.provider !== undefined) {
126
+ return isAffectedProviderModel(selected.provider, selected.id);
127
+ }
128
+ if (selected.id?.includes("/")) {
129
+ return isAffectedQualifiedModel(selected.id);
130
+ }
131
+
132
+ return typeof payloadModel === "string" && isAffectedQualifiedModel(payloadModel.trim().toLowerCase());
133
+ }
134
+
135
+ function isAffectedQualifiedModel(modelRef: string): boolean {
136
+ const slash = modelRef.indexOf("/");
137
+ if (slash <= 0 || slash === modelRef.length - 1) return false;
138
+ return isAffectedProviderModel(modelRef.slice(0, slash), modelRef.slice(slash + 1));
139
+ }
140
+
141
+ function isAffectedProviderModel(provider: string, modelId: string | undefined): boolean {
142
+ if (provider === OPENAI_CODEX_PROVIDER) return true;
143
+ return provider === OPENAI_PROVIDER && modelId !== undefined && isGpt56OrNewer(modelId);
144
+ }
145
+
146
+ function isGpt56OrNewer(modelId: string): boolean {
147
+ const match = /^gpt-(\d+)(?:\.(\d+))?(?:-|$)/u.exec(modelId);
148
+ if (!match) return false;
149
+ const major = Number(match[1]);
150
+ const minor = Number(match[2] ?? 0);
151
+ return major > 5 || (major === 5 && minor >= 6);
152
+ }
153
+
154
+ function modelIdentity(model: unknown): { provider?: string; id?: string } {
155
+ if (typeof model === "string") return { id: model.trim().toLowerCase() };
156
+ if (!isRecord(model)) return {};
157
+
158
+ const provider = firstString(model.provider, model.providerId, model.providerID);
159
+ const id = firstString(model.id, model.modelId, model.modelID, model.model);
160
+ return {
161
+ provider: provider?.toLowerCase(),
162
+ id: id?.toLowerCase(),
163
+ };
164
+ }
165
+
166
+ function firstString(...values: unknown[]): string | undefined {
167
+ for (const value of values) {
168
+ if (typeof value === "string" && value.trim()) return value.trim();
169
+ }
170
+ return undefined;
171
+ }
172
+
93
173
  function isRecord(value: unknown): value is Record<string, unknown> {
94
174
  return value !== null && typeof value === "object" && !Array.isArray(value);
95
175
  }
@@ -9,6 +9,7 @@ export interface PiToolsSuiteConfig {
9
9
  enabled: boolean;
10
10
  disabledModules: string[];
11
11
  todoThinking: boolean;
12
+ todoThinkingOverrides: Record<string, TodoThinkingLevel>;
12
13
  /** Vision-capable model used by the coding-discipline lookup tool; unset disables lookup. */
13
14
  lookupModel?: string;
14
15
  /**
@@ -25,6 +26,7 @@ type MutableConfig = {
25
26
  enabled: boolean;
26
27
  disabledModules: Set<string>;
27
28
  todoThinking: boolean;
29
+ todoThinkingOverrides: Map<string, TodoThinkingLevel>;
28
30
  lookupModel: string | undefined;
29
31
  codingDisciplineStrictness: CodingDisciplineStrictness;
30
32
  };
@@ -32,6 +34,8 @@ type MutableConfig = {
32
34
  export const CODING_DISCIPLINE_STRICTNESS_VALUES = ["strict", "lenient"] as const;
33
35
  export type CodingDisciplineStrictness = (typeof CODING_DISCIPLINE_STRICTNESS_VALUES)[number];
34
36
  export const DEFAULT_CODING_DISCIPLINE_STRICTNESS: CodingDisciplineStrictness = "lenient";
37
+ const TODO_THINKING_OVERRIDE_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
38
+ export type TodoThinkingLevel = (typeof TODO_THINKING_OVERRIDE_LEVELS)[number];
35
39
 
36
40
  type Env = Record<string, string | undefined>;
37
41
 
@@ -42,6 +46,7 @@ const DISABLED_LIST_KEYS = ["disabledModules", "disabledExtensions"];
42
46
  const ENABLED_LIST_KEYS = ["enabledModules", "enabledExtensions"];
43
47
  const MODULE_MAP_KEYS = ["modules", "extensions"];
44
48
  const DEFAULT_DISABLED_MODULES = new Set<string>(["credential-firewall"]);
49
+ const DEFAULT_TODO_THINKING_OVERRIDES = new Map<string, TodoThinkingLevel>([["zai/glm-5.3", "max"]]);
45
50
 
46
51
  export function getPiToolsSuiteUserConfigPath(homeDir = homedir()): string {
47
52
  return join(homeDir, ".config", "pi", "pi-tools-suite.jsonc");
@@ -79,6 +84,23 @@ function normalizeCodingDisciplineStrictness(raw: unknown): CodingDisciplineStri
79
84
  return raw === "strict" ? "strict" : "lenient";
80
85
  }
81
86
 
87
+ function isTodoThinkingLevel(raw: unknown): raw is TodoThinkingLevel {
88
+ return TODO_THINKING_OVERRIDE_LEVELS.includes(raw as TodoThinkingLevel);
89
+ }
90
+
91
+ function mergeTodoThinkingOverrides(config: MutableConfig, raw: unknown): void {
92
+ if (!isRecord(raw)) return;
93
+ for (const [rawPattern, value] of Object.entries(raw)) {
94
+ const pattern = rawPattern.trim().toLowerCase();
95
+ if (!pattern) continue;
96
+ if (value === null) {
97
+ config.todoThinkingOverrides.delete(pattern);
98
+ continue;
99
+ }
100
+ if (isTodoThinkingLevel(value)) config.todoThinkingOverrides.set(pattern, value);
101
+ }
102
+ }
103
+
82
104
  function boolFromEnv(value: string | undefined): boolean | undefined {
83
105
  if (value === undefined) return undefined;
84
106
  const normalized = value.trim().toLowerCase();
@@ -137,6 +159,7 @@ function removeDisabled(config: MutableConfig, value: unknown, knownModules: Rea
137
159
  function mergeConfigLayer(config: MutableConfig, raw: Record<string, unknown>, knownModules: ReadonlySet<string>): MutableConfig {
138
160
  if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
139
161
  if (typeof raw.todoThinking === "boolean") config.todoThinking = raw.todoThinking;
162
+ mergeTodoThinkingOverrides(config, raw.todoThinkingOverrides);
140
163
  if (Object.prototype.hasOwnProperty.call(raw, "lookupModel")) config.lookupModel = normalizeLookupModel(raw.lookupModel);
141
164
  if (Object.prototype.hasOwnProperty.call(raw, "codingDisciplineStrictness")) {
142
165
  config.codingDisciplineStrictness = normalizeCodingDisciplineStrictness(raw.codingDisciplineStrictness);
@@ -197,6 +220,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
197
220
  enabled: true,
198
221
  disabledModules: new Set([...DEFAULT_DISABLED_MODULES].filter((name) => knownModules.has(name))),
199
222
  todoThinking: false,
223
+ todoThinkingOverrides: new Map(DEFAULT_TODO_THINKING_OVERRIDES),
200
224
  lookupModel: undefined,
201
225
  codingDisciplineStrictness: DEFAULT_CODING_DISCIPLINE_STRICTNESS,
202
226
  };
@@ -217,6 +241,7 @@ export function loadPiToolsSuiteConfig(moduleNames: readonly string[], options:
217
241
  enabled: config.enabled,
218
242
  disabledModules: [...config.disabledModules].sort(),
219
243
  todoThinking: config.todoThinking,
244
+ todoThinkingOverrides: Object.fromEntries(config.todoThinkingOverrides),
220
245
  ...(config.lookupModel ? { lookupModel: config.lookupModel } : {}),
221
246
  codingDisciplineStrictness: config.codingDisciplineStrictness,
222
247
  };
@@ -15,6 +15,12 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
15
15
  // When true, todo items may carry a per-task thinking level and the todo
16
16
  // module will switch/restore Pi's thinking level as in-progress tasks change.
17
17
  "todoThinking": true,
18
+ // Force every todo mutation made under matching models to the configured
19
+ // thinking level. Supports provider/model or bare-model keys with * / ? globs.
20
+ // Set an inherited key to null in a later config layer to remove it.
21
+ "todoThinkingOverrides": {
22
+ "zai/glm-5.3": "max"
23
+ },
18
24
  // Vision-capable model used by the coding-discipline lookup tool for blind-model
19
25
  // screenshot/image questions. Remove or set to null to disable lookup.
20
26
  "lookupModel": "openai-codex/gpt-5.4-mini",
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { loadPiToolsSuiteConfig } from "../config.js";
2
+ import { loadPiToolsSuiteConfig, type TodoThinkingLevel as ConfigTodoThinkingLevel } from "../config.js";
3
3
  import { isAgentBusyRaceError } from "../context-usage.js";
4
4
  import { autoClearCompletedTodos } from "./state/auto-clear.js";
5
5
  import { loadPersistedPlan, syncPersistedPlan } from "./state/persistence.js";
@@ -31,6 +31,7 @@ function isStaleExtensionContextError(error: unknown): boolean {
31
31
 
32
32
  type ModelLike = {
33
33
  provider?: string;
34
+ providerId?: string;
34
35
  id?: string;
35
36
  modelId?: string;
36
37
  reasoning?: boolean;
@@ -38,6 +39,51 @@ type ModelLike = {
38
39
  compat?: { thinkingFormat?: unknown };
39
40
  };
40
41
 
42
+ function escapeRegExp(text: string): string {
43
+ return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
44
+ }
45
+
46
+ function modelPatternMatches(pattern: string, candidate: string): boolean {
47
+ let source = "^";
48
+ for (const char of pattern) {
49
+ if (char === "*") source += ".*";
50
+ else if (char === "?") source += ".";
51
+ else source += escapeRegExp(char);
52
+ }
53
+ return new RegExp(`${source}$`, "i").test(candidate);
54
+ }
55
+
56
+ function modelKeys(model: unknown): { bare?: string; full?: string } {
57
+ const candidate = model as ModelLike | undefined;
58
+ const provider = candidate?.provider ?? candidate?.providerId;
59
+ const rawId = candidate?.modelId ?? candidate?.id;
60
+ if (!rawId) return {};
61
+ if (rawId.includes("/")) {
62
+ const slash = rawId.indexOf("/");
63
+ return { bare: rawId.slice(slash + 1), full: rawId };
64
+ }
65
+ return { bare: rawId, ...(provider ? { full: `${provider}/${rawId}` } : {}) };
66
+ }
67
+
68
+ function resolveTodoThinkingOverride(
69
+ model: unknown,
70
+ overrides: Record<string, ConfigTodoThinkingLevel>,
71
+ ): TodoThinkingLevel | undefined {
72
+ const keys = modelKeys(model);
73
+ let best: { level: TodoThinkingLevel; score: number } | undefined;
74
+ for (const [rawPattern, level] of Object.entries(overrides)) {
75
+ const pattern = rawPattern.trim();
76
+ const isFull = pattern.includes("/");
77
+ const candidate = isFull ? keys.full : keys.bare;
78
+ if (!candidate || !modelPatternMatches(pattern, candidate)) continue;
79
+ const exact = !pattern.includes("*") && !pattern.includes("?");
80
+ const literalLength = pattern.replace(/[?*]/g, "").length;
81
+ const score = (isFull ? 30_000 : 10_000) + (exact ? 10_000 : 0) + literalLength;
82
+ if (!best || score >= best.score) best = { level, score };
83
+ }
84
+ return best?.level;
85
+ }
86
+
41
87
  function isTodoThinkingLevel(value: unknown): value is TodoThinkingLevel {
42
88
  return TODO_THINKING_LEVEL_VALUES.includes(value as TodoThinkingLevel);
43
89
  }
@@ -167,7 +213,9 @@ function emitPersistedPlanPrompt(pi: ExtensionAPI, ctx: ExtensionContext, prompt
167
213
 
168
214
  export default function (pi: ExtensionAPI) {
169
215
  let currentModel: unknown;
170
- const todoThinkingEnabled = loadPiToolsSuiteConfig(["todo"]).todoThinking;
216
+ const todoConfig = loadPiToolsSuiteConfig(["todo"]);
217
+ const todoThinkingEnabled = todoConfig.todoThinking;
218
+ const todoThinkingOverrides = todoConfig.todoThinkingOverrides;
171
219
  const rememberedThinkingByTaskId = new Map<number, TodoThinkingLevel>();
172
220
  let lastNudgedSignature: string | undefined;
173
221
  let nudgeTimer: ReturnType<typeof setTimeout> | undefined;
@@ -237,7 +285,11 @@ export default function (pi: ExtensionAPI) {
237
285
 
238
286
  function prepareTodoThinkingMutation(state: ReturnType<typeof getState>, params: TaskMutationParams): TaskMutationParams {
239
287
  let nextParams = params;
240
- if (params.thinking !== undefined) {
288
+ const configuredOverride = resolveTodoThinkingOverride(currentModel, todoThinkingOverrides);
289
+ if (configuredOverride !== undefined) {
290
+ const forced = normalizeTodoThinkingLevelForModel(currentModel, configuredOverride);
291
+ nextParams = { ...nextParams, thinking: forced };
292
+ } else if (params.thinking !== undefined) {
241
293
  const normalized = normalizeTodoThinkingLevelForModel(currentModel, params.thinking);
242
294
  if (normalized !== params.thinking) nextParams = { ...nextParams, thinking: normalized };
243
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
5
5
  "private": false,
6
6
  "repository": {
@@ -36,6 +36,47 @@
36
36
  "type": "boolean",
37
37
  "description": "Enable per-todo thinking levels and automatic thinking switch/restore when tasks become in-progress/completed."
38
38
  },
39
+ "todoThinkingOverrides": {
40
+ "type": "object",
41
+ "patternProperties": {
42
+ "^.*$": {
43
+ "anyOf": [
44
+ {
45
+ "type": "string",
46
+ "const": "off"
47
+ },
48
+ {
49
+ "type": "string",
50
+ "const": "minimal"
51
+ },
52
+ {
53
+ "type": "string",
54
+ "const": "low"
55
+ },
56
+ {
57
+ "type": "string",
58
+ "const": "medium"
59
+ },
60
+ {
61
+ "type": "string",
62
+ "const": "high"
63
+ },
64
+ {
65
+ "type": "string",
66
+ "const": "xhigh"
67
+ },
68
+ {
69
+ "type": "string",
70
+ "const": "max"
71
+ },
72
+ {
73
+ "type": "null"
74
+ }
75
+ ]
76
+ }
77
+ },
78
+ "description": "Force per-todo thinking for matching provider/model or bare-model keys. Keys support * and ? wildcards; null removes an inherited override."
79
+ },
39
80
  "lookupModel": {
40
81
  "anyOf": [
41
82
  {