@oh-my-pi/pi-coding-agent 15.2.3 → 15.3.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/types/config/settings-schema.d.ts +34 -1
  3. package/dist/types/config/settings.d.ts +6 -0
  4. package/dist/types/discovery/helpers.d.ts +1 -0
  5. package/dist/types/goals/runtime.d.ts +4 -0
  6. package/dist/types/hashline/constants.d.ts +0 -2
  7. package/dist/types/hashline/hash.d.ts +13 -39
  8. package/dist/types/hashline/parser.d.ts +2 -6
  9. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  10. package/dist/types/modes/components/status-line.d.ts +10 -0
  11. package/dist/types/modes/interactive-mode.d.ts +3 -1
  12. package/dist/types/modes/shared.d.ts +9 -0
  13. package/dist/types/modes/theme/shimmer.d.ts +6 -3
  14. package/dist/types/modes/types.d.ts +3 -1
  15. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  16. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  17. package/dist/types/session/agent-session.d.ts +9 -0
  18. package/dist/types/task/executor.d.ts +3 -1
  19. package/dist/types/task/types.d.ts +35 -0
  20. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  21. package/dist/types/utils/clipboard.d.ts +3 -1
  22. package/dist/types/utils/image-resize.d.ts +4 -1
  23. package/package.json +7 -7
  24. package/src/config/prompt-templates.ts +1 -8
  25. package/src/config/settings-schema.ts +29 -1
  26. package/src/config/settings.ts +19 -0
  27. package/src/discovery/helpers.ts +5 -1
  28. package/src/edit/index.ts +1 -1
  29. package/src/edit/renderer.ts +5 -7
  30. package/src/edit/streaming.ts +24 -12
  31. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  32. package/src/goals/runtime.ts +35 -13
  33. package/src/hashline/constants.ts +0 -3
  34. package/src/hashline/diff.ts +1 -1
  35. package/src/hashline/execute.ts +2 -2
  36. package/src/hashline/grammar.lark +7 -8
  37. package/src/hashline/hash.ts +21 -43
  38. package/src/hashline/input.ts +15 -13
  39. package/src/hashline/parser.ts +62 -161
  40. package/src/internal-urls/docs-index.generated.ts +2 -2
  41. package/src/main.ts +1 -1
  42. package/src/modes/components/model-selector.ts +53 -22
  43. package/src/modes/components/status-line/segments.ts +53 -0
  44. package/src/modes/components/status-line/types.ts +4 -0
  45. package/src/modes/components/status-line.ts +147 -12
  46. package/src/modes/controllers/command-controller.ts +9 -0
  47. package/src/modes/controllers/event-controller.ts +10 -1
  48. package/src/modes/interactive-mode.ts +74 -18
  49. package/src/modes/shared.ts +16 -0
  50. package/src/modes/theme/shimmer.ts +15 -6
  51. package/src/modes/theme/theme.ts +1 -1
  52. package/src/modes/types.ts +1 -1
  53. package/src/modes/utils/context-usage.ts +25 -2
  54. package/src/modes/utils/ui-helpers.ts +11 -1
  55. package/src/prompts/agents/frontmatter.md +1 -0
  56. package/src/prompts/tools/hashline.md +62 -81
  57. package/src/sdk.ts +24 -0
  58. package/src/session/agent-session.ts +58 -0
  59. package/src/session/session-manager.ts +54 -1
  60. package/src/slash-commands/builtin-registry.ts +10 -0
  61. package/src/task/executor.ts +50 -1
  62. package/src/task/index.ts +11 -0
  63. package/src/task/render.ts +26 -2
  64. package/src/task/types.ts +35 -0
  65. package/src/tools/bash-command-fixup.ts +0 -10
  66. package/src/tools/bash.ts +1 -9
  67. package/src/utils/clipboard.ts +68 -3
  68. package/src/utils/commit-message-generator.ts +6 -1
  69. package/src/utils/image-resize.ts +51 -26
  70. package/src/utils/title-generator.ts +45 -13
  71. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  72. package/src/modes/components/status-line-segment-editor.ts +0 -359
package/src/task/types.ts CHANGED
@@ -173,6 +173,7 @@ export interface AgentDefinition {
173
173
  thinkingLevel?: ThinkingLevel;
174
174
  output?: unknown;
175
175
  blocking?: boolean;
176
+ autoloadSkills?: string[];
176
177
  source: AgentSource;
177
178
  filePath?: string;
178
179
  }
