@gavin-hu/my-pi-agent 0.13.1 → 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 (42) hide show
  1. package/README.md +14 -11
  2. package/README.zh-CN.md +12 -12
  3. package/extensions/bytefly/README.md +140 -58
  4. package/extensions/bytefly/doctor/config.ts +4 -1
  5. package/extensions/bytefly/footer/price/config.ts +3 -2
  6. package/extensions/bytefly/header/component.ts +30 -8
  7. package/extensions/bytefly/header/config.ts +22 -0
  8. package/extensions/bytefly/header/register.ts +2 -1
  9. package/extensions/bytefly/index.ts +3 -3
  10. package/extensions/bytefly/notify/config.ts +1 -1
  11. package/extensions/bytefly/panel/commands.ts +1 -1
  12. package/extensions/bytefly/panel/format.ts +12 -19
  13. package/extensions/bytefly/panel/settings-runtime.ts +62 -18
  14. package/extensions/bytefly/panel/settings.ts +239 -43
  15. package/extensions/bytefly/panel/tui/settings-view.ts +49 -3
  16. package/extensions/bytefly/panel/tui.ts +87 -19
  17. package/extensions/bytefly/separator/component.ts +7 -6
  18. package/extensions/bytefly/separator/config.ts +15 -0
  19. package/extensions/bytefly/separator/format.ts +60 -6
  20. package/extensions/bytefly/separator/register.ts +68 -9
  21. package/extensions/bytefly/separator/types.ts +8 -0
  22. package/extensions/bytefly/working/config.ts +4 -5
  23. package/extensions/bytefly/working/format.ts +24 -7
  24. package/extensions/bytefly/working/register.ts +34 -3
  25. package/extensions/memory/README.md +2 -0
  26. package/extensions/plan/README.md +12 -0
  27. package/extensions/subagent/README.md +60 -5
  28. package/extensions/subagent/config.ts +58 -0
  29. package/extensions/subagent/index.ts +27 -1
  30. package/extensions/subagent/orchestrate.ts +16 -3
  31. package/extensions/subagent/render.ts +12 -0
  32. package/extensions/subagent/run.ts +100 -37
  33. package/extensions/subagent/transcripts.ts +202 -0
  34. package/extensions/subagent/types.ts +12 -0
  35. package/extensions/watch/README.md +2 -0
  36. package/extensions/wechat/README.md +2 -0
  37. package/lib/README.md +1 -1
  38. package/lib/config.ts +33 -9
  39. package/lib/format.ts +6 -0
  40. package/package.json +2 -1
  41. package/themes/bytefly-dark.json +2 -3
  42. package/themes/bytefly-light.json +2 -3
@@ -18,9 +18,9 @@ import { BODY_INDENT } from "../../../lib/tui/indent.ts";
18
18
  import { RULE_GLYPH } from "../../../lib/tui/rules.ts";
19
19
  import { paint } from "../../../lib/tui/tokens.ts";
20
20
  import { updateMarker } from "../update/format.ts";
21
- import { HEADER_CONFIG, type HelpEntry } from "./config.ts";
21
+ import { HEADER_CONFIG, type HeaderHeight, type HelpEntry } from "./config.ts";
22
22
 
