@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.6

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 (119) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/dist/types/config/settings-schema.d.ts +12 -22
  3. package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
  4. package/dist/types/eval/heartbeat.d.ts +45 -0
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  6. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  7. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +24 -5
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  11. package/dist/types/internal-urls/types.d.ts +14 -0
  12. package/dist/types/lsp/client.d.ts +3 -0
  13. package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
  14. package/dist/types/lsp/index.d.ts +2 -0
  15. package/dist/types/lsp/utils.d.ts +4 -0
  16. package/dist/types/mcp/manager.d.ts +14 -5
  17. package/dist/types/modes/acp/acp-agent.d.ts +1 -1
  18. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  19. package/dist/types/modes/components/custom-editor.d.ts +0 -1
  20. package/dist/types/modes/components/hook-selector.d.ts +7 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  22. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
  23. package/dist/types/modes/interactive-mode.d.ts +2 -2
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  25. package/dist/types/modes/theme/theme.d.ts +1 -1
  26. package/dist/types/modes/types.d.ts +2 -2
  27. package/dist/types/session/agent-session.d.ts +10 -7
  28. package/dist/types/session/shake-types.d.ts +3 -3
  29. package/dist/types/task/repair-args.d.ts +52 -0
  30. package/dist/types/tiny/models.d.ts +0 -14
  31. package/dist/types/tiny/title-client.d.ts +28 -2
  32. package/dist/types/tiny/title-protocol.d.ts +8 -9
  33. package/dist/types/tools/ask.d.ts +8 -6
  34. package/dist/types/tools/eval-backends.d.ts +12 -0
  35. package/dist/types/tools/eval-render.d.ts +52 -0
  36. package/dist/types/tools/eval.d.ts +2 -35
  37. package/dist/types/tools/find.d.ts +1 -1
  38. package/dist/types/tools/index.d.ts +4 -11
  39. package/dist/types/tools/path-utils.d.ts +7 -0
  40. package/dist/types/tui/output-block.d.ts +11 -10
  41. package/examples/extensions/README.md +1 -0
  42. package/examples/extensions/thinking-note.ts +13 -0
  43. package/package.json +9 -9
  44. package/scripts/build-binary.ts +0 -1
  45. package/src/cli.ts +59 -0
  46. package/src/config/model-registry.ts +33 -4
  47. package/src/config/settings-schema.ts +13 -24
  48. package/src/config/settings.ts +10 -0
  49. package/src/discovery/claude.ts +41 -22
  50. package/src/edit/index.ts +23 -3
  51. package/src/eval/__tests__/agent-bridge.test.ts +90 -0
  52. package/src/eval/__tests__/heartbeat.test.ts +84 -0
  53. package/src/eval/__tests__/llm-bridge.test.ts +30 -0
  54. package/src/eval/agent-bridge.ts +44 -38
  55. package/src/eval/heartbeat.ts +74 -0
  56. package/src/eval/js/executor.ts +13 -9
  57. package/src/eval/llm-bridge.ts +20 -14
  58. package/src/eval/py/executor.ts +14 -18
  59. package/src/exec/bash-executor.ts +31 -5
  60. package/src/extensibility/custom-tools/types.ts +2 -2
  61. package/src/extensibility/extensions/loader.ts +16 -18
  62. package/src/extensibility/extensions/runner.ts +22 -17
  63. package/src/extensibility/extensions/types.ts +39 -5
  64. package/src/extensibility/shared-events.ts +2 -2
  65. package/src/internal-urls/docs-index.generated.ts +5 -5
  66. package/src/internal-urls/local-protocol.ts +23 -11
  67. package/src/internal-urls/types.ts +15 -0
  68. package/src/lsp/client.ts +28 -5
  69. package/src/lsp/diagnostics-ledger.ts +51 -0
  70. package/src/lsp/index.ts +9 -22
  71. package/src/lsp/utils.ts +21 -0
  72. package/src/mcp/manager.ts +87 -4
  73. package/src/modes/acp/acp-agent.ts +8 -4
  74. package/src/modes/components/assistant-message.ts +28 -1
  75. package/src/modes/components/custom-editor.ts +9 -7
  76. package/src/modes/components/hook-selector.ts +159 -32
  77. package/src/modes/components/tool-execution.ts +20 -4
  78. package/src/modes/controllers/command-controller.ts +7 -39
  79. package/src/modes/controllers/event-controller.ts +38 -28
  80. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  81. package/src/modes/controllers/input-controller.ts +0 -15
  82. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  83. package/src/modes/interactive-mode.ts +2 -1
  84. package/src/modes/rpc/rpc-mode.ts +17 -6
  85. package/src/modes/theme/theme-schema.json +30 -0
  86. package/src/modes/theme/theme.ts +39 -2
  87. package/src/modes/types.ts +2 -1
  88. package/src/modes/utils/ui-helpers.ts +5 -2
  89. package/src/prompts/system/project-prompt.md +3 -2
  90. package/src/prompts/system/subagent-system-prompt.md +12 -8
  91. package/src/prompts/system/system-prompt.md +8 -6
  92. package/src/prompts/tools/ask.md +2 -1
  93. package/src/prompts/tools/eval.md +1 -1
  94. package/src/session/agent-session.ts +75 -103
  95. package/src/session/shake-types.ts +4 -5
  96. package/src/slash-commands/builtin-registry.ts +2 -4
  97. package/src/task/executor.ts +14 -4
  98. package/src/task/index.ts +3 -2
  99. package/src/task/repair-args.ts +117 -0
  100. package/src/tiny/models.ts +0 -28
  101. package/src/tiny/title-client.ts +133 -43
  102. package/src/tiny/title-protocol.ts +11 -16
  103. package/src/tiny/worker.ts +6 -61
  104. package/src/tools/ask.ts +74 -32
  105. package/src/tools/ast-edit.ts +3 -0
  106. package/src/tools/ast-grep.ts +3 -0
  107. package/src/tools/eval-backends.ts +38 -0
  108. package/src/tools/eval-render.ts +750 -0
  109. package/src/tools/eval.ts +27 -754
  110. package/src/tools/find.ts +20 -6
  111. package/src/tools/gh.ts +1 -0
  112. package/src/tools/index.ts +7 -37
  113. package/src/tools/path-utils.ts +13 -2
  114. package/src/tools/read.ts +1 -0
  115. package/src/tools/renderers.ts +1 -1
  116. package/src/tools/search.ts +12 -1
  117. package/src/tools/write.ts +9 -1
  118. package/src/tui/output-block.ts +42 -79
  119. package/src/utils/git.ts +9 -3
