@oh-my-pi/pi-coding-agent 15.8.2 → 15.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +82 -29
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/config/settings-schema.d.ts +36 -22
  4. package/dist/types/debug/protocol-probe.d.ts +38 -0
  5. package/dist/types/debug/terminal-info.d.ts +34 -0
  6. package/dist/types/export/html/template.generated.d.ts +1 -1
  7. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  9. package/dist/types/extensibility/shared-events.d.ts +1 -1
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  12. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  13. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  14. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  15. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  16. package/dist/types/modes/interactive-mode.d.ts +2 -1
  17. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  18. package/dist/types/modes/theme/theme.d.ts +20 -1
  19. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  20. package/dist/types/plan-mode/plan-protection.d.ts +12 -0
  21. package/dist/types/session/agent-session.d.ts +1 -1
  22. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  23. package/dist/types/session/redis-session-storage.d.ts +12 -85
  24. package/dist/types/session/session-manager.d.ts +20 -0
  25. package/dist/types/session/session-storage.d.ts +5 -7
  26. package/dist/types/session/sql-session-storage.d.ts +16 -85
  27. package/dist/types/task/executor.d.ts +9 -0
  28. package/dist/types/task/index.d.ts +3 -1
  29. package/dist/types/telemetry-export.d.ts +19 -0
  30. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  31. package/dist/types/tools/ask.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -2
  33. package/dist/types/tools/path-utils.d.ts +1 -1
  34. package/dist/types/tools/search.d.ts +2 -2
  35. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  36. package/dist/types/utils/jj.d.ts +1 -1
  37. package/dist/types/utils/session-color.d.ts +7 -2
  38. package/dist/types/web/search/index.d.ts +1 -1
  39. package/dist/types/web/search/provider.d.ts +2 -2
  40. package/dist/types/web/search/types.d.ts +65 -1
  41. package/package.json +15 -9
  42. package/scripts/build-binary.ts +12 -0
  43. package/src/capability/skill.ts +7 -0
  44. package/src/cli/args.ts +1 -1
  45. package/src/config/settings-schema.ts +22 -55
  46. package/src/debug/index.ts +67 -1
  47. package/src/debug/protocol-probe.ts +267 -0
  48. package/src/debug/terminal-info.ts +127 -0
  49. package/src/export/html/template.generated.ts +1 -1
  50. package/src/export/html/template.js +3 -3
  51. package/src/extensibility/custom-tools/types.ts +1 -1
  52. package/src/extensibility/extensions/types.ts +11 -0
  53. package/src/extensibility/shared-events.ts +1 -1
  54. package/src/extensibility/skills.ts +3 -3
  55. package/src/index.ts +1 -0
  56. package/src/internal-urls/docs-index.generated.ts +7 -7
  57. package/src/main.ts +12 -0
  58. package/src/mcp/transports/stdio.ts +37 -12
  59. package/src/modes/acp/acp-event-mapper.ts +7 -7
  60. package/src/modes/components/assistant-message.ts +3 -2
  61. package/src/modes/components/hook-selector.ts +149 -14
  62. package/src/modes/components/session-selector.ts +36 -5
  63. package/src/modes/components/status-line/segments.ts +2 -1
  64. package/src/modes/components/status-line.ts +1 -1
  65. package/src/modes/components/tips.txt +1 -1
  66. package/src/modes/components/todo-reminder.ts +1 -1
  67. package/src/modes/components/tool-execution.ts +9 -4
  68. package/src/modes/components/transcript-container.ts +91 -0
  69. package/src/modes/controllers/event-controller.ts +12 -7
  70. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  71. package/src/modes/controllers/todo-command-controller.ts +1 -1
  72. package/src/modes/interactive-mode.ts +18 -6
  73. package/src/modes/print-mode.ts +5 -0
  74. package/src/modes/rpc/rpc-types.ts +1 -1
  75. package/src/modes/theme/theme.ts +48 -8
  76. package/src/modes/utils/ui-helpers.ts +1 -0
  77. package/src/plan-mode/plan-handoff.ts +37 -0
  78. package/src/plan-mode/plan-protection.ts +31 -0
  79. package/src/priority.json +4 -0
  80. package/src/prompts/goals/goal-continuation.md +1 -1
  81. package/src/prompts/system/eager-todo.md +3 -3
  82. package/src/prompts/system/orchestrate-notice.md +5 -5
  83. package/src/prompts/system/plan-mode-approved.md +14 -17
  84. package/src/prompts/system/plan-mode-reference.md +3 -6
  85. package/src/prompts/system/subagent-system-prompt.md +11 -0
  86. package/src/prompts/system/workflow-notice.md +1 -1
  87. package/src/prompts/tools/browser.md +5 -2
  88. package/src/prompts/tools/search.md +1 -1
  89. package/src/prompts/tools/task.md +3 -1
  90. package/src/prompts/tools/{todo-write.md → todo.md} +8 -0
  91. package/src/sdk.ts +1 -0
  92. package/src/session/agent-session.ts +29 -16
  93. package/src/session/indexed-session-storage.ts +430 -0
  94. package/src/session/redis-session-storage.ts +66 -377
  95. package/src/session/session-manager.ts +102 -22
  96. package/src/session/session-storage.ts +148 -68
  97. package/src/session/sql-session-storage.ts +131 -382
  98. package/src/slash-commands/helpers/todo.ts +2 -2
  99. package/src/task/executor.ts +9 -1
  100. package/src/task/index.ts +51 -1
  101. package/src/telemetry-export.ts +126 -0
  102. package/src/tiny/compiled-runtime.ts +179 -0
  103. package/src/tiny/worker.ts +24 -2
  104. package/src/tools/ask.ts +133 -87
  105. package/src/tools/fetch.ts +17 -4
  106. package/src/tools/find.ts +2 -2
  107. package/src/tools/index.ts +6 -4
  108. package/src/tools/path-utils.ts +16 -7
  109. package/src/tools/renderers.ts +2 -2
  110. package/src/tools/search.ts +6 -3
  111. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  112. package/src/utils/jj.ts +28 -5
  113. package/src/utils/session-color.ts +39 -14
  114. package/src/web/search/index.ts +1 -1
  115. package/src/web/search/provider.ts +18 -34
  116. package/src/web/search/types.ts +73 -32
