@gavin-hu/my-pi-agent 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +163 -61
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/doctor/runtime.ts +2 -0
  6. package/extensions/bytefly/footer/budget.ts +64 -0
  7. package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
  8. package/extensions/bytefly/footer/price/config.ts +18 -1
  9. package/extensions/bytefly/footer/price/types.ts +5 -0
  10. package/extensions/bytefly/footer/register.ts +34 -3
  11. package/extensions/bytefly/header/component.ts +30 -8
  12. package/extensions/bytefly/header/config.ts +22 -0
  13. package/extensions/bytefly/header/register.ts +2 -1
  14. package/extensions/bytefly/index.ts +7 -5
  15. package/extensions/bytefly/notify/config.ts +1 -1
  16. package/extensions/bytefly/notify/register.ts +15 -1
  17. package/extensions/bytefly/panel/commands.ts +1 -1
  18. package/extensions/bytefly/panel/format.ts +12 -19
  19. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  20. package/extensions/bytefly/panel/settings.ts +269 -42
  21. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  22. package/extensions/bytefly/panel/tui.ts +87 -19
  23. package/extensions/bytefly/separator/component.ts +7 -6
  24. package/extensions/bytefly/separator/config.ts +15 -0
  25. package/extensions/bytefly/separator/format.ts +60 -6
  26. package/extensions/bytefly/separator/register.ts +68 -9
  27. package/extensions/bytefly/separator/types.ts +8 -0
  28. package/extensions/bytefly/working/config.ts +4 -5
  29. package/extensions/bytefly/working/format.ts +24 -7
  30. package/extensions/bytefly/working/register.ts +34 -3
  31. package/extensions/job/format.ts +4 -7
  32. package/extensions/memory/README.md +2 -0
  33. package/extensions/plan/README.md +12 -0
  34. package/extensions/subagent/README.md +60 -5
  35. package/extensions/subagent/config.ts +58 -0
  36. package/extensions/subagent/index.ts +27 -1
  37. package/extensions/subagent/orchestrate.ts +16 -3
  38. package/extensions/subagent/render.ts +12 -0
  39. package/extensions/subagent/run.ts +100 -37
  40. package/extensions/subagent/transcripts.ts +202 -0
  41. package/extensions/subagent/types.ts +12 -0
  42. package/extensions/todo/nudge.ts +5 -1
  43. package/extensions/watch/README.md +13 -5
  44. package/extensions/watch/commands.ts +4 -3
  45. package/extensions/watch/config.ts +1 -1
  46. package/extensions/watch/format.ts +4 -7
  47. package/extensions/watch/runtime.ts +83 -12
  48. package/extensions/wechat/README.md +2 -0
  49. package/extensions/worktree/README.md +7 -1
  50. package/extensions/worktree/enter.ts +10 -3
  51. package/extensions/worktree/exit.ts +25 -5
  52. package/extensions/worktree/git.ts +28 -0
  53. package/extensions/worktree/index.ts +2 -0
  54. package/extensions/worktree/root-tools.ts +1 -1
  55. package/extensions/worktree/runtime.ts +5 -0
  56. package/lib/README.md +5 -3
  57. package/lib/config.ts +33 -9
  58. package/lib/format.ts +6 -0
  59. package/lib/requirements.ts +80 -1
  60. package/lib/tui/columns.ts +17 -0
  61. package/lib/tui/tool.ts +6 -2
  62. package/package.json +2 -1
  63. package/themes/bytefly-dark.json +2 -3
  64. package/themes/bytefly-light.json +2 -3
@@ -7,12 +7,14 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
7
7
  import { loadConfig } from "./config.ts";
8
8
  import { WorktreeCancelledError } from "./errors.ts";
9
9
  import {
10
+ commitsAheadOf,
10
11
  currentBranch,
11
12
  defaultBranch,
12
13
  deleteBranch,
13
14
  inspectWork,
14
15
  isBranchMerged,
15
16
  lockWorktree,
17
+ refExists,
16
18
  unlockWorktree,
17
19
  worktreeRemove,
18
20
  } from "./git.ts";
