@oh-my-pi/pi-coding-agent 15.8.3 → 15.9.1

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 (172) hide show
  1. package/CHANGELOG.md +113 -31
  2. package/dist/types/capability/skill.d.ts +7 -0
  3. package/dist/types/cli/update-cli.d.ts +15 -1
  4. package/dist/types/config/append-only-context-mode.d.ts +8 -0
  5. package/dist/types/config/model-registry.d.ts +3 -0
  6. package/dist/types/config/models-config-schema.d.ts +15 -0
  7. package/dist/types/config/settings-schema.d.ts +38 -24
  8. package/dist/types/debug/protocol-probe.d.ts +38 -0
  9. package/dist/types/debug/terminal-info.d.ts +34 -0
  10. package/dist/types/exa/mcp-client.d.ts +2 -1
  11. package/dist/types/export/html/template.generated.d.ts +1 -1
  12. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +11 -0
  14. package/dist/types/extensibility/shared-events.d.ts +1 -1
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/mcp/json-rpc.d.ts +6 -1
  17. package/dist/types/mcp/tool-bridge.d.ts +4 -0
  18. package/dist/types/mcp/transports/stdio.d.ts +16 -7
  19. package/dist/types/mnemopi/state.d.ts +2 -2
  20. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  21. package/dist/types/modes/components/assistant-message.d.ts +3 -2
  22. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +1 -0
  23. package/dist/types/modes/components/hook-selector.d.ts +11 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +40 -8
  25. package/dist/types/modes/components/session-selector.d.ts +8 -3
  26. package/dist/types/modes/components/settings-selector.d.ts +1 -1
  27. package/dist/types/modes/components/todo-reminder.d.ts +1 -1
  28. package/dist/types/modes/components/transcript-container.d.ts +36 -0
  29. package/dist/types/modes/interactive-mode.d.ts +2 -1
  30. package/dist/types/modes/rpc/rpc-types.d.ts +1 -1
  31. package/dist/types/modes/theme/theme.d.ts +20 -1
  32. package/dist/types/modes/utils/keybinding-matchers.d.ts +4 -0
  33. package/dist/types/plan-mode/plan-handoff.d.ts +20 -0
  34. package/dist/types/session/agent-session.d.ts +5 -2
  35. package/dist/types/session/history-storage.d.ts +3 -4
  36. package/dist/types/session/indexed-session-storage.d.ts +59 -0
  37. package/dist/types/session/messages.d.ts +1 -0
  38. package/dist/types/session/redis-session-storage.d.ts +12 -85
  39. package/dist/types/session/session-manager.d.ts +21 -0
  40. package/dist/types/session/session-storage.d.ts +5 -7
  41. package/dist/types/session/sql-session-storage.d.ts +16 -85
  42. package/dist/types/slash-commands/types.d.ts +17 -4
  43. package/dist/types/task/executor.d.ts +9 -0
  44. package/dist/types/task/index.d.ts +3 -1
  45. package/dist/types/telemetry-export.d.ts +19 -0
  46. package/dist/types/tiny/compiled-runtime.d.ts +35 -0
  47. package/dist/types/tiny/text.d.ts +17 -0
  48. package/dist/types/tools/ask.d.ts +1 -0
  49. package/dist/types/tools/index.d.ts +4 -2
  50. package/dist/types/tools/path-utils.d.ts +1 -1
  51. package/dist/types/tools/search.d.ts +2 -2
  52. package/dist/types/tools/{todo-write.d.ts → todo.d.ts} +18 -18
  53. package/dist/types/utils/session-color.d.ts +7 -2
  54. package/dist/types/web/search/index.d.ts +1 -1
  55. package/dist/types/web/search/provider.d.ts +2 -2
  56. package/dist/types/web/search/providers/base.d.ts +14 -0
  57. package/dist/types/web/search/providers/exa.d.ts +9 -0
  58. package/dist/types/web/search/providers/perplexity.d.ts +2 -2
  59. package/dist/types/web/search/types.d.ts +66 -1
  60. package/package.json +15 -9
  61. package/scripts/build-binary.ts +12 -0
  62. package/src/capability/skill.ts +7 -0
  63. package/src/cli/args.ts +1 -1
  64. package/src/cli/session-picker.ts +1 -0
  65. package/src/cli/update-cli.ts +54 -2
  66. package/src/commands/completions.ts +1 -1
  67. package/src/config/append-only-context-mode.ts +37 -0
  68. package/src/config/models-config-schema.ts +1 -0
  69. package/src/config/settings-schema.ts +24 -57
  70. package/src/debug/index.ts +67 -1
  71. package/src/debug/protocol-probe.ts +267 -0
  72. package/src/debug/terminal-info.ts +127 -0
  73. package/src/exa/mcp-client.ts +11 -5
  74. package/src/export/html/template.generated.ts +1 -1
  75. package/src/export/html/template.js +3 -3
  76. package/src/extensibility/custom-tools/types.ts +1 -1
  77. package/src/extensibility/extensions/types.ts +11 -0
  78. package/src/extensibility/shared-events.ts +1 -1
  79. package/src/extensibility/skills.ts +3 -3
  80. package/src/index.ts +1 -0
  81. package/src/internal-urls/docs-index.generated.ts +7 -7
  82. package/src/main.ts +16 -2
  83. package/src/mcp/json-rpc.ts +8 -0
  84. package/src/mcp/render.ts +3 -0
  85. package/src/mcp/tool-bridge.ts +10 -2
  86. package/src/mcp/transports/http.ts +33 -16
  87. package/src/mcp/transports/stdio.ts +37 -12
  88. package/src/mnemopi/state.ts +4 -4
  89. package/src/modes/acp/acp-agent.ts +168 -3
  90. package/src/modes/acp/acp-event-mapper.ts +7 -7
  91. package/src/modes/components/agent-dashboard.ts +103 -31
  92. package/src/modes/components/assistant-message.ts +3 -2
  93. package/src/modes/components/extensions/extension-dashboard.ts +56 -10
  94. package/src/modes/components/history-search.ts +128 -14
  95. package/src/modes/components/hook-selector.ts +149 -14
  96. package/src/modes/components/plugin-settings.ts +270 -36
  97. package/src/modes/components/session-selector.ts +81 -19
  98. package/src/modes/components/settings-selector.ts +1 -1
  99. package/src/modes/components/status-line/segments.ts +2 -1
  100. package/src/modes/components/status-line.ts +1 -1
  101. package/src/modes/components/tips.txt +6 -2
  102. package/src/modes/components/todo-reminder.ts +1 -1
  103. package/src/modes/components/tool-execution.ts +9 -4
  104. package/src/modes/components/transcript-container.ts +109 -0
  105. package/src/modes/controllers/command-controller.ts +4 -3
  106. package/src/modes/controllers/event-controller.ts +12 -7
  107. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  108. package/src/modes/controllers/input-controller.ts +10 -5
  109. package/src/modes/controllers/selector-controller.ts +30 -19
  110. package/src/modes/controllers/todo-command-controller.ts +1 -1
  111. package/src/modes/interactive-mode.ts +56 -9
  112. package/src/modes/print-mode.ts +5 -0
  113. package/src/modes/rpc/rpc-types.ts +1 -1
  114. package/src/modes/theme/theme.ts +48 -8
  115. package/src/modes/utils/keybinding-matchers.ts +10 -0
  116. package/src/modes/utils/ui-helpers.ts +1 -0
  117. package/src/plan-mode/plan-handoff.ts +37 -0
  118. package/src/priority.json +4 -0
  119. package/src/prompts/goals/goal-continuation.md +1 -1
  120. package/src/prompts/steering/user-interjection.md +10 -0
  121. package/src/prompts/system/agent-creation-architect.md +1 -26
  122. package/src/prompts/system/eager-todo.md +3 -3
  123. package/src/prompts/system/orchestrate-notice.md +5 -5
  124. package/src/prompts/system/plan-mode-approved.md +14 -17
  125. package/src/prompts/system/plan-mode-reference.md +3 -6
  126. package/src/prompts/system/subagent-system-prompt.md +11 -0
  127. package/src/prompts/system/system-prompt.md +143 -145
  128. package/src/prompts/system/title-system.md +3 -2
  129. package/src/prompts/system/workflow-notice.md +1 -1
  130. package/src/prompts/tools/browser.md +33 -30
  131. package/src/prompts/tools/render-mermaid.md +2 -2
  132. package/src/prompts/tools/search.md +1 -1
  133. package/src/prompts/tools/task.md +3 -1
  134. package/src/prompts/tools/{todo-write.md → todo.md} +5 -5
  135. package/src/sdk.ts +6 -21
  136. package/src/session/agent-session.ts +44 -21
  137. package/src/session/history-storage.ts +11 -18
  138. package/src/session/indexed-session-storage.ts +430 -0
  139. package/src/session/messages.ts +80 -0
  140. package/src/session/redis-session-storage.ts +66 -377
  141. package/src/session/session-manager.ts +109 -23
  142. package/src/session/session-storage.ts +148 -68
  143. package/src/session/sql-session-storage.ts +131 -382
  144. package/src/slash-commands/helpers/todo.ts +2 -2
  145. package/src/slash-commands/types.ts +27 -10
  146. package/src/task/executor.ts +9 -1
  147. package/src/task/index.ts +51 -1
  148. package/src/telemetry-export.ts +126 -0
  149. package/src/tiny/compiled-runtime.ts +179 -0
  150. package/src/tiny/text.ts +112 -1
  151. package/src/tiny/worker.ts +24 -2
  152. package/src/tools/ask.ts +133 -87
  153. package/src/tools/fetch.ts +17 -4
  154. package/src/tools/find.ts +2 -2
  155. package/src/tools/index.ts +6 -4
  156. package/src/tools/memory-recall.ts +1 -1
  157. package/src/tools/memory-reflect.ts +1 -1
  158. package/src/tools/path-utils.ts +16 -7
  159. package/src/tools/renderers.ts +2 -2
  160. package/src/tools/search.ts +6 -3
  161. package/src/tools/ssh.ts +26 -10
  162. package/src/tools/{todo-write.ts → todo.ts} +32 -35
  163. package/src/tools/write.ts +14 -2
  164. package/src/tui/status-line.ts +15 -4
  165. package/src/utils/session-color.ts +39 -14
  166. package/src/utils/title-generator.ts +9 -2
  167. package/src/web/search/index.ts +4 -2
  168. package/src/web/search/provider.ts +19 -35
  169. package/src/web/search/providers/base.ts +17 -0
  170. package/src/web/search/providers/exa.ts +111 -7
  171. package/src/web/search/providers/perplexity.ts +8 -4
  172. package/src/web/search/types.ts +74 -32
