@oh-my-pi/pi-coding-agent 16.1.0 → 16.1.2

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 (76) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/dist/cli.js +3134 -3158
  3. package/dist/types/cli/bench-cli.d.ts +2 -1
  4. package/dist/types/config/settings-schema.d.ts +28 -37
  5. package/dist/types/lsp/types.d.ts +5 -3
  6. package/dist/types/main.d.ts +2 -0
  7. package/dist/types/modes/components/assistant-message.d.ts +12 -0
  8. package/dist/types/modes/components/cache-invalidation-marker.d.ts +7 -2
  9. package/dist/types/modes/components/welcome.d.ts +1 -1
  10. package/dist/types/sdk.d.ts +19 -2
  11. package/dist/types/session/auth-broker-config.d.ts +33 -6
  12. package/dist/types/system-prompt.d.ts +5 -1
  13. package/dist/types/task/executor.d.ts +10 -0
  14. package/dist/types/tools/find.d.ts +0 -2
  15. package/dist/types/tools/search.d.ts +3 -3
  16. package/package.json +12 -12
  17. package/scripts/measure-prompt-tokens.ts +63 -0
  18. package/src/cli/bench-cli.ts +64 -3
  19. package/src/cli/startup-cwd.ts +3 -13
  20. package/src/config/settings-schema.ts +34 -37
  21. package/src/config/settings.ts +40 -0
  22. package/src/cursor.ts +1 -1
  23. package/src/debug/raw-sse-buffer.ts +31 -10
  24. package/src/eval/py/prelude.py +1 -1
  25. package/src/export/html/tool-views.generated.js +1 -1
  26. package/src/extensibility/extensions/runner.ts +8 -2
  27. package/src/internal-urls/docs-index.generated.txt +1 -1
  28. package/src/lsp/client.ts +9 -9
  29. package/src/lsp/types.ts +6 -3
  30. package/src/main.ts +29 -9
  31. package/src/modes/components/assistant-message.ts +86 -0
  32. package/src/modes/components/cache-invalidation-marker.ts +12 -2
  33. package/src/modes/components/settings-defs.ts +7 -0
  34. package/src/modes/components/tips.txt +2 -1
  35. package/src/modes/components/welcome.ts +86 -8
  36. package/src/modes/controllers/event-controller.ts +1 -1
  37. package/src/prompts/system/personalities/default.md +8 -16
  38. package/src/prompts/system/system-prompt.md +101 -115
  39. package/src/prompts/tools/ast-edit.md +10 -12
  40. package/src/prompts/tools/ast-grep.md +14 -18
  41. package/src/prompts/tools/bash.md +19 -21
  42. package/src/prompts/tools/browser.md +24 -24
  43. package/src/prompts/tools/checkpoint.md +0 -1
  44. package/src/prompts/tools/debug.md +11 -15
  45. package/src/prompts/tools/eval.md +27 -27
  46. package/src/prompts/tools/find.md +6 -10
  47. package/src/prompts/tools/github.md +11 -15
  48. package/src/prompts/tools/goal.md +0 -7
  49. package/src/prompts/tools/inspect-image.md +0 -1
  50. package/src/prompts/tools/irc.md +15 -24
  51. package/src/prompts/tools/job.md +5 -8
  52. package/src/prompts/tools/learn.md +2 -2
  53. package/src/prompts/tools/lsp.md +27 -30
  54. package/src/prompts/tools/manage-skill.md +4 -4
  55. package/src/prompts/tools/read.md +21 -23
  56. package/src/prompts/tools/replace.md +0 -1
  57. package/src/prompts/tools/resolve.md +4 -9
  58. package/src/prompts/tools/rewind.md +1 -1
  59. package/src/prompts/tools/search.md +8 -10
  60. package/src/prompts/tools/task.md +33 -38
  61. package/src/prompts/tools/todo.md +14 -18
  62. package/src/prompts/tools/web-search.md +0 -4
  63. package/src/prompts/tools/write.md +1 -1
  64. package/src/sdk.ts +49 -102
  65. package/src/session/agent-session.ts +23 -12
  66. package/src/session/auth-broker-config.ts +36 -76
  67. package/src/session/session-history-format.ts +1 -1
  68. package/src/session/session-manager.ts +33 -6
  69. package/src/system-prompt.ts +28 -8
  70. package/src/task/executor.ts +57 -0
  71. package/src/task/index.ts +15 -1
  72. package/src/tools/browser.ts +1 -1
  73. package/src/tools/eval.ts +1 -1
  74. package/src/tools/find.ts +4 -17
  75. package/src/tools/memory-edit.ts +1 -1
  76. package/src/tools/search.ts +5 -5