@@ -32,17 +34,35 @@ export interface ExitOptions {
32
34
  * A failed lookup degrades to the facts already in hand: an exit that succeeded
33
35
  * must not fail because the default branch could not be read.
34
36
  */
35
- async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, ahead: number): Promise<Handoff> {
36
- if (!state.branch) return { branch: null, base: null, ahead, merged: false };
37
+ async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded: number): Promise<Handoff> {
38
+ if (!state.branch) return { branch: null, base: null, ahead: recorded, merged: false };
37
39
  try {
38
40
  // `origin/HEAD` names the merge target when the repository has a remote; a
39
41
  // local-only repository has none, and the main checkout's own branch is the
40
42
  // branch a merge would actually land on.
41
43
  const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
42
- const merged = base ? await isBranchMerged(pi, state.repoRoot, state.branch, base) : false;
43
- return { branch: state.branch, base, ahead, merged };
44
+ if (!base) return { branch: state.branch, base: null, ahead: recorded, merged: false };
45
+ // Count against the resolved base branch, not the commit recorded at entry:
46
+ // a worktree branch rebased onto a newer local branch would otherwise report
47
+ // every commit the base gained since, as its own.
48
+ // Every lookup runs in the main checkout: the handoff is read after the exit,
49
+ // when the worktree directory may already be gone, while the branch refs it
50
+ // describes outlive it.
51
+ const root = state.repoRoot;
52
+ const ref = (await refExists(pi, root, base))
53
+ ? base
54
+ : (await refExists(pi, root, `origin/${base}`))
55
+ ? `origin/${base}`
56
+ : null;
57
+ if (!ref) return { branch: state.branch, base: null, ahead: recorded, merged: false };
58
+ return {
59
+ branch: state.branch,
60
+ base,
61
+ ahead: await commitsAheadOf(pi, root, state.branch, ref),
62
+ merged: await isBranchMerged(pi, root, state.branch, ref),
63
+ };
44
64
  } catch {
45
- return { branch: state.branch, base: null, ahead, merged: false };
65
+ return { branch: state.branch, base: null, ahead: recorded, merged: false };
46
66
  }
47
67
  }
48
68
 
@@ -191,6 +191,25 @@ export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boo
191
191
  }
192
192
 
193
193
  /** Whether a local branch exists. */
194
+ /**
195
+ * Count of commits on `branch` that `base` does not contain.
196
+ *
197
+ * Takes the branch by name rather than reading `HEAD`, because a handoff is read
198
+ * after the worktree may already be gone while its branch refs outlive it.
199
+ */
200
+ export async function commitsAheadOf(pi: ExtensionAPI, dir: string, branch: string, base: string): Promise<number> {
201
+ const result = await git(pi, ["rev-list", "--count", `${base}..${branch}`], dir);
202
+ if (result.code !== 0) return 0;
203
+ const count = Number.parseInt(result.stdout.trim(), 10);
204
+ return Number.isFinite(count) ? count : 0;
205
+ }
206
+
207
+ /** Whether `ref` resolves here: a branch, a remote-tracking ref, or a tag. */
208
+ export async function refExists(pi: ExtensionAPI, dir: string, ref: string): Promise<boolean> {
209
+ const result = await git(pi, ["rev-parse", "--verify", "--quiet", ref], dir);
210
+ return result.code === 0 && result.stdout.trim() !== "";
211
+ }
212
+
194
213
  export async function branchExists(pi: ExtensionAPI, repoRoot: string, branch: string): Promise<boolean> {
195
214
  const result = await git(pi, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot);
196
215
  return result.code === 0;
@@ -240,6 +259,15 @@ export async function deleteBranch(
240
259
  return { ok: false, error: outcome.error || `Could not delete branch ${branch}` };
241
260
  }
242
261
 
262
+ /**
263
+ * Whether git ignores `path` in `dir`. Asks git rather than matching ignore
264
+ * text, so a directory covered by a wider rule also counts as ignored.
265
+ */
266
+ export async function isIgnored(pi: ExtensionAPI, dir: string, path: string): Promise<boolean> {
267
+ const result = await git(pi, ["check-ignore", "-q", "--", path], dir);
268
+ return result.code === 0;
269
+ }
270
+
243
271
  /** Porcelain status lines for a working tree; empty when clean. */
244
272
  export async function statusEntries(pi: ExtensionAPI, dir: string): Promise<string[]> {
245
273
  const result = await git(pi, ["status", "--porcelain"], dir);
@@ -53,6 +53,7 @@ import {
53
53
  noteUnverifiableCommand,
54
54
  publishWorktree,
55
55
  resetDiagnostics,
56
+ resetGitignoreOffer,
56
57
  setActive,
57
58
  setBaseCwd,
58
59
  setInactiveOverrides,
@@ -91,6 +92,7 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
91
92
  setActive(null);
92
93
  clearConfigCache();
93
94
  resetDiagnostics();
95
+ resetGitignoreOffer();
94
96
 
95
97
  // A child `pi` process (subagent) inherits an active worktree. Bind to
96
98
  // it without creating, locking, or persisting anything; the parent owns
@@ -133,7 +133,7 @@ export function registerRootTools(pi: ExtensionAPI, host: RootToolHost, deps: Ro
133
133
  signal,
134
134
  onUpdate,
135
135
  withCwd(ctx, root),
136
- )) as AgentToolResult<any>;
136
+ )) as AgentToolResult<unknown>;
137
137
  },