package/src/main.ts CHANGED
@@ -66,6 +66,7 @@ import type { AgentSession } from "./session/agent-session";
66
66
  import type { AuthStorage } from "./session/auth-storage";
67
67
  import { resolveResumableSession, type SessionInfo, SessionManager } from "./session/session-manager";
68
68
  import { resolvePromptInput } from "./system-prompt";
69
+ import { initTelemetryExport, isTelemetryExportEnabled } from "./telemetry-export";
69
70
  import { AUTO_THINKING } from "./thinking";
70
71
  import type { LspStartupServerInfo } from "./tools";
71
72
  import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
@@ -973,6 +974,17 @@ export async function runRootCommand(
973
974
  sessionOptions.hasUI = isInteractive || mode === "rpc-ui";
974
975
  sessionOptions.settings = settingsInstance;
975
976
 
977
+ // OTEL: register the global OTLP trace exporter when an OTLP endpoint is
978
+ // configured via env, then switch on the agent loop's telemetry so its
979
+ // GenAI spans (invoke_agent / chat / execute_tool) are actually emitted.
980
+ // Both are no-ops when OTEL_EXPORTER_OTLP_ENDPOINT is unset. An empty config
981
+ // is enough to enable telemetry — content capture is governed by the
982
+ // standard OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT env var.
983
+ initTelemetryExport();
984
+ if (isTelemetryExportEnabled()) {
985
+ sessionOptions.telemetry = {};
986
+ }
987
+
976
988
  // Handle CLI --api-key as runtime override (not persisted)
977
989
  if (parsedArgs.apiKey) {
978
990
  if (!sessionOptions.model && !sessionOptions.modelPattern) {
@@ -25,22 +25,42 @@ interface FrameSink {
25
25
  flush(): unknown;
26
26
  }
27
27
 
28
+ /** Narrow a value to a thenable so a rejection handler can be attached. */
29
+ function isThenable(value: unknown): value is PromiseLike<unknown> {
30
+ return (
31
+ value != null &&
32
+ (typeof value === "object" || typeof value === "function") &&
33
+ typeof (value as { then?: unknown }).then === "function"
34
+ );
35
+ }
36
+
28
37
  /**
29
38
  * Write a newline-delimited JSON-RPC frame to the subprocess's stdin sink,
30
- * swallowing synchronous errors so the caller can decide how to react.
39
+ * swallowing both synchronous throws and asynchronous rejections so the caller
40
+ * can decide how to react.
41
+ *
42
+ * Bun's `FileSink.write()`/`flush()` can fail two ways once the read end of the
43
+ * pipe has been closed by a subprocess that exited between read-loop ticks:
44
+ * - a synchronous throw (most reliably observed on Windows), and
45
+ * - a *rejected Promise* returned from `write()`/`flush()`, i.e. the EPIPE is
46
+ * surfaced asynchronously (note the `processTicksAndRejections` frame in the
47
+ * stack traces on #1710 and the follow-up report).
31
48
  *
32
- * Bun's `FileSink` may throw synchronously (most reliably on Windows) when
33
- * the read end of the pipe has been closed by a subprocess that exited
34
- * between read-loop ticks. Letting that throw escape an `async` method
35
- * surfaces as an unhandled promise rejection at the call site.
49
+ * A sibling `async` method's `try/catch` only catches the synchronous case; an
50
+ * un-awaited rejected Promise escapes as a fatal unhandled rejection. So we both
51
+ * catch the throw and neutralize any returned promise's rejection.
36
52
  *
37
- * Returns `true` when the frame was accepted by the sink, `false` when the
38
- * sink threw — callers signal transport closure on `false`.
53
+ * Returns `true` when the frame was accepted synchronously, `false` when the
54
+ * sink threw — callers signal transport closure on `false`. An asynchronous
55
+ * failure cannot be reflected in the return value; it is neutralized here and
56
+ * the dead transport is detected by the read loop / request timeout instead.
39
57
  */
40
58
  export function writeFrame(stdin: FrameSink, frame: string): boolean {
41
59
  try {
42
- stdin.write(frame);
43
- stdin.flush();
60
+ const wrote = stdin.write(frame);
61
+ const flushed = stdin.flush();
62
+ if (isThenable(wrote)) wrote.then(undefined, () => {});
63
+ if (isThenable(flushed)) flushed.then(undefined, () => {});
44
64
  return true;
45
65
  } catch {
46
66
  return false;
@@ -287,11 +307,16 @@ export class StdioTransport implements MCPTransport {
287
307
  }, timeout);
288
308
  }
289
309
 
310
+ const stdin = this.#process.stdin;
290
311
  const message = `${JSON.stringify(request)}\n`;
291
312
  try {
292
- // Bun's FileSink has write() method directly
293
- this.#process.stdin.write(message);
294
- this.#process.stdin.flush();
313
+ // Await both: Bun's FileSink can surface a broken pipe either as a
314
+ // synchronous throw or as a rejected Promise (the EPIPE arrives on a
315
+ // processTicksAndRejections tick). Awaiting funnels both into this catch
316
+ // so the request rejects cleanly instead of leaving a floating rejected
317
+ // promise that crashes the process via the unhandledRejection handler.
318
+ await stdin.write(message);
319
+ await stdin.flush();
295
320
  } catch (error: unknown) {
296
321
  cleanup();
297
322
  reject(error instanceof Error ? error : new Error(String(error)));
@@ -8,7 +8,7 @@ import type {
8
8
  } from "@agentclientprotocol/sdk";
9
9
  import type { AgentSessionEvent } from "../../session/agent-session";
10
10
  import { resolveToCwd } from "../../tools/path-utils";
11
- import type { TodoStatus } from "../../tools/todo-write";
11
+ import type { TodoStatus } from "../../tools/todo";
12
12
 
13
13
  interface MessageProgress {
14
14
  textEmitted: boolean;
@@ -148,7 +148,7 @@ export function mapToolKind(toolName: string): ToolKind {
148
148
  return "search";
149
149
  case "web_search":
150
150
  return "fetch";
151
- case "todo_write":
151
+ case "todo":
152
152
  return "think";
153
153
  default:
154
154
  return "other";
@@ -215,7 +215,7 @@ export function mapAgentSessionEventToAcpSessionUpdates(
215
215
  update.locations = locations;
216
216
  }
217
217
  const notifications = [toSessionNotification(sessionId, update)];
218
- const planUpdate = mapTodoWriteResultToPlanUpdate(event);
218
+ const planUpdate = mapTodoResultToPlanUpdate(event);
219
219
  if (planUpdate) {
220
220
  notifications.push(toSessionNotification(sessionId, planUpdate));
221
221
  }
@@ -336,13 +336,13 @@ function mapTodoStatus(status: TodoStatus): "pending" | "in_progress" | "complet
336
336
  return todoStatusMap[status];
337
337
  }
338
338
 
339
- function mapTodoWriteResultToPlanUpdate(
339
+ function mapTodoResultToPlanUpdate(
340
340
  event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
341
341
  ): SessionUpdate | undefined {
342
- if (event.toolName !== "todo_write" || event.isError) {
342
+ if (event.toolName !== "todo" || event.isError) {
343
343
  return undefined;
344
344
  }
345
- const phases = extractTodoWritePhases(event.result);
345
+ const phases = extractTodoPhases(event.result);
346
346
  if (!Array.isArray(phases)) {
347
347
  return undefined;
348
348
  }
@@ -356,7 +356,7 @@ function mapTodoWriteResultToPlanUpdate(
356
356
  };
357
357
  }
358
358
 
359
- function extractTodoWritePhases(result: unknown): unknown {
359
+ function extractTodoPhases(result: unknown): unknown {
360
360
  if (typeof result !== "object" || result === null || !("details" in result)) {
361
361
  return undefined;
362
362
  }
@@ -1,5 +1,5 @@
1
1
  import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai";
2
- import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
2
+ import { Container, Image, type ImageBudget, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@oh-my-pi/pi-tui";
3
3
  import { formatNumber } from "@oh-my-pi/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
5
  import type { AssistantThinkingRenderer } from "../../extensibility/extensions/types";
@@ -23,6 +23,7 @@ export class AssistantMessageComponent extends Container {
23
23
  private hideThinkingBlock = false,
24
24
  private readonly onImageUpdate?: () => void,
25
25
  private readonly thinkingRenderers: readonly AssistantThinkingRenderer[] = [],
26
+ private readonly imageBudget?: ImageBudget,
26
27
  ) {
27
28
  super();
28
29
 
@@ -124,7 +125,7 @@ export class AssistantMessageComponent extends Container {
124
125
  displayImage.data,
125
126
  displayImage.mimeType,
126
127
  { fallbackColor: (text: string) => theme.fg("toolOutput", text) },
127
- resolveImageOptions(),
128
+ { ...resolveImageOptions(), budget: this.imageBudget, imageKey: key },
128
129
  ),
129
130
  );
130
131
  continue;
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import {
6
6
  Container,
7
+ Ellipsis,
7
8
  extractPrintableText,
8
9
  fuzzyFilter,
9
10
  Markdown,
@@ -15,6 +16,7 @@ import {
15
16
  Spacer,
16
17
  Text,
17
18
  type TUI,
19
+ truncateToWidth,
18
20
  visibleWidth,
19
21
  wrapTextWithAnsi,
20
22
  } from "@oh-my-pi/pi-tui";
@@ -71,6 +73,17 @@ export interface HookSelectorOptions {
71
73
  /** Indices into the original options that cannot be selected: they render
72
74
  * dimmed, are skipped during navigation, and reject enter/timeout. */
73
75
  disabledIndices?: readonly number[];
76
+ /** Render a leading radio/checkbox marker before each markable option,
77
+ * matching the ask transcript. "radio" fills the marker on the cursor row
78
+ * (single-choice); "checkbox" reflects {@link checkedIndices} per row
79
+ * (multi-select). Options at or beyond {@link markableCount} keep the plain
80
+ * cursor prefix — used for trailing control rows like "Other"/"Done". */
81
+ selectionMarker?: "radio" | "checkbox";
82
+ /** For `selectionMarker: "checkbox"`: original-indices currently checked. */
83
+ checkedIndices?: readonly number[];
84
+ /** Number of leading options (original order) that receive a selection
85
+ * marker. Defaults to every option when {@link selectionMarker} is set. */
86
+ markableCount?: number;
74
87
  }
75
88
 
76
89
  export interface HookSelectorOption {
@@ -140,6 +153,9 @@ export class HookSelectorComponent extends Container {
140
153
  #searchQuery = "";
141
154
  #selectedIndex: number;
142
155
  #disabledIndices: Set<number>;
156
+ #selectionMarker: "radio" | "checkbox" | undefined;
157
+ #checkedIndices: Set<number>;
158
+ #markableCount: number;
143
159
  #maxVisible: number;
144
160
  #listContainer: Container | undefined;
145
161
  #outlinedList: OutlinedList | undefined;
@@ -171,6 +187,13 @@ export class HookSelectorComponent extends Container {
171
187
  index => Number.isInteger(index) && index >= 0 && index < this.#options.length,
172
188
  ),
173
189
  );
190
+ this.#selectionMarker = opts?.selectionMarker;
191
+ this.#checkedIndices = new Set(
192
+ (opts?.checkedIndices ?? []).filter(
193
+ index => Number.isInteger(index) && index >= 0 && index < this.#options.length,
194
+ ),
195
+ );
196
+ this.#markableCount = Math.max(0, Math.min(opts?.markableCount ?? this.#options.length, this.#options.length));
174
197
  this.#selectedIndex = this.#coerceSelectedIndex(opts?.initialIndex ?? 0);
175
198
  this.#maxVisible = Math.max(3, opts?.maxVisible ?? 12);
176
199
  this.#onSelectCallback = onSelect;
@@ -278,20 +301,71 @@ export class HookSelectorComponent extends Container {
278
301
  isSelected: boolean,
279
302
  isDisabled: boolean,
280
303
  mdTheme: MarkdownTheme,
304
+ descRows: number | "full",
305
+ renderWidth?: number,
306
+ index?: number,
281
307
  ): string[] {
282
308
  const textColor = isDisabled ? "dim" : isSelected ? "accent" : "text";
283
309
  const prefixColor = isDisabled ? "dim" : "accent";
284
310
  const label = renderInlineMarkdown(option.label, mdTheme, t => theme.fg(textColor, t));
285
- const prefix = isSelected ? theme.fg(prefixColor, `${theme.nav.cursor} `) : " ";
311
+ const marker = index !== undefined ? this.#renderMarkerPrefix(index, isSelected, isDisabled) : undefined;
312
+ const prefix = marker ?? (isSelected ? theme.fg(prefixColor, `${theme.nav.cursor} `) : " ");
286
313
  const lines = [prefix + label];
287
- if (option.description) {
288
- const descriptionColor = isDisabled ? "dim" : "muted";
289
- const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t));
290
- lines.push(` ${description}`);
314
+ if (option.description && descRows !== 0) {
315
+ const descriptionColor: ThemeColor = isDisabled ? "dim" : "muted";
316
+ if (descRows === "full") {
317
+ const description = renderInlineMarkdown(option.description, mdTheme, t => theme.fg(descriptionColor, t));
318
+ lines.push(` ${description}`);
319
+ } else {
320
+ lines.push(
321
+ ...this.#wrapDescriptionRows(option.description, descRows, descriptionColor, mdTheme, renderWidth),
322
+ );
323
+ }
291
324
  }
292
325
  return lines;
293
326
  }
294
327
 
328
+ /** Styled leading marker (`"<glyph> "`) for a markable option row, or
329
+ * `undefined` when no marker applies (control rows beyond `markableCount`,
330
+ * or when {@link selectionMarker} is unset) so the caller falls back to the
331
+ * classic cursor prefix. Radio fills on the cursor row; checkbox reflects
332
+ * the per-row checked state, with the cursor row drawn in accent. */
333
+ #renderMarkerPrefix(index: number, isSelected: boolean, isDisabled: boolean): string | undefined {
334
+ if (this.#selectionMarker === undefined || index >= this.#markableCount) return undefined;
335
+ if (this.#selectionMarker === "radio") {
336
+ const glyph = isSelected ? theme.radio.selected : theme.radio.unselected;
337
+ const color = isDisabled ? "dim" : isSelected ? "accent" : "dim";
338
+ return theme.fg(color, `${glyph} `);
339
+ }
340
+ const checked = this.#checkedIndices.has(index);
341
+ const glyph = checked ? theme.checkbox.checked : theme.checkbox.unchecked;
342
+ const color = isDisabled ? "dim" : isSelected ? "accent" : checked ? "success" : "dim";
343
+ return theme.fg(color, `${glyph} `);
344
+ }
345
+
346
+ /** Wrap an option description into indented rows, truncating to `maxRows`
347
+ * with an ellipsis. Pre-wrapping (rather than emitting one long line that the
348
+ * list re-wraps) lets compact mode bound how much of the highlighted option's
349
+ * detail is shown, so every option label stays on screen on short terminals. */
350
+ #wrapDescriptionRows(
351
+ description: string,
352
+ maxRows: number,
353
+ color: ThemeColor,
354
+ mdTheme: MarkdownTheme,
355
+ renderWidth = this.#lastRenderWidth,
356
+ ): string[] {
357
+ if (maxRows <= 0) return [];
358
+ const indent = " ";
359
+ const innerWidth = Math.max(1, (renderWidth ?? 80) - 2);
360
+ const bodyWidth = Math.max(1, innerWidth - indent.length);
361
+ const colored = renderInlineMarkdown(description, mdTheme, t => theme.fg(color, t));
362
+ const wrapped = wrapTextWithAnsi(colored, bodyWidth);
363
+ if (wrapped.length <= maxRows) return wrapped.map(row => indent + row);
364
+ const kept = wrapped.slice(0, maxRows);
365
+ kept[maxRows - 1] = truncateToWidth(wrapped.slice(maxRows - 1).join(" "), bodyWidth, Ellipsis.Unicode);
366
+ return kept.map(row => indent + row);
367
+ }
368
+
295
369
  #renderedLineRowCount(line: string, renderWidth: number): number {
296
370
  const normalized = replaceTabs(line);
297
371
  if (this.#outlinedList) {
@@ -309,10 +383,11 @@ export class HookSelectorComponent extends Container {
309
383
  renderWidth: number | undefined,
310
384
  isSelected: boolean,
311
385
  mdTheme: MarkdownTheme,
386
+ descRows: number | "full",
312
387
  ): number {
313
- if (renderWidth === undefined) return option.description ? 2 : 1;
388
+ if (renderWidth === undefined) return option.description && descRows !== 0 ? 2 : 1;
314
389
  let rows = 0;
315
- for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme)) {
390
+ for (const line of this.#renderOptionLines(option, isSelected, false, mdTheme, descRows, renderWidth)) {
316
391
  rows += this.#renderedLineRowCount(line, renderWidth);
317
392
  }
318
393
  return rows;
@@ -322,7 +397,7 @@ export class HookSelectorComponent extends Container {
322
397
  const themeForRows = mdTheme ?? getMarkdownTheme();
323
398
  let rows = 0;
324
399
  for (const option of options) {
325
- rows += this.#optionRowCount(option, renderWidth, false, themeForRows);
400
+ rows += this.#optionRowCount(option, renderWidth, false, themeForRows, "full");
326
401
  }
327
402
  return rows;
328
403
  }
@@ -331,19 +406,37 @@ export class HookSelectorComponent extends Container {
331
406
  total: number,
332
407
  renderWidth?: number,
333
408
  mdTheme: MarkdownTheme = getMarkdownTheme(),
409
+ compact = false,
334
410
  ): { startIndex: number; endIndex: number } {
335
411
  if (total === 0) return { startIndex: 0, endIndex: 0 };
336
412
 
413
+ // In compact mode every option contributes only its label rows; the
414
+ // highlighted option's description is layered on afterwards (see
415
+ // #updateList), so the window is sized to keep as many labels visible as
416
+ // possible rather than letting one long description swallow the budget.
417
+ const descMode: number | "full" = compact ? 0 : "full";
337
418
  const rowBudget = Math.max(1, this.#maxVisible);
338
419
  const selectedIndex = Math.max(0, Math.min(this.#selectedIndex, total - 1));
339
420
  let startIndex = selectedIndex;
340
421
  let endIndex = selectedIndex + 1;
341
- let rows = this.#optionRowCount(this.#filteredOptions[selectedIndex]!.option, renderWidth, true, mdTheme);
422
+ let rows = this.#optionRowCount(
423
+ this.#filteredOptions[selectedIndex]!.option,
424
+ renderWidth,
425
+ true,
426
+ mdTheme,
427
+ descMode,
428
+ );
342
429
  let beforeRows = 0;
343
430
  const targetBeforeRows = Math.max(0, Math.floor((rowBudget - rows) / 2));
344
431
 
345
432
  while (startIndex > 0) {
346
- const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme);
433
+ const cost = this.#optionRowCount(
434
+ this.#filteredOptions[startIndex - 1]!.option,
435
+ renderWidth,
436
+ false,
437
+ mdTheme,
438
+ descMode,
439
+ );
347
440
  if (beforeRows + cost > targetBeforeRows || rows + cost > rowBudget) break;
348
441
  startIndex--;
349
442
  beforeRows += cost;
@@ -351,14 +444,26 @@ export class HookSelectorComponent extends Container {
351
444
  }
352
445
 
353
446
  while (endIndex < total) {
354
- const cost = this.#optionRowCount(this.#filteredOptions[endIndex]!.option, renderWidth, false, mdTheme);
447
+ const cost = this.#optionRowCount(
448
+ this.#filteredOptions[endIndex]!.option,
449
+ renderWidth,
450
+ false,
451
+ mdTheme,
452
+ descMode,
453
+ );
355
454
  if (rows + cost > rowBudget) break;
356
455
  endIndex++;
357
456
  rows += cost;
358
457
  }
359
458
 
360
459
  while (startIndex > 0) {
361
- const cost = this.#optionRowCount(this.#filteredOptions[startIndex - 1]!.option, renderWidth, false, mdTheme);
460
+ const cost = this.#optionRowCount(
461
+ this.#filteredOptions[startIndex - 1]!.option,
462
+ renderWidth,
463
+ false,
464
+ mdTheme,
465
+ descMode,
466
+ );
362
467
  if (rows + cost > rowBudget) break;
363
468
  startIndex--;
364
469
  rows += cost;
@@ -371,13 +476,43 @@ export class HookSelectorComponent extends Container {
371
476
  const lines: string[] = [];
372
477
  const total = this.#filteredOptions.length;
373
478
  const mdTheme = getMarkdownTheme();
374
- const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme);
479
+ // Compact mode kicks in exactly when the fully-expanded list (all
480
+ // descriptions) would overflow the row budget — the same condition that
481
+ // enables search. There we collapse every option to its label and show
482
+ // only the highlighted option's description, so the whole menu stays
483
+ // visible on short terminals instead of collapsing to a single entry.
484
+ const compact = this.#isSearchEnabled(renderWidth, mdTheme);
485
+ const { startIndex, endIndex } = this.#getVisibleOptionRange(total, renderWidth, mdTheme, compact);
486
+
487
+ let selectedDescRows = 0;
488
+ if (compact && renderWidth !== undefined) {
489
+ let labelRows = 0;
490
+ for (let i = startIndex; i < endIndex; i++) {
491
+ const filtered = this.#filteredOptions[i];
492
+ if (filtered === undefined) continue;
493
+ labelRows += this.#optionRowCount(filtered.option, renderWidth, i === this.#selectedIndex, mdTheme, 0);
494
+ }
495
+ // Reserve one row for the status line; give the remainder to the
496
+ // highlighted option's description.
497
+ selectedDescRows = Math.max(0, Math.max(1, this.#maxVisible) - labelRows - 1);
498
+ }
375
499
 
376
500
  for (let i = startIndex; i < endIndex; i++) {
377
501
  const filtered = this.#filteredOptions[i];
378
502
  if (filtered === undefined) continue;
379
503
  const isSelected = i === this.#selectedIndex;
380
- lines.push(...this.#renderOptionLines(filtered.option, isSelected, this.#isDisabled(filtered.index), mdTheme));
504
+ const descMode: number | "full" = compact ? (isSelected ? selectedDescRows : 0) : "full";
505
+ lines.push(
506
+ ...this.#renderOptionLines(
507
+ filtered.option,
508
+ isSelected,
509
+ this.#isDisabled(filtered.index),
510
+ mdTheme,
511
+ descMode,
512
+ renderWidth,
513
+ filtered.index,
514
+ ),
515
+ );
381
516
  }
382
517
 
383
518
  if (total === 0) {
@@ -14,11 +14,34 @@ import {
14
14
  import { formatBytes } from "@oh-my-pi/pi-utils";
15
15
  import { theme } from "../../modes/theme/theme";
16
16
  import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../../modes/utils/keybinding-matchers";
17
- import type { SessionInfo } from "../../session/session-manager";
17
+ import type { SessionInfo, SessionStatus } from "../../session/session-manager";
18
18
  import { shortenPath } from "../../tools/render-utils";
19
19
  import { DynamicBorder } from "./dynamic-border";
20
20
  import { HookSelectorComponent } from "./hook-selector";
21
21
 
22
+ /**
23
+ * Themed glyph + colored label for a session's lifecycle status, or `undefined`
24
+ * when there is nothing useful to show (`unknown`/unset) so the metadata line
25
+ * stays uncluttered. The glyph resolves through the active symbol preset
26
+ * (nerdfont / unicode / ascii) via `theme.status.*`.
27
+ */
28
+ function formatSessionStatus(status: SessionStatus | undefined): string | undefined {
29
+ switch (status) {
30
+ case "complete":
31
+ return theme.fg("success", `${theme.status.success} done`);
32
+ case "interrupted":
33
+ return theme.fg("warning", `${theme.status.warning} interrupted`);
34
+ case "aborted":
35
+ return theme.fg("muted", `${theme.status.aborted} aborted`);
36
+ case "error":
37
+ return theme.fg("error", `${theme.status.error} error`);
38
+ case "pending":
39
+ return theme.fg("accent", `${theme.status.pending} pending`);
40
+ default:
41
+ return undefined;
42
+ }
43
+ }
44
+
22
45
  /** Returns the IDs of sessions whose recorded prompts match a query, best first. */
23
46
  export type SessionHistoryMatcher = (query: string) => string[];
24
47
 
@@ -227,13 +250,21 @@ class SessionList implements Component {
227
250
  lines.push(messageLine);
228
251
  }
229
252
 
230
- // Metadata line: date + file size (+ project dir in all-projects scope)
253
+ // Metadata line: date + file size + lifecycle status (+ project dir in
254
+ // all-projects scope). The status segment carries its own color, so each
255
+ // segment is dimmed individually rather than wrapping the whole line.
256
+ const dim = (s: string) => theme.fg("dim", s);
257
+ const dot = dim(theme.sep.dot);
231
258
  const modified = formatDate(session.modified);
232
- let metadata = ` ${modified} ${theme.sep.dot} ${formatBytes(session.size)}`;
259
+ let metadata = ` ${dim(modified)} ${dot} ${dim(formatBytes(session.size))}`;
260
+ const status = formatSessionStatus(session.status);
261
+ if (status) {
262
+ metadata += ` ${dot} ${status}`;
263
+ }
233
264
  if (this.#showCwd && session.cwd) {
234
- metadata += ` ${theme.sep.dot} ${shortenPath(session.cwd)}`;
265
+ metadata += ` ${dot} ${dim(shortenPath(session.cwd))}`;
235
266
  }
236
- const metadataLine = theme.fg("dim", truncateToWidth(metadata, width));
267
+ const metadataLine = truncateToWidth(metadata, width);
237
268
 
238
269
  lines.push(metadataLine);
239
270
  lines.push(""); // Blank line between sessions
@@ -485,7 +485,8 @@ const sessionNameSegment: StatusLineSegment = {
485
485
  const name = sessionManager?.getSessionName();
486
486
  if (!name) return { content: "", visible: false };
487
487
 
488
- const ansi = getSessionAccentAnsi(getSessionAccentHex(name)) ?? theme.getFgAnsi("accent");
488
+ const ansi =
489
+ getSessionAccentAnsi(getSessionAccentHex(name, theme.accentSurfaceLuminance)) ?? theme.getFgAnsi("accent");
489
490
  return { content: `${ansi}${sanitizeStatusText(name)}\x1b[39m`, visible: true };
490
491
  },
491
492
  };
@@ -764,7 +764,7 @@ export class StatusLineComponent implements Component {
764
764
  const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
765
765
  const sessionName =
766
766
  effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
767
- const accentHex = sessionName ? getSessionAccentHex(sessionName) : undefined;
767
+ const accentHex = sessionName ? getSessionAccentHex(sessionName, theme.accentSurfaceLuminance) : undefined;
768
768
  const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
769
769
  const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
770
770
  return leftGroup + gapFill + rightGroup;
@@ -5,7 +5,7 @@ Find out which model you emotionally abuse the most with `omp stats`
5
5
  Try task isolation to create CoW worktrees
6
6
  Your LLM can call an LLM using `llm(x...)`. Have a big batch of tasks? Ask clanker to use it!
7
7
  Spaghetti code? Try complaining with /omfg
8
- Did you know? Each kitty/tmux split keeps its own session — `omp -c` resumes the right one
8
+ Did you know? Each kitty/tmux/cmux split keeps its own session — `omp -c` resumes the right one
9
9
  Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
10
10
  Say `orchestrate` in your message to drive a multi-phase task with parallel subagents — watch it glow as you type
11
11
  Say `workflow` in your message to drive the task with parallel subagents in eval — watch it glow as you type
@@ -1,6 +1,6 @@
1
1
  import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
2
2
  import { theme } from "../../modes/theme/theme";
3
- import type { TodoItem } from "../../tools/todo-write";
3
+ import type { TodoItem } from "../../tools/todo";
4
4
 
5
5
  /**
6
6
  * Component that renders a todo completion reminder notification.
@@ -32,7 +32,7 @@ import {
32
32
  } from "../../tools/json-tree";
33
33
  import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
34
34
  import { toolRenderers } from "../../tools/renderers";
35
- import { TODO_WRITE_STRIKE_TOTAL_FRAMES } from "../../tools/todo-write";
35
+ import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
36
36
  import { renderStatusLine } from "../../tui";
37
37
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
38
38
  import { renderDiff } from "./diff";
@@ -149,6 +149,10 @@ const SPINNER_RENDER_INTERVAL_MS = 16;
149
149
  * 60fps render cadence (mirrors `Loader`). */
150
150
  const SPINNER_GLYPH_ADVANCE_MS = 80;
151
151
 
152
+ // Stable per-instance counter so each tool execution's inline images get a
153
+ // graphics id that survives child re-creation (the image budget keys off it).
154
+ let toolExecutionInstanceSeq = 0;
155
+
152
156
  /**
153
157
  * Component that renders a tool call with its result (updateable)
154
158
  */
@@ -158,6 +162,7 @@ export class ToolExecutionComponent extends Container {
158
162
  #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
159
163
  #imageComponents: Image[] = [];
160
164
  #imageSpacers: Spacer[] = [];
165
+ readonly #instanceId = ++toolExecutionInstanceSeq;
161
166
  #toolName: string;
162
167
  #toolLabel: string;
163
168
  #args: any;
@@ -447,7 +452,7 @@ export class ToolExecutionComponent extends Container {
447
452
  }
448
453
 
449
454
  #updateTodoStrikeAnimation(): void {
450
- if (this.#toolName !== "todo_write" || this.#isPartial || this.#result?.isError) {
455
+ if (this.#toolName !== "todo" || this.#isPartial || this.#result?.isError) {
451
456
  this.#stopTodoStrikeAnimation();
452
457
  return;
453
458
  }
@@ -462,7 +467,7 @@ export class ToolExecutionComponent extends Container {
462
467
  this.#renderState.spinnerFrame = 0;
463
468
  this.#todoStrikeInterval = setInterval(() => {
464
469
  const nextFrame = (this.#spinnerFrame ?? 0) + 1;
465
- if (nextFrame > TODO_WRITE_STRIKE_TOTAL_FRAMES) {
470
+ if (nextFrame > TODO_STRIKE_TOTAL_FRAMES) {
466
471
  this.#stopTodoStrikeAnimation();
467
472
  } else {
468
473
  this.#spinnerFrame = nextFrame;
@@ -754,7 +759,7 @@ export class ToolExecutionComponent extends Container {
754
759
  imageData,
755
760
  imageMimeType,
756
761
  { fallbackColor: (s: string) => theme.fg("toolOutput", s) },
757
- resolveImageOptions(),
762
+ { ...resolveImageOptions(), budget: this.#ui.imageBudget, imageKey: `te${this.#instanceId}:${i}` },
758
763
  );
759
764
  this.#imageComponents.push(imageComponent);
760
765
  this.addChild(imageComponent);