@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.
- package/README.md +14 -11
- package/README.zh-CN.md +12 -12
- package/extensions/bytefly/README.md +163 -61
- package/extensions/bytefly/doctor/config.ts +4 -1
- package/extensions/bytefly/doctor/runtime.ts +2 -0
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +22 -4
- package/extensions/bytefly/footer/price/config.ts +18 -1
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +34 -3
- package/extensions/bytefly/header/component.ts +30 -8
- package/extensions/bytefly/header/config.ts +22 -0
- package/extensions/bytefly/header/register.ts +2 -1
- package/extensions/bytefly/index.ts +7 -5
- package/extensions/bytefly/notify/config.ts +1 -1
- package/extensions/bytefly/notify/register.ts +15 -1
- package/extensions/bytefly/panel/commands.ts +1 -1
- package/extensions/bytefly/panel/format.ts +12 -19
- package/extensions/bytefly/panel/settings-runtime.ts +62 -18
- package/extensions/bytefly/panel/settings.ts +269 -42
- package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
- package/extensions/bytefly/panel/tui.ts +87 -19
- package/extensions/bytefly/separator/component.ts +7 -6
- package/extensions/bytefly/separator/config.ts +15 -0
- package/extensions/bytefly/separator/format.ts +60 -6
- package/extensions/bytefly/separator/register.ts +68 -9
- package/extensions/bytefly/separator/types.ts +8 -0
- package/extensions/bytefly/working/config.ts +4 -5
- package/extensions/bytefly/working/format.ts +24 -7
- package/extensions/bytefly/working/register.ts +34 -3
- package/extensions/job/format.ts +4 -7
- package/extensions/memory/README.md +2 -0
- package/extensions/plan/README.md +12 -0
- package/extensions/subagent/README.md +60 -5
- package/extensions/subagent/config.ts +58 -0
- package/extensions/subagent/index.ts +27 -1
- package/extensions/subagent/orchestrate.ts +16 -3
- package/extensions/subagent/render.ts +12 -0
- package/extensions/subagent/run.ts +100 -37
- package/extensions/subagent/transcripts.ts +202 -0
- package/extensions/subagent/types.ts +12 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +13 -5
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/wechat/README.md +2 -0
- package/extensions/worktree/README.md +7 -1
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +25 -5
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/index.ts +2 -0
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +5 -0
- package/lib/README.md +5 -3
- package/lib/config.ts +33 -9
- package/lib/format.ts +6 -0
- package/lib/requirements.ts +80 -1
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/tool.ts +6 -2
- package/package.json +2 -1
- package/themes/bytefly-dark.json +2 -3
- 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,
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
|
5
|
-
*
|
|
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;
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
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
|
|
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:
|
|
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(
|
|
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
|
|
33
|
-
if (typeof turn !== "number" || !Number.isFinite(turn)) return undefined;
|
|
34
|
-
return new SeparatorComponent(
|
|
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
|
-
|
|
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")
|
|
85
|
+
if (ctx.mode !== "tui") {
|
|
86
|
+
reset();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
40
89
|
const turn = countSeparators(ctx.sessionManager.getBranch()) + 1;
|
|
41
|
-
|
|
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
|
|
2
|
+
* Tuning for bytefly's live working line.
|
|
3
3
|
*
|
|
4
|
-
* While Pi's working row is up, the
|
|
5
|
-
*
|
|
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
|
|
2
|
+
* Pure text for the live working line.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
23
|
-
export function workingCounter(ms: number): string {
|
|
24
|
-
|
|
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
|
|
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(
|
|
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;
|
package/extensions/job/format.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 ?
|
|
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
|