138
138
  });
139
139
  }
@@ -70,6 +70,11 @@ export function markGitignoreOffered(): void {
70
70
  gitignoreOffered = true;
71
71
  }
72
72
 
73
+ /** Re-arm the offer for the next session; `session_start` owns the reset. */
74
+ export function resetGitignoreOffer(): void {
75
+ gitignoreOffered = false;
76
+ }
77
+
73
78
  /**
74
79
  * Count a bash command the guard let through without being able to verify it.
75
80
  * Session-scoped on purpose: it is the one gap a user cannot see in the
package/lib/README.md CHANGED
@@ -4,8 +4,10 @@ Small modules imported by two or more extensions. Pi loads each extension with
4
4
  an **isolated module cache**, so these are *value-only* libraries: pure
5
5
  functions, constants, and classes. Never keep cross-extension state here — a
6
6
  module-level variable would have one copy per extension, not one shared copy.
7
- The single exception is the event-bus pattern in `rails.ts`, where the state
8
- lives in the caller's closure, not in this module.
7
+ `test/structure.test.ts` fails a module-scope `let`, `var`, `Map`, `WeakMap`, or
8
+ empty array, so this is a test and not a convention. The single exception is the
9
+ event-bus pattern in `rails.ts`, where the state lives in the caller's closure,
10
+ not in this module.
9
11
 
10
12
  This directory sits outside `extensions/` on purpose: nothing here is a
11
13
  loadable extension entrypoint, and every module is a contract shared *between*
@@ -16,7 +18,7 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
16
18
  | Module | Responsibility |
17
19
  |---|---|
18
20
  | [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
19
- | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` (the last two edit one leaf, flat or nested, preserving siblings and metadata). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
21
+ | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `unsetConfigValueAt` (the last three edit one leaf, flat or nested, preserving siblings and metadata; the last removes it and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
20
22
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
21
23
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
22
24
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
package/lib/config.ts CHANGED
@@ -246,27 +246,51 @@ function setPath(source: Record<string, unknown>, path: readonly string[], value
246
246
  }
247
247
 
248
248
  /**
249
- * Delete one key from one top-level section, preserving its siblings and the
250
- * rest of the document. An emptied section is removed rather than left as `{}`.
251
- * A no-op when the file, section, or key is absent; refuses a malformed
252
- * document.
249
+ * Delete the leaf at `keyPath` from one top-level section, preserving every
250
+ * sibling key, every other section, and document metadata. An object left empty
251
+ * by the deletion goes with it, the section included, so a file does not fill
252
+ * with `{}` shells. A no-op when the file, section, or leaf is absent; refuses a
253
+ * malformed document and an empty path.
253
254
  */
