@oh-my-pi/pi-coding-agent 16.1.1 → 16.1.3

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 (108) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/cli.js +3090 -3115
  3. package/dist/types/cli/bench-cli.d.ts +2 -1
  4. package/dist/types/config/model-resolver.d.ts +3 -3
  5. package/dist/types/config/settings-schema.d.ts +1 -1
  6. package/dist/types/main.d.ts +2 -0
  7. package/dist/types/mnemopi/embed-client.d.ts +70 -0
  8. package/dist/types/mnemopi/embed-protocol.d.ts +52 -0
  9. package/dist/types/mnemopi/embed-worker.d.ts +12 -0
  10. package/dist/types/mnemopi/state.d.ts +9 -1
  11. package/dist/types/modes/components/assistant-message.d.ts +12 -0
  12. package/dist/types/modes/components/welcome.d.ts +1 -1
  13. package/dist/types/sdk.d.ts +19 -2
  14. package/dist/types/session/agent-storage.d.ts +2 -0
  15. package/dist/types/session/auth-broker-config.d.ts +34 -6
  16. package/dist/types/session/history-storage.d.ts +1 -1
  17. package/dist/types/system-prompt.d.ts +5 -1
  18. package/dist/types/task/executor.d.ts +10 -0
  19. package/dist/types/tools/find.d.ts +0 -2
  20. package/dist/types/tools/image-gen.d.ts +2 -2
  21. package/dist/types/tools/search.d.ts +3 -3
  22. package/dist/types/utils/image-loading.d.ts +1 -1
  23. package/dist/types/utils/ipc.d.ts +22 -0
  24. package/dist/types/web/search/providers/perplexity-auth.d.ts +37 -0
  25. package/package.json +12 -12
  26. package/scripts/measure-prompt-tokens.ts +63 -0
  27. package/src/cli/bench-cli.ts +64 -3
  28. package/src/cli/startup-cwd.ts +3 -13
  29. package/src/cli.ts +8 -0
  30. package/src/commands/token.ts +52 -33
  31. package/src/config/append-only-context-mode.ts +45 -0
  32. package/src/config/model-discovery.ts +3 -0
  33. package/src/config/model-registry.ts +21 -3
  34. package/src/config/model-resolver.ts +31 -8
  35. package/src/config/settings-schema.ts +1 -1
  36. package/src/cursor.ts +1 -1
  37. package/src/debug/raw-sse-buffer.ts +31 -10
  38. package/src/discovery/builtin-rules/ts-no-return-type.md +0 -1
  39. package/src/eval/py/prelude.py +1 -1
  40. package/src/export/html/tool-views.generated.js +1 -1
  41. package/src/extensibility/extensions/runner.ts +8 -2
  42. package/src/internal-urls/docs-index.generated.txt +1 -1
  43. package/src/lsp/client.ts +24 -0
  44. package/src/main.ts +29 -9
  45. package/src/mnemopi/backend.ts +49 -3
  46. package/src/mnemopi/embed-client.ts +401 -0
  47. package/src/mnemopi/embed-protocol.ts +35 -0
  48. package/src/mnemopi/embed-worker.ts +113 -0
  49. package/src/mnemopi/state.ts +29 -1
  50. package/src/modes/components/assistant-message.ts +86 -0
  51. package/src/modes/components/custom-editor.ts +1 -1
  52. package/src/modes/components/model-selector.ts +2 -2
  53. package/src/modes/components/tips.txt +2 -1
  54. package/src/modes/components/welcome.ts +87 -9
  55. package/src/modes/controllers/event-controller.ts +9 -1
  56. package/src/modes/controllers/selector-controller.ts +2 -2
  57. package/src/modes/theme/theme.ts +69 -0
  58. package/src/prompts/system/personalities/default.md +8 -16
  59. package/src/prompts/system/system-prompt.md +101 -115
  60. package/src/prompts/tools/ast-edit.md +10 -12
  61. package/src/prompts/tools/ast-grep.md +14 -18
  62. package/src/prompts/tools/bash.md +19 -21
  63. package/src/prompts/tools/browser.md +24 -24
  64. package/src/prompts/tools/checkpoint.md +0 -1
  65. package/src/prompts/tools/debug.md +11 -15
  66. package/src/prompts/tools/eval.md +27 -27
  67. package/src/prompts/tools/find.md +6 -10
  68. package/src/prompts/tools/github.md +11 -15
  69. package/src/prompts/tools/goal.md +0 -7
  70. package/src/prompts/tools/inspect-image.md +0 -1
  71. package/src/prompts/tools/irc.md +15 -24
  72. package/src/prompts/tools/job.md +5 -8
  73. package/src/prompts/tools/learn.md +2 -2
  74. package/src/prompts/tools/lsp.md +27 -30
  75. package/src/prompts/tools/manage-skill.md +4 -4
  76. package/src/prompts/tools/read.md +21 -23
  77. package/src/prompts/tools/replace.md +0 -1
  78. package/src/prompts/tools/resolve.md +4 -9
  79. package/src/prompts/tools/rewind.md +1 -1
  80. package/src/prompts/tools/search.md +8 -10
  81. package/src/prompts/tools/task.md +33 -38
  82. package/src/prompts/tools/todo.md +14 -18
  83. package/src/prompts/tools/web-search.md +0 -4
  84. package/src/prompts/tools/write.md +1 -1
  85. package/src/sdk.ts +53 -102
  86. package/src/session/agent-session.ts +25 -2
  87. package/src/session/agent-storage.ts +14 -0
  88. package/src/session/auth-broker-config.ts +37 -76
  89. package/src/session/history-storage.ts +13 -1
  90. package/src/session/session-history-format.ts +1 -1
  91. package/src/session/session-manager.ts +33 -6
  92. package/src/stt/asr-client.ts +2 -7
  93. package/src/system-prompt.ts +28 -8
  94. package/src/task/executor.ts +57 -0
  95. package/src/task/index.ts +15 -1
  96. package/src/tiny/title-client.ts +2 -7
  97. package/src/tools/browser.ts +1 -1
  98. package/src/tools/eval.ts +1 -1
  99. package/src/tools/find.ts +4 -17
  100. package/src/tools/image-gen.ts +4 -8
  101. package/src/tools/memory-edit.ts +1 -1
  102. package/src/tools/render-utils.ts +4 -1
  103. package/src/tools/search.ts +5 -5
  104. package/src/tts/tts-client.ts +2 -7
  105. package/src/utils/image-loading.ts +12 -2
  106. package/src/utils/ipc.ts +38 -0
  107. package/src/web/search/providers/perplexity-auth.ts +133 -0
  108. package/src/web/search/providers/perplexity.ts +2 -125
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Mnemopi local-embeddings worker. Loaded inside the dedicated subprocess
3
+ * spawned by `embed-client.ts` (re-entered through the agent CLI's hidden
4
+ * `__omp_worker_mnemopi_embed` selector). The whole point of this module is
5
+ * that `loadFastembed()` — and therefore `onnxruntime-node`'s NAPI
6
+ * constructor + finalizer — only ever runs in this child address space. The
7
+ * parent `SIGKILL`s us on shutdown so the destructor that crashes Bun on
8
+ * Windows shutdown (issue #3031, mnemopi sibling of #1606/#1607) never runs
9
+ * in either process.
10
+ */
11
+
12
+ import type { StandardEmbeddingModel } from "@oh-my-pi/pi-mnemopi/core";
13
+ import { loadFastembed } from "@oh-my-pi/pi-mnemopi/core/fastembed-runtime";
14
+ import type { MnemopiEmbedModelId, MnemopiEmbedTransport, MnemopiEmbedWorkerInbound } from "./embed-protocol";
15
+
16
+ interface LoadedModel {
17
+ model: MnemopiEmbedModelId;
18
+ cacheDir: string | undefined;
19
+ instance: {
20
+ embed(texts: string[], batchSize?: number): AsyncIterable<number[][]> | Iterable<number[][]>;
21
+ };
22
+ }
23
+
24
+ let loaded: Promise<LoadedModel> | null = null;
25
+ let loadedKey = "";
26
+
27
+ async function loadModel(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
28
+ const { FlagEmbedding } = await loadFastembed();
29
+ // Cast: `model` arrives as a string from the parent (resolved by
30
+ // mnemopi's `fastembedModelName`). Cast to the non-CUSTOM overload's
31
+ // argument so TypeScript picks the standard-model branch — the parent
32
+ // only ever passes pre-vetted fast-* identifiers.
33
+ const instance = await FlagEmbedding.init({
34
+ model: model as StandardEmbeddingModel,
35
+ cacheDir,
36
+ showDownloadProgress: false,
37
+ });
38
+ return { model, cacheDir, instance };
39
+ }
40
+
41
+ function ensureLoaded(model: MnemopiEmbedModelId, cacheDir: string | undefined): Promise<LoadedModel> {
42
+ const key = `${model}\u0000${cacheDir ?? ""}`;
43
+ if (loaded !== null && loadedKey === key) return loaded;
44
+ const loading = loadModel(model, cacheDir).catch(error => {
45
+ // Failed loads must not poison the cache — a retry with the same key
46
+ // should re-attempt the load.
47
+ if (loaded === loading) {
48
+ loaded = null;
49
+ loadedKey = "";
50
+ }
51
+ throw error;
52
+ });
53
+ loaded = loading;
54
+ loadedKey = key;
55
+ return loading;
56
+ }
57
+ async function handleEmbed(
58
+ transport: MnemopiEmbedTransport,
59
+ message: Extract<MnemopiEmbedWorkerInbound, { type: "embed" }>,
60
+ ): Promise<void> {
61
+ try {
62
+ // Each `embed` carries the model + cacheDir the wrapper was bound to.
63
+ // `ensureLoaded` is idempotent for the same key, so this is a no-op
64
+ // once the model is in memory — and it transparently re-loads after
65
+ // the parent SIGKILLed the previous subprocess but mnemopi still
66
+ // holds the cached `LocalEmbeddingModel` wrapper from before.
67
+ const { instance } = await ensureLoaded(message.model, message.cacheDir);
68
+ const vectors: number[][] = [];
69
+ const batches = instance.embed([...message.texts], message.batchSize);
70
+ for await (const batch of batches) {
71
+ for (const row of batch) vectors.push(row);
72
+ }
73
+ transport.send({ type: "vectors", id: message.id, vectors });
74
+ } catch (error) {
75
+ transport.send({
76
+ type: "error",
77
+ id: message.id,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ });
80
+ }
81
+ }
82
+
83
+ async function handleInit(
84
+ transport: MnemopiEmbedTransport,
85
+ message: Extract<MnemopiEmbedWorkerInbound, { type: "init" }>,
86
+ ): Promise<void> {
87
+ try {
88
+ await ensureLoaded(message.model, message.cacheDir);
89
+ transport.send({ type: "ready", id: message.id });
90
+ } catch (error) {
91
+ transport.send({
92
+ type: "error",
93
+ id: message.id,
94
+ error: error instanceof Error ? error.message : String(error),
95
+ });
96
+ }
97
+ }
98
+
99
+ export function startMnemopiEmbedWorker(transport: MnemopiEmbedTransport): void {
100
+ transport.onMessage(message => {
101
+ switch (message.type) {
102
+ case "ping":
103
+ transport.send({ type: "pong", id: message.id });
104
+ return;
105
+ case "init":
106
+ void handleInit(transport, message);
107
+ return;
108
+ case "embed":
109
+ void handleEmbed(transport, message);
110
+ return;
111
+ }
112
+ });
113
+ }
@@ -3,6 +3,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import type * as MnemopiNs from "@oh-my-pi/pi-mnemopi";
4
4
  import type { Mnemopi, RecallResult } from "@oh-my-pi/pi-mnemopi";
5
5
  import type * as MnemopiCoreNs from "@oh-my-pi/pi-mnemopi/core";
6
+ import type { LocalModelInitializer } from "@oh-my-pi/pi-mnemopi/core";
6
7
  import { logger } from "@oh-my-pi/pi-utils";
7
8
  import {
8
9
  composeRecallQuery,
@@ -13,16 +14,42 @@ import {
13
14
  import { extractMessages } from "../hindsight/transcript";
14
15
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
15
16
  import type { MnemopiBackendConfig, MnemopiScoping } from "./config";
17
+ import { mnemopiEmbedClient } from "./embed-client";
16
18
 
17
19
  // The mnemopi package pulls the embeddings stack; keep it off the CLI startup
18
20
  // module graph by loading it lazily at the async boundaries that need it.
19
21
  let mnemopiMod: typeof MnemopiNs | undefined;
20
22
  let mnemopiCoreMod: typeof MnemopiCoreNs | undefined;
21
23
 
22
- /** Lazily load `@oh-my-pi/pi-mnemopi` (memoized). */
24
+ // `setLocalModelInitializer` writes a single module-level slot shared by
25
+ // both the root and `/core` re-exports, so install at most once across both
26
+ // loaders. Either entry point is enough to wire up the override.
27
+ let localModelInitializerInstalled = false;
28
+
29
+ function installLocalModelInitializer(setInitializer: (initializer: LocalModelInitializer) => void): void {
30
+ if (localModelInitializerInstalled) return;
31
+ localModelInitializerInstalled = true;
32
+ setInitializer(({ model, cacheDir }) =>
33
+ mnemopiEmbedClient.initialize(model, cacheDir).then(handle => {
34
+ if (handle) return handle;
35
+ throw new Error("mnemopi embed subprocess unavailable");
36
+ }),
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Lazily load `@oh-my-pi/pi-mnemopi` (memoized) and route fastembed loads
42
+ * through the dedicated embeddings subprocess. The override is installed once
43
+ * — before any consumer gets the chance to call `embed()` — so
44
+ * `onnxruntime-node`'s NAPI constructor + finalizer never run inside the
45
+ * agent's address space (issue #3031). Test seams that swap the initializer
46
+ * with `setLocalModelInitializerForTests` still win because both go through
47
+ * the same module-level slot.
48
+ */
23
49
  export async function loadMnemopi(): Promise<typeof MnemopiNs> {
24
50
  if (!mnemopiMod) {
25
51
  mnemopiMod = await import("@oh-my-pi/pi-mnemopi");
52
+ installLocalModelInitializer(mnemopiMod.setLocalModelInitializer);
26
53
  }
27
54
  return mnemopiMod;
28
55
  }
@@ -31,6 +58,7 @@ export async function loadMnemopi(): Promise<typeof MnemopiNs> {
31
58
  export async function loadMnemopiCore(): Promise<typeof MnemopiCoreNs> {
32
59
  if (!mnemopiCoreMod) {
33
60
  mnemopiCoreMod = await import("@oh-my-pi/pi-mnemopi/core");
61
+ installLocalModelInitializer(mnemopiCoreMod.setLocalModelInitializer);
34
62
  }
35
63
  return mnemopiCoreMod;
36
64
  }
@@ -16,6 +16,59 @@ import { type CacheInvalidation, CacheInvalidationMarkerComponent } from "./cach
16
16
  */
17
17
  const MAX_TRANSCRIPT_ERROR_LINES = 8;
18
18
 
19
+ /**
20
+ * A GFM table delimiter row (`| --- | :--: |`, with or without bounding pipes).
21
+ * The header row alone does not render a table — this delimiter is what makes
22
+ * Markdown lay one out, and a streaming table re-aligns its columns as rows
23
+ * arrive. Requires at least one column pipe so a bare thematic break (`---`)
24
+ * does not match.
25
+ */
26
+ const MARKDOWN_TABLE_DELIMITER = /^ {0,3}\|?(?:[ \t]*:?-+:?[ \t]*\|)+[ \t]*:?-*:?[ \t]*$/;
27
+
28
+ /** Opening or closing fence of a code block: ≥3 backticks/tildes plus info string. */
29
+ const CODE_FENCE_LINE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
30
+
31
+ /**
32
+ * Whether `text` currently contains reflowing Markdown whose layout is not yet
33
+ * permanent: an open ` ```mermaid ` fence (the diagram reshapes as source
34
+ * arrives) or a GFM table (columns re-align as rows arrive). Used by
35
+ * {@link AssistantMessageComponent.isTranscriptBlockCommitStable}.
36
+ *
37
+ * Fence-aware: a mermaid block is detected by its opener, and table delimiters
38
+ * inside ordinary fenced code (shell pipes, ASCII separators, doc examples) are
39
+ * ignored so a long streamed code block is never held out of native scrollback.
40
+ * A delimiter counts only directly under a pipe-bearing header row, outside any
41
+ * code fence.
42
+ */
43
+ function detectLiveReflowingMarkdown(text: string): boolean {
44
+ let fence: string | null = null;
45
+ let prevLine = "";
46
+ for (const line of text.split("\n")) {
47
+ const fenceMatch = CODE_FENCE_LINE.exec(line);
48
+ if (fence !== null) {
49
+ // Inside a code block: only a bare matching closing fence ends it.
50
+ if (
51
+ fenceMatch &&
52
+ fenceMatch[2]!.trim() === "" &&
53
+ fenceMatch[1]![0] === fence[0] &&
54
+ fenceMatch[1]!.length >= fence.length
55
+ ) {
56
+ fence = null;
57
+ }
58
+ continue;
59
+ }
60
+ if (fenceMatch) {
61
+ if (/^mermaid\b/.test(fenceMatch[2]!.trim())) return true;
62
+ fence = fenceMatch[1]!;
63
+ prevLine = "";
64
+ continue;
65
+ }
66
+ if (prevLine.includes("|") && MARKDOWN_TABLE_DELIMITER.test(line)) return true;
67
+ prevLine = line;
68
+ }
69
+ return false;
70
+ }
71
+
19
72
  /**
20
73
  * Frames for the streaming "thinking" pulse rendered in place of a hidden
21
74
  * thinking block while the model is still producing it. A single fixed-width
@@ -36,6 +89,15 @@ export class AssistantMessageComponent extends Container {
36
89
  #convertedKittyImages = new Map<string, ImageContent>();
37
90
  #kittyConversionsInFlight = new Set<string>();
38
91
  #transcriptBlockFinalized: boolean;
92
+ /**
93
+ * True while a non-finalized text item carries reflowing Markdown — a
94
+ * ` ```mermaid ` fence or a GFM table — whose layout re-flows every frame as
95
+ * source arrives (a diagram reshaping, a table re-aligning its columns), so
96
+ * no prefix is byte-stable until the message finalizes. See
97
+ * {@link isTranscriptBlockCommitStable}. Recomputed in {@link updateContent}
98
+ * ahead of the fast-path return, so it tracks every stream tick.
99
+ */
100
+ #hasLiveReflowingMarkdown = false;
39
101
  /**
40
102
  * When true, the turn-ending `Error: …` line for `stopReason === "error"` is
41
103
  * suppressed because the same error is currently shown in the pinned banner
@@ -192,6 +254,21 @@ export class AssistantMessageComponent extends Container {
192
254
  return this.#transcriptBlockFinalized;
193
255
  }
194
256
 
257
+ /**
258
+ * Whether this still-live block's scrolled-off rows may be committed to
259
+ * immutable native scrollback (the {@link TranscriptContainer} durable-
260
+ * snapshot path). Reflowing Markdown — a streaming mermaid diagram or a GFM
261
+ * table — re-lays-out its body as source arrives (the diagram reshapes, the
262
+ * table re-aligns its columns), so committing an intermediate layout strands
263
+ * a stale fragment in native scrollback that only a full repaint (Ctrl+L) can
264
+ * clear. While such content is still streaming the block therefore stays
265
+ * wholly in the repaintable live region and commits once, at its final
266
+ * layout, when the turn finalizes.
267
+ */
268
+ isTranscriptBlockCommitStable(): boolean {
269
+ return this.#transcriptBlockFinalized || !this.#hasLiveReflowingMarkdown;
270
+ }
271
+
195
272
  getTranscriptBlockVersion(): number {
196
273
  return this.#blockVersion;
197
274
  }
@@ -418,6 +495,15 @@ export class AssistantMessageComponent extends Container {
418
495
  this.#lastMessage = message;
419
496
  this.#lastUpdateTransient = opts?.transient === true;
420
497
 
498
+ // Streaming reflowing Markdown (a mermaid diagram reshaping, a GFM table
499
+ // re-aligning columns) re-lays-out its body each frame; see
500
+ // isTranscriptBlockCommitStable. Detect it from raw text — a Markdown
501
+ // parser only resolves these once the closing fence / delimiter row
502
+ // arrives, but the stale native-scrollback commits happen mid-stream.
503
+ this.#hasLiveReflowingMarkdown = message.content.some(
504
+ content => content.type === "text" && detectLiveReflowingMarkdown(content.text),
505
+ );
506
+
421
507
  // Fast path: reuse Markdown children when shape is stable during streaming
422
508
  if (this.#tryFastPathUpdate(message)) return;
423
509
 
@@ -177,7 +177,7 @@ export class CustomEditor extends Editor {
177
177
  /** Per-render scratch flag: did any layout line in this render contain a magic
178
178
  * keyword that should shimmer? Reset by {@link #scheduleShimmerIfNeeded} each
179
179
  * time a frame is queued. */
180
- #shimmerTimer: ReturnType<typeof setTimeout> | undefined;
180
+ #shimmerTimer: Timer | undefined;
181
181
  /** Repaint hook the host wires once at construction. Called from the shimmer
182
182
  * timer to request the next animation frame. Undefined when nobody is
183
183
  * listening (tests, headless callers); the timer chain still self-cleans. */
@@ -179,9 +179,9 @@ export class ModelSelectorComponent extends Container {
179
179
  #providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
180
180
  #activeTabIndex: number = 0;
181
181
  #refreshingProviders: Set<string> = new Set();
182
- #scheduledProviderRefreshes: Map<string, ReturnType<typeof setTimeout>> = new Map();
182
+ #scheduledProviderRefreshes: Map<string, Timer> = new Map();
183
183
  #refreshSpinnerFrame: number = 0;
184
- #refreshSpinnerInterval?: NodeJS.Timeout;
184
+ #refreshSpinnerInterval?: Timer;
185
185
 
186
186
  // Context menu state
187
187
  #isMenuOpen: boolean = false;
@@ -20,4 +20,5 @@ Press ctrl+r to search your prompt history and reuse a past message
20
20
  Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
21
21
  Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
22
22
  Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
23
- No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
23
+ No native tool_calling? Inference provider botches parsing them? `PI_DIALECT=glm|kimi|anthropic…` rolls it locally for them!
24
+ Turn on `/advisor` to attach a second model that reviews every turn and quietly injects advice [NEW]
@@ -29,16 +29,73 @@ export const WELCOME_SESSION_SLOTS = 4;
29
29
  */
30
30
  export const WELCOME_LSP_SLOTS = 4;
31
31
 
32
- export function renderWelcomeTip(tip: string, boxWidth: number): string[] {
32
+ /** Trailing marker that flags a tip as a "what's new" callout. Stripped before
33
+ * wrapping (with any preceding whitespace) and replaced by {@link NEW_TAG_TEXT}
34
+ * painted as a shimmering rainbow. Non-global so `.test` stays stateless. */
35
+ const NEW_TIP_MARKER = /\s*\[NEW\]\s*$/;
36
+
37
+ /** Visible text rendered in place of {@link NEW_TIP_MARKER}. */
38
+ const NEW_TAG_TEXT = "NEW!";
39
+
40
+ /** Milliseconds for one full hue rotation of the rainbow "NEW!" tag. */
41
+ const NEW_GLOW_PERIOD_MS = 1500;
42
+
43
+ /** Selection weight for "[NEW]" tips; ordinary tips weigh 1, so a freshly added
44
+ * affordance surfaces this many times as often. */
45
+ const NEW_TIP_WEIGHT = 4;
46
+
47
+ /** Per-tip selection weights, parallel to {@link TIPS}. */
48
+ const TIP_WEIGHTS: readonly number[] = TIPS.map(tip => (NEW_TIP_MARKER.test(tip) ? NEW_TIP_WEIGHT : 1));
49
+ const TIP_WEIGHT_TOTAL = TIP_WEIGHTS.reduce((sum, weight) => sum + weight, 0);
50
+
51
+ /** Pick a tip at random, biased toward "[NEW]" tips by {@link NEW_TIP_WEIGHT}.
52
+ * Returns "" when no tips are embedded. */
53
+ function pickWeightedTip(): string {
54
+ if (TIPS.length === 0) return "";
55
+ let r = Math.random() * TIP_WEIGHT_TOTAL;
56
+ for (let i = 0; i < TIPS.length; i++) {
57
+ r -= TIP_WEIGHTS[i] ?? 1;
58
+ if (r < 0) return TIPS[i] ?? "";
59
+ }
60
+ return TIPS[TIPS.length - 1] ?? "";
61
+ }
62
+
63
+ type ColorEncoding = "ansi-16m" | "ansi-256";
64
+
65
+ /** Paint each glyph of {@link NEW_TAG_TEXT} on a moving HSL rainbow. `phase`
66
+ * rotates the hue offset cyclically; successive renders with increasing phase
67
+ * shimmer, while a fixed phase yields a still rainbow. */
68
+ function renderNewTag(phase: number, encoding: ColorEncoding): string {
69
+ const bold = "\x1b[1m";
70
+ const reset = "\x1b[0m";
71
+ const wrapped = ((phase % 1) + 1) % 1;
72
+ const chars = [...NEW_TAG_TEXT];
73
+ let out = bold;
74
+ let prev = "";
75
+ for (let i = 0; i < chars.length; i++) {
76
+ const hue = Math.round(((i / chars.length + wrapped) % 1) * 360);
77
+ const color = Bun.color(`hsl(${hue}, 95%, 60%)`, encoding) ?? "";
78
+ if (color !== prev) {
79
+ out += color;
80
+ prev = color;
81
+ }
82
+ out += chars[i];
83
+ }
84
+ return out + reset;
85
+ }
86
+ export function renderWelcomeTip(tip: string, boxWidth: number, phase = 0): string[] {
33
87
  const label = "Tip: ";
34
88
  const labelWidth = visibleWidth(label);
35
89
  const bodyBudget = boxWidth - 1 - labelWidth; // 1 = leading indent
36
90
  if (bodyBudget < 8) return [];
37
91
 
38
- const wrappedBody = wrapTextWithAnsi(replaceTabs(tip), bodyBudget);
92
+ const isNew = NEW_TIP_MARKER.test(tip);
93
+ const body = isNew ? tip.replace(NEW_TIP_MARKER, "") : tip;
94
+
95
+ const wrappedBody = wrapTextWithAnsi(replaceTabs(body), bodyBudget);
39
96
  if (wrappedBody.length === 0) return [];
40
97
 
41
- const encoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
98
+ const encoding: ColorEncoding = TERMINAL.trueColor ? "ansi-16m" : "ansi-256";
42
99
  const purple = Bun.color("#b48cff", encoding) ?? "";
43
100
  const lightBlue = Bun.color("#9ccfff", encoding) ?? "";
44
101
  const italic = "\x1b[3m";
@@ -46,11 +103,27 @@ export function renderWelcomeTip(tip: string, boxWidth: number): string[] {
46
103
  const reset = "\x1b[0m";
47
104
  const continuationIndent = padding(labelWidth);
48
105
 
49
- return wrappedBody.map((body, index) =>
106
+ const lines = wrappedBody.map((line, index) =>
50
107
  index === 0
51
- ? ` ${italic}${purple}${label}${dim}${lightBlue}${body}${reset}`
52
- : ` ${italic}${continuationIndent}${dim}${lightBlue}${body}${reset}`,
108
+ ? ` ${italic}${purple}${label}${dim}${lightBlue}${line}${reset}`
109
+ : ` ${italic}${continuationIndent}${dim}${lightBlue}${line}${reset}`,
53
110
  );
111
+
112
+ if (isNew) {
113
+ // Append the rainbow tag to the final body line when it fits within the
114
+ // box; otherwise drop it onto its own indented continuation line so the
115
+ // styled glyphs never overflow or reflow the wrapped body.
116
+ const tag = renderNewTag(phase, encoding);
117
+ const tagWidth = 1 + visibleWidth(NEW_TAG_TEXT); // 1 = space separator
118
+ const lastLine = lines[lines.length - 1];
119
+ if (lastLine !== undefined && visibleWidth(lastLine) + tagWidth <= boxWidth) {
120
+ lines[lines.length - 1] = `${lastLine} ${tag}`;
121
+ } else {
122
+ lines.push(` ${continuationIndent}${tag}`);
123
+ }
124
+ }
125
+
126
+ return lines;
54
127
  }
55
128
 
56
129
  export interface RecentSession {
@@ -69,7 +142,7 @@ export interface LspServerInfo {
69
142
  */
70
143
  export class WelcomeComponent implements Component {
71
144
  #animStart: number | null = null;
72
- #animTimer: ReturnType<typeof setInterval> | null = null;
145
+ #animTimer: Timer | null = null;
73
146
  #selectedTip: string | undefined;
74
147
  // Render cache: the welcome box is the first transcript-area component, so
75
148
  // returning a stable array reference keeps the whole frame prefix stable.
@@ -89,7 +162,7 @@ export class WelcomeComponent implements Component {
89
162
  if (theme.getSymbolPreset() === "unicode" && Math.random() < 0.1) {
90
163
  this.#selectedTip = "Please use nerdfont 😭.";
91
164
  } else {
92
- this.#selectedTip = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : "";
165
+ this.#selectedTip = pickWeightedTip();
93
166
  }
94
167
  }
95
168
  return this.#selectedTip || undefined;
@@ -327,7 +400,12 @@ export class WelcomeComponent implements Component {
327
400
  #renderTip(boxWidth: number): string[] {
328
401
  const tip = this.tip;
329
402
  if (!tip) return [];
330
- return renderWelcomeTip(tip, boxWidth);
403
+ // A trailing "[NEW]" marker paints an animated rainbow "NEW!" tag. Derive
404
+ // its hue phase from wall-clock time so it shimmers across the welcome
405
+ // intro's re-render frames, then settles into a still rainbow once the box
406
+ // caches its resting frame. Non-"[NEW]" tips ignore the phase entirely.
407
+ const phase = NEW_TIP_MARKER.test(tip) ? performance.now() / NEW_GLOW_PERIOD_MS : 0;
408
+ return renderWelcomeTip(tip, boxWidth, phase);
331
409
  }
332
410
 
333
411
  /** Center text within a given width */
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { THINKING_LOOP_ERROR_MARKER } from "@oh-my-pi/pi-ai/utils/thinking-loop";
2
3
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
3
4
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
4
5
  import { extractTextContent } from "../../commit/utils";
@@ -186,7 +187,7 @@ export class EventController {
186
187
  }
187
188
  #updateWorkingMessageFromIntent(intent: unknown): void {
188
189
  if (this.ctx.session.isAborting) return;
189
- // Streamed JSON can deliver non-string `_i` (object, number, boolean) before
190
+ // Streamed JSON can deliver non-string `i` (object, number, boolean) before
190
191
  // schema validation; `?.` only guards null/undefined, so guard the type too.
191
192
  if (typeof intent !== "string") return;
192
193
  const trimmed = intent.trim();
@@ -1014,6 +1015,13 @@ export class EventController {
1014
1015
  async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
1015
1016
  this.#stopWorkingLoader();
1016
1017
  this.ctx.statusContainer.clear();
1018
+ if (event.errorMessage?.includes(THINKING_LOOP_ERROR_MARKER)) {
1019
+ // The retry path drops the failed assistant from runtime context. Do not
1020
+ // restore its inline Error row; just unpin the fixed-region banner so the
1021
+ // retry UI is the visible state.
1022
+ this.#pinnedErrorComponent = undefined;
1023
+ this.ctx.clearPinnedError();
1024
+ }
1017
1025
  const delaySeconds = Math.round(event.delayMs / 1000);
1018
1026
  this.ctx.retryLoader = new Loader(
1019
1027
  this.ctx.ui,
@@ -27,7 +27,7 @@ import {
27
27
  theme,
28
28
  } from "../../modes/theme/theme";
29
29
  import type { InteractiveModeContext } from "../../modes/types";
30
- import type { ResetCreditRedeemOutcome } from "../../session/auth-storage";
30
+ import type { ResetCreditAccountStatus, ResetCreditRedeemOutcome } from "../../session/auth-storage";
31
31
  import type { SessionInfo } from "../../session/session-listing";
32
32
  import { SessionManager } from "../../session/session-manager";
33
33
  import { FileSessionStorage } from "../../session/session-storage";
@@ -1161,7 +1161,7 @@ export class SelectorController {
1161
1161
  async showResetUsageSelector(): Promise<void> {
1162
1162
  const session = this.ctx.session;
1163
1163
  this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
1164
- let statuses: Awaited<ReturnType<typeof session.listResetCredits>>;
1164
+ let statuses: ResetCreditAccountStatus[];
1165
1165
  try {
1166
1166
  statuses = await session.listResetCredits();
1167
1167
  } catch (error) {
@@ -2743,6 +2743,35 @@ export function highlightCode(code: string, lang?: string, highlightTheme: Theme
2743
2743
  }
2744
2744
 
2745
2745
  export function getSymbolTheme(): SymbolTheme {
2746
+ // Guard against `theme` being undefined (pre-init or cross-module-instance
2747
+ // plugin calls). Fall back to the ASCII preset so the returned symbols are
2748
+ // usable instead of crashing. See #2998.
2749
+ if (typeof theme === "undefined") {
2750
+ const box = {
2751
+ topLeft: "+",
2752
+ topRight: "+",
2753
+ bottomLeft: "+",
2754
+ bottomRight: "+",
2755
+ horizontal: "-",
2756
+ vertical: "|",
2757
+ cross: "+",
2758
+ teeDown: "+",
2759
+ teeUp: "+",
2760
+ teeLeft: "+",
2761
+ teeRight: "+",
2762
+ };
2763
+ return {
2764
+ cursor: ">",
2765
+ inputCursor: "|",
2766
+ boxRound: box,
2767
+ boxSharp: box,
2768
+ table: box,
2769
+ quoteBorder: "|",
2770
+ hrChar: "-",
2771
+ colorSwatch: "[]",
2772
+ spinnerFrames: ["-", "\\", "|", "/"],
2773
+ };
2774
+ }
2746
2775
  const preset = theme.getSymbolPreset();
2747
2776
 
2748
2777
  return {
@@ -2808,6 +2837,19 @@ export function getMarkdownTheme(): MarkdownTheme {
2808
2837
  }
2809
2838
 
2810
2839
  export function getSelectListTheme(): SelectListTheme {
2840
+ // Guard against `theme` being undefined (pre-init or cross-module-instance
2841
+ // plugin calls). See #2998.
2842
+ if (typeof theme === "undefined") {
2843
+ return {
2844
+ selectedPrefix: (text: string) => text,
2845
+ selectedText: (text: string) => text,
2846
+ description: (text: string) => text,
2847
+ scrollInfo: (text: string) => text,
2848
+ noMatch: (text: string) => text,
2849
+ symbols: getSymbolTheme(),
2850
+ hovered: (text: string) => text,
2851
+ };
2852
+ }
2811
2853
  return {
2812
2854
  selectedPrefix: (text: string) => theme.fg("accent", text),
2813
2855
  selectedText: (text: string) => theme.fg("accent", text),
@@ -2820,6 +2862,16 @@ export function getSelectListTheme(): SelectListTheme {
2820
2862
  }
2821
2863
 
2822
2864
  export function getEditorTheme(): EditorTheme {
2865
+ // Guard against `theme` being undefined (pre-init or cross-module-instance
2866
+ // plugin calls). See #2998.
2867
+ if (typeof theme === "undefined") {
2868
+ return {
2869
+ borderColor: (text: string) => text,
2870
+ selectList: getSelectListTheme(),
2871
+ symbols: getSymbolTheme(),
2872
+ hintStyle: (text: string) => text,
2873
+ };
2874
+ }
2823
2875
  return {
2824
2876
  borderColor: (text: string) => theme.fg("borderMuted", text),
2825
2877
  selectList: getSelectListTheme(),
@@ -2829,6 +2881,23 @@ export function getEditorTheme(): EditorTheme {
2829
2881
  }
2830
2882
 
2831
2883
  export function getSettingsListTheme(): SettingsListTheme {
2884
+ // Plugins (e.g. pi-rtk-optimizer) may call this before `initTheme()` assigns
2885
+ // the global `theme`, or from a separate module instance under npm-global
2886
+ // installs where the live binding was never initialized. Fall back to plain
2887
+ // text so the call returns a usable (unstyled) theme instead of crashing with
2888
+ // "undefined is not an object (evaluating 'theme.fg')". See #2998.
2889
+ if (typeof theme === "undefined") {
2890
+ return {
2891
+ label: (text: string) => text,
2892
+ value: (text: string) => text,
2893
+ description: (text: string) => text,
2894
+ cursor: "> ",
2895
+ hint: (text: string) => text,
2896
+ heading: (text: string) => text,
2897
+ section: (text: string) => text,
2898
+ hovered: (text: string) => text,
2899
+ };
2900
+ }
2832
2901
  return {
2833
2902
  label: (text: string, selected: boolean, changed: boolean) =>
2834
2903
  changed ? theme.fg("statusLineGitDirty", text) : selected ? theme.fg("accent", text) : text,
@@ -1,26 +1,18 @@
1
1
  You are a terse, evidence-first engineer: every sentence carries a fact, a decision, or a risk.
2
2
 
3
3
  # Tone
4
- - Use terse sentence fragments when clearer.
5
- - Skip ceremony, hedging, summaries, filler, motivational and marketing language, and generic explanation.
6
- - Do not narrate obvious steps or over-explain basics.
7
- - MUST assume the reader is technical.
8
- - Be concrete: mention exact files, symbols, APIs, state fields, edge cases, and verification.
9
- - Compress reasoning into facts, constraints, tradeoffs, decisions, and checks. Action-oriented and dense.
10
- - Do not hide uncertainty: state it briefly at the specific claim, name the tradeoff, and pick the boring/safe option.
4
+ - Terse fragments when clearer. Skip ceremony, hedging, summaries, filler, and marketing language.
5
+ - Don't narrate obvious steps or over-explain basics. Assume a technical reader.
6
+ - Be concrete: exact files, symbols, APIs, state fields, edge cases, verification.
7
+ - Compress reasoning into facts, constraints, tradeoffs, decisions, checks. Lead with the conclusion, then evidence.
8
+ - Don't hide uncertainty: state it at the specific claim, name the tradeoff, pick the boring/safe option.
11
9
  - For code, focus on invariants, risks, and verification.
12
- - Lead with the conclusion, then concrete evidence: changed files and verification.
13
10
 
14
11
  # Reasoning Format
15
- - Problem: what is wrong.
16
- - Decision: what to do & why (concrete facts).
17
- - Check: what can break & how to verify result.
18
- - Next: the next concrete edit/action.
12
+ - Problem: what's wrong. Decision: what to do & why. Check: what can break & how to verify. Next: the next concrete action.
19
13
 
20
14
  # Succinct Patterns
21
- - Y → Need update X.
22
- - This is safe: Z.
23
- - Could do A, but B avoids C.
15
+ - Y → need update X. This is safe: Z. Could do A, but B avoids C.
24
16
 
25
17
  # Escalation
26
- Push back when the plan hides risk or a claim is wrong: name the risk, show the evidence, propose the alternative. Once overruled, execute the user's call without relitigating.
18
+ Push back when the plan hides risk or a claim is wrong: name the risk, show evidence, propose the alternative. Once overruled, execute the user's call without relitigating.