@@ -6,7 +6,7 @@ import chalk from "chalk";
6
6
  import * as z from "zod/v4";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
8
  import type { Theme } from "../modes/theme/theme";
9
- import todoWriteDescription from "../prompts/tools/todo-write.md" with { type: "text" };
9
+ import todoDescription from "../prompts/tools/todo.md" with { type: "text" };
10
10
  import type { ToolSession } from "../sdk";
11
11
  import type { SessionEntry } from "../session/session-manager";
12
12
  import { renderStatusLine, renderTreeList } from "../tui";
@@ -40,7 +40,7 @@ export interface TodoCompletionTransition {
40
40
  content: string;
41
41
  }
42
42
 
43
- export interface TodoWriteToolDetails {
43
+ export interface TodoToolDetails {
44
44
  phases: TodoPhase[];
45
45
  storage: "session" | "memory";
46
46
  completedTasks?: TodoCompletionTransition[];
@@ -68,14 +68,14 @@ const TodoOpEntry = z.object({
68
68
  text: z.string().optional().describe("note text"),
69
69
  });
70
70
 
71
- const todoWriteSchema = z
71
+ const todoSchema = z
72
72
  .object({
73
73
  ops: z.array(TodoOpEntry).min(1).describe("ordered todo operations"),
74
74
  })
75
75
  .describe("apply ordered todo operations");
76
76
 
77
- type TodoWriteParams = z.infer<typeof todoWriteSchema>;
78
- type TodoOpEntryValue = TodoWriteParams["ops"][number];
77
+ type TodoParams = z.infer<typeof todoSchema>;
78
+ type TodoOpEntryValue = TodoParams["ops"][number];
79
79
 
80
80
  // =============================================================================
81
81
  // State helpers
@@ -159,7 +159,7 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha
159
159
  }
160
160
  if (entry.type !== "message") continue;
161
161
  const message = entry.message as { role?: string; toolName?: string; details?: unknown; isError?: boolean };
162
- if (message.role !== "toolResult" || message.toolName !== "todo_write" || message.isError) continue;
162
+ if (message.role !== "toolResult" || message.toolName !== "todo" || message.isError) continue;
163
163
 
164
164
  const details = message.details as { phases?: unknown } | undefined;
165
165
  if (!details || !Array.isArray(details.phases)) continue;
@@ -178,7 +178,7 @@ export function getLatestTodoPhasesFromEntries(entries: SessionEntry[]): TodoPha
178
178
  * the caller can render a `+N more` hint. When every task in `tasks` is
179
179
  * closed (completed or abandoned), returns the trailing `maxVisible` tasks
180
180
  * with `hiddenOpenCount = 0`, so the panel keeps useful context until the
181
- * active-phase pointer advances on the next `todo_write`.
181
+ * active-phase pointer advances on the next `todo`.
182
182
  *
183
183
  * Task identity and order are preserved — this is a slice, never a sort.
184
184
  */
@@ -383,7 +383,7 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
383
383
  }