254
- export function unsetConfigValue(path: string, section: string, key: string): void {
255
+ export function unsetConfigValueAt(path: string, section: string, keyPath: readonly string[]): void {
256
+ if (keyPath.length === 0) throw new Error(`Cannot update ${path}: empty key path`);
255
257
  const result = readJsonResult(path);
256
258
  if (result.state === "absent") return;
257
259
  if (result.state === "error") throw new Error(`Cannot update ${path}: ${result.error}`);
258
260
 
259
261
  const document = result.value;
260
262
  const current = configSection(document, section);
261
- if (!current || !(key in current)) return;
263
+ if (!current) return;
262
264
 
263
- const next = { ...current };
264
- delete next[key];
265
- if (Object.keys(next).length === 0) delete document[section];
265
+ const next = withoutPath(current, keyPath);
266
+ if (next === current) return;
267
+ if (next === undefined) delete document[section];
266
268
  else document[section] = next;
267
269
  writeConfigFile(path, document);
268
270
  }
269
271
 
272
+ /**
273
+ * Copy `source` without the leaf at `path`, or `undefined` once nothing is left.
274
+ * Returns `source` itself when the leaf is absent or sits behind a non-object,
275
+ * so the caller can tell "nothing to delete" from "a deletion happened".
276
+ */
277
+ function withoutPath(source: Record<string, unknown>, path: readonly string[]): Record<string, unknown> | undefined {
278
+ const [head, ...rest] = path;
279
+ if (head === undefined || !(head in source)) return source;
280
+ const next = { ...source };
281
+ if (rest.length === 0) {
282
+ delete next[head];
283
+ } else {
284
+ const child = asRecord(source[head]);
285
+ if (!child) return source;
286
+ const pruned = withoutPath(child, rest);
287
+ if (pruned === child) return source;
288
+ if (pruned === undefined) delete next[head];
289
+ else next[head] = pruned;
290
+ }
291
+ return Object.keys(next).length === 0 ? undefined : next;
292
+ }
293
+
270
294
  /** Write a whole config document atomically (sibling temp file, then rename). */
271
295
  export function writeConfigFile(path: string, document: Record<string, unknown>): void {
272
296
  mkdirSync(dirname(path), { recursive: true });
package/lib/format.ts CHANGED
@@ -49,6 +49,12 @@ export function formatDuration(ms: number): string {
49
49
  return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
50
50
  }
51
51
 
52
+ /** `1 tool`, `3 tools`: a tool count with the right word. */
53
+ export function formatToolCount(count: number): string {
54
+ const n = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0;
55
+ return `${formatCount(n)} ${n === 1 ? "tool" : "tools"}`;
56
+ }
57
+
52
58
  /**
53
59
  * Replace terminal control characters (including ESC and the C1 block) with
54
60
  * spaces, keeping `\n` and `\t` for callers that render multi-line text.
@@ -111,6 +111,21 @@ export interface SafetyCheck {
111
111
  hint: string;
112
112
  }
113
113
 
114
+ /**
115
+ * A fact that ages: something checked by hand on a date, which is still worth
116
+ * trusting only for a while. The date arrives through a seam because `lib/` cannot
117
+ * import the extension that owns the record.
118
+ */
119
+ export interface AgeCheck {
120
+ kind: "age";
121
+ /** What was verified, phrased for the report, e.g. `the built-in rate tables`. */
122
+ what: string;
123
+ /** Days after which the record counts as stale. */
124
+ maxAgeDays: number;
125
+ enables: string;
126
+ hint: string;
127
+ }
128
+
114
129
  export type RequirementCheck =
115
130
  | BinaryCheck
116
131
  | ModuleCheck
@@ -118,7 +133,8 @@ export type RequirementCheck =
118
133
  | InvocationCheck
119
134
  | NotifyCheck
120
135
  | ProviderCheck
121
- | SafetyCheck;
136
+ | SafetyCheck
137
+ | AgeCheck;
122
138
 
123
139
  /** What an extension needs, split by how badly it needs it. */
124
140
  export interface ExtensionRequirements {
@@ -274,6 +290,13 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
274
290
  bytefly: {
275
291
  configFiles: [SHARED_CONFIG_FILE],
276
292
  optional: [
293
+ {
294
+ kind: "age",
295
+ what: "the built-in DeepSeek rate tables",
296
+ maxAgeDays: 90,
297
+ enables: "the footer's cost total tracks the published tariff",
298
+ hint: "re-check https://api-docs.deepseek.com/quick_start/pricing against `footer/price/config.ts`, then update `PRICE_VERIFIED_AT`.",
299
+ },
277
300
  {
278
301
  kind: "notify",
279
302
  enables: "the configured notification delivery",
@@ -328,6 +351,10 @@ export interface ProbeSeams {
328
351
  reach?: (url: string) => Promise<{ ok: boolean; error?: string }>;
329
352
  /** True when the caller asked for an explicit refresh, which is when a probe may touch the network. */
330
353
  refresh?: boolean;
354
+ /** The day a hand-checked record was verified, as `YYYY-MM-DD`, when the extension records one. */
355
+ priceVerifiedAt?: () => string | undefined;
356
+ /** The clock the age check reads; defaults to the process clock. */
357
+ now?: () => number;
331
358
  }
332
359
 
333
360
  /**
@@ -494,6 +521,56 @@ function safetyFindings(
494
521
  return [{ extension, check: label, severity: "ok", detail: check.enables }];
495
522
  }
496
523
 
524
+ /**
525
+ * Whole days between an ISO `YYYY-MM-DD` date and `now`, or undefined for a date
526
+ * that does not parse or does not exist on the calendar.
527
+ *
528
+ * A date up to a day in the future reads as zero days old: a record made in a
529
+ * timezone ahead of UTC is not a typo, and reporting it as missing would hide a
530
+ * fresh record. Anything further ahead is a typo, so it is not a date.
531
+ */
532
+ export function ageInDays(date: string, now: number): number | undefined {
533
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return undefined;
534
+ const stamp = Date.parse(`${date}T00:00:00Z`);
535
+ if (!Number.isFinite(stamp)) return undefined;
536
+ // `Date.parse` accepts 2026-02-31 by rolling it into March; reject the roll.
537
+ if (new Date(stamp).toISOString().slice(0, 10) !== date) return undefined;
538
+ const days = Math.floor((now - stamp) / 86_400_000);
539
+ if (days >= 0) return days;
540
+ // One day of skew is a timezone, not a mistake.
541
+ return days >= -1 ? 0 : undefined;
542
+ }
543
+
544
+ /** The findings for a fact that ages. */
545
+ function ageFindings(extension: string, check: AgeCheck, failSeverity: FindingSeverity, seams: ProbeSeams): Finding[] {
546
+ const label = check.what;
547
+ const date = seams.priceVerifiedAt?.();
548
+ const age = date === undefined ? undefined : ageInDays(date, (seams.now ?? Date.now)());
549
+ if (date === undefined || age === undefined) {
550
+ return [
551
+ {
552
+ extension,
553
+ check: label,
554
+ severity: "info",
555
+ detail: `no verification date is recorded for ${label}`,
556
+ },
557
+ ];
558
+ }
559
+ const days = `${age} day${age === 1 ? "" : "s"} ago`;
560
+ if (age <= check.maxAgeDays) {
561
+ return [{ extension, check: label, severity: "ok", detail: `${label} last checked ${days}` }];
562
+ }
563
+ return [
564
+ {
565
+ extension,
566
+ check: label,
567
+ severity: failSeverity,
568
+ detail: `${label} were last checked ${days}, beyond the ${check.maxAgeDays}-day window — ${check.enables}`,
569
+ hint: check.hint,
570
+ },
571
+ ];
572
+ }
573
+
497
574
  /** Run one check. `failSeverity` comes from which list the check sits in. */
498
575
  async function runCheck(
499
576
  extension: string,
@@ -611,6 +688,8 @@ async function runCheck(
611
688
  return providerFindings(extension, check, failSeverity, seams);
612
689
  case "safety":
613
690
  return safetyFindings(extension, check, failSeverity, seams);
691
+ case "age":
692
+ return ageFindings(extension, check, failSeverity, seams);
614
693
  }
615
694
  }
616
695
 
@@ -10,6 +10,10 @@
10
10
  */
11
11
 
12
12
  import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+ import { sanitizeLine } from "../format.ts";
14
+
15
+ /** The columns a label gets before it stops reading as a label. */
16
+ export const LABEL_WIDTH = 48;
13
17
 
14
18
  /** A usable origin: terminal arithmetic starts from one column, never zero. */
15
19
  function columns(width: number): number {
@@ -45,6 +49,19 @@ export function clipMiddle(text: string, width: number, ellipsis = "…"): strin
45
49
  return clip(`${head}${ellipsis}${tail}`, w, ellipsis);
46
50
  }
47
51
 
52
+ /**
53
+ * A single-line label clipped to {@link LABEL_WIDTH}.
54
+ *
55
+ * The sanitizer runs first, then the clip: cutting the raw text first would put
56
+ * a cut inside an escape sequence a sanitizer can no longer recognize, and a
57
+ * newline would be left to break the row in two. The default is
58
+ * {@link sanitizeLine}; an extension with its own log- or file-authored text
59
+ * passes that sanitizer instead.
60
+ */
61
+ export function labelLine(text: string, sanitize: (text: string) => string = sanitizeLine): string {
62
+ return clip(sanitize(text), LABEL_WIDTH);
63
+ }
64
+
48
65
  /** Pad `text` on the right to exactly `width` columns. Never truncates. */
49
66
  export function padTo(text: string, width: number): string {
50
67
  const missing = columns(width) - visibleWidth(text);
package/lib/tui/tool.ts CHANGED
@@ -136,7 +136,8 @@ export function moreHint(theme: Theme, hidden: number, noun = "lines"): string {
136
136
 
137
137
  /** The render context fields the elapsed timer needs. */
138
138
  export interface ToolRenderClock {
139
- state: any;
139
+ /** Pi's per-call render state, opaque here: this module only ever casts it. */
140
+ state: unknown;
140
141
  invalidate(): void;
141
142
  }
142
143
 
@@ -191,7 +192,10 @@ export function elapsedSuffix(theme: Theme, durationMs: number | undefined, minM
191
192
  * (the renderer's `isPartial` branch draws its own running line) and the host
192
193
  * replaces it with the final result.
193
194
  */
194
- export function startToolProgress(onUpdate: ((partial: any) => void) | undefined): void {
195
+ export function startToolProgress(
196
+ // allow:any Pi's partial-result type is generic over each tool's details, so a shared helper cannot name it.
197
+ onUpdate: ((partial: any) => void) | undefined,
198
+ ): void {
195
199
  onUpdate?.({ content: [], details: undefined });
196
200
  }
197
201
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -105,6 +105,7 @@
105
105
  "config:migrate": "bun run scripts/migrate-config.ts",
106
106
  "compat:versions": "bun run scripts/peer-versions.ts",
107
107
  "bench:render": "bun run scripts/render-bench.ts",
108
+ "screenshot:file-browse": "bun run scripts/file-browse-shot.ts",
108
109
  "screenshot:plan": "bun run scripts/plan-review-shot.ts",
109
110
  "screenshot:tools": "bun run scripts/tool-renderings.ts gallery assets/tool-renderings.png",
110
111
  "screenshot:tool-pngs": "bun run scripts/tool-renderings.ts tools assets/tools",
@@ -17,7 +17,6 @@
17
17
  "purple": "#bc8cff",
18
18
  "pink": "#f778ba",
19
19
  "selectionBg": "#17273a",
20
- "customBg": "#26233a",
21
20
  "successBg": "#12271a",
22
21
  "errorBg": "#3d1a21",
23
22
  "searchBg": "#3d331a",
@@ -43,9 +42,9 @@
43
42
  "searchMatchText": "text",
44
43
  "userMessageBg": "",
45
44
  "userMessageText": "text",
46
- "customMessageBg": "customBg",
45
+ "customMessageBg": "",
47
46
  "customMessageText": "muted",
48
- "customMessageLabel": "purple",
47
+ "customMessageLabel": "blue",
49
48
  "toolPendingBg": "canvasSubtle",
50
49
  "toolSuccessBg": "successBg",
51
50
  "toolErrorBg": "errorBg",
@@ -16,7 +16,6 @@
16
16
  "yellow": "#9a6700",
17
17
  "purple": "#8250df",
18
18
  "pink": "#bf3989",
19
- "customBg": "#f5f1fd",
20
19
  "successBg": "#e9f6ed",
21
20
  "errorBg": "#fcf0f1",
22
21
  "selectionBg": "#e8f2fc",
@@ -43,9 +42,9 @@
43
42
  "searchMatchText": "text",
44
43
  "userMessageBg": "",
45
44
  "userMessageText": "text",
46
- "customMessageBg": "customBg",
45
+ "customMessageBg": "",
47
46
  "customMessageText": "muted",
48
- "customMessageLabel": "purple",
47
+ "customMessageLabel": "blue",
49
48
  "toolPendingBg": "canvasSubtle",
50
49
  "toolSuccessBg": "successBg",
51
50
  "toolErrorBg": "errorBg",