@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
@@ -43,6 +43,7 @@ import {
43
43
  import {
44
44
  editHints,
45
45
  projectNote,
46
+ sectionJsonPane,
46
47
  settingDetail,
47
48
  settingRow,
48
49
  settingsHints,
@@ -103,6 +104,8 @@ export class PanelComponent implements Component {
103
104
  private notice: string | undefined;
104
105
  /** The text row being edited: its id, the buffer, and the caret offset. */
105
106
  private editing: { id: string; buffer: string; caret: number } | undefined;
107
+ /** True while the Settings detail pane shows the focused row's section as JSON. */
108
+ private json = false;
106
109
  /** The visible section. */
107
110
  private section: PanelSection;
108
111
 
@@ -182,6 +185,8 @@ export class PanelComponent implements Component {
182
185
  );
183
186
  return;
184
187
  }
188
+ // The JSON pane names a file, so a scope switch has to close it.
189
+ this.json = false;
185
190
  this.options.runtime.setScope(next);
186
191
  this.options.requestRender();
187
192
  }
@@ -234,6 +239,29 @@ export class PanelComponent implements Component {
234
239
  .then(() => this.options.requestRender());
235
240
  }
236
241
 
242
+ /** Delete the focused setting's leaf, so its default applies again. */
243
+ private resetSetting(): void {
244
+ const entry = this.activeEntries()[this.cursor.selected];
245
+ if (entry?.kind !== "setting") return;
246
+ const scope = this.options.runtime.scope();
247
+ this.chain = this.chain
248
+ .then(() => this.options.settings.reset(scope, entry.setting.id))
249
+ .catch((error: unknown) => this.notify(error instanceof Error ? error.message : String(error)))
250
+ .then(() => this.options.requestRender());
251
+ }
252
+
253
+ /** Toggle the detail pane between the row and its whole section as stored. */
254
+ private toggleJson(): void {
255
+ const entry = this.activeEntries()[this.cursor.selected];
256
+ if (entry?.kind !== "setting") return;
257
+ if (!entry.setting.config) {
258
+ this.notify(`${entry.setting.label} is a Pi setting; bytefly.json has no section for it.`);
259
+ return;
260
+ }
261
+ this.json = !this.json;
262
+ this.options.requestRender();
263
+ }
264
+
237
265
  /** Store the editor's buffer; a refusal keeps it open with the reason shown. */
238
266
  private commitEdit(): void {
239
267
  const editing = this.editing;
@@ -348,6 +376,7 @@ export class PanelComponent implements Component {
348
376
  private switchSection(next: PanelSection): void {
349
377
  if (next === this.section) return;
350
378
  this.section = next;
379
+ this.json = false;
351
380
  this.prime();
352
381
  this.options.requestRender();
353
382
  }
@@ -362,6 +391,14 @@ export class PanelComponent implements Component {
362
391
  this.select(lastRow(this.activeEntries()));
363
392
  return;
364
393
  }
394
+ if (data === "[") {
395
+ this.jumpGroup(-1);
396
+ return;
397
+ }
398
+ if (data === "]") {
399
+ this.jumpGroup(1);
400
+ return;
401
+ }
365
402
  switch (navIntent(data)) {
366
403
  case "up":
367
404
  this.move(-1);
@@ -384,6 +421,26 @@ export class PanelComponent implements Component {
384
421
  }
385
422
  }
386
423
 
424
+ /** Move to the first row of the next (or previous) group. */
425
+ private jumpGroup(direction: -1 | 1): void {
426
+ const entries = this.activeEntries();
427
+ const headers = entries.flatMap((entry, index) => (entry.kind === "group" ? [index] : []));
428
+ const selected = this.cursor.selected;
429
+ // Step past the focused row's own header: landing on it would not move.
430
+ const own = headers.filter((index) => index < selected).at(-1);
431
+ const target =
432
+ direction === 1
433
+ ? headers.find((index) => index > selected)
434
+ : headers.filter((index) => index < (own ?? 0)).at(-1);
435
+ if (target === undefined) return;
436
+ for (let index = target + 1; index < entries.length; index += 1) {
437
+ if (isSelectable(entries[index])) {
438
+ this.select(index);
439
+ return;
440
+ }
441
+ }
442
+ }
443
+
387
444
  /** Settings keys: change, scope, navigation. `←`/`→` stay on sections. */
388
445
  private handleSettingsInput(data: string): void {
389
446
  this.clampSelection(this.activeEntries());
@@ -395,6 +452,14 @@ export class PanelComponent implements Component {
395
452
  this.switchScope();
396
453
  return;
397
454
  }
455
+ if (data === "u") {
456
+ this.resetSetting();
457
+ return;
458
+ }
459
+ if (data === "J") {
460
+ this.toggleJson();
461
+ return;
462
+ }
398
463
  this.navigate(data);
399
464
  }
400
465
 
@@ -483,7 +548,7 @@ export class PanelComponent implements Component {
483
548
  if (this.editing) return hintLine(this.theme, width, editHints());
484
549
  const hints =
485
550
  this.section === "settings"
486
- ? settingsHints()
551
+ ? settingsHints(this.json)
487
552
  : extensionHints({
488
553
  expanded,
489
554
  refreshing: this.refreshing,
@@ -527,33 +592,36 @@ export class PanelComponent implements Component {
527
592
  this.theme,
528
593
  width,
529
594
  this.section,
530
- settingsSummary(this.theme, width, scope, this.options.settings.count(scope)),
595
+ settingsSummary(this.theme, width, scope, views.length),
531
596
  );
532
597
  const entries = settingEntries(views);
533
598
  const note = scope === "project" ? projectNote(this.theme, width) : undefined;
534
599
 
535
- if (entries.length === 0) {
536
- return dockListFrame({
537
- header,
538
- summary,
539
- body: emptyState(this.theme, width, { label: "No settings found." }),
540
- hint: this.hintRow(width),
541
- });
542
- }
543
-
544
600
  this.retainFocus(entries);
545
601
  this.clampSelection(entries);
546
602
 
547
603
  const focusedEntry = entries[this.cursor.selected];
548
604
  const focused = focusedEntry?.kind === "setting" ? focusedEntry.setting : undefined;
549
- const detail = settingDetail({
550
- theme: this.theme,
551
- width,
552
- view: focused,
553
- budget: this.detailRows(false),
554
- dirty: this.dirty,
555
- paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
556
- });
605
+ const jsonConfig = this.json ? focused?.config : undefined;
606
+ const detail =
607
+ jsonConfig && focused
608
+ ? sectionJsonPane({
609
+ theme: this.theme,
610
+ width,
611
+ section: jsonConfig.section,
612
+ view: focused,
613
+ document: this.options.settings.sectionDocument(scope, jsonConfig.section),
614
+ budget: this.detailRows(true),
615
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
616
+ })
617
+ : settingDetail({
618
+ theme: this.theme,
619
+ width,
620
+ view: focused,
621
+ budget: this.detailRows(false),
622
+ dirty: this.dirty,
623
+ paths: { scope, cwd: this.options.cwd, agentDir: this.options.agentDir },
624
+ });
557
625
 
558
626
  this.cursor.visible = fitRows(
559
627
  this.options.viewportRows,
@@ -1,27 +1,28 @@
1
1
  /**
2
2
  * The transcript component that draws one separator entry.
3
3
  *
4
- * It is stateless: the line is derived from the stored turn number and the
5
- * width passed to `render`, so there is nothing to invalidate.
4
+ * It is stateless: the line is derived from the stored entry data and the width
5
+ * passed to `render`, so there is nothing to invalidate.
6
6
  */
7
7
 
8
8
  import type { Theme } from "@earendil-works/pi-coding-agent";
9
9
  import type { Component } from "@earendil-works/pi-tui";
10
10
  import { separatorLine } from "./format.ts";
11
+ import type { TurnSeparatorData } from "./types.ts";
11
12
 
12
- /** Renders one separator entry; `turn` comes from the stored entry data. */
13
+ /** Renders one separator entry; the data comes from the stored entry. */
13
14
  export class SeparatorComponent implements Component {
14
15
  constructor(
15
- private readonly turn: number,
16
+ private readonly data: TurnSeparatorData,
16
17
  private readonly theme: Theme,
17
18
  private readonly pad = 0,
18
19
  ) {}
19
20
 
20
21
  invalidate(): void {
21
- // Stateless: the line is derived from `turn` and the width passed to render.
22
+ // Stateless: the line is derived from the data and the width passed to render.
22
23
  }
23
24
 
24
25
  render(width: number): string[] {
25
- return separatorLine(this.turn, width, this.theme, this.pad);
26
+ return separatorLine(this.data, width, this.theme, this.pad);
26
27
  }
27
28
  }
@@ -22,6 +22,16 @@ export const CONFIG = {
22
22
  dashRole: "ruleStrong",
23
23
  /** Role for the label. */
24
24
  labelRole: "meta",
25
+ /** Role for a turn with a failed tool. */
26
+ failureRole: "bad",
27
+ /** Role for an aborted turn; escape is a warning, not an error. */
28
+ abortedRole: "warn",
29
+ /** Smallest run duration printed on the receipt; shorter turns keep the label quiet. */
30
+ minDurationMs: 1000,
31
+ /** Word appended when the run was aborted. */
32
+ abortedLabel: "aborted",
33
+ /** Glyph before a failed tool count. */
34
+ failureGlyph: GLYPHS.cross,
25
35
  } as const satisfies {
26
36
  customType: string;
27
37
  glyph: string;
@@ -29,4 +39,9 @@ export const CONFIG = {
29
39
  pad: number;
30
40
  dashRole: TuiRole;
31
41
  labelRole: TuiRole;
42
+ failureRole: TuiRole;
43
+ abortedRole: TuiRole;
44
+ minDurationMs: number;
45
+ abortedLabel: string;
46
+ failureGlyph: string;
32
47
  };
@@ -1,14 +1,67 @@
1
1
  /**
2
2
  * Pure formatting for bytefly's turn separator line.
3
3
  *
4
- * The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
5
- * `centeredRule`, so this module only names the turn; the shared rule owns the
6
- * centering, the degradation, and the colors.
4
+ * The line is a receipt for the settled run: the turn number, then as much of
5
+ * the duration, the top-level tool count, and the outcome as the width can
6
+ * carry. The centered `╌╌╌ turn N ╌╌╌` layout comes from the design system's
7
+ * `centeredRule`, so this module only builds the label and picks its role; the
8
+ * shared rule owns the centering and the dashes.
7
9
  */
8
10
 
9
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
+ import { visibleWidth } from "@earendil-works/pi-tui";
13
+ import { formatDuration, formatToolCount } from "../../../lib/format.ts";
14
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
10
15
  import { centeredRule } from "../../../lib/tui/rules.ts";
16
+ import type { TuiRole } from "../../../lib/tui/tokens.ts";
11
17
  import { CONFIG } from "./config.ts";
18
+ import type { TurnSeparatorData } from "./types.ts";
19
+
20
+ /** Whether the label and `centeredRule`'s two pads leave room for a dash. */
21
+ function fits(label: string, width: number): boolean {
22
+ return visibleWidth(label) + CONFIG.pad * 2 < width;
23
+ }
24
+
25
+ /**
26
+ * The widest receipt that fits, shed one field at a time.
27
+ *
28
+ * The tool count goes first, then the duration; the outcome stays beside the
29
+ * turn as long as it fits, because a failed or aborted run is the part the rule
30
+ * should not hide. The turn number itself is never dropped here — a terminal
31
+ * too narrow even for that falls back to `centeredRule`'s plain dashed rule.
32
+ */
33
+ export function receiptLabel(data: TurnSeparatorData, width: number): string {
34
+ const turn = `${CONFIG.labelPrefix} ${data.turn}`;
35
+ const duration =
36
+ data.durationMs !== undefined && data.durationMs >= CONFIG.minDurationMs
37
+ ? formatDuration(data.durationMs)
38
+ : undefined;
39
+ const tools = data.tools !== undefined && data.tools > 0 ? formatToolCount(data.tools) : undefined;
40
+ const outcome = data.aborted
41
+ ? CONFIG.abortedLabel
42
+ : data.failures !== undefined && data.failures > 0
43
+ ? `${CONFIG.failureGlyph}${data.failures}`
44
+ : undefined;
45
+
46
+ const steps: Array<Array<string | undefined>> = [
47
+ [turn, duration, tools, outcome],
48
+ [turn, duration, outcome],
49
+ [turn, outcome],
50
+ [turn],
51
+ ];
52
+ for (const parts of steps) {
53
+ const label = parts.filter((part): part is string => part !== undefined).join(SEPARATORS.item);
54
+ if (fits(label, width)) return label;
55
+ }
56
+ return turn;
57
+ }
58
+
59
+ /** The label's role: an aborted turn warns, a failed tool is an error. */
60
+ export function receiptRole(data: TurnSeparatorData): TuiRole {
61
+ if (data.aborted) return CONFIG.abortedRole;
62
+ if (data.failures !== undefined && data.failures > 0) return CONFIG.failureRole;
63
+ return CONFIG.labelRole;
64
+ }
12
65
 
13
66
  /**
14
67
  * One rendered line: the centered turn rule, colored by role.
@@ -17,14 +70,15 @@ import { CONFIG } from "./config.ts";
17
70
  * component raw, so the rule insets itself symmetrically by that many columns,
18
71
  * keeping its left edge on the transcript's margin.
19
72
  */
20
- export function separatorLine(turn: number, width: number, theme: Theme, pad = 0): string[] {
73
+ export function separatorLine(data: TurnSeparatorData, width: number, theme: Theme, pad = 0): string[] {
21
74
  const w = Math.max(1, Math.floor(width));
22
75
  const inset = Math.max(0, Math.min(Math.floor(pad), Math.floor((w - 1) / 2)));
23
- const line = centeredRule(theme, Math.max(1, w - inset * 2), `${CONFIG.labelPrefix} ${turn}`, {
76
+ const ruleWidth = Math.max(1, w - inset * 2);
77
+ const line = centeredRule(theme, ruleWidth, receiptLabel(data, ruleWidth), {
24
78
  glyph: CONFIG.glyph,
25
79
  pad: CONFIG.pad,
26
80
  fillRole: CONFIG.dashRole,
27
- labelRole: CONFIG.labelRole,
81
+ labelRole: receiptRole(data),
28
82
  });
29
83
  return [`${" ".repeat(inset)}${line}`];
30
84
  }
@@ -4,11 +4,15 @@
4
4
  * Pi has no built-in turn separator. On every fully settled agent run
5
5
  * (`agent_settled`, one per user turn) this appends an inert custom entry that
6
6
  * is excluded from LLM context, and a registered entry renderer draws it as a
7
- * centered `╌╌╌ turn N ╌╌╌` rule in the interactive transcript.
7
+ * centered `╌╌╌ turn N ╌╌╌` rule in the interactive transcript. The entry also
8
+ * carries the run's receipt — duration, top-level tool count, failed tools, and
9
+ * whether the user aborted it — so the rule can read as a flight log.
8
10
  *
9
11
  * `agent_settled` is deliberate: `turn_end` fires once per assistant message,
10
12
  * so tool-calling rounds would each get a line, while a settled run is exactly
11
- * one user turn.
13
+ * one user turn. The run's first `agent_start` opens the receipt and later
14
+ * starts (a retry or a continuation) stay in it, so the duration covers the
15
+ * whole settled run rather than one attempt.
12
16
  */
13
17
 
14
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
@@ -17,6 +21,12 @@ import { SeparatorComponent } from "./component.ts";
17
21
  import { CONFIG } from "./config.ts";
18
22
  import type { TurnSeparatorData } from "./types.ts";
19
23
 
24
+ /** Host seams, injected by tests so the duration is deterministic. */
25
+ export interface SeparatorDeps {
26
+ /** The clock the duration is read from. */
27
+ now?: () => number;
28
+ }
29
+
20
30
  /** Count separators already on the branch, so numbering survives resume and fork. */
21
31
  function countSeparators(entries: readonly { type: string; customType?: string }[]): number {
22
32
  let count = 0;
@@ -27,17 +37,66 @@ function countSeparators(entries: readonly { type: string; customType?: string }
27
37
  }
28
38
 
29
39
  /** Register the separator renderer and append one on every settled turn. */
30
- export function registerTurnSeparator(pi: ExtensionAPI, pad: PadSource = createPadStore()): void {
40
+ export function registerTurnSeparator(
41
+ pi: ExtensionAPI,
42
+ pad: PadSource = createPadStore(),
43
+ deps: SeparatorDeps = {},
44
+ ): void {
45
+ const now = deps.now ?? (() => Date.now());
46
+ // The current run's receipt. `startedAt` is the first `agent_start`; the
47
+ // counters span every top-level tool in the settled run.
48
+ let startedAt: number | undefined;
49
+ let tools = 0;
50
+ let failures = 0;
51
+
52
+ const reset = (): void => {
53
+ startedAt = undefined;
54
+ tools = 0;
55
+ failures = 0;
56
+ };
57
+
31
58
  pi.registerEntryRenderer<TurnSeparatorData>(CONFIG.customType, (entry, _options, theme) => {
32
- const turn = entry.data?.turn;
33
- if (typeof turn !== "number" || !Number.isFinite(turn)) return undefined;
34
- return new SeparatorComponent(turn, theme, pad.resolve());
59
+ const data = entry.data;
60
+ if (!data || typeof data.turn !== "number" || !Number.isFinite(data.turn)) return undefined;
61
+ return new SeparatorComponent(data, theme, pad.resolve());
62
+ });
63
+
64
+ pi.on("agent_start", (_event, ctx) => {
65
+ if (ctx.mode !== "tui" || startedAt !== undefined) return;
66
+ startedAt = now();
67
+ tools = 0;
68
+ failures = 0;
35
69
  });
36
70
 
37
- pi.on("agent_settled", (_event, ctx) => {
71
+ // A nested tool is an implementation detail of its parent, not a transcript
72
+ // block, so it neither counts nor contributes a failure.
73
+ pi.on("tool_execution_start", (event) => {
74
+ if (startedAt === undefined || event.parentToolCallId) return;
75
+ tools += 1;
76
+ });
77
+
78
+ pi.on("tool_execution_end", (event) => {
79
+ if (startedAt === undefined || event.parentToolCallId || !event.isError) return;
80
+ failures += 1;
81
+ });
82
+
83
+ pi.on("agent_settled", (event, ctx) => {
38
84
  // The transcript is interactive-only; keep RPC/JSON/print sessions clean.
39
- if (ctx.mode !== "tui") return;
85
+ if (ctx.mode !== "tui") {
86
+ reset();
87
+ return;
88
+ }
40
89
  const turn = countSeparators(ctx.sessionManager.getBranch()) + 1;
41
- pi.appendEntry<TurnSeparatorData>(CONFIG.customType, { turn });
90
+ const data: TurnSeparatorData = { turn };
91
+ if (startedAt !== undefined) {
92
+ data.durationMs = Math.max(0, now() - startedAt);
93
+ if (tools > 0) data.tools = tools;
94
+ if (failures > 0) data.failures = failures;
95
+ }
96
+ if (event.aborted) data.aborted = true;
97
+ pi.appendEntry<TurnSeparatorData>(CONFIG.customType, data);
98
+ reset();
42
99
  });
100
+
101
+ pi.on("session_shutdown", () => reset());
43
102
  }
@@ -9,4 +9,12 @@
9
9
  export interface TurnSeparatorData {
10
10
  /** 1-based index of the completed user turn this separator follows. */
11
11
  turn: number;
12
+ /** From the run's first `agent_start` to `agent_settled`, in milliseconds. */
13
+ durationMs?: number;
14
+ /** Top-level tool calls the run started. */
15
+ tools?: number;
16
+ /** Top-level tool calls that ended in error. */
17
+ failures?: number;
18
+ /** Whether the run ended because the user aborted it. */
19
+ aborted?: boolean;
12
20
  }
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Tuning for bytefly's live working timer.
2
+ * Tuning for bytefly's live working line.
3
3
  *
4
- * While Pi's working row is up, the counter is written into the working message
5
- * Pi renders beside its spinner. Edit this file to change the word or how often
6
- * the count advances.
4
+ * While Pi's working row is up, the message is written into the row beside Pi's
5
+ * spinner. Edit this file to change the words or how often the count advances.
7
6
  */
8
7
 
9
8
  export const WORKING_CONFIG = {
10
- /** Word before the count. */
9
+ /** Word before the count, before the first tool starts. */
11
10
  working: "Working",
12
11
  /** Between the word and the count. */
13
12
  ellipsis: "…",
@@ -1,13 +1,24 @@
1
1
  /**
2
- * Pure text for the live working timer.
2
+ * Pure text for the live working line.
3
3
  *
4
- * Whole seconds, because the counter advances on a one-second tick: `0s` at the
5
- * start, `42s`, `1m05s`, `1h02m`. Negative and non-finite input reads as `0s`,
6
- * so a clock that steps backwards cannot print a negative count.
4
+ * Before the first tool starts it is a plain count (`Working… 42s`). After that
5
+ * it names the last top-level tool and how many this run has started, then the
6
+ * elapsed time (`edit · 3 tools · 42s`). Whole seconds, because the tick
7
+ * advances once a second; negative and non-finite input reads as `0s`.
7
8
  */
8
9
 
10
+ import { sanitizeLine, formatToolCount } from "../../../lib/format.ts";
11
+ import { SEPARATORS } from "../../../lib/tui/glyphs.ts";
9
12
  import { WORKING_CONFIG } from "./config.ts";
10
13
 
14
+ /** What the current run has done so far, for the live line. */
15
+ export interface WorkingRun {
16
+ /** The last top-level tool started, or `null` before the first one. */
17
+ readonly tool: string | null;
18
+ /** Top-level tool calls started in this run. */
19
+ readonly tools: number;
20
+ }
21
+
11
22
  /** Compact elapsed time in whole seconds: `0s`, `42s`, `1m05s`, `1h02m`. */
12
23
  export function formatElapsed(ms: number): string {
13
24
  const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0;
@@ -19,7 +30,13 @@ export function formatElapsed(ms: number): string {
19
30
  return `${seconds}s`;
20
31
  }
21
32
 
22
- /** The working-row message for an elapsed time, e.g. `Working… 4s`. */
23
- export function workingCounter(ms: number): string {
24
- return `${WORKING_CONFIG.working}${WORKING_CONFIG.ellipsis} ${formatElapsed(ms)}`;
33
+ /** The working-row message: `Working… 4s` until a tool starts, then the run line. */
34
+ export function workingCounter(ms: number, run?: WorkingRun): string {
35
+ const elapsed = formatElapsed(ms);
36
+ const tools = run?.tools ?? 0;
37
+ const tool = run?.tool ? sanitizeLine(run.tool) : "";
38
+ if (tools > 0) {
39
+ return [tool, formatToolCount(tools), elapsed].filter((part) => part !== "").join(SEPARATORS.item);
40
+ }
41
+ return `${WORKING_CONFIG.working}${WORKING_CONFIG.ellipsis} ${elapsed}`;
25
42
  }
@@ -1,9 +1,11 @@
1
1
  /**
2
- * bytefly's live working timer.
2
+ * bytefly's live working line.
3
3
  *
4
4
  * Pi's working row shows a static `Working`, so a long run reads the same as a
5
5
  * short one. This surface advances the message once a second while the row is
6
- * up, and hands it back to Pi when the row comes down.
6
+ * up, and hands it back to Pi when the row comes down. Once a top-level tool
7
+ * starts, the message also names that tool and how many this run has started,
8
+ * turning the row into a live run line (`edit · 3 tools · 12s`).
7
9
  *
8
10
  * The timer is tied to the row's visibility. The host shows the working
9
11
  * indicator in its own `turn_start` handler and clears it at `agent_end`
@@ -12,6 +14,11 @@
12
14
  * one run do not restart it: the host leaves the same indicator up across
13
15
  * turns, and `start` is a no-op while it is counting.
14
16
  *
17
+ * A nested tool call (one carrying `parentToolCallId`, such as a codemode
18
+ * script's inner call) is not counted and does not take over the line, so the
19
+ * number matches the tool blocks the transcript shows. The tool events repaint
20
+ * the row immediately; the one-second tick is still the only timer.
21
+ *
15
22
  * Compaction and auto-retry also replace the row, but both run outside this
16
23
  * window (`auto_retry_start` follows `agent_end`; the before-settle compaction
17
24
  * runs after it), and neither is an extension event, so there is nothing to
@@ -49,6 +56,17 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
49
56
  let startedAt = 0;
50
57
  // A boolean, not `startedAt === 0`: an injected clock is allowed to start at 0.
51
58
  let active = false;
59
+ // The last top-level tool and how many this run has started. Reset with the run.
60
+ let tool: string | null = null;
61
+ let tools = 0;
62
+
63
+ /** The current row text, from the run state and the elapsed time. */
64
+ const message = (): string => workingCounter(now() - startedAt, { tool, tools });
65
+
66
+ /** Repaint an active row now, so a tool start does not wait for the next tick. */
67
+ const refresh = (ctx: ExtensionContext): void => {
68
+ if (active) ctx.ui.setWorkingMessage(message());
69
+ };
52
70
 
53
71
  /** Stop the tick and hand the working message back to Pi. Idempotent. */
54
72
  const stop = (ctx: ExtensionContext): void => {
@@ -56,6 +74,8 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
56
74
  cancel = undefined;
57
75
  if (!active) return;
58
76
  active = false;
77
+ tool = null;
78
+ tools = 0;
59
79
  ctx.ui.setWorkingMessage(undefined);
60
80
  };
61
81
 
@@ -64,8 +84,10 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
64
84
  if (active) return;
65
85
  startedAt = now();
66
86
  active = true;
87
+ tool = null;
88
+ tools = 0;
67
89
  const tick = (): void => {
68
- ctx.ui.setWorkingMessage(workingCounter(now() - startedAt));
90
+ ctx.ui.setWorkingMessage(message());
69
91
  };
70
92
  tick();
71
93
  cancel = schedule(tick, WORKING_CONFIG.intervalMs);
@@ -78,6 +100,15 @@ export function registerWorkingTimer(pi: ExtensionAPI, deps: WorkingDeps = {}):
78
100
  start(ctx);
79
101
  });
80
102
 
103
+ // A nested tool is an implementation detail of its parent run, not a
104
+ // transcript block, so it neither counts nor takes over the line.
105
+ pi.on("tool_execution_start", (event, ctx) => {
106
+ if (ctx.mode !== "tui" || !active || event.parentToolCallId) return;
107
+ tool = event.toolName;
108
+ tools += 1;
109
+ refresh(ctx);
110
+ });
111
+
81
112
  // The host clears the working indicator at `agent_end`.
82
113
  pi.on("agent_end", (_event, ctx) => {
83
114
  if (ctx.mode !== "tui") return;
@@ -13,8 +13,8 @@
13
13
  */
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
- import { truncateToWidth } from "@earendil-works/pi-tui";
17
16
  import { formatDuration } from "../../lib/format.ts";
17
+ import { clip, LABEL_WIDTH, labelLine } from "../../lib/tui/columns.ts";
18
18
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
19
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
20
20
  import type { JobRecord, JobStatus } from "./types.ts";
@@ -22,9 +22,6 @@ import type { JobRecord, JobStatus } from "./types.ts";
22
22
  /** Longest sanitized log line kept for display; longer lines are clipped. */
23
23
  const MAX_LOG_LINE = 200;
24
24
 
25
- /** Longest label shown in the transcript or `/jobs` screen. */
26
- const MAX_DISPLAY_LABEL = 48;
27
-
28
25
  /** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
29
26
  const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
30
27
  const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
@@ -111,7 +108,7 @@ function statusWord(job: JobRecord): string {
111
108
 
112
109
  /** A label clipped to one safe display line. */
113
110
  export function clipLabel(text: string): string {
114
- return truncateToWidth(sanitizeLogLine(text), MAX_DISPLAY_LABEL, "…");
111
+ return labelLine(text, sanitizeLogLine);
115
112
  }
116
113
 
117
114
  /** A job's label (falling back to its command) clipped to one safe display line. */
@@ -180,7 +177,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
180
177
  case "start": {
181
178
  const command = typeof args.command === "string" ? sanitizeLogLine(args.command.trim()) : "";
182
179
  if (!command) return argsComplete ? "start" : "start …";
183
- const preview = truncateToWidth(command, MAX_DISPLAY_LABEL, "…");
180
+ const preview = clip(command, LABEL_WIDTH);
184
181
  return `start → ${preview}`;
185
182
  }
186
183
  case "kill":
@@ -188,7 +185,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
188
185
  case "send": {
189
186
  if (!target) return "send";
190
187
  const input = typeof args.input === "string" ? sanitizeLogLine(args.input) : "";
191
- const preview = input ? truncateToWidth(input, MAX_DISPLAY_LABEL, "…") : "";
188
+ const preview = input ? clip(input, LABEL_WIDTH) : "";
192
189
  const parts = [preview, args.eof === true ? "EOF" : ""].filter(Boolean).join(" + ");
193
190
  return parts ? `send ${target} → ${parts}` : `send ${target}`;
194
191
  }
@@ -7,6 +7,8 @@ live as human-editable markdown, not in the session branch, so they survive
7
7
  `AGENTS.md`; `memory` is for the one-liners the model should not have to be
8
8
  reminded of twice.
9
9
 
10
+ <img src="../../assets/memory.png" alt="The /memory picker: a Memory title rule, an `All · 3 project, 2 global` summary, five notes tagged by scope with the focused one marked, and the key hints" width="100%">
11
+
10
12
  ```bash
11
13
  pi --extension ./extensions/memory # load just this extension
12
14
  pi -e . # load the whole @gavin-hu/my-pi-agent package