384
384
  }
385
385
 
386
- function applyParams(phases: TodoPhase[], params: TodoWriteParams): { phases: TodoPhase[]; errors: string[] } {
386
+ function applyParams(phases: TodoPhase[], params: TodoParams): { phases: TodoPhase[]; errors: string[] } {
387
387
  const errors: string[] = [];
388
388
  let next = phases;
389
389
  for (const entry of params.ops) {
@@ -393,10 +393,10 @@ function applyParams(phases: TodoPhase[], params: TodoWriteParams): { phases: To
393
393
  return { phases: next, errors };
394
394
  }
395
395
 
396
- /** Apply an array of `todo_write`-style ops to existing phases. Used by /todo slash command. */
396
+ /** Apply an array of `todo`-style ops to existing phases. Used by /todo slash command. */
397
397
  export function applyOpsToPhases(
398
398
  currentPhases: TodoPhase[],
399
- ops: TodoWriteParams["ops"],
399
+ ops: TodoParams["ops"],
400
400
  ): { phases: TodoPhase[]; errors: string[] } {
401
401
  return applyParams(clonePhases(currentPhases), { ops });
402
402
  }
@@ -586,33 +586,33 @@ function formatSummary(phases: TodoPhase[], errors: string[]): string {
586
586
  // Tool Class
587
587
  // =============================================================================
588
588
 
589
- export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWriteToolDetails> {
590
- readonly name = "todo_write";
589
+ export class TodoTool implements AgentTool<typeof todoSchema, TodoToolDetails> {
590
+ readonly name = "todo";
591
591
  readonly approval = "read" as const;
592
- readonly label = "Todo Write";
592
+ readonly label = "Todo";
593
593
  readonly summary = "Write a structured todo list to track progress within a session";
594
594
  readonly description: string;
595
- readonly parameters = todoWriteSchema;
595
+ readonly parameters = todoSchema;
596
596
  readonly concurrency = "exclusive";
597
597
  readonly strict = true;
598
598
  readonly loadMode = "discoverable";
599
599
  constructor(private readonly session: ToolSession) {
600
- this.description = prompt.render(todoWriteDescription);
600
+ this.description = prompt.render(todoDescription);
601
601
  }
602
602
 
603
603
  async execute(
604
604
  _toolCallId: string,
605
- params: TodoWriteParams,
605
+ params: TodoParams,
606
606
  _signal?: AbortSignal,
607
- _onUpdate?: AgentToolUpdateCallback<TodoWriteToolDetails>,
607
+ _onUpdate?: AgentToolUpdateCallback<TodoToolDetails>,
608
608
  _context?: AgentToolContext,
609
- ): Promise<AgentToolResult<TodoWriteToolDetails>> {
609
+ ): Promise<AgentToolResult<TodoToolDetails>> {
610
610
  const previousPhases = clonePhases(this.session.getTodoPhases?.() ?? []);
611
611
  const { phases: updated, errors } = applyParams(clonePhases(previousPhases), params);
612
612
  const completedTasks = getCompletionTransitions(previousPhases, updated);
613
613
  this.session.setTodoPhases?.(updated);
614
614
  const storage = this.session.getSessionFile() ? "session" : "memory";
615
- const details: TodoWriteToolDetails = { phases: updated, storage };
615
+ const details: TodoToolDetails = { phases: updated, storage };
616
616
  if (completedTasks.length > 0) details.completedTasks = completedTasks;
617
617
 
618
618
  return {
@@ -627,7 +627,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
627
627
  // TUI Renderer
628
628
  // =============================================================================
629
629
 
630
- type TodoWriteRenderArgs = {
630
+ type TodoRenderArgs = {
631
631
  ops?: Array<{
632
632
  op?: string;
633
633
  task?: string;
@@ -701,9 +701,9 @@ function noteMarker(count: number, uiTheme: Theme): string {
701
701
  return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
702
702
  }
703
703
 
704
- export const TODO_WRITE_STRIKE_HOLD_FRAMES = 2;
705
- export const TODO_WRITE_STRIKE_REVEAL_FRAMES = 12;
706
- export const TODO_WRITE_STRIKE_TOTAL_FRAMES = TODO_WRITE_STRIKE_HOLD_FRAMES + TODO_WRITE_STRIKE_REVEAL_FRAMES;
704
+ export const TODO_STRIKE_HOLD_FRAMES = 2;
705
+ export const TODO_STRIKE_REVEAL_FRAMES = 12;
706
+ export const TODO_STRIKE_TOTAL_FRAMES = TODO_STRIKE_HOLD_FRAMES + TODO_STRIKE_REVEAL_FRAMES;
707
707
  const EMPTY_COMPLETION_KEYS = new Set<string>();
708
708
  const STRIKE_START = "\x1b[9m";
709
709
  const STRIKE_END = "\x1b[29m";
@@ -721,11 +721,11 @@ function partialStrikethrough(text: string, visibleChars: number): string {
721
721
 
722
722
  function strikeRevealCount(text: string, frame: number | undefined): number | undefined {
723
723
  if (frame === undefined) return undefined;
724
- if (frame <= TODO_WRITE_STRIKE_HOLD_FRAMES) return 0;
724
+ if (frame <= TODO_STRIKE_HOLD_FRAMES) return 0;
725
725
  const chars = [...text];
726
726
  if (chars.length === 0) return undefined;
727
- const revealFrame = Math.min(frame - TODO_WRITE_STRIKE_HOLD_FRAMES, TODO_WRITE_STRIKE_REVEAL_FRAMES);
728
- return Math.ceil((chars.length * revealFrame) / TODO_WRITE_STRIKE_REVEAL_FRAMES);
727
+ const revealFrame = Math.min(frame - TODO_STRIKE_HOLD_FRAMES, TODO_STRIKE_REVEAL_FRAMES);
728
+ return Math.ceil((chars.length * revealFrame) / TODO_STRIKE_REVEAL_FRAMES);
729
729
  }
730
730
 
731
731
  function formatTodoLine(
@@ -775,8 +775,8 @@ function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme): string[] {
775
775
  return lines;
776
776
  }
777
777
 
778
- export const todoWriteToolRenderer = {
779
- renderCall(args: TodoWriteRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
778
+ export const todoToolRenderer = {
779
+ renderCall(args: TodoRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
780
780
  const ops = args?.ops?.map(entry => {
781
781
  const parts = [entry.op ?? "update"];
782
782
  if (entry.task) parts.push(entry.task);
@@ -784,15 +784,15 @@ export const todoWriteToolRenderer = {
784
784
  if (entry.items?.length) parts.push(`${entry.items.length} item${entry.items.length === 1 ? "" : "s"}`);
785
785
  return parts.join(" ");
786
786
  }) ?? ["update"];
787
- const text = renderStatusLine({ icon: "pending", title: "Todo Write", meta: ops }, uiTheme);
787
+ const text = renderStatusLine({ icon: "pending", title: "Todo", meta: ops }, uiTheme);
788
788
  return new Text(text, 0, 0);
789
789
  },
790
790
 
791
791
  renderResult(
792
- result: { content: Array<{ type: string; text?: string }>; details?: TodoWriteToolDetails },
792
+ result: { content: Array<{ type: string; text?: string }>; details?: TodoToolDetails },
793
793
  options: RenderResultOptions,
794
794
  uiTheme: Theme,
795
- _args?: TodoWriteRenderArgs,
795
+ _args?: TodoRenderArgs,
796
796
  ): Component {
797
797
  const phases = (result.details?.phases ?? []).filter(phase => phase.tasks.length > 0);
798
798
  const completedTasks = result.details?.completedTasks ?? [];
@@ -806,10 +806,7 @@ export const todoWriteToolRenderer = {
806
806
  keys.add(task.content);
807
807
  }
808
808
  const allTasks = phases.flatMap(phase => phase.tasks);
809
- const header = renderStatusLine(
810
- { icon: "success", title: "Todo Write", meta: [`${allTasks.length} tasks`] },
811
- uiTheme,
812
- );
809
+ const header = renderStatusLine({ icon: "success", title: "Todo", meta: [`${allTasks.length} tasks`] }, uiTheme);
813
810
  if (allTasks.length === 0) {
814
811
  const fallback = result.content?.find(content => content.type === "text")?.text ?? "No todos";
815
812
  return new Text(`${header}\n${uiTheme.fg("dim", fallback)}`, 0, 0);
@@ -135,6 +135,15 @@ function maybeWriteSnapshotHeader(session: ToolSession, absolutePath: string, co
135
135
  return formatHashlineHeader(formatPathRelativeToCwd(absolutePath, session.cwd), tag);
136
136
  }
137
137
 
138
+ function shouldRouteWriteThroughBridge(session: ToolSession, requestedPath: string, absolutePath: string): boolean {
139
+ if (isInternalUrlPath(requestedPath)) return false;
140
+
141
+ const state = session.getPlanModeState?.();
142
+ if (!state?.enabled || !isInternalUrlPath(state.planFilePath)) return true;
143
+
144
+ return absolutePath !== resolvePlanPath(session, state.planFilePath);
145
+ }
146
+
138
147
  /**
139
148
  * Append a trailing note line to the first text block of a tool result.
140
149
  * Mutates `result` in place (the result object is owned by this call).
@@ -845,8 +854,11 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
845
854
  await assertEditableFile(absolutePath, path);
846
855
  }
847
856
 
848
- // Try ACP bridge first no disk write when client handles it
849
- const bridgePromise = this.#routeWriteThroughBridge(absolutePath, cleanContent);
857
+ // Try ACP bridge first for editor-visible filesystem paths. Internal
858
+ // artifacts such as local:// plans are owned by OMP, not the editor.
859
+ const bridgePromise = shouldRouteWriteThroughBridge(this.session, path, absolutePath)
860
+ ? this.#routeWriteThroughBridge(absolutePath, cleanContent)
861
+ : undefined;
850
862
  if (bridgePromise !== undefined) {
851
863
  try {
852
864
  await bridgePromise;
@@ -15,22 +15,33 @@ export interface StatusLineOptions {
15
15
  meta?: string[];
16
16
  }
17
17
 
18
+ /**
19
+ * Flatten CR/LF runs in caller-supplied header fragments so a single newline
20
+ * embedded in `description` or `meta` cannot expand the status line into
21
+ * multiple rows — which would otherwise break the bordered output block the
22
+ * header sits on. Tab characters are left alone; tool renderers that need
23
+ * tab-safe text run `replaceTabs()` themselves.
24
+ */
25
+ function flattenForHeader(text: string): string {
26
+ return text.replace(/\r\n?|\n/g, " ");
27
+ }
28
+
18
29
  export function renderStatusLine(options: StatusLineOptions, theme: Theme): string {
19
30
  const icon = options.icon ? formatStatusIcon(options.icon, theme, options.spinnerFrame) : "";
20
31
  const titleColor = options.titleColor ?? "accent";
21
- const title = theme.fg(titleColor, options.title);
32
+ const title = theme.fg(titleColor, flattenForHeader(options.title));
22
33
  let line = icon ? `${icon} ${title}` : title;
23
34
 
24
35
  if (options.description) {
25
- line += `: ${theme.fg("muted", options.description)}`;
36
+ line += `: ${theme.fg("muted", flattenForHeader(options.description))}`;
26
37
  }
27
38
 
28
39
  if (options.badge) {
29
40
  const { label, color } = options.badge;
30
- line += ` ${theme.fg(color, `${theme.format.bracketLeft}${label}${theme.format.bracketRight}`)}`;
41
+ line += ` ${theme.fg(color, `${theme.format.bracketLeft}${flattenForHeader(label)}${theme.format.bracketRight}`)}`;
31
42
  }
32
43
 
33
- const meta = options.meta?.filter(value => value.trim().length > 0) ?? [];
44
+ const meta = options.meta?.map(flattenForHeader).filter(value => value.trim().length > 0) ?? [];
34
45
  if (meta.length > 0) {
35
46
  line += ` ${theme.fg("dim", meta.join(theme.sep.dot))}`;
36
47
  }
@@ -1,3 +1,5 @@
1
+ import { hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
2
+
1
3
  /**
2
4
  * Derive a stable hue (0-359) from a string using djb2 hash.
3
5
  */
@@ -10,27 +12,50 @@ function nameToHue(name: string): number {
10
12
  return hash % 360;
11
13
  }
12
14
 
15
+ const ACCENT_SATURATION = 0.9;
16
+ const ACCENT_DARK_LIGHTNESS = 0.72;
17
+ /** Minimum contrast ratio (WCAG AA large text) between a light-theme accent and its surface. */
18
+ const ACCENT_MIN_CONTRAST = 3;
19
+
13
20
  /**
14
- * Convert HSL (h: 0-360, s: 0-1, l: 0-1) to a CSS hex string.
21
+ * Largest relative luminance an accent may have while still meeting
22
+ * {@link ACCENT_MIN_CONTRAST} against a surface of the given luminance.
15
23
  */
16
- function hslToHex(h: number, s: number, l: number): string {
17
- const a = s * Math.min(l, 1 - l);
18
- const f = (n: number) => {
19
- const k = (n + h / 30) % 12;
20
- const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
21
- return Math.round(255 * color)
22
- .toString(16)
23
- .padStart(2, "0");
24
- };
25
- return `#${f(0)}${f(8)}${f(4)}`;
24
+ function accentLuminanceCap(surfaceLuminance: number): number {
25
+ return Math.max(0, (surfaceLuminance + 0.05) / ACCENT_MIN_CONTRAST - 0.05);
26
26
  }
27
27
 
28
28
  /**
29
29
  * Derive a stable CSS hex accent color from a session name.
30
- * High saturation, vivid — suitable for both status bar text and border coloring.
30
+ *
31
+ * On dark themes (`surfaceLuminance` undefined) the accent is vivid (high
32
+ * saturation, high lightness). On light themes the lightness is reduced until the
33
+ * accent's perceived luminance clears {@link ACCENT_MIN_CONTRAST} against the
34
+ * actual surface it renders on — so it stays legible on near-white *and* mid-light
35
+ * backgrounds — while keeping the same per-session hue.
31
36
  */
32
- export function getSessionAccentHex(name: string): string {
33
- return hslToHex(nameToHue(name), 0.9, 0.72);
37
+ export function getSessionAccentHex(name: string, surfaceLuminance?: number): string {
38
+ const hue = nameToHue(name);
39
+ if (surfaceLuminance === undefined) {
40
+ return hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
41
+ }
42
+
43
+ const cap = accentLuminanceCap(surfaceLuminance);
44
+ const top = hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
45
+ if ((relativeLuminance(top) ?? 0) <= cap) return top;
46
+
47
+ // Bisect lightness: `lo` always yields luminance <= cap, `hi` always above it.
48
+ let lo = 0;
49
+ let hi = ACCENT_DARK_LIGHTNESS;
50
+ for (let i = 0; i < 20; i++) {
51
+ const mid = (lo + hi) / 2;
52
+ if ((relativeLuminance(hslToHex(hue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
53
+ hi = mid;
54
+ } else {
55
+ lo = mid;
56
+ }
57
+ }
58
+ return hslToHex(hue, ACCENT_SATURATION, lo);
34
59
  }
35
60
 
36
61
  /**
@@ -10,7 +10,7 @@ import { resolveRoleSelection } from "../config/model-resolver";
10
10
  import type { Settings } from "../config/settings";
11
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
12
12
  import { ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
13
- import { formatTitleUserMessage, normalizeGeneratedTitle } from "../tiny/text";
13
+ import { formatTitleUserMessage, isLowSignalTitleInput, normalizeGeneratedTitle } from "../tiny/text";
14
14
  import { tinyTitleClient } from "../tiny/title-client";
15
15
 
16
16
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
@@ -31,7 +31,8 @@ const setTitleTool: Tool = {
31
31
  properties: {
32
32
  title: {
33
33
  type: "string",
34
- description: "A concise 3-6 word title for the session.",
34
+ description:
35
+ 'A concise 3-6 word title for the session, or exactly "none" when the message carries no concrete task yet (greeting, small talk, vague).',
35
36
  },
36
37
  },
37
38
  required: ["title"],
@@ -144,6 +145,12 @@ export async function generateSessionTitle(
144
145
  currentModel?: Model<Api>,
145
146
  metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
146
147
  ): Promise<string | null> {
148
+ // Defer titling for greetings / acknowledgements / empty input. The default
149
+ // tiny title model can't reliably decline trivial input, so this happens
150
+ // deterministically before any model is invoked; the caller retries on the
151
+ // next user message while the session stays unnamed.
152
+ if (isLowSignalTitleInput(firstMessage)) return null;
153
+
147
154
  const tinyModel = settings.get("providers.tinyModel");
148
155
  if (tinyModel === ONLINE_TINY_TITLE_MODEL_KEY) {
149
156
  return generateTitleOnline(firstMessage, registry, settings, sessionId, currentModel, metadataResolver);
@@ -131,7 +131,9 @@ async function executeSearch(
131
131
  const providers =
132
132
  params.provider && params.provider !== "auto"
133
133
  ? await getSearchProvider(params.provider).then(async provider =>
134
- (await provider.isAvailable(authStorage)) ? [provider] : resolveProviderChain(authStorage, "auto"),
134
+ (await provider.isExplicitlyAvailable(authStorage))
135
+ ? [provider]
136
+ : resolveProviderChain(authStorage, "auto"),
135
137
  )
136
138
  : await resolveProviderChain(authStorage);
137
139
  if (providers.length === 0) {
@@ -220,7 +222,7 @@ export async function runSearchQuery(
220
222
  /**
221
223
  * Web search tool implementation.
222
224
  *
223
- * Supports Anthropic, Perplexity, Exa, Brave, Jina, Kimi, Gemini, Codex, Z.AI, SearXNG, and Synthetic providers with automatic fallback.
225
+ * Supports the configured web-search provider chain with automatic fallback.
224
226
  */
225
227
  export class WebSearchTool implements AgentTool<typeof webSearchSchema, SearchRenderDetails> {
226
228
  readonly name = "web_search";
@@ -5,15 +5,16 @@
5
5
  // fetch/parse/format helpers) and only one — at most — is needed per session,
6
6
  // so eager construction was wasted work at startup.
7
7
  //
8
- // The `label`/`id` metadata is kept inline so callers needing a display name
9
- // (error formatting, UI listings) do not force a load.
8
+ // Provider modules are loaded lazily; display metadata lives in types.ts so UI
9
+ // listings can share it without importing provider implementations.
10
10
 
11
11
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
12
12
  import type { SearchProvider } from "./providers/base";
13
- import type { SearchProviderId } from "./types";
13
+ import { SEARCH_PROVIDER_LABELS, SEARCH_PROVIDER_ORDER, type SearchProviderId } from "./types";
14
14
 
15
15
  export type { SearchParams } from "./providers/base";
16
16
  export { SearchProvider } from "./providers/base";
17
+ export { SEARCH_PROVIDER_ORDER } from "./types";
17
18
 
18
19
  interface ProviderMeta {
19
20
  id: SearchProviderId;
@@ -25,72 +26,72 @@ interface ProviderMeta {
25
26
  const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
26
27
  exa: {
27
28
  id: "exa",
28
- label: "Exa",
29
+ label: SEARCH_PROVIDER_LABELS.exa,
29
30
  load: async () => new (await import("./providers/exa")).ExaProvider(),
30
31
  },
31
32
  brave: {
32
33
  id: "brave",
33
- label: "Brave",
34
+ label: SEARCH_PROVIDER_LABELS.brave,
34
35
  load: async () => new (await import("./providers/brave")).BraveProvider(),
35
36
  },
36
37
  jina: {
37
38
  id: "jina",
38
- label: "Jina",
39
+ label: SEARCH_PROVIDER_LABELS.jina,
39
40
  load: async () => new (await import("./providers/jina")).JinaProvider(),
40
41
  },
41
42
  perplexity: {
42
43
  id: "perplexity",
43
- label: "Perplexity",
44
+ label: SEARCH_PROVIDER_LABELS.perplexity,
44
45
  load: async () => new (await import("./providers/perplexity")).PerplexityProvider(),
45
46
  },
46
47
  kimi: {
47
48
  id: "kimi",
48
- label: "Kimi",
49
+ label: SEARCH_PROVIDER_LABELS.kimi,
49
50
  load: async () => new (await import("./providers/kimi")).KimiProvider(),
50
51
  },
51
52
  zai: {
52
53
  id: "zai",
53
- label: "Z.AI",
54
+ label: SEARCH_PROVIDER_LABELS.zai,
54
55
  load: async () => new (await import("./providers/zai")).ZaiProvider(),
55
56
  },
56
57
  anthropic: {
57
58
  id: "anthropic",
58
- label: "Anthropic",
59
+ label: SEARCH_PROVIDER_LABELS.anthropic,
59
60
  load: async () => new (await import("./providers/anthropic")).AnthropicProvider(),
60
61
  },
61
62
  gemini: {
62
63
  id: "gemini",
63
- label: "Gemini",
64
+ label: SEARCH_PROVIDER_LABELS.gemini,
64
65
  load: async () => new (await import("./providers/gemini")).GeminiProvider(),
65
66
  },
66
67
  codex: {
67
68
  id: "codex",
68
- label: "OpenAI",
69
+ label: SEARCH_PROVIDER_LABELS.codex,
69
70
  load: async () => new (await import("./providers/codex")).CodexProvider(),
70
71
  },
71
72
  tavily: {
72
73
  id: "tavily",
73
- label: "Tavily",
74
+ label: SEARCH_PROVIDER_LABELS.tavily,
74
75
  load: async () => new (await import("./providers/tavily")).TavilyProvider(),
75
76
  },
76
77
  parallel: {
77
78
  id: "parallel",
78
- label: "Parallel",
79
+ label: SEARCH_PROVIDER_LABELS.parallel,
79
80
  load: async () => new (await import("./providers/parallel")).ParallelProvider(),
80
81
  },
81
82
  kagi: {
82
83
  id: "kagi",
83
- label: "Kagi",
84
+ label: SEARCH_PROVIDER_LABELS.kagi,
84
85
  load: async () => new (await import("./providers/kagi")).KagiProvider(),
85
86
  },
86
87
  synthetic: {
87
88
  id: "synthetic",
88
- label: "Synthetic",
89
+ label: SEARCH_PROVIDER_LABELS.synthetic,
89
90
  load: async () => new (await import("./providers/synthetic")).SyntheticProvider(),
90
91
  },
91
92
  searxng: {
92
93
  id: "searxng",
93
- label: "SearXNG",
94
+ label: SEARCH_PROVIDER_LABELS.searxng,
94
95
  load: async () => new (await import("./providers/searxng")).SearXNGProvider(),
95
96
  },
96
97
  };
@@ -118,23 +119,6 @@ export async function getSearchProvider(id: SearchProviderId): Promise<SearchPro
118
119
  return provider;
119
120
  }
120
121
 
121
- export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
122
- "tavily",
123
- "perplexity",
124
- "brave",
125
- "jina",
126
- "kimi",
127
- "anthropic",
128
- "gemini",
129
- "codex",
130
- "zai",
131
- "exa",
132
- "parallel",
133
- "kagi",
134
- "synthetic",
135
- "searxng",
136
- ];
137
-
138
122
  /** Preferred provider set via settings (default: auto) */
139
123
  let preferredProvId: SearchProviderId | "auto" = "auto";
140
124
 
@@ -156,7 +140,7 @@ export async function resolveProviderChain(
156
140
 
157
141
  if (preferredProvider !== "auto") {
158
142
  const provider = await getSearchProvider(preferredProvider);
159
- if (await provider.isAvailable(authStorage)) {
143
+ if (await provider.isExplicitlyAvailable(authStorage)) {
160
144
  providers.push(provider);
161
145
  }
162
146
  }
@@ -61,9 +61,26 @@ export abstract class SearchProvider {
61
61
  * Indicates whether this provider has the credentials/config it needs to
62
62
  * service a request right now. Implementations consult the passed
63
63
  * {@link AuthStorage} — never a sibling store.
64
+ *
65
+ * Drives auto-chain admission: providers that return `false` are skipped
66
+ * when {@link resolveProviderChain} walks the order. Explicit selection
67
+ * uses {@link isExplicitlyAvailable} instead.
64
68
  */
65
69
  abstract isAvailable(authStorage: AuthStorage): Promise<boolean> | boolean;
66
70
 
71
+ /**
72
+ * Returns `true` when this provider should run when the user explicitly
73
+ * selects it, even if {@link isAvailable} would reject it for the auto
74
+ * chain. Providers that ship an unauthenticated fallback (e.g. Exa's
75
+ * public MCP) override this so explicit selection still routes through
76
+ * the fallback rather than silently falling back to another provider.
77
+ *
78
+ * Defaults to mirroring {@link isAvailable}.
79
+ */
80
+ isExplicitlyAvailable(authStorage: AuthStorage): Promise<boolean> | boolean {
81
+ return this.isAvailable(authStorage);
82
+ }
83
+
67
84
  /**
68
85
  * Execute a search. Credentials MUST be resolved through `params.authStorage`.
69
86
  */