@@ -211,6 +212,30 @@ export interface AgentProgress {
211
212
  modelOverride?: string | string[];
212
213
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
213
214
  extractedToolData?: Record<string, unknown[]>;
215
+ /**
216
+ * Auto-retry state when the subagent is sleeping between provider retries
217
+ * (e.g. 429 rate-limit with retry-after). Cleared when the retry resolves
218
+ * or fails. Surfacing this to the parent prevents the task tool from
219
+ * looking indefinitely "in progress" when a child is actually blocked on
220
+ * provider quota.
221
+ */
222
+ retryState?: {
223
+ attempt: number;
224
+ maxAttempts: number;
225
+ delayMs: number;
226
+ errorMessage: string;
227
+ startedAtMs: number;
228
+ };
229
+ /**
230
+ * Terminal retry failure surfaced once the subagent gave up retrying
231
+ * (e.g. retry-after exceeded the cap, or all attempts exhausted). Carries
232
+ * the final error so the parent UI can render "blocked: rate-limited"
233
+ * instead of waiting for a status that never arrives.
234
+ */
235
+ retryFailure?: {
236
+ attempt: number;
237
+ errorMessage: string;
238
+ };
214
239
  }
215
240
 
216
241
  /** Result from a single agent execution */
@@ -250,6 +275,16 @@ export interface SingleResult {
250
275
  nestedPatches?: NestedRepoPatch[];
251
276
  /** Data extracted by registered subprocess tool handlers (keyed by tool name) */
252
277
  extractedToolData?: Record<string, unknown[]>;
278
+ /**
279
+ * Terminal retry failure, when the subagent exited because the auto-retry
280
+ * loop gave up (retry-after exceeded the cap, or all attempts exhausted).
281
+ * Lets the parent task tool surface a "blocked: rate-limited" outcome
282
+ * instead of a generic failure.
283
+ */
284
+ retryFailure?: {
285
+ attempt: number;
286
+ errorMessage: string;
287
+ };
253
288
  /** Output metadata for agent:// URL integration */
254
289
  outputMeta?: { lineCount: number; charCount: number };
255
290
  }