package/src/lsp/client.ts CHANGED
@@ -5,6 +5,7 @@ import { applyWorkspaceEdit } from "./edits";
5
5
  import { getLspmuxCommand, isLspmuxSupported } from "./lspmux";
6
6
  import type {
7
7
  LspClient,
8
+ LspJsonRpcId,
8
9
  LspJsonRpcNotification,
9
10
  LspJsonRpcRequest,
10
11
  LspJsonRpcResponse,
@@ -416,7 +417,6 @@ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name:
416
417
  * Handle workspace/workspaceFolders requests from the server.
417
418
  */
418
419
  async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
419
- if (typeof message.id !== "number") return;
420
420
  await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
421
421
  }
422
422
 
@@ -424,7 +424,6 @@ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJson
424
424
  * Handle workspace/configuration requests from the server.
425
425
  */
426
426
  async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
427
- if (typeof message.id !== "number") return;
428
427
  const params = message.params as { items?: Array<{ section?: string }> };
429
428
  const items = params?.items ?? [];
430
429
  const result = items.map(item => {
@@ -438,7 +437,6 @@ async function handleConfigurationRequest(client: LspClient, message: LspJsonRpc
438
437
  * Handle workspace/applyEdit requests from the server.
439
438
  */
440
439
  async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
441
- if (typeof message.id !== "number") return;
442
440
  const params = message.params as { edit?: WorkspaceEdit };
443
441
  if (!params?.edit) {
444
442
  await sendResponse(
@@ -475,13 +473,15 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
475
473
  return;
476
474
  }
477
475
  if (message.method === "window/workDoneProgress/create") {
478
- // Accept progress token registration from the server
479
- if (typeof message.id === "number") {
480
- await sendResponse(client, message.id, null, message.method);
481
- }
476
+ // Accept progress token registration from the server.
477
+ await sendResponse(client, message.id, null, message.method);
478
+ return;
479
+ }
480
+ if (message.method === "client/registerCapability" || message.method === "client/unregisterCapability") {
481
+ // Some servers block semantic requests until dynamic registration succeeds.
482
+ await sendResponse(client, message.id, null, message.method);
482
483
  return;
483
484
  }
484
- if (typeof message.id !== "number") return;
485
485
  await sendResponse(client, message.id, null, message.method, {
486
486
  code: -32601,
487
487
  message: `Method not found: ${message.method}`,
@@ -493,7 +493,7 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
493
493
  */
494
494
  async function sendResponse(
495
495
  client: LspClient,
496
- id: number,
496
+ id: LspJsonRpcId,
497
497
  result: unknown,
498
498
  method: string,
499
499
  error?: { code: number; message: string; data?: unknown },
package/src/lsp/types.ts CHANGED
@@ -399,7 +399,7 @@ export interface LspClient {
399
399
  diagnostics: Map<string, PublishedDiagnostics>;
400
400
  diagnosticsVersion: number;
401
401
  openFiles: Map<string, OpenFile>;
402
- pendingRequests: Map<number, PendingRequest>;
402
+ pendingRequests: Map<number | string, PendingRequest>;
403
403
  messageBuffer: Uint8Array;
404
404
  isReading: boolean;
405
405
  /** Lifecycle state: "connecting" until initialize completes, then "ready"; "error" on init failure or reader death. */
@@ -420,16 +420,19 @@ export interface LspClient {
420
420
  // JSON-RPC Protocol Types
421
421
  // =============================================================================
422
422
 
423
+ /** JSON-RPC request/response identifier accepted by LSP peers. */
424
+ export type LspJsonRpcId = number | string;
425
+
423
426
  export interface LspJsonRpcRequest {
424
427
  jsonrpc: "2.0";
425
- id: number;
428
+ id: LspJsonRpcId;
426
429
  method: string;
427
430
  params: unknown;
428
431
  }
429
432
 
430
433
  export interface LspJsonRpcResponse {
431
434
  jsonrpc: "2.0";
432
- id?: number;
435
+ id?: LspJsonRpcId;
433
436
  result?: unknown;
434
437
  error?: { code: number; message: string; data?: unknown };
435
438
  }
package/src/main.ts CHANGED
@@ -11,6 +11,7 @@ import { EventLoopKeepalive } from "@oh-my-pi/pi-agent-core";
11
11
  import type { ImageContent } from "@oh-my-pi/pi-ai";
12
12
  import {
13
13
  $env,
14
+ directoryExists,
14
15
  getLogPath,
15
16
  getProjectDir,
16
17
  logger,
@@ -575,7 +576,11 @@ async function moveMissingCwdSessionIfNeeded(
575
576
  return { status: "declined" };
576
577
  }
577
578
 
578
- const manager = await SessionManager.open(session.path, sessionDir);
579
+ // Open anchored at the (now-missing) recorded cwd: `open` otherwise falls back
580
+ // to the launch cwd, which would make the `moveTo` below a no-op whenever the
581
+ // move target equals the current project dir. moveTo never chdirs, so the
582
+ // stale cwd is only a relocation source, not a directory we enter.
583
+ const manager = await SessionManager.open(session.path, sessionDir, undefined, { initialCwd: sourceCwd });
579
584
  await manager.moveTo(cwd, sessionDir);
580
585
  return { status: "moved", manager };
581
586
  }
@@ -751,6 +756,20 @@ function discoverAppendSystemPromptFile(): string | undefined {
751
756
  return undefined;
752
757
  }
753
758
 
759
+ /** Apply resolved CLI/discovered prompt files without bypassing system prompt templates. */
760
+ export function applyResolvedSystemPromptInputs(
761
+ options: CreateAgentSessionOptions,
762
+ resolvedSystemPrompt: string | undefined,
763
+ resolvedAppendPrompt: string | undefined,
764
+ ): void {
765
+ if (resolvedSystemPrompt) {
766
+ options.customSystemPrompt = resolvedSystemPrompt;
767
+ }
768
+ if (resolvedAppendPrompt) {
769
+ options.appendSystemPrompt = resolvedAppendPrompt;
770
+ }
771
+ }
772
+
754
773
  async function buildSessionOptions(
755
774
  parsed: Args,
756
775
  scopedModels: ScopedModel[],
@@ -875,13 +894,7 @@ async function buildSessionOptions(
875
894
  // (handled by caller before createAgentSession)
876
895
 
877
896
  // System prompt
878
- if (resolvedSystemPrompt && resolvedAppendPrompt) {
879
- options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, resolvedAppendPrompt, ...defaultPrompt.slice(1)];
880
- } else if (resolvedSystemPrompt) {
881
- options.systemPrompt = defaultPrompt => [resolvedSystemPrompt, ...defaultPrompt.slice(1)];
882
- } else if (resolvedAppendPrompt) {
883
- options.systemPrompt = defaultPrompt => [...defaultPrompt, resolvedAppendPrompt];
884
- }
897
+ applyResolvedSystemPromptInputs(options, resolvedSystemPrompt, resolvedAppendPrompt);
885
898
 
886
899
  // Tools
887
900
  if (parsed.noTools) {
@@ -1141,7 +1154,14 @@ export async function runRootCommand(
1141
1154
  // Resuming a session from another project: switch the process into that
1142
1155
  // project's directory and refresh cwd-derived caches before the session is
1143
1156
  // built, so settings discovery, plugins, and capabilities all scope to it.
1144
- if (selected.cwd && normalizePathForComparison(selected.cwd) !== normalizePathForComparison(getProjectDir())) {
1157
+ // Skip the chdir when the recorded project directory is gone: `setProjectDir`
1158
+ // would throw on the missing path. `SessionManager.open` then falls back to
1159
+ // the launch cwd, so the resumed session simply stays where the user is.
1160
+ if (
1161
+ selected.cwd &&
1162
+ normalizePathForComparison(selected.cwd) !== normalizePathForComparison(getProjectDir()) &&
1163
+ (await directoryExists(selected.cwd))
1164
+ ) {
1145
1165
  // Let the original (launch-cwd) plugin-root preload settle first so its
1146
1166
  // late resolution can't clobber the re-warm we trigger below.
1147
1167
  await pluginPreloadPromise.catch(() => {});
@@ -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
 
@@ -25,8 +25,13 @@ export interface CacheInvalidation {
25
25
  * request reads nothing from cache and re-pays for the whole prompt. We detect
26
26
  * that as: the previous turn cached a meaningful prefix, yet this turn's
27
27
  * `cacheRead` collapsed to zero while it still reprocessed a non-trivial prompt.
28
- * Returns `undefined` (no marker) for the first turn, tiny contexts, and turns
29
- * that reused any cache.
28
+ * Returns `undefined` (no marker) for the first turn, tiny contexts, turns
29
+ * that reused any cache, and — crucially — turns on providers with *implicit*
30
+ * best-effort caching. Only an explicit, prefix-controlled cache (Anthropic /
31
+ * Bedrock `cache_control`) re-creates the prefix on a cold turn (`cacheWrite >
32
+ * 0`); implicit caches (Google / OpenAI / Fireworks) report `cacheWrite: 0` and
33
+ * drop `cacheRead` to zero intermittently as routine propagation noise that
34
+ * self-heals the next turn, so flagging it would be a false positive.
30
35
  */
31
36
  export function detectCacheInvalidation(prev: Usage | undefined, current: Usage): CacheInvalidation | undefined {
32
37
  if (!prev) return undefined;
@@ -34,6 +39,11 @@ export function detectCacheInvalidation(prev: Usage | undefined, current: Usage)
34
39
  if (prevFootprint < MIN_CACHE_FOOTPRINT) return undefined;
35
40
  // Any cache reuse this turn means the prefix survived (at least partly).
36
41
  if (current.cacheRead > 0) return undefined;
42
+ // Only an explicit, prefix-controlled cache re-creates the prefix on a cold
43
+ // turn — Anthropic/Bedrock report that as `cacheWrite`. Implicit best-effort
44
+ // caches (Google/OpenAI/Fireworks) report `cacheWrite: 0` and drop `cacheRead`
45
+ // to zero intermittently as propagation noise, not a real invalidation.
46
+ if (current.cacheWrite <= 0) return undefined;
37
47
  const reprocessedTokens = current.cacheWrite + current.input;
38
48
  if (reprocessedTokens < MIN_CACHE_FOOTPRINT) return undefined;
39
49
  return { reprocessedTokens };
@@ -76,6 +76,13 @@ export type SettingDef = BooleanSettingDef | EnumSettingDef | SubmenuSettingDef
76
76
 
77
77
  const CONDITIONS: Record<string, () => boolean> = {
78
78
  hasImageProtocol: () => !!TERMINAL.imageProtocol,
79
+ advisorEnabled: () => {
80
+ try {
81
+ return Settings.instance.get("advisor.enabled") === true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ },
79
86
  hindsightActive: () => {
80
87
  try {
81
88
  return Settings.instance.get("memory.backend") === "hindsight";
@@ -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 {
@@ -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 */
@@ -186,7 +186,7 @@ export class EventController {
186
186
  }
187
187
  #updateWorkingMessageFromIntent(intent: unknown): void {
188
188
  if (this.ctx.session.isAborting) return;
189
- // Streamed JSON can deliver non-string `_i` (object, number, boolean) before
189
+ // Streamed JSON can deliver non-string `i` (object, number, boolean) before
190
190
  // schema validation; `?.` only guards null/undefined, so guard the type too.
191
191
  if (typeof intent !== "string") return;
192
192
  const trimmed = intent.trim();
@@ -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.