23
- /** The header's shape, resolved once per apply so `render` stays cheap. */
23
+ /** The header's shape: everything but the live terminal height is resolved once per apply. */
24
24
  export interface HeaderView {
25
25
  /** Print Pi's version on the agent line. */
26
26
  showVersion: boolean;
@@ -28,6 +28,8 @@ export interface HeaderView {
28
28
  halfBlocks: boolean;
29
29
  /** Columns of left margin: Pi's output pad, so the nameplate sits on the transcript margin. */
30
30
  pad: number;
31
+ /** Terminal rows, when the host reports them; short terminals shed help rows. */
32
+ rows?: number;
31
33
  /** A newer published version of this package, when one is known. */
32
34
  latest?: string;
33
35
  }
@@ -35,6 +37,15 @@ export interface HeaderView {
35
37
  /** The mark block is four columns on both rows, so the identity column aligns. */
36
38
  const MARK_WIDTH = 4;
37
39
 
40
+ /**
41
+ * The tallest shape whose threshold the terminal meets. An unknown or impossible
42
+ * height keeps the full header, so a host without terminal rows still reads well.
43
+ */
44
+ function heightShape(rows: number | undefined): HeaderHeight {
45
+ if (rows === undefined || !Number.isFinite(rows) || rows <= 0) return HEADER_CONFIG.heights[0];
46
+ return HEADER_CONFIG.heights.find((shape) => rows >= shape.minRows) ?? HEADER_CONFIG.heights[0];
47
+ }
48
+
38
49
  /** Pad a themed mark line to the block width with plain spaces. */
39
50
  function markBlock(line: string): string {
40
51
  return line + " ".repeat(Math.max(0, MARK_WIDTH - visibleWidth(line)));
@@ -81,7 +92,7 @@ export class ByteflyHeader implements Component {
81
92
  const w = Math.max(1, width);
82
93
  const theme = this.getTheme();
83
94
  const view = this.view();
84
- const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}`;
95
+ const viewKey = `${view.showVersion}:${view.halfBlocks}:${view.pad}:${view.latest ?? ""}:${view.rows ?? ""}`;
85
96
  const keys = this.keySignature();
86
97
  if (
87
98
  this.cachedWidth === w &&
@@ -111,6 +122,7 @@ export class ByteflyHeader implements Component {
111
122
  private build(w: number, theme: Theme, view: HeaderView): string[] {
112
123
  const { nameRole, chromeRole, detailRole, updateRole, chromeTagline, chromeName, chromeVersion, help } =
113
124
  HEADER_CONFIG;
125
+ const shape = heightShape(view.rows);
114
126
  const padWidth = Math.max(0, Math.min(Math.floor(view.pad), Math.max(0, w - 1)));
115
127
  const pad = " ".repeat(padWidth);
116
128
  const [markTop, markBottom] = markLines(theme, view.halfBlocks);
@@ -145,13 +157,17 @@ export class ByteflyHeader implements Component {
145
157
 
146
158
  // The header owns its leading blank: Pi mounts the built-in header inside
147
159
  // `Spacer(1)`s, but the `quietStartup: true` branch that hands this header the
148
- // screen adds none, and `setHeader` swaps only the header child.
160
+ // screen adds none, and `setHeader` swaps only the header child. A short
161
+ // terminal gives that row back, then sheds whole help groups.
149
162
  return [
150
- "",
163
+ ...(shape.blank ? [""] : []),
151
164
  this.identityLine(pad, markTop, agentCell, w),
152
165
  this.identityLine(pad, markBottom, chromeCell, w),
153
166
  this.rule(theme, w, pad),
154
- ...help.map((row) => this.helpLine(theme, w, row, padWidth)),
167
+ ...shape.helpRows.flatMap((index) => {
168
+ const group = help[index];
169
+ return group ? [this.helpLine(theme, w, group, padWidth)] : [];
170
+ }),
155
171
  ];
156
172
  }
157
173
 
@@ -193,6 +209,12 @@ export class ByteflyHeader implements Component {
193
209
  }
194
210
 
195
211
  /** Header factory passed to `ctx.ui.setHeader`. */
196
- export function createHeader(getTheme: () => Theme, view: () => HeaderView): (tui: TUI, theme: Theme) => Component {
197
- return () => new ByteflyHeader(getTheme, view, VERSION);
212
+ export function createHeader(
213
+ getTheme: () => Theme,
214
+ view: (rows: number | undefined) => HeaderView,
215
+ ): (tui: TUI, theme: Theme) => Component {
216
+ // Read the height on every render, so a terminal resize reflows the header
217
+ // instead of keeping the shape it was installed with. Tests may pass a bare
218
+ // object as the host, so the terminal is optional at the boundary.
219
+ return (tui) => new ByteflyHeader(getTheme, () => view(tui.terminal?.rows), VERSION);
198
220
  }
@@ -19,6 +19,16 @@ export type HelpEntry =
19
19
  | { readonly binding: AppKeybinding; readonly description: string }
20
20
  | { readonly raw: string; readonly description: string };
21
21
 
22
+ /** How much of the help block a terminal height can carry. */
23
+ export interface HeaderHeight {
24
+ /** Smallest terminal height, in rows, this shape applies to. Taller tiers come first. */
25
+ readonly minRows: number;
26
+ /** Help groups to print, by index in `help`; a group is dropped whole. */
27
+ readonly helpRows: readonly number[];
28
+ /** Open with a blank row above the nameplate. */
29
+ readonly blank: boolean;
30
+ }
31
+
22
32
  /** The header's shape and content. */
23
33
  export interface HeaderConfig {
24
34
  /** The agent's name, shown before its version. */
@@ -49,6 +59,12 @@ export interface HeaderConfig {
49
59
  readonly chromeVersion: string;
50
60
  /** Grouped help rows, printed one line each under the rule. */
51
61
  readonly help: readonly (readonly HelpEntry[])[];
62
+ /**
63
+ * The height ladder, tallest first: the first tier the terminal meets decides
64
+ * which help groups print and whether the leading blank row survives. The
65
+ * floor is `minRows: 0`, so a short terminal keeps the nameplate and rule.
66
+ */
67
+ readonly heights: readonly HeaderHeight[];
52
68
  }
53
69
 
54
70
  export const HEADER_CONFIG: HeaderConfig = {
@@ -89,4 +105,10 @@ export const HEADER_CONFIG: HeaderConfig = {
89
105
  { binding: "app.clipboard.pasteImage", description: "paste files" },
90
106
  ],
91
107
  ],
108
+ heights: [
109
+ { minRows: 28, helpRows: [0, 1, 2], blank: true },
110
+ { minRows: 22, helpRows: [0, 2], blank: true },
111
+ { minRows: 16, helpRows: [2], blank: true },
112
+ { minRows: 0, helpRows: [], blank: false },
113
+ ],
92
114
  };
@@ -55,11 +55,12 @@ export function registerHeader(pi: ExtensionAPI, updates: UpdateSource = createU
55
55
  // value the tool frame and the notice inset by. An older host without
56
56
  // `getSettings` keeps the default margin.
57
57
  const pad = pi.getSettings?.().outputPad ?? DEFAULT_HOST_PAD;
58
- const view = (): HeaderView => ({
58
+ const view = (rows: number | undefined): HeaderView => ({
59
59
  showVersion: HEADER_CONFIG.showVersion,
60
60
  halfBlocks,
61
61
  pad,
62
62
  latest: updates.latest(),
63
+ rows,
63
64
  });
64
65
  ctx.ui.setHeader(createHeader(() => ctx.ui.theme, view));
65
66
  };
@@ -4,13 +4,13 @@
4
4
  * (the two-line, width-adaptive status bar with its price calculation),
5
5
  * `message/` (the user-message nameplate), `notice/` (the shared transcript
6
6
  * notice renderer), `block/` (the unified tool-block frame), `separator/` (the
7
- * transcript turn rule), `working/` (the live elapsed counter on Pi's working
8
- * row), `panel/` (the unified `/bytefly` screen and its
7
+ * transcript turn rule), `working/` (the live run line on Pi's working row),
8
+ * `panel/` (the unified `/bytefly` screen and its
9
9
  * `doctor` report), `doctor/` (the `run_doctor` tool and its session-scoped
10
10
  * probe runtime), `notify/` (OS notifications for unattended runs), and
11
11
  * `update/` (the published-version check behind the header's update marker).
12
12
  *
13
- * The header, footer, turn rule, and working timer are installed only in
13
+ * The header, footer, turn rule, and working line are installed only in
14
14
  * interactive (`tui`) sessions, and are always on; the user-message marker,
15
15
  * notice, and tool blocks register at load and take effect wherever a
16
16
  * transcript renders. `pi config` turns bytefly's chrome off. The panel and doctor work
@@ -18,7 +18,7 @@ import { cleanString, loadConfigSection } from "../../../lib/config.ts";
18
18
  import { NOTIFY_METHODS, type NotifyConfig, type NotifyMethod } from "./types.ts";
19
19
 
20
20
  /** A notification title longer than this is clamped; it is a label, not a message. */
21
- const MAX_TITLE_LENGTH = 40;
21
+ export const MAX_TITLE_LENGTH = 40;
22
22
 
23
23
  export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
24
24
  enabled: false,
@@ -172,7 +172,7 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
172
172
  if (section === "settings" && ctx.mode !== "tui") {
173
173
  const stores = createSettingsStores(cwd, agentDir, trusted);
174
174
  const settings = makeSettings({ stores, cwd, agentDir, trusted, onError: settingsNotice });
175
- ctx.ui.notify(formatSettingsListing(settings.views("global"), "global"), "info");
175
+ ctx.ui.notify(formatSettingsListing(settings.views("global")), "info");
176
176
  return;
177
177
  }
178
178
 
@@ -31,7 +31,7 @@ export interface SummarySegment {
31
31
  }
32
32
 
33
33
  /** Row and problem counts over a model. */
34
- export interface PanelCounts {
34
+ interface PanelCounts {
35
35
  extensions: number;
36
36
  /** Rows switched off in settings (the picker's checkbox), not the env flag. */
37
37
  off: number;
@@ -113,28 +113,21 @@ export function sectionSegments(active: PanelSection): SummarySegment[] {
113
113
  export function settingsSummarySegments(scope: PanelScope, count: number): SummarySegment[] {
114
114
  return [
115
115
  { text: scope === "global" ? "Global" : "Project", color: "accent" },
116
- { text: `${count} ${count === 1 ? "setting" : "settings"}`, color: "meta" },
116
+ { text: `${count} settings`, color: "meta" },
117
117
  ];
118
118
  }
119
119
 
120
- /** The Settings headline for non-TUI modes, e.g. `bytefly: Settings · Global · 5 settings`. */
121
- export function formatSettingsHeadline(scope: PanelScope, count: number): string {
122
- return `bytefly: Settings · ${settingsSummarySegments(scope, count)
123
- .map((segment) => segment.text)
124
- .join(" · ")}`;
125
- }
126
-
127
120
  /**
128
- * The grouped Settings listing for non-TUI modes. The project form names the
129
- * missing app rows rather than leaving their absence unexplained.
121
+ * The Settings headline for non-TUI modes, e.g. `bytefly: Settings · Global · 16 settings`.
122
+ * The listing only ever reads the personal document, so the scope is not a parameter.
130
123
  */
131
- export function formatSettingsListing(views: readonly SettingView[], scope: PanelScope): string {
132
- const lines = [formatSettingsHeadline(scope, views.length)];
133
- if (scope === "project") lines.push("", " App settings are Global only.");
134
- if (views.length === 0) {
135
- lines.push("", "No settings found.");
136
- return lines.join("\n");
137
- }
124
+ export function formatSettingsHeadline(count: number): string {
125
+ return `bytefly: Settings · Global · ${count} settings`;
126
+ }
127
+
128
+ /** The grouped Settings listing for non-TUI modes. */
129
+ export function formatSettingsListing(views: readonly SettingView[]): string {
130
+ const lines = [formatSettingsHeadline(views.length)];
138
131
  for (const group of groupViews(views)) {
139
132
  lines.push("", `${group.label} (${group.views.length})`);
140
133
  for (const view of group.views) {
@@ -152,7 +145,7 @@ export function formatHeadline(model: PanelModel, scope: PanelScope, options: Pa
152
145
  }
153
146
 
154
147
  /** A row's glyph and verdict, drawn from its enablement and health axes. */
155
- export interface RowBadge {
148
+ interface RowBadge {
156
149
  /** The status glyph, or `undefined` when the row has nothing to say. */
157
150
  glyph?: string;
158
151
  /** The role the glyph is painted in. */
@@ -1,20 +1,28 @@
1
1
  /**
2
2
  * The stateful Settings section: one snapshot of the two config documents and
3
- * the personal settings, the dirty flag, and the two write paths.
3
+ * the personal settings, the dirty flag, and the writes it offers.
4
4
  *
5
5
  * App rows are written through Pi's `SettingsStore` (personal file only), and
6
- * `bytefly.json` rows through the per-key writer, so an edit cannot disturb a
7
- * sibling key or another extension's section. A failed write leaves the
8
- * snapshot untouched and reports through `onError`; the screen never shows a
9
- * value the file did not accept.
6
+ * `bytefly.json` rows through the per-key writers, so an edit cannot disturb a
7
+ * sibling key or another extension's section. A reset deletes one leaf through the
8
+ * same path, and the JSON pane reads the section the row belongs to. A failed
9
+ * write leaves the snapshot untouched and reports through `onError`; the screen
10
+ * never shows a value the file did not accept.
10
11
  */
11
12
 
12
13
  import { join } from "node:path";
13
14
  import { CONFIG_DIR_NAME, type QuietStartup } from "@earendil-works/pi-coding-agent";
14
- import { readJson, SHARED_CONFIG_FILE, writeConfigValueAt } from "../../../lib/config.ts";
15
+ import {
16
+ configSection,
17
+ configValueAt,
18
+ readJson,
19
+ SHARED_CONFIG_FILE,
20
+ unsetConfigValueAt,
21
+ writeConfigValueAt,
22
+ } from "../../../lib/config.ts";
15
23
  import type { SettingsStore, WriteScope } from "../picker/types.ts";
16
24
  import {
17
- countSettings,
25
+ type ConfigSectionName,
18
26
  nextValue,
19
27
  type SettingDescriptor,
20
28
  type SettingsSnapshot,
@@ -23,7 +31,6 @@ import {
23
31
  settingViews,
24
32
  storedValue,
25
33
  textError,
26
- validValue,
27
34
  } from "./settings.ts";
28
35
 
29
36
  /** Host seams for the Settings runtime. */
@@ -40,11 +47,14 @@ export interface SettingsRuntimeDeps {
40
47
  /** What the panel needs from the runtime. */
41
48
  export interface SettingsSurface {
42
49
  views(scope: WriteScope): SettingView[];
43
- count(scope: WriteScope): number;
44
50
  /** Cycle an enum or toggle a boolean. */
45
51
  change(scope: WriteScope, id: string): Promise<void>;
46
52
  /** Store a value the user typed; `false` when the row refused it. */
47
53
  setValue(scope: WriteScope, id: string, raw: string): Promise<boolean>;
54
+ /** Delete the focused row's leaf so its default applies; `false` when nothing changed. */
55
+ reset(scope: WriteScope, id: string): Promise<boolean>;
56
+ /** The raw `bytefly.json` section for `scope`, for the JSON pane. */
57
+ sectionDocument(scope: WriteScope, section: ConfigSectionName): unknown;
48
58
  isDirty(): boolean;
49
59
  }
50
60
 
@@ -62,8 +72,8 @@ export function configPath(scope: WriteScope, cwd: string, agentDir: string): st
62
72
  function readSnapshot(deps: SettingsRuntimeDeps): SettingsSnapshot {
63
73
  return {
64
74
  app: deps.stores.global.globalSettings(),
65
- global: readJson(join(deps.agentDir, SHARED_CONFIG_FILE)) ?? {},
66
- project: deps.trusted ? (readJson(join(deps.cwd, CONFIG_DIR_NAME, SHARED_CONFIG_FILE)) ?? {}) : {},
75
+ global: readJson(configPath("global", deps.cwd, deps.agentDir)) ?? {},
76
+ project: deps.trusted ? (readJson(configPath("project", deps.cwd, deps.agentDir)) ?? {}) : {},
67
77
  };
68
78
  }
69
79
 
@@ -133,10 +143,15 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
133
143
 
134
144
  return {
135
145
  views: (scope) => settingViews(snapshot, scope),
136
- count: (scope) => countSettings(scope),
137
146
  change: async (scope, id) => {
138
147
  const row = SETTING_ROWS.find((entry) => entry.id === id);
139
148
  if (!row) return;
149
+ // Only a boolean or an enum cycles; a text row is stored from its editor,
150
+ // so refusing here keeps `nextValue`'s cycle contract honest.
151
+ if (row.kind === "text") {
152
+ deps.onError?.(`${row.label} is edited as text, not cycled.`);
153
+ return;
154
+ }
140
155
  const refused = refusal(row, scope, deps.trusted);
141
156
  if (refused !== undefined) {
142
157
  deps.onError?.(refused);
@@ -145,10 +160,6 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
145
160
  const current = settingViews(snapshot, scope).find((entry) => entry.id === id);
146
161
  if (!current) return;
147
162
  const next = nextValue(row, current.value);
148
- if (!validValue(row, next.value)) {
149
- deps.onError?.(`Cannot save ${row.label}: unknown value.`);
150
- return;
151
- }
152
163
  try {
153
164
  await write(scope, row, next.value);
154
165
  } catch (error) {
@@ -159,8 +170,8 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
159
170
  },
160
171
  async setValue(scope, id, raw) {
161
172
  const row = SETTING_ROWS.find((entry) => entry.id === id);
162
- if (!row || row.kind !== "text") {
163
- deps.onError?.(`${row?.label ?? id} cannot be edited as text.`);
173
+ if (!row) {
174
+ deps.onError?.(`${id} is not a setting.`);
164
175
  return false;
165
176
  }
166
177
  const refused = refusal(row, scope, deps.trusted);
@@ -182,6 +193,39 @@ export function createSettingsRuntime(deps: SettingsRuntimeDeps): SettingsRuntim
182
193
  committed();
183
194
  return true;
184
195
  },
196
+ async reset(scope, id) {
197
+ const row = SETTING_ROWS.find((entry) => entry.id === id);
198
+ if (!row) {
199
+ deps.onError?.(`${id} is not a setting.`);
200
+ return false;
201
+ }
202
+ if (row.source === "app") {
203
+ deps.onError?.(`${row.label} is a Pi setting; Pi has no unset, so set the value instead.`);
204
+ return false;
205
+ }
206
+ const refused = refusal(row, scope, deps.trusted);
207
+ if (refused !== undefined) {
208
+ deps.onError?.(refused);
209
+ return false;
210
+ }
211
+ const config = row.config;
212
+ if (!config) return false;
213
+ const document = scope === "global" ? snapshot.global : snapshot.project;
214
+ if (configValueAt(configSection(document, config.section), config.path) === undefined) {
215
+ deps.onError?.(`${row.label} is already at its default.`);
216
+ return false;
217
+ }
218
+ try {
219
+ unsetConfigValueAt(configPath(scope, deps.cwd, deps.agentDir), config.section, config.path);
220
+ } catch (error) {
221
+ deps.onError?.(`Could not reset ${row.label}: ${error instanceof Error ? error.message : String(error)}`);
222
+ return false;
223
+ }
224
+ committed();
225
+ return true;
226
+ },
227
+ sectionDocument: (scope, section) =>
228
+ configSection(scope === "global" ? snapshot.global : snapshot.project, section),
185
229
  isDirty: () => dirty,
186
230
  onChange: (listener) => {
187
231
  listeners.add(listener);