package/src/tools/find.ts CHANGED
@@ -192,7 +192,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
192
192
  if (hasGlobPathChars(rawPattern)) {
193
193
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
194
194
  }
195
- const resource = await internalRouter.resolve(rawPattern);
195
+ const resource = await internalRouter.resolve(rawPattern, {
196
+ cwd: this.session.cwd,
197
+ settings: this.session.settings,
198
+ signal,
199
+ localProtocolOptions: this.session.localProtocolOptions,
200
+ });
196
201
  if (!resource.sourcePath) {
197
202
  throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
198
203
  }
@@ -443,10 +448,14 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
443
448
  // =============================================================================
444
449
 
445
450
  interface FindRenderArgs {
446
- paths?: string[];
451
+ paths?: string | string[];
447
452
  limit?: number;
448
453
  }
449
454
 
455
+ function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefined {
456
+ return Array.isArray(paths) ? paths.join(", ") : paths;
457
+ }
458
+
450
459
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
451
460
 
452
461
  export const findToolRenderer = {
@@ -456,7 +465,7 @@ export const findToolRenderer = {
456
465
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
457
466
 
458
467
  const text = renderStatusLine(
459
- { icon: "pending", title: "Find", description: args.paths?.join(", ") || "*", meta },
468
+ { icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },
460
469
  uiTheme,
461
470
  );
462
471
  return new Text(text, 0, 0);
@@ -493,7 +502,7 @@ export const findToolRenderer = {
493
502
  {
494
503
  icon: "success",
495
504
  title: "Find",
496
- description: args?.paths?.join(", "),
505
+ description: formatFindRenderPaths(args?.paths),
497
506
  meta: [formatCount("file", lines.length)],
498
507
  },
499
508
  uiTheme,
@@ -528,7 +537,7 @@ export const findToolRenderer = {
528
537
 
529
538
  if (fileCount === 0) {
530
539
  const header = renderStatusLine(
531
- { icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
540
+ { icon: "warning", title: "Find", description: formatFindRenderPaths(args?.paths), meta: ["0 files"] },
532
541
  uiTheme,
533
542
  );
534
543
  const lines = [header, formatEmptyMessage("No files found", uiTheme)];
@@ -539,7 +548,12 @@ export const findToolRenderer = {
539
548
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
540
549
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
541
550
  const header = renderStatusLine(
542
- { icon: truncated ? "warning" : "success", title: "Find", description: args?.paths?.join(", "), meta },
551
+ {
552
+ icon: truncated ? "warning" : "success",
553
+ title: "Find",
554
+ description: formatFindRenderPaths(args?.paths),
555
+ meta,
556
+ },
543
557
  uiTheme,
544
558
  );
545
559
 
package/src/tools/gh.ts CHANGED
@@ -75,6 +75,7 @@ const GH_PR_FIELDS = [
75
75
  "labels",
76
76
  "mergeStateStatus",
77
77
  "number",
78
+ "reviews",
78
79
  "reviewDecision",
79
80
  "state",
80
81
  "title",
@@ -1,7 +1,7 @@
1
1
  import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
2
2
  import type { AgentTelemetryConfig, AgentTool } from "@oh-my-pi/pi-agent-core";
3
3
  import type { ToolChoice } from "@oh-my-pi/pi-ai";
4
- import { $env, $flag, logger } from "@oh-my-pi/pi-utils";
4
+ import { logger } from "@oh-my-pi/pi-utils";
5
5
  import type { PromptTemplate } from "../config/prompt-templates";
6
6
  import type { Settings } from "../config/settings";
7
7
  import { EditTool } from "../edit";
@@ -34,6 +34,7 @@ import { BrowserTool } from "./browser";
34
34
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
35
35
  import { DebugTool } from "./debug";
36
36
  import { EvalTool } from "./eval";
37
+ import { resolveEvalBackends } from "./eval-backends";
37
38
  import { FindTool } from "./find";
38
39
  import { GithubTool } from "./gh";
39
40
  import { InspectImageTool } from "./inspect-image";
@@ -74,6 +75,7 @@ export * from "./browser";
74
75
  export * from "./checkpoint";
75
76
  export * from "./debug";
76
77
  export * from "./eval";
78
+ export * from "./eval-backends";
77
79
  export * from "./find";
78
80
  export * from "./gh";
79
81
  export * from "./image-gen";
@@ -257,6 +259,10 @@ export interface ToolSession {
257
259
  * by `getConflictHistory`. */
258
260
  conflictHistory?: import("./conflict-detect").ConflictHistory;
259
261
 
262
+ /** Per-session ledger of post-edit LSP diagnostics already surfaced to the
263
+ * model for each file. Lazily initialized by `getDiagnosticsLedger`. */
264
+ diagnosticsLedger?: import("../lsp/diagnostics-ledger").DiagnosticsLedger;
265
+
260
266
  /** Queue a hidden message to be injected at the next agent turn. */
261
267
  queueDeferredMessage?(message: CustomMessage): void;
262
268
  /** Get the active OpenTelemetry config so subagent dispatch can forward
@@ -331,42 +337,6 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
331
337
 
332
338
  export type ToolName = keyof typeof BUILTIN_TOOLS;
333
339
 
334
- export interface EvalBackendsAllowance {
335
- python: boolean;
336
- js: boolean;
337
- }
338
-
339
- /**
340
- * Parse PI_PY / PI_JS environment variables. Each is a boolean flag; unset
341
- * means "not specified, defer to settings". Returns null when neither is set
342
- * so the caller can fall through to `readEvalBackendsAllowance` per key.
343
- */
344
- function getEvalBackendsFromEnv(): EvalBackendsAllowance | null {
345
- const pyEnv = $env.PI_PY;
346
- const jsEnv = $env.PI_JS;
347
- if (pyEnv === undefined && jsEnv === undefined) return null;
348
- return {
349
- python: pyEnv === undefined ? true : $flag("PI_PY"),
350
- js: jsEnv === undefined ? true : $flag("PI_JS"),
351
- };
352
- }
353
-
354
- /** Read per-backend allowance from settings (defaults true). */
355
- export function readEvalBackendsAllowance(session: ToolSession): EvalBackendsAllowance {
356
- return {
357
- python: session.settings.get("eval.py") ?? true,
358
- js: session.settings.get("eval.js") ?? true,
359
- };
360
- }
361
-
362
- /**
363
- * Materialize the active eval backend allowance: PI_PY / PI_JS env flags
364
- * override the per-key settings; otherwise settings (defaults true) win.
365
- */
366
- export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance {
367
- return getEvalBackendsFromEnv() ?? readEvalBackendsAllowance(session);
368
- }
369
-
370
340
  /**
371
341
  * Create tools from BUILTIN_TOOLS registry.
372
342
  */
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
- import { InternalUrlRouter } from "../internal-urls";
6
+ import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
7
7
  import { ToolError } from "./tool-errors";
8
8
 
9
9
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -740,6 +740,12 @@ export interface ToolScopeOptions {
740
740
  surfaceExactFilePaths?: boolean;
741
741
  /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
742
742
  multipathStatHint?: string;
743
+ /** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
744
+ settings?: unknown;
745
+ /** Caller's abort signal — forwarded to the internal-URL router. */
746
+ signal?: AbortSignal;
747
+ /** Calling session's `local://` root mapping — pins resolutions to the calling session. */
748
+ localProtocolOptions?: LocalProtocolOptions;
743
749
  }
744
750
 
745
751
  export interface ToolScopeResolution {
@@ -778,7 +784,12 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
778
784
  if (hasGlobPathChars(rawPath)) {
779
785
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
780
786
  }
781
- const resource = await internalRouter.resolve(rawPath);
787
+ const resource = await internalRouter.resolve(rawPath, {
788
+ cwd,
789
+ settings: opts.settings,
790
+ signal: opts.signal,
791
+ localProtocolOptions: opts.localProtocolOptions,
792
+ });
782
793
  if (!resource.sourcePath) {
783
794
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
784
795
  }
package/src/tools/read.ts CHANGED
@@ -2141,6 +2141,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2141
2141
  cwd: this.session.cwd,
2142
2142
  settings: this.session.settings,
2143
2143
  signal,
2144
+ localProtocolOptions: this.session.localProtocolOptions,
2144
2145
  });
2145
2146
  const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType };
2146
2147
 
@@ -17,7 +17,7 @@ import { astGrepToolRenderer } from "./ast-grep";
17
17
  import { bashToolRenderer } from "./bash";
18
18
  import { browserToolRenderer } from "./browser/render";
19
19
  import { debugToolRenderer } from "./debug";
20
- import { evalToolRenderer } from "./eval";
20
+ import { evalToolRenderer } from "./eval-render";
21
21
  import { findToolRenderer } from "./find";
22
22
  import { githubToolRenderer } from "./gh-renderer";
23
23
  import { inspectImageToolRenderer } from "./inspect-image-renderer";
@@ -10,6 +10,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import * as z from "zod/v4";
11
11
  import { recordFileSnapshot } from "../edit/file-snapshot-store";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
+ import type { LocalProtocolOptions } from "../internal-urls/local-protocol";
13
14
  import { InternalUrlRouter } from "../internal-urls/router";
14
15
  import type { InternalResource, ResolveContext } from "../internal-urls/types";
15
16
  import type { Theme } from "../modes/theme/theme";
@@ -543,6 +544,7 @@ async function resolveInternalSearchInputs(opts: {
543
544
  settings: unknown;
544
545
  signal?: AbortSignal;
545
546
  archiveDisplayMap: ReadonlyMap<string, string>;
547
+ localProtocolOptions?: LocalProtocolOptions;
546
548
  }): Promise<InternalSearchInputResolution> {
547
549
  const internalRouter = InternalUrlRouter.instance();
548
550
  const paths = opts.resolvedPaths.slice();
@@ -551,7 +553,12 @@ async function resolveInternalSearchInputs(opts: {
551
553
  const virtualInputIndexes = new Set<number>();
552
554
  const immutableSourcePaths = new Set<string>();
553
555
  let virtualScopePath: string | undefined;
554
- const context: ResolveContext = { cwd: opts.cwd, settings: opts.settings, signal: opts.signal };
556
+ const context: ResolveContext = {
557
+ cwd: opts.cwd,
558
+ settings: opts.settings,
559
+ signal: opts.signal,
560
+ localProtocolOptions: opts.localProtocolOptions,
561
+ };
555
562
 
556
563
  for (let idx = 0; idx < paths.length; idx++) {
557
564
  const rawPath = paths[idx];
@@ -674,6 +681,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
674
681
  settings: this.session.settings,
675
682
  signal,
676
683
  archiveDisplayMap,
684
+ localProtocolOptions: this.session.localProtocolOptions,
677
685
  });
678
686
  const searchablePaths = internalResolution.paths;
679
687
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
@@ -738,6 +746,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
738
746
  trackImmutableSources: true,
739
747
  surfaceExactFilePaths: true,
740
748
  multipathStatHint: " (`paths` entries must each exist relative to cwd)",
749
+ settings: this.session.settings,
750
+ signal,
751
+ localProtocolOptions: this.session.localProtocolOptions,
741
752
  });
742
753
  searchPath = scope.searchPath;
743
754
  isDirectory = scope.isDirectory;
@@ -15,6 +15,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
15
15
  import { InternalUrlRouter } from "../internal-urls";
16
16
  import { parseInternalUrl } from "../internal-urls/parse";
17
17
  import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "../lsp";
18
+ import { getDiagnosticsLedger } from "../lsp/diagnostics-ledger";
18
19
  import { getLanguageFromPath, highlightCode, type Theme } from "../modes/theme/theme";
19
20
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
20
21
  import type { ToolSession } from "../sdk";
@@ -278,8 +279,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
278
279
  const enableLsp = session.enableLsp ?? true;
279
280
  const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite");
280
281
  const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnWrite");
282
+ const dedup = enableDiagnostics && session.settings.get("lsp.diagnosticsDeduplicate");
281
283
  this.#writethrough = enableLsp
282
- ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics })
284
+ ? createLspWritethrough(session.cwd, {
285
+ enableFormat,
286
+ enableDiagnostics,
287
+ transformDiagnostics: dedup
288
+ ? (path, result) => getDiagnosticsLedger(session).reduce(path, result)
289
+ : undefined,
290
+ })
283
291
  : writethroughNoop;
284
292
  this.description = prompt.render(writeDescription);
285
293
  }
@@ -19,55 +19,43 @@ export interface OutputBlockOptions {
19
19
  animate?: boolean;
20
20
  }
21
21
 
22
- const BORDER_SHIMMER_TICK_MS = 50;
23
- /** Duration of one full clockwise lap around the border, in ms. Fixed so a box
24
- * growing (new output line) or resizing only nudges the segment proportionally
25
- * instead of teleporting it. */
26
- const BORDER_LAP_MS = 4000;
22
+ const BORDER_SHIMMER_TICK_MS = 16;
23
+ /** Duration of one full left↔right↔left bounce of the bottom-edge segment, in
24
+ * ms. Position is derived from the wall clock against this fixed cycle so a
25
+ * resize only nudges the segment proportionally instead of teleporting it. */
26
+ const BORDER_BOUNCE_MS = 3000;
27
27
  /** Length, in border cells, of the moving segment. */
28
28
  const BORDER_SEGMENT_LEN = 8;
29
29
 
30
30
  /**
31
- * Monotonic frame counter for animated borders. Quantized coarse enough to
32
- * coalesce multiple render passes inside one frame, fine enough to advance on
33
- * every spinner interval so cached blocks re-render while the segment travels.
31
+ * Monotonic frame counter for animated borders, quantized to the TUI's ~16ms
32
+ * render cap so the cache key advances once per ~60fps frame fine enough for a
33
+ * smooth segment sweep, coarse enough to coalesce multiple render passes that
34
+ * land inside the same frame.
34
35
  */
35
36
  export function borderShimmerTick(): number {
36
37
  return Math.floor(Date.now() / BORDER_SHIMMER_TICK_MS);
37
38
  }
38
39
 
39
- /** Ease-in-out so the segment decelerates into and accelerates out of corners. */
40
+ /** Ease-in-out so the segment decelerates into and accelerates out of each wall. */
40
41
  function easeInOutQuad(t: number): number {
41
42
  return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
42
43
  }
43
44
 
44
45
  /**
45
- * Perimeter index of the moving segment's head for a box of inner width `W` and
46
- * height `H` at time `now`. The lap is split across the four edges in proportion
47
- * to their length (so the average speed is uniform) and each edge is eased, for a
48
- * deliberate, non-linear glide that slows at every corner. Position is derived
49
- * from the wall clock against a fixed lap duration, so a perimeter change (new
50
- * row / resize) shifts the head by at most a cell or two — no reset.
46
+ * Column of the travelling segment's center on the bottom edge for a box of
47
+ * inner width `W` at time `now`. The segment bounces left right left across
48
+ * the bottom border: a triangle wave over one full there-and-back cycle, eased
49
+ * per leg so it slows as it nears each wall before reversing. Position is
50
+ * derived from the wall clock against a fixed cycle, so a resize shifts the
51
+ * center proportionally — no reset.
51
52
  */
52
- export function borderSegmentHead(W: number, H: number, now: number): number {
53
- const P = 2 * W + 2 * H - 4;
54
- if (P <= 0) return 0;
55
- // Edge cell counts, clockwise from top-left: top, right, bottom, left.
56
- const edgeLens = [W, H - 1, W - 1, H - 2];
57
- const t = (((now % BORDER_LAP_MS) + BORDER_LAP_MS) % BORDER_LAP_MS) / BORDER_LAP_MS;
58
- let acc = 0;
59
- let start = 0;
60
- for (let i = 0; i < 4; i++) {
61
- const len = edgeLens[i]!;
62
- const frac = len / P;
63
- if (len > 0 && t < acc + frac) {
64
- const lf = (t - acc) / frac;
65
- return (start + Math.floor(easeInOutQuad(lf) * len)) % P;
66
- }
67
- acc += frac;
68
- start += len;
69
- }
70
- return P - 1;
53
+ export function borderSegmentHeadCol(W: number, now: number): number {
54
+ if (W <= 1) return 0;
55
+ const phase = (((now % BORDER_BOUNCE_MS) + BORDER_BOUNCE_MS) % BORDER_BOUNCE_MS) / BORDER_BOUNCE_MS;
56
+ // Triangle: 0→1 rightward over the first half, 1→0 leftward over the second.
57
+ const leg = phase < 0.5 ? phase * 2 : 2 - phase * 2;
58
+ return easeInOutQuad(leg) * (W - 1);
71
59
  }
72
60
 
73
61
  /**
@@ -164,30 +152,26 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
164
152
  const W = lineWidth;
165
153
  const animate = (options.animate ?? false) && (state === "running" || state === "pending") && W >= 2 && H >= 2;
166
154
 
167
- // ── Segment geometry: one dark run travels the outer edge clockwise,
168
- // top right bottom left top. ──
169
- const P = animate ? 2 * W + 2 * H - 4 : 0;
170
- const segLen = Math.min(BORDER_SEGMENT_LEN, P);
171
- const head = animate ? borderSegmentHead(W, H, Date.now()) : 0;
155
+ // ── Segment geometry: one dark run bounces left right along the bottom
156
+ // edge only. The top, interior separators, and side borders stay the flat
157
+ // accent color. ──
158
+ const segLen = animate ? Math.min(BORDER_SEGMENT_LEN, W) : 0;
159
+ const head = animate ? borderSegmentHeadCol(W, Date.now()) : 0;
160
+ const segHalf = segLen / 2;
172
161
  const segAnsi = animate ? (darkenFgAnsi(theme.getFgAnsi(borderColor), 0.4) ?? theme.getFgAnsi("borderMuted")) : "";
173
162
  const seg = (text: string) => `${segAnsi}${text}\x1b[39m`;
174
163
 
175
- // Perimeter index of border cell (row r, col c), clockwise from top-left.
176
- const perimIndex = (r: number, c: number): number => {
177
- if (r === 0) return c;
178
- if (c === W - 1) return W - 1 + r;
179
- if (r === H - 1) return W - 1 + (H - 1) + (W - 1 - c);
180
- return W - 1 + (H - 1) + (W - 1) + (H - 1 - r);
181
- };
182
- const isLit = (idx: number): boolean => (((idx - head) % P) + P) % P < segLen;
183
- // Color a run of border glyphs starting at (row r, col startCol), grouping
164
+ // A bottom-edge column is lit when it lies within half a segment of the
165
+ // travelling center.
166
+ const isLit = (col: number): boolean => Math.abs(col - head) < segHalf;
167
+ // Color a run of bottom-edge glyphs starting at column `startCol`, grouping
184
168
  // consecutive same-state cells so each run emits a single escape pair.
185
- const colorEdge = (glyphs: string, r: number, startCol: number): string => {
169
+ const colorEdge = (glyphs: string, startCol: number): string => {
186
170
  let out = "";
187
171
  let runLit: boolean | null = null;
188
172
  let buf = "";
189
173
  for (let i = 0; i < glyphs.length; i++) {
190
- const lit = isLit(perimIndex(r, startCol + i));
174
+ const lit = isLit(startCol + i);
191
175
  if (lit !== runLit) {
192
176
  if (runLit !== null) out += (runLit ? seg : border)(buf);
193
177
  buf = "";
@@ -199,10 +183,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
199
183
  return out;
200
184
  };
201
185
 
202
- const renderBar = (
203
- row: { leftChar: string; rightChar: string; label?: string; meta?: string },
204
- r: number,
205
- ): string => {
186
+ const renderBar = (row: { leftChar: string; rightChar: string; label?: string; meta?: string }): string => {
206
187
  const leftGlyphs = `${row.leftChar}${cap}`;
207
188
  const rightGlyph = row.rightChar;
208
189
  if (lineWidth <= 0) return border(leftGlyphs) + border(rightGlyph);
@@ -215,36 +196,22 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
215
196
  const labelWidth = visibleWidth(trimmedLabel);
216
197
  const fillCount = Math.max(0, lineWidth - leftWidth - labelWidth - rightWidth);
217
198
  const fillGlyphs = h.repeat(fillCount);
218
- if (!animate) {
219
- return `${border(leftGlyphs)}${trimmedLabel}${border(fillGlyphs)}${border(rightGlyph)}`;
220
- }
221
- if (r === 0 || r === H - 1) {
222
- // Top/bottom edge: the whole horizontal run lies on the perimeter.
223
- const leftStr = colorEdge(leftGlyphs, r, 0);
224
- const fillStr = colorEdge(fillGlyphs, r, leftWidth + labelWidth);
225
- const rightStr = colorEdge(rightGlyph, r, lineWidth - rightWidth);
226
- return `${leftStr}${trimmedLabel}${fillStr}${rightStr}`;
227
- }
228
- // Interior separator: only the first/last cell sit on the outer edge.
229
- return `${colorEdge(row.leftChar, r, 0)}${border(cap)}${trimmedLabel}${border(fillGlyphs)}${colorEdge(rightGlyph, r, lineWidth - rightWidth)}`;
199
+ return `${border(leftGlyphs)}${trimmedLabel}${border(fillGlyphs)}${border(rightGlyph)}`;
230
200
  };
231
201
 
232
- const renderBottom = (row: { leftChar: string; rightChar: string }, r: number): string => {
202
+ const renderBottom = (row: { leftChar: string; rightChar: string }): string => {
233
203
  const leftGlyphs = `${row.leftChar}${cap}`;
234
204
  const rightGlyph = row.rightChar;
235
205
  const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
236
206
  const fillGlyphs = h.repeat(fillCount);
237
207
  if (!animate) return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
238
- const leftStr = colorEdge(leftGlyphs, r, 0);
239
- const fillStr = colorEdge(fillGlyphs, r, visibleWidth(leftGlyphs));
240
- const rightStr = colorEdge(rightGlyph, r, lineWidth - visibleWidth(rightGlyph));
208
+ const leftStr = colorEdge(leftGlyphs, 0);
209
+ const fillStr = colorEdge(fillGlyphs, visibleWidth(leftGlyphs));
210
+ const rightStr = colorEdge(rightGlyph, lineWidth - visibleWidth(rightGlyph));
241
211
  return `${leftStr}${fillStr}${rightStr}`;
242
212
  };
243
213
 
244
- const renderContent = (inner: string, r: number): string => {
245
- if (!animate) return `${border(`${v} `)}${inner}${border(v)}`;
246
- return `${colorEdge(v, r, 0)} ${inner}${colorEdge(v, r, lineWidth - 1)}`;
247
- };
214
+ const renderContent = (inner: string): string => `${border(`${v} `)}${inner}${border(v)}`;
248
215
 
249
216
  const lines: string[] = [];
250
217
  for (let r = 0; r < H; r++) {
@@ -254,11 +221,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
254
221
  continue;
255
222
  }
256
223
  const line =
257
- row.kind === "bar"
258
- ? renderBar(row, r)
259
- : row.kind === "bottom"
260
- ? renderBottom(row, r)
261
- : renderContent(row.inner, r);
224
+ row.kind === "bar" ? renderBar(row) : row.kind === "bottom" ? renderBottom(row) : renderContent(row.inner);
262
225
  lines.push(padToWidth(line, lineWidth, bgFn));
263
226
  }
264
227
 
package/src/utils/git.ts CHANGED
@@ -1261,9 +1261,15 @@ export async function clone(url: string, targetDir: string, options: CloneOption
1261
1261
  const absoluteTarget = path.resolve(targetDir);
1262
1262
  await fs.promises.mkdir(path.dirname(absoluteTarget), { recursive: true });
1263
1263
 
1264
- const args = ["clone", "--depth", "1"];
1264
+ // `git clone --depth 1 --single-branch` only fetches the tip of the target
1265
+ // branch, so any subsequent `git checkout <sha>` for a non-tip commit fails
1266
+ // with "reference is not a tree". When the caller pinned a specific SHA we
1267
+ // fall back to a full clone so the object is guaranteed to be present.
1268
+ const shallow = !options.sha;
1269
+ const args = ["clone"];
1270
+ if (shallow) args.push("--depth", "1");
1265
1271
  if (options.ref) args.push("--branch", options.ref, "--single-branch");
1266
- else args.push("--single-branch");
1272
+ else if (shallow) args.push("--single-branch");
1267
1273
  args.push(url, absoluteTarget);
1268
1274
 
1269
1275
  try {
@@ -1273,7 +1279,7 @@ export async function clone(url: string, targetDir: string, options: CloneOption
1273
1279
  await checkout(absoluteTarget, options.sha, options.signal);
1274
1280
  } catch {
1275
1281
  await fs.promises.rm(absoluteTarget, { force: true, recursive: true });
1276
- throw new Error(`Failed to checkout SHA ${options.sha} - shallow clone may not contain this commit`);
1282
+ throw new Error(`Failed to checkout SHA ${options.sha} in cloned repository ${url}`);
1277
1283
  }
1278
1284
  }
1279
1285
  } catch (err) {