@@ -35,13 +35,3 @@ export interface BashFixupResult {
35
35
  export function applyBashFixups(command: string): BashFixupResult {
36
36
  return nativeApplyBashFixups(command);
37
37
  }
38
-
39
- /**
40
- * Human-readable notice for the fixups that fired. Mirrors the shape of
41
- * `formatTimeoutClampNotice` so it can ride alongside the other bash notices.
42
- */
43
- export function formatBashFixupNotice(stripped: readonly string[]): string | undefined {
44
- if (!stripped.length) return undefined;
45
- const quoted = stripped.map(s => `\`${s}\``).join(", ");
46
- return `<system-warning>Stripped redundant ${quoted} — bash output is already truncated and stderr is already merged into stdout. NEVER use these patterns.</system-warning>`;
47
- }
package/src/tools/bash.ts CHANGED
@@ -17,7 +17,7 @@ import { renderStatusLine } from "../tui";
17
17
  import { CachedOutputBlock } from "../tui/output-block";
18
18
  import { getSixelLineMask } from "../utils/sixel";
19
19
  import type { ToolSession } from ".";
20
- import { applyBashFixups, formatBashFixupNotice } from "./bash-command-fixup";
20
+ import { applyBashFixups } from "./bash-command-fixup";
21
21
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
22
22
  import { checkBashInterception } from "./bash-interceptor";
23
23
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
@@ -233,7 +233,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
233
233
  readonly #asyncEnabled: boolean;
234
234
  readonly #autoBackgroundEnabled: boolean;
235
235
  readonly #autoBackgroundThresholdMs: number;
236
- #bashFixupNoticeEmitted = false;
237
236
 
238
237
  constructor(private readonly session: ToolSession) {
239
238
  this.#asyncEnabled = this.session.settings.get("async.enabled");
@@ -475,12 +474,10 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
475
474
  // Apply conservative bash fixups (strip trailing `| head|tail` and redundant
476
475
  // `2>&1`). The helper is single-line only and refuses anything that could
477
476
  // change semantics.
478
- let bashFixups: string[] = [];
479
477
  if (this.session.settings.get("bash.stripTrailingHeadTail")) {
480
478
  const fixup = applyBashFixups(command);
481
479
  if (fixup.stripped.length > 0) {
482
480
  command = fixup.command;
483
- bashFixups = fixup.stripped;
484
481
  }
485
482
  }
486
483
 
@@ -562,11 +559,6 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
562
559
  const pendingNotices: string[] = [];
563
560
  const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
564
561
  if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
565
- const bashFixupNotice = this.#bashFixupNoticeEmitted ? undefined : formatBashFixupNotice(bashFixups);
566
- if (bashFixupNotice) {
567
- pendingNotices.push(bashFixupNotice);
568
- this.#bashFixupNoticeEmitted = true;
569
- }
570
562
 
571
563
  if (asyncRequested) {
572
564
  if (!AsyncJobManager.instance()) {
@@ -2,7 +2,13 @@ import { execSync } from "node:child_process";
2
2
  import type { ClipboardImage } from "@oh-my-pi/pi-natives";
3
3
  import * as native from "@oh-my-pi/pi-natives";
4
4
 
5
- const hasDisplay = process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
5
+ function hasDisplay(): boolean {
6
+ return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
7
+ }
8
+
9
+ function isWsl(): boolean {
10
+ return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
11
+ }
6
12
 
7
13
  /**
8
14
  * Copy text to the system clipboard.
@@ -59,11 +65,66 @@ export async function copyToClipboard(text: string): Promise<void> {
59
65
  }
60
66
  }
61
67
 
68
+ // PowerShell one-liner that emits the clipboard image as base64-encoded PNG on
69
+ // stdout, or nothing when the clipboard does not hold image data. Used as the
70
+ // WSL bridge — arboard cannot read the Windows clipboard through WSLg.
71
+ const POWERSHELL_IMAGE_SCRIPT = `
72
+ $ErrorActionPreference = 'Stop'
73
+ Add-Type -AssemblyName System.Windows.Forms
74
+ Add-Type -AssemblyName System.Drawing
75
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
76
+ if ($img -ne $null) {
77
+ $ms = New-Object System.IO.MemoryStream
78
+ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
79
+ [Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
80
+ }
81
+ `;
82
+
83
+ const POWERSHELL_TIMEOUT_MS = 5000;
84
+
85
+ /**
86
+ * Read a clipboard image through the Windows host's PowerShell.
87
+ *
88
+ * WSLg exposes a Wayland socket but no native clipboard image transport, so
89
+ * `arboard` returns `ContentNotAvailable`. PowerShell, reached via WSL interop,
90
+ * can read the Windows clipboard directly and round-trip the bitmap as PNG.
91
+ *
92
+ * Returns null when no image is on the clipboard, the host PowerShell is
93
+ * missing, or the bridge times out.
94
+ */
95
+ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
96
+ try {
97
+ const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_IMAGE_SCRIPT], {
98
+ stdout: "pipe",
99
+ stderr: "ignore",
100
+ stdin: "ignore",
101
+ });
102
+ const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
103
+ let stdout = "";
104
+ try {
105
+ stdout = await new Response(proc.stdout).text();
106
+ await proc.exited;
107
+ } finally {
108
+ clearTimeout(timer);
109
+ }
110
+ if (proc.exitCode !== 0) return null;
111
+ const b64 = stdout.trim();
112
+ if (!b64) return null;
113
+ const bytes = Buffer.from(b64, "base64");
114
+ if (bytes.byteLength === 0) return null;
115
+ return { data: new Uint8Array(bytes), mimeType: "image/png" };
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
62
121
  /**
63
122
  * Read an image from the system clipboard.
64
123
  *
65
124
  * Returns null on Termux (no image clipboard support) or when no display
66
- * server is available (headless/SSH without forwarding).
125
+ * server is available (headless/SSH without forwarding). Under WSL the
126
+ * Windows clipboard is reached through `powershell.exe`, since WSLg's
127
+ * Wayland clipboard does not carry image payloads through to `arboard`.
67
128
  *
68
129
  * @returns PNG payload or null when no image is available.
69
130
  */
@@ -72,7 +133,11 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
72
133
  return null;
73
134
  }
74
135
 
75
- if (!hasDisplay) {
136
+ if (isWsl()) {
137
+ const image = await readImageViaPowerShell();
138
+ if (image) return image;
139
+ // Fall through: arboard may still succeed on a future WSLg release.
140
+ } else if (!hasDisplay()) {
76
141
  return null;
77
142
  }
78
143
 
@@ -15,6 +15,8 @@ import { toReasoningEffort } from "../thinking";
15
15
 
16
16
  const COMMIT_SYSTEM_PROMPT = prompt.render(commitSystemPrompt);
17
17
  const MAX_DIFF_CHARS = 4000;
18
+ const COMMIT_MAX_TOKENS = 60;
19
+ const REASONING_SAFE_MAX_TOKENS = 1024;
18
20
 
19
21
  /** File patterns that should be excluded from commit message generation diffs. */
20
22
  const NOISE_SUFFIXES = [".lock", ".lockb", "-lock.json", "-lock.yaml"];
@@ -99,13 +101,16 @@ export async function generateCommitMessage(
99
101
  if (!apiKey) continue;
100
102
 
101
103
  try {
104
+ const maxTokens = candidate.model.reasoning
105
+ ? Math.max(COMMIT_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS)
106
+ : COMMIT_MAX_TOKENS;
102
107
  const response = await completeSimple(
103
108
  candidate.model,
104
109
  {
105
110
  systemPrompt: [COMMIT_SYSTEM_PROMPT],
106
111
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
107
112
  },
108
- { apiKey, maxTokens: 60, reasoning: toReasoningEffort(candidate.thinkingLevel) },
113
+ { apiKey, maxTokens, reasoning: toReasoningEffort(candidate.thinkingLevel) },
109
114
  );
110
115
 
111
116
  if (response.stopReason === "error") {
@@ -5,6 +5,7 @@ export interface ImageResizeOptions {
5
5
  maxHeight?: number;
6
6
  maxBytes?: number;
7
7
  jpegQuality?: number;
8
+ excludeWebP?: boolean;
8
9
  }
9
10
 
10
11
  export interface ResizedImage {
@@ -29,6 +30,7 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
29
30
  maxHeight: 1568,
30
31
  maxBytes: DEFAULT_MAX_BYTES,
31
32
  jpegQuality: 80,
33
+ excludeWebP: Bun.env.OMP_NO_WEBP !== undefined,
32
34
  };
33
35
 
34
36
  /** Pick the smallest of N encoded buffers. */
@@ -49,11 +51,13 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
49
51
  *
50
52
  * Strategy:
51
53
  * 1. Probe metadata. If already within all limits, return original.
52
- * 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG/WebP — return smallest.
54
+ * 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG (+ WebP) — return smallest.
53
55
  * 3. If still too large, walk a lossy JPEG/WebP quality ladder.
54
56
  * 4. If still too large, walk a dimension-scale ladder × quality ladder.
55
57
  * 5. If still too large, return the smallest variant produced.
56
58
  *
59
+ * Set OMP_NO_WEBP to exclude WebP from encoding (llama.cpp STB doesn't decode it).
60
+ *
57
61
  * Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
58
62
  * off the JS thread when the terminal (`.bytes()`) is awaited.
59
63
  */
@@ -99,44 +103,65 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
99
103
  targetHeight = opts.maxHeight;
100
104
  }
101
105
 
102
- // First-attempt encoder: try PNG, JPEG, and lossy WebP — return whichever is smallest.
103
- // PNG wins for line art / few-color UI; JPEG and WebP win for photographic content;
104
- // WebP usually beats JPEG by 25–35% at the same perceptual quality.
106
+ // First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
107
+ // PNG wins for line art / few-color UI; JPEG wins for photographic content;
108
+ // WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
109
+ // because many local inference backends (llama.cpp STB) don't decode it.
105
110
  async function encodeSmallest(
106
111
  width: number,
107
112
  height: number,
108
113
  quality: number,
109
114
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
110
- const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
111
- new Bun.Image(inputBuffer).resize(width, height).png().bytes(),
112
- new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
113
- new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
115
+ const candidates = await Promise.all([
116
+ new Bun.Image(inputBuffer)
117
+ .resize(width, height)
118
+ .png()
119
+ .bytes()
120
+ .then(b => ({ buffer: b, mimeType: "image/png" })),
121
+ new Bun.Image(inputBuffer)
122
+ .resize(width, height)
123
+ .jpeg({ quality })
124
+ .bytes()
125
+ .then(b => ({ buffer: b, mimeType: "image/jpeg" })),
126
+ ...(opts.excludeWebP
127
+ ? []
128
+ : [
129
+ new Bun.Image(inputBuffer)
130
+ .resize(width, height)
131
+ .webp({ quality })
132
+ .bytes()
133
+ .then(b => ({ buffer: b, mimeType: "image/webp" })),
134
+ ]),
114
135
  ]);
115
- return pickSmallest(
116
- { buffer: pngBuffer, mimeType: "image/png" },
117
- { buffer: jpegBuffer, mimeType: "image/jpeg" },
118
- { buffer: webpBuffer, mimeType: "image/webp" },
119
- );
136
+ return pickSmallest(...candidates);
120
137
  }
121
138
 
122
- // Lossy-only encoder used in quality/dimension fallback ladders where PNG can't shrink
123
- // further (PNG quality is a no-op). Picks the smaller of JPEG vs lossy WebP at the
124
- // requested quality.
139
+ // Lossy encoder for quality/dimension fallback ladders. PNG is excluded since
140
+ // it's lossless and doesn't respond to quality parameters. WebP is included
141
+ // unless OMP_NO_WEBP is set (llama.cpp STB incompatibility).
125
142
  async function encodeLossy(
126
143
  width: number,
127
144
  height: number,
128
145
  quality: number,
129
146
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
130
- const [jpegBuffer, webpBuffer] = await Promise.all([
131
- new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
132
- new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
147
+ const candidates = await Promise.all([
148
+ new Bun.Image(inputBuffer)
149
+ .resize(width, height)
150
+ .jpeg({ quality })
151
+ .bytes()
152
+ .then(b => ({ buffer: b, mimeType: "image/jpeg" })),
153
+ ...(opts.excludeWebP
154
+ ? []
155
+ : [
156
+ new Bun.Image(inputBuffer)
157
+ .resize(width, height)
158
+ .webp({ quality })
159
+ .bytes()
160
+ .then(b => ({ buffer: b, mimeType: "image/webp" })),
161
+ ]),
133
162
  ]);
134
- return pickSmallest(
135
- { buffer: jpegBuffer, mimeType: "image/jpeg" },
136
- { buffer: webpBuffer, mimeType: "image/webp" },
137
- );
163
+ return pickSmallest(...candidates);
138
164
  }
139
-
140
165
  // Quality ladder — more aggressive steps for tighter budgets
141
166
  const qualitySteps = [70, 60, 50, 40];
142
167
  const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
@@ -145,7 +170,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
145
170
  let finalWidth = targetWidth;
146
171
  let finalHeight = targetHeight;
147
172
 
148
- // First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
173
+ // First attempt: resize to target, try PNG/JPEG (+ WebP), pick smallest
149
174
  best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
150
175
 
151
176
  if (best.buffer.length <= opts.maxBytes) {
@@ -163,7 +188,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
163
188
  };
164
189
  }
165
190
 
166
- // Still too large — lossy ladder (JPEG vs WebP, smallest wins) with decreasing quality
191
+ // Still too large — lossy JPEG (+ WebP) ladder with decreasing quality
167
192
  for (const quality of qualitySteps) {
168
193
  best = await encodeLossy(targetWidth, targetHeight, quality);
169
194
 
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import * as path from "node:path";
5
5
 
6
- import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
6
+ import { type Api, type AssistantMessage, completeSimple, type Model, type Tool } from "@oh-my-pi/pi-ai";
7
7
  import { logger, prompt } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../config/model-registry";
9
9
  import { resolveRoleSelection } from "../config/model-resolver";
@@ -16,6 +16,25 @@ const DEFAULT_TERMINAL_TITLE = "π";
16
16
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
17
17
 
18
18
  const MAX_INPUT_CHARS = 2000;
19
+ const TITLE_MAX_TOKENS = 30;
20
+ const REASONING_SAFE_MAX_TOKENS = 1024;
21
+ const SET_TITLE_TOOL_NAME = "set_title";
22
+
23
+ const setTitleTool: Tool = {
24
+ name: SET_TITLE_TOOL_NAME,
25
+ description: "Set the generated session title.",
26
+ parameters: {
27
+ type: "object",
28
+ properties: {
29
+ title: {
30
+ type: "string",
31
+ description: "A concise 3-6 word title for the session.",
32
+ },
33
+ },
34
+ required: ["title"],
35
+ additionalProperties: false,
36
+ },
37
+ };
19
38
 
20
39
  function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
21
40
  const availableModels = registry.getAvailable();
@@ -76,14 +95,16 @@ ${truncatedMessage}
76
95
  // account_uuid rather than the snapshot-at-call-site value.
77
96
  const metadata = metadataResolver?.(model.provider);
78
97
 
79
- // Title generation is a 3-6 word task; force reasoning off so reasoning models
80
- // don't burn the entire output budget on internal thinking and return an empty
81
- // string. With reasoning disabled, 30 tokens of output is plenty.
98
+ // Title generation is a 3-6 word task, but some reasoning backends ignore
99
+ // disableReasoning. Keep the normal cheap budget for non-reasoning models
100
+ // while reserving enough output room for reasoning models to still emit
101
+ // the forced tool call after any unavoidable thinking tokens.
102
+ const maxTokens = model.reasoning ? Math.max(TITLE_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS) : TITLE_MAX_TOKENS;
82
103
  const request = {
83
104
  model: `${model.provider}/${model.id}`,
84
105
  systemPrompt: TITLE_SYSTEM_PROMPT,
85
106
  userMessage,
86
- maxTokens: 30,
107
+ maxTokens,
87
108
  };
88
109
  logger.debug("title-generator: request", request);
89
110
 
@@ -93,11 +114,13 @@ ${truncatedMessage}
93
114
  {
94
115
  systemPrompt: [request.systemPrompt],
95
116
  messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
117
+ tools: [setTitleTool],
96
118
  },
97
119
  {
98
120
  apiKey,
99
- maxTokens: 30,
121
+ maxTokens: request.maxTokens,
100
122
  disableReasoning: true,
123
+ toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
101
124
  metadata,
102
125
  },
103
126
  );
@@ -111,13 +134,7 @@ ${truncatedMessage}
111
134
  return null;
112
135
  }
113
136
 
114
- let title = "";
115
- for (const content of response.content) {
116
- if (content.type === "text") {
117
- title += content.text;
118
- }
119
- }
120
- title = title.trim();
137
+ const title = extractGeneratedTitle(response.content);
121
138
 
122
139
  logger.debug("title-generator: response", {
123
140
  model: request.model,
@@ -140,6 +157,21 @@ ${truncatedMessage}
140
157
  }
141
158
  }
142
159
 
160
+ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): string {
161
+ let textTitle = "";
162
+ for (const content of contentBlocks) {
163
+ if (content.type === "toolCall" && content.name === SET_TITLE_TOOL_NAME) {
164
+ const args = content.arguments as Record<string, unknown>;
165
+ const title = args.title;
166
+ return typeof title === "string" ? title.trim() : "";
167
+ }
168
+ if (content.type === "text") {
169
+ textTitle += content.text;
170
+ }
171
+ }
172
+ return textTitle.trim();
173
+ }
174
+
143
175
  /**
144
176
  * Remove control characters so model-generated titles cannot inject terminal escapes.
145
177
  */
@@ -1,24 +0,0 @@
1
- /**
2
- * Status Line Segment Editor
3
- *
4
- * Interactive component for configuring status line segments.
5
- * - Three-column layout: Left | Right | Disabled
6
- * - Space: Toggle segment visibility (disabled ↔ left)
7
- * - Tab: Cycle segment between columns (left → right → disabled → left)
8
- * - Shift+J/K: Reorder segment within column
9
- * - Live preview shown in the actual status line above
10
- */
11
- import { Container } from "@oh-my-pi/pi-tui";
12
- import type { StatusLineSegmentId } from "../../config/settings-schema";
13
- export interface SegmentEditorCallbacks {
14
- onSave: (leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[]) => void;
15
- onCancel: () => void;
16
- onPreview?: (leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[]) => void;
17
- }
18
- export declare class StatusLineSegmentEditorComponent extends Container {
19
- #private;
20
- private readonly callbacks;
21
- constructor(currentLeft: StatusLineSegmentId[], currentRight: StatusLineSegmentId[], callbacks: SegmentEditorCallbacks);
22
- handleInput(data: string): void;
23
- render(width: number): string[];
24
- }