@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 (112) hide show
  1. package/README.md +17 -9
  2. package/README.zh-CN.md +16 -9
  3. package/extensions/bytefly/README.md +25 -18
  4. package/extensions/bytefly/notify/types.ts +1 -1
  5. package/extensions/bytefly/panel/settings.ts +25 -18
  6. package/extensions/file-browse/README.md +60 -21
  7. package/extensions/file-browse/chrome.ts +83 -0
  8. package/extensions/file-browse/client.ts +62 -0
  9. package/extensions/file-browse/config.ts +12 -3
  10. package/extensions/file-browse/diff-html.ts +94 -0
  11. package/extensions/file-browse/html.ts +19 -147
  12. package/extensions/file-browse/live-client.ts +47 -1
  13. package/extensions/file-browse/live.ts +35 -14
  14. package/extensions/file-browse/rev.ts +42 -0
  15. package/extensions/file-browse/router.ts +25 -4
  16. package/extensions/file-browse/server.ts +7 -0
  17. package/extensions/file-browse/styles.ts +2 -0
  18. package/extensions/goal/plan-route.ts +8 -2
  19. package/extensions/job/README.md +7 -5
  20. package/extensions/job/index.ts +15 -7
  21. package/extensions/memory/README.md +3 -3
  22. package/extensions/plan/README.md +280 -86
  23. package/extensions/plan/commands.ts +56 -5
  24. package/extensions/plan/feedback.ts +300 -63
  25. package/extensions/plan/index.ts +1 -1
  26. package/extensions/plan/plan-index.ts +1 -1
  27. package/extensions/plan/plan-text.ts +18 -1
  28. package/extensions/plan/plans.ts +165 -57
  29. package/extensions/plan/review-prompt.ts +93 -0
  30. package/extensions/plan/review.ts +78 -98
  31. package/extensions/plan/runtime.ts +1 -1
  32. package/extensions/plan/split.ts +87 -0
  33. package/extensions/plan/steps.ts +80 -93
  34. package/extensions/plan/tools/enter.ts +8 -3
  35. package/extensions/plan/tools/exit.ts +101 -23
  36. package/extensions/plan/tools/write.ts +8 -3
  37. package/extensions/plan/tools.ts +1 -1
  38. package/extensions/plan/tui.ts +51 -8
  39. package/extensions/plan/types.ts +37 -3
  40. package/extensions/plan/web-chrome.ts +2 -1
  41. package/extensions/plan/web-client.ts +121 -177
  42. package/extensions/plan/web-collab.ts +207 -0
  43. package/extensions/plan/web-comments.ts +269 -0
  44. package/extensions/plan/web-page.ts +103 -17
  45. package/extensions/plan/web-router.ts +146 -30
  46. package/extensions/plan/web-share-session.ts +197 -0
  47. package/extensions/plan/web-share.ts +169 -0
  48. package/extensions/plan/web-streams.ts +124 -0
  49. package/extensions/plan/web-tokens.ts +63 -0
  50. package/extensions/plan/web.ts +193 -45
  51. package/extensions/question/README.md +3 -0
  52. package/extensions/question/dialogs.ts +91 -1
  53. package/extensions/question/tools.ts +2 -1
  54. package/extensions/session-bus/README.md +247 -0
  55. package/extensions/session-bus/commands.ts +165 -0
  56. package/extensions/session-bus/format.ts +124 -0
  57. package/extensions/session-bus/index.ts +69 -0
  58. package/extensions/session-bus/notice.ts +58 -0
  59. package/extensions/session-bus/protocol.ts +134 -0
  60. package/extensions/session-bus/result.ts +243 -0
  61. package/extensions/session-bus/rows.ts +169 -0
  62. package/extensions/session-bus/runtime.ts +575 -0
  63. package/extensions/session-bus/store.ts +329 -0
  64. package/extensions/session-bus/team.ts +102 -0
  65. package/extensions/session-bus/tools.ts +227 -0
  66. package/extensions/session-bus/tui.ts +394 -0
  67. package/extensions/session-bus/types.ts +98 -0
  68. package/extensions/subagent/README.md +1 -1
  69. package/extensions/watch/README.md +9 -5
  70. package/extensions/watch/index.ts +15 -9
  71. package/extensions/wechat/README.md +20 -33
  72. package/extensions/wechat/bridge.ts +8 -8
  73. package/extensions/wechat/commands.ts +2 -2
  74. package/extensions/wechat/deliver.ts +57 -37
  75. package/extensions/wechat/interaction.ts +31 -16
  76. package/extensions/wechat/notices.ts +7 -7
  77. package/extensions/wechat/peer-worker.ts +19 -34
  78. package/extensions/wechat/presence.ts +3 -4
  79. package/extensions/wechat/prompt.ts +39 -24
  80. package/extensions/wechat/reply-pipeline.ts +12 -4
  81. package/extensions/worktree/git.ts +5 -0
  82. package/extensions/worktree/registry.ts +11 -5
  83. package/extensions/worktree/status.ts +1 -3
  84. package/lib/README.md +6 -2
  85. package/lib/claims/overlap.ts +43 -0
  86. package/lib/http/router.ts +40 -13
  87. package/lib/http/server.ts +27 -13
  88. package/lib/interaction.ts +10 -0
  89. package/lib/notice.ts +29 -2
  90. package/lib/plan-entry.ts +7 -2
  91. package/lib/plans/sections.ts +172 -0
  92. package/lib/plans/setting.ts +52 -0
  93. package/lib/plans/split.ts +215 -0
  94. package/lib/plans/store.ts +19 -0
  95. package/lib/plans/team.ts +151 -0
  96. package/lib/qr.ts +64 -0
  97. package/lib/repo-key.ts +74 -0
  98. package/lib/report.ts +52 -33
  99. package/lib/requirements.ts +1 -2
  100. package/lib/tui/README.md +16 -2
  101. package/lib/tui/dock-screen.ts +85 -17
  102. package/lib/tui/line-pane.ts +15 -4
  103. package/lib/tui/tool.ts +23 -4
  104. package/lib/web/components.ts +1 -1
  105. package/package.json +3 -2
  106. package/skills/init/SKILL.md +80 -45
  107. package/skills/init/references/agents-md-quality.md +26 -2
  108. package/skills/init/references/interview-questions.md +36 -14
  109. package/skills/next/SKILL.md +78 -53
  110. package/skills/parallel-dev/SKILL.md +150 -0
  111. package/extensions/plan/config.ts +0 -27
  112. package/extensions/wechat/qrcode.ts +0 -46
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The per-repository storage key that outlives one checkout.
3
+ *
4
+ * Two extensions file state by repository — `session-bus` its sessions, mail, and
5
+ * claims, `plan` its plans — and both must resolve the same key from any
6
+ * worktree, so a plan approved in `.pi/worktrees/x` is the same plan from the
7
+ * main checkout. The key is the repository's *common* git directory, which every
8
+ * worktree of that repository shares; outside a repository the working directory
9
+ * is the identity.
10
+ *
11
+ * Hashing keeps a mail directory, a claims directory, and a plans directory short
12
+ * enough to read and safe on every filesystem, while staying unique per
13
+ * repository.
14
+ */
15
+
16
+ import { createHash } from "node:crypto";
17
+ import { realpathSync } from "node:fs";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { createExecRunner, gitCommonDir, repoRoot } from "./git/index.ts";
20
+
21
+ /** Hex characters kept from the digest: collision-free in practice and short enough for a path. */
22
+ export const REPO_KEY_LENGTH = 12;
23
+
24
+ /** The key for one identity string, hashed and shortened. */
25
+ export function repoKeyOf(value: string): string {
26
+ return createHash("sha256").update(canonical(value)).digest("hex").slice(0, REPO_KEY_LENGTH);
27
+ }
28
+
29
+ /**
30
+ * One path, one spelling.
31
+ *
32
+ * git prints the path it was told to work from, so a repository reached through a
33
+ * symlinked prefix reports a different common dir for the same repository. Two
34
+ * spellings would file one repository's plans and claims under two keys, so the
35
+ * real path is what gets hashed; a path that cannot be resolved keeps its own
36
+ * spelling rather than failing.
37
+ */
38
+ function canonical(value: string): string {
39
+ try {
40
+ return realpathSync.native(value);
41
+ } catch {
42
+ return value;
43
+ }
44
+ }
45
+
46
+ /** A resolved repository identity. */
47
+ export interface RepoKey {
48
+ /** Absolute repository root, or `cwd` when git did not answer. */
49
+ root: string;
50
+ /** The shared key, derived from the common git directory. */
51
+ key: string;
52
+ /**
53
+ * Whether git resolved the repository. A caller that needs a *project-local*
54
+ * location branches on this; one that only needs a stable directory does not.
55
+ */
56
+ fromGit: boolean;
57
+ }
58
+
59
+ /**
60
+ * Resolve the repository identity for `cwd`.
61
+ *
62
+ * Never throws: without git, or when a git call fails, the working directory is
63
+ * the identity — which is what a caller outside a repository wants anyway.
64
+ */
65
+ export async function repoKeyFor(pi: Pick<ExtensionAPI, "exec">, cwd: string): Promise<RepoKey> {
66
+ try {
67
+ const run = createExecRunner(pi);
68
+ const [common, root] = await Promise.all([gitCommonDir(run, cwd), repoRoot(run, cwd)]);
69
+ if (root) return { root, key: repoKeyOf(common ?? root), fromGit: true };
70
+ } catch {
71
+ // Fall through: no git, or a git call that failed, is the same answer here.
72
+ }
73
+ return { root: cwd, key: repoKeyOf(cwd), fromGit: false };
74
+ }
package/lib/report.ts CHANGED
@@ -2,12 +2,15 @@
2
2
  * The pending-report coordinator: how a live feature tells the model what
3
3
  * happened without interrupting it.
4
4
  *
5
- * A watcher match and a finished job follow one rule — report at the next turn,
6
- * and raise at most one triggered turn when the work opted into `wake`. The two
7
- * traps are subtle enough to have been re-derived once per extension:
5
+ * A watcher match, a finished job, and arriving mail follow one rule — report at
6
+ * the next turn, and raise at most one triggered turn per burst when the work
7
+ * opted into `wake`, as soon as the agent is idle. The three traps are subtle
8
+ * enough to have been re-derived once per extension:
8
9
  *
9
10
  * - a wake is only safe while the agent is idle, so an outcome that arrives
10
- * mid-turn must be queued rather than sent;
11
+ * mid-turn is queued rather than sent, and `agent_settled` retries it;
12
+ * - an outcome outlives the run it arrived in, so the retry asks
13
+ * `wakeRequested()` rather than trusting the one call that queued it;
11
14
  * - between the triggered send and the `agent_start` it causes, the agent still
12
15
  * looks idle, so a second outcome in that window must not trigger a second
13
16
  * turn. `waking` closes that window.
@@ -33,6 +36,11 @@ export interface PendingReporterOptions<T> {
33
36
  markReported(items: T[]): void;
34
37
  /** Build the notice for one burst: model text plus renderer details. */
35
38
  buildNotice(items: T[]): NoticeMessage;
39
+ /**
40
+ * Whether any pending outcome asks for a triggered turn. Called by the settle
41
+ * retry, so a mid-turn outcome still wakes once the agent is idle.
42
+ */
43
+ wakeRequested(): boolean;
36
44
  /** The notice's source, so only its own stale entries are pruned. */
37
45
  source: string;
38
46
  /** Runs after a wake turn was successfully requested, for an extra announcement. */
@@ -47,8 +55,8 @@ export interface PendingReporter {
47
55
  sessionStart(): void;
48
56
  /** A turn began: outcomes queue up, and none of them triggers a turn. */
49
57
  agentStart(): void;
50
- /** The turn settled: a later outcome may wake again. */
51
- agentSettled(): void;
58
+ /** The turn settled: retry a wake it suppressed, unless it was aborted. */
59
+ agentSettled(event?: { aborted?: boolean }): void;
52
60
  /** Report what is queued at the start of a turn, without triggering one. */
53
61
  beforeAgentStart(): { message: NoticeMessage } | undefined;
54
62
  /** Drop this source's older notices from the outgoing context. */
@@ -56,26 +64,29 @@ export interface PendingReporter {
56
64
  }
57
65
 
58
66
  export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
59
- const { wake, pending, markReported, buildNotice, source, onWoke } = options;
67
+ const { wake, pending, markReported, buildNotice, wakeRequested, source, onWoke } = options;
60
68
  let busy = false;
61
69
  let waking = false;
62
70
 
71
+ const report = (): void => {
72
+ if (busy || waking) return;
73
+ if (!wakeRequested()) return;
74
+ const items = pending();
75
+ if (items.length === 0) return;
76
+ try {
77
+ wake(buildNotice(items));
78
+ } catch {
79
+ // Reporting is best-effort; the next turn reports the same items.
80
+ return;
81
+ }
82
+ // Mark only after the send succeeded, so a throw changes nothing.
83
+ markReported(items);
84
+ waking = true;
85
+ onWoke?.(items);
86
+ };
87
+
63
88
  return {
64
- report: () => {
65
- if (busy || waking) return;
66
- const items = pending();
67
- if (items.length === 0) return;
68
- try {
69
- wake(buildNotice(items));
70
- } catch {
71
- // Reporting is best-effort; the next turn reports the same items.
72
- return;
73
- }
74
- // Mark only after the send succeeded, so a throw changes nothing.
75
- markReported(items);
76
- waking = true;
77
- onWoke?.(items);
78
- },
89
+ report,
79
90
  sessionStart: () => {
80
91
  busy = false;
81
92
  waking = false;
@@ -83,9 +94,13 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
83
94
  agentStart: () => {
84
95
  busy = true;
85
96
  },
86
- agentSettled: () => {
97
+ agentSettled: (event) => {
87
98
  busy = false;
88
99
  waking = false;
100
+ // An interrupted run is not a request to interrupt again; its outcomes
101
+ // stay queued for the next turn.
102
+ if (event?.aborted === true) return;
103
+ report();
89
104
  },
90
105
  beforeAgentStart: () => {
91
106
  const items = pending();
@@ -103,10 +118,14 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
103
118
 
104
119
  /** The session-scoped state a channel drives, narrowed to what its hooks need. */
105
120
  export interface PendingChannelRuntime {
106
- /** Load this session's records; called on every session start. */
107
- load(ctx: ExtensionContext): void;
108
- /** Repaint the status chip. */
109
- setStatus(ctx: ExtensionContext): void;
121
+ /**
122
+ * Load this session's records on every session start. A promise is awaited, so a
123
+ * runtime that resolves a repository or opens a store is fully loaded before the
124
+ * report trigger is armed.
125
+ */
126
+ load(ctx: ExtensionContext): void | Promise<void>;
127
+ /** Repaint the status chip; a runtime that publishes none omits it. */
128
+ setStatus?(ctx: ExtensionContext): void;
110
129
  /** Release the session's resources; idempotent. */
111
130
  shutdown(): Promise<void>;
112
131
  }
@@ -135,29 +154,29 @@ export function registerPendingChannel(
135
154
  pi: Pick<ExtensionAPI, "on">,
136
155
  { reporter, runtime, onSessionStart, onSessionShutdown }: PendingChannelOptions,
137
156
  ): void {
138
- pi.on("session_start", (_event, ctx) => {
157
+ pi.on("session_start", async (_event, ctx) => {
139
158
  reporter.sessionStart();
140
- runtime.load(ctx);
159
+ await runtime.load(ctx);
141
160
  onSessionStart?.(ctx);
142
161
  });
143
162
 
144
163
  // Tree navigation does not change the pending records; only repaint.
145
164
  pi.on("session_tree", (_event, ctx) => {
146
- runtime.setStatus(ctx);
165
+ runtime.setStatus?.(ctx);
147
166
  });
148
167
 
149
168
  pi.on("agent_start", () => {
150
169
  reporter.agentStart();
151
170
  });
152
- pi.on("agent_settled", () => {
153
- reporter.agentSettled();
171
+ pi.on("agent_settled", (event) => {
172
+ reporter.agentSettled(event);
154
173
  });
155
174
 
156
175
  // Report what is queued at the start of the next turn, without triggering one.
157
176
  pi.on("before_agent_start", (_event, ctx) => {
158
177
  const reported = reporter.beforeAgentStart();
159
178
  if (!reported) return undefined;
160
- runtime.setStatus(ctx);
179
+ runtime.setStatus?.(ctx);
161
180
  return reported;
162
181
  });
163
182
 
@@ -19,7 +19,6 @@ import { configSection, configValueAt, type JsonReadResult, SHARED_CONFIG_FILE }
19
19
  import type { WebSearchProviderEvent } from "./events.ts";
20
20
  import { pdfGuidance } from "./pdf.ts";
21
21
  import { getPiInvocation, type PiInvocation } from "./pi-invocation.ts";
22
-
23
22
  /** How a check came out. Only `missing` is worth interrupting a session for. */
24
23
  export type FindingSeverity = "ok" | "missing" | "degraded" | "info";
25
24
 
@@ -216,7 +215,6 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
216
215
  notes: ["Runs the project's detected command through a shell; a stack with no detector needs `check.command`."],
217
216
  },
218
217
  plan: {
219
- optional: [gitCheck("project-local `.pi/plans` (otherwise plans land in the agent directory)")],
220
218
  notes: ["Needs an interactive UI to enter plan mode."],
221
219
  },
222
220
  subagent: {
@@ -267,6 +265,7 @@ export const REQUIREMENTS: Readonly<Record<string, ExtensionRequirements>> = {
267
265
  optional: [gitCheck("project-scope memory under the repository root")],
268
266
  },
269
267
  context: {},
268
+ "session-bus": {},
270
269
  wechat: {
271
270
  optional: [
272
271
  {
package/lib/tui/README.md CHANGED
@@ -35,8 +35,8 @@ Reach for a `lib/tui/` primitive before composing your own: `plainRule` /
35
35
  `fitCells` for two columns, `LinePane` for a scrollable log, `ListCursor` /
36
36
  `IdCursor` for selection, `byRunningFirst` for a live list's order,
37
37
  `DockListScreen` for a list that opens a pane, `TailResult` for a tool result
38
- that lists rows or tails output, `screenFooter` for a screen's own last three
39
- rows, and `ConfirmState` before a destructive action.
38
+ that lists rows, heads several row sections, or tails output, `screenFooter` for a
39
+ screen's own last three rows, and `ConfirmState` before a destructive action.
40
40
 
41
41
  ## Docked list screens
42
42
 
@@ -81,6 +81,20 @@ what a plain list wants — do not hand-roll the frame to get one of them:
81
81
  (`c` collect, `x` cancel). Return `true` for the keys it claimed; everything
82
82
  else falls through to the pane's scroll keys.
83
83
 
84
+ A pane is a log by default: it opens at the tail and its subtitle says
85
+ `following` or `paused`. Two more fields cover the other body:
86
+
87
+ - `DetailContents.text` — prose instead of `lines`, wrapped to the width the pane
88
+ renders at and re-wrapped on a resize. One source line is one paragraph; a blank
89
+ line stays blank. `lines` remains the right shape for output that is already
90
+ split.
91
+ - `DetailContents.tail: false` — a prose or list pane opens at the top and never
92
+ claims to follow anything. Leave it out for a log.
93
+
94
+ A screen whose list is one of several scopes overrides `headerLabel()` (the
95
+ header rule's name) and `emptyLabel()` (the empty-list line), so `Tab` can change
96
+ what is listed without a second renderer; both default to the screen's options.
97
+
84
98
  A destructive action asks through [`confirm.ts`](./confirm.ts):
85
99
  `ConfirmState.ask(prompt, accept)` holds the question, `resolve(data)` accepts on
86
100
  `y`/`Y`/Enter, dismisses on `n`/`N`/Esc/`q`, and returns `undefined` for any
@@ -20,11 +20,13 @@
20
20
 
21
21
  import type { Theme } from "@earendil-works/pi-coding-agent";
22
22
  import { Key, matchesKey, type Component, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
23
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
23
24
  import { detailBlockRows, dockListFrame, screenHint, type ScreenHint, type ViewportRowsSource } from "./chrome.ts";
24
25
  import { clip, fitCells } from "./columns.ts";
25
26
  import { ConfirmState } from "./confirm.ts";
26
27
  import { fitRows, formatRange, IdCursor, isCloseKey, navIntent, wheelDelta } from "./cursor.ts";
27
28
  import { SEPARATORS } from "./glyphs.ts";
29
+ import { BODY_INDENT } from "./indent.ts";
28
30
  import { LinePane } from "./line-pane.ts";
29
31
  import { emptyState } from "./rows.ts";
30
32
  import { titleRule } from "./rules.ts";
@@ -43,12 +45,23 @@ const DETAIL_CHROME_ROWS = 6;
43
45
  export interface DetailContents {
44
46
  /** Pane title, e.g. `j1 build`. */
45
47
  title: string;
46
- /** The body lines, already sanitized by the caller. */
47
- lines: string[];
48
+ /** The body lines, already sanitized by the caller. Omit for a `text` pane. */
49
+ lines?: string[];
50
+ /**
51
+ * Prose body, built once at open and wrapped to the render width on every
52
+ * frame. Takes precedence over `lines`. A resize re-wraps; a blank line in the
53
+ * source stays a blank line in the pane.
54
+ */
55
+ text?: string;
48
56
  /** More lines exist above the ones supplied. */
49
57
  more?: boolean;
50
58
  /** Subtitle under the title, e.g. the log file path. */
51
59
  subtitle?: string;
60
+ /**
61
+ * Whether the pane follows its tail. Default true, which is a log's behavior;
62
+ * false opens a prose or list pane at the top and never says `following`.
63
+ */
64
+ tail?: boolean;
52
65
  /**
53
66
  * Rows above the scrollable body that depend on the width: key/value facts,
54
67
  * a legend, a divider. Read fresh every frame with the live width and already
@@ -101,6 +114,9 @@ export abstract class DockListScreen<T> implements Component {
101
114
  private readonly confirmState = new ConfirmState();
102
115
  private feedback: string | undefined;
103
116
  private detailFacts: ((width: number) => string[]) | undefined;
117
+ /** Prose body for the open pane, wrapped to the width it is rendered at. */
118
+ private detailText: string | undefined;
119
+ private detailWrap: { width: number; lines: string[] } | undefined;
104
120
 
105
121
  constructor(protected readonly options: DockListScreenOptions) {}
106
122
 
@@ -163,6 +179,19 @@ export abstract class DockListScreen<T> implements Component {
163
179
  this.options.requestRender();
164
180
  }
165
181
 
182
+ /**
183
+ * The header rule's label. A screen whose list is one of several scopes
184
+ * overrides it, so the rule names what is on screen.
185
+ */
186
+ protected headerLabel(): string {
187
+ return this.options.title;
188
+ }
189
+
190
+ /** The empty-list line; a screen whose scopes differ overrides it. */
191
+ protected emptyLabel(): string {
192
+ return this.options.emptyList;
193
+ }
194
+
166
195
  /** Show one line of feedback in place of the hints. */
167
196
  protected notify(text: string): void {
168
197
  this.feedback = text;
@@ -182,6 +211,12 @@ export abstract class DockListScreen<T> implements Component {
182
211
  // Facts stay with the pane unless this update supplies its own, so a poll that
183
212
  // only refreshes the body cannot drop the facts the row opened with.
184
213
  if (contents.facts) this.detailFacts = contents.facts;
214
+ // `text` and `tail` belong to the row that opened the pane; an update either
215
+ // replaces the prose or settles the pane back onto a line buffer.
216
+ if (contents.text !== undefined || contents.lines !== undefined) {
217
+ this.detailText = contents.text;
218
+ this.detailWrap = undefined;
219
+ }
185
220
  const changed =
186
221
  contents.subtitle === undefined
187
222
  ? this.pane.update({ title: contents.title, lines: contents.lines, more: contents.more })
@@ -201,6 +236,7 @@ export abstract class DockListScreen<T> implements Component {
201
236
 
202
237
  /** Re-read the open detail pane, so a poll cannot show stale lines. */
203
238
  refreshDetail(id: string): void {
239
+ if (this.detailText !== undefined) return;
204
240
  const source = this.detailById?.(id);
205
241
  if (source && this.pane.update({ lines: source.lines, more: source.more })) this.options.requestRender();
206
242
  }
@@ -242,10 +278,30 @@ export abstract class DockListScreen<T> implements Component {
242
278
  const contents = this.detailFor(row);
243
279
  this.mode = "detail";
244
280
  this.detailFacts = contents.facts;
245
- this.pane.open(this.idOf(row), contents);
281
+ this.detailText = contents.text;
282
+ this.detailWrap = undefined;
283
+ this.pane.open(
284
+ this.idOf(row),
285
+ {
286
+ title: contents.title,
287
+ lines: contents.text === undefined ? (contents.lines ?? []) : [],
288
+ more: contents.more,
289
+ subtitle: contents.subtitle,
290
+ },
291
+ contents.tail ?? true,
292
+ );
246
293
  this.options.requestRender();
247
294
  }
248
295
 
296
+ /** Leave detail mode and drop everything that belonged to the open row. */
297
+ private closeDetail(): void {
298
+ this.mode = "list";
299
+ this.pane.close();
300
+ this.detailFacts = undefined;
301
+ this.detailText = undefined;
302
+ this.detailWrap = undefined;
303
+ }
304
+
249
305
  private handleListKey(data: string): void {
250
306
  if (isCloseKey(data) || data === "q") {
251
307
  this.options.onClose();
@@ -294,9 +350,7 @@ export abstract class DockListScreen<T> implements Component {
294
350
 
295
351
  private handleDetailKey(data: string): void {
296
352
  if (matchesKey(data, Key.escape) || matchesKey(data, Key.backspace) || data === "q") {
297
- this.mode = "list";
298
- this.pane.close();
299
- this.detailFacts = undefined;
353
+ this.closeDetail();
300
354
  this.options.requestRender();
301
355
  return;
302
356
  }
@@ -307,11 +361,11 @@ export abstract class DockListScreen<T> implements Component {
307
361
  }
308
362
 
309
363
  /** The footer: a pending confirmation, one line of feedback, or the hints. */
310
- private hint(width: number, rows: readonly T[]): string {
364
+ private footer(width: number, hints: ScreenHint[]): string {
311
365
  const pending = this.confirmState.pending;
312
366
  if (pending) return clip(` ${paint(this.theme, "warn", pending.prompt)}`, width);
313
367
  if (this.feedback) return clip(` ${paint(this.theme, "meta", this.feedback)}`, width);
314
- return screenHint(this.theme, width, this.listHints(rows));
368
+ return screenHint(this.theme, width, hints);
315
369
  }
316
370
 
317
371
  private summary(rows: readonly T[], width: number): string {
@@ -343,7 +397,7 @@ export abstract class DockListScreen<T> implements Component {
343
397
  const body: string[] = [];
344
398
  let range: string | undefined;
345
399
  if (rows.length === 0) {
346
- body.push(...emptyState(this.theme, w, { label: this.options.emptyList }));
400
+ body.push(...emptyState(this.theme, w, { label: this.emptyLabel() }));
347
401
  } else {
348
402
  const end = Math.min(rows.length, this.cursor.scroll + this.cursor.visible);
349
403
  for (let i = this.cursor.scroll; i < end; i++) body.push(this.rowLine(rows[i], w, i === index));
@@ -355,12 +409,12 @@ export abstract class DockListScreen<T> implements Component {
355
409
  return dockListFrame({
356
410
  theme: this.theme,
357
411
  width: w,
358
- header: titleRule(this.theme, w, this.options.title),
412
+ header: titleRule(this.theme, w, this.headerLabel()),
359
413
  summary: this.summary(rows, w),
360
414
  body,
361
415
  range,
362
416
  detail: block.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w)),
363
- hint: this.hint(w, rows),
417
+ hint: this.footer(w, this.listHints(rows)),
364
418
  });
365
419
  }
366
420
 
@@ -368,6 +422,7 @@ export abstract class DockListScreen<T> implements Component {
368
422
  this.syncDetail();
369
423
  // A live pane whose row is gone has already closed itself: draw the list.
370
424
  if (this.mode !== "detail") return this.renderList(w);
425
+ this.syncText(w);
371
426
  const facts = this.detailFacts ? this.detailFacts(w) : [];
372
427
  const visible = fitRows(
373
428
  this.options.viewportRowsSource,
@@ -397,14 +452,15 @@ export abstract class DockListScreen<T> implements Component {
397
452
  summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
398
453
  body,
399
454
  range,
400
- hint: screenHint(this.theme, w, this.detailHints(this.detailRow)),
455
+ hint: this.footer(w, this.detailHints(this.detailRow)),
401
456
  });
402
457
  }
403
458
 
404
459
  /**
405
460
  * Re-read a live pane in place. This runs inside a render, so it must not ask
406
461
  * for another frame; a source that disappeared closes the pane instead of
407
- * showing lines the list no longer has a row for.
462
+ * showing lines the list no longer has a row for. A prose pane is skipped here:
463
+ * `syncText` owns its lines, and this still closes it when the row is gone.
408
464
  */
409
465
  private syncDetail(): void {
410
466
  if (!this.liveDetail()) return;
@@ -412,11 +468,23 @@ export abstract class DockListScreen<T> implements Component {
412
468
  if (!id) return;
413
469
  const source = this.detailById?.(id);
414
470
  if (!source) {
415
- this.mode = "list";
416
- this.pane.close();
417
- this.detailFacts = undefined;
471
+ this.closeDetail();
418
472
  return;
419
473
  }
420
- this.pane.update({ lines: source.lines, more: source.more });
474
+ if (this.detailText === undefined) this.pane.update({ lines: source.lines, more: source.more });
475
+ }
476
+
477
+ /**
478
+ * Wrap a prose body to the width the pane actually renders at, once per width.
479
+ * The pane's body lines are built at open, so this is the one source that can
480
+ * follow a resize.
481
+ */
482
+ private syncText(width: number): void {
483
+ if (this.detailText === undefined) return;
484
+ const inner = Math.max(1, width - BODY_INDENT);
485
+ if (this.detailWrap?.width === inner) return;
486
+ const lines = this.detailText.split("\n").flatMap((line) => (line === "" ? [""] : wrapTextWithAnsi(line, inner)));
487
+ this.detailWrap = { width: inner, lines };
488
+ this.pane.update({ lines });
421
489
  }
422
490
  }
@@ -45,6 +45,11 @@ export class LinePane {
45
45
  visible = 1;
46
46
  /** Whether the view is parked at the tail; set false once the user scrolls up. */
47
47
  follow = true;
48
+ /**
49
+ * Whether this pane follows its tail. A log does; a prose or list pane does not:
50
+ * it opens at the top and never reports `following`/`paused`.
51
+ */
52
+ tail = true;
48
53
 
49
54
  private buffer: string[] = [];
50
55
  private label = "";
@@ -66,16 +71,21 @@ export class LinePane {
66
71
  return Math.max(0, this.buffer.length - this.visible);
67
72
  }
68
73
 
69
- /** Open the pane on a row, reset to the tail. */
70
- open(id: string, source: PaneSource): void {
74
+ /** Open the pane on a row, reset to the tail for a log, or to the top for prose. */
75
+ open(id: string, source: PaneSource, tail = true): void {
71
76
  this.id = id;
72
77
  this.buffer = [];
73
78
  this.label = "";
74
79
  this.subtitle = undefined;
75
80
  this.more = false;
76
81
  this.scroll = 0;
77
- this.follow = true;
82
+ this.tail = tail;
83
+ this.follow = tail;
78
84
  this.update(source);
85
+ if (!tail) {
86
+ this.follow = false;
87
+ this.scroll = 0;
88
+ }
79
89
  }
80
90
 
81
91
  /** Close the pane and drop its buffer. */
@@ -87,6 +97,7 @@ export class LinePane {
87
97
  this.more = false;
88
98
  this.scroll = 0;
89
99
  this.follow = true;
100
+ this.tail = true;
90
101
  }
91
102
 
92
103
  /**
@@ -127,7 +138,7 @@ export class LinePane {
127
138
  const parts: string[] = [];
128
139
  if (this.subtitle) parts.push(this.subtitle);
129
140
  if (this.more && this.buffer.length > 0) parts.push(`showing last ${this.buffer.length} lines`);
130
- if (this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
141
+ if (this.tail && this.buffer.length > 0) parts.push(this.follow ? "following" : "paused");
131
142
  return parts.join(" · ");
132
143
  }
133
144
 
package/lib/tui/tool.ts CHANGED
@@ -200,18 +200,29 @@ export function earlierHint(theme: Theme, hidden: number, label: string): string
200
200
  /** The row renderer a {@link TailResult} list uses: the extension's row grammar at a width. */
201
201
  export type TailRowLine<Row> = (row: Row, width: number, theme: Theme) => string;
202
202
 
203
- /** What a {@link TailResult} draws: the caller's rows plus a hidden count, or a bounded tail. */
203
+ /** One group of rows inside a {@link TailResult}, under an optional faint heading. */
204
+ export interface TailSection<Row> {
205
+ /** A faint heading above the rows, e.g. `Claims`; omit for the block's primary list. */
206
+ label?: string;
207
+ rows: readonly Row[];
208
+ more: number;
209
+ rowLine: TailRowLine<Row>;
210
+ }
211
+
212
+ /** What a {@link TailResult} draws: the caller's rows plus a hidden count, a headed row list, or a bounded tail. */
204
213
  export type TailResultInput<Row> =
205
214
  | { kind: "list"; rows: readonly Row[]; more: number; rowLine: TailRowLine<Row> }
215
+ | { kind: "sections"; sections: readonly TailSection<Row>[] }
206
216
  | { kind: "lines"; lines: readonly string[]; earlier: number; earlierLabel: string };
207
217
 
208
218
  /**
209
219
  * The transcript result block a tool that lists rows or tails output shares.
210
220
  *
211
221
  * One leading blank line per the block grammar, then either the caller's rows
212
- * (already ordered and capped) with a "more" note, or the themed tail with an
213
- * "earlier" note. The caller owns the row renderer, so the extension's own row
214
- * grammar — a peer-session tag, a status glyph — stays with its vocabulary.
222
+ * (already ordered and capped) with a "more" note, one headed group of rows per
223
+ * section, or the themed tail with an "earlier" note. The caller owns the row
224
+ * renderer, so the extension's own row grammar — a peer-session tag, a status
225
+ * glyph — stays with its vocabulary.
215
226
  */
216
227
  export class TailResult<Row> implements Component {
217
228
  private input: TailResultInput<Row>;
@@ -239,6 +250,14 @@ export class TailResult<Row> implements Component {
239
250
  if (input.more > 0) lines.push(clip(` ${overflowHint(this.theme, input.more)}`, w));
240
251
  return lines;
241
252
  }
253
+ if (input.kind === "sections") {
254
+ for (const section of input.sections) {
255
+ if (section.label) lines.push(clip(paint(this.theme, "faint", section.label), w));
256
+ for (const row of section.rows) lines.push(section.rowLine(row, w, this.theme));
257
+ if (section.more > 0) lines.push(clip(` ${overflowHint(this.theme, section.more)}`, w));
258
+ }
259
+ return lines;
260
+ }
242
261
  if (input.earlier > 0) lines.push(clip(earlierHint(this.theme, input.earlier, input.earlierLabel), w));
243
262
  for (const line of input.lines) lines.push(this.theme.fg("toolOutput", clip(line, w)));
244
263
  return lines;
@@ -60,7 +60,7 @@ export const WEB_COMPONENTS_CSS = `.bf-brand{display:inline-flex;align-items:cen
60
60
  .bf-dialog mark{background:transparent;color:var(--warn);font-weight:var(--fw-semibold)}
61
61
  .bf-dialog__head{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--border)}
62
62
  .bf-dialog__head h2{margin:0;font-size:var(--fs-lg)}
63
- .bf-dialog__body{padding:var(--space-3) var(--space-4);font-size:var(--fs-sm);line-height:var(--lh-relaxed)}
63
+ .bf-dialog__body{padding:var(--space-3) var(--space-4);font-size:var(--fs-sm);line-height:var(--lh-relaxed);overflow:auto;min-height:0}
64
64
  .bf-dialog__body p{margin:0 0 var(--space-2)}
65
65
  .bf-dialog__body p:last-child{margin:0}
66
66
  .bf-dialog__hint{color:var(--muted)}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.17.0",
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, file-read, check, memory, context, wechat, and bytefly.",
3
+ "version": "0.18.0",
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, file-read, check, memory, context, session-bus, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -101,6 +101,7 @@
101
101
  "./extensions/file-open/index.ts",
102
102
  "./extensions/memory/index.ts",
103
103
  "./extensions/context/index.ts",
104
+ "./extensions/session-bus/index.ts",
104
105
  "./extensions/wechat/index.ts",
105
106
  "./extensions/bytefly/index.ts"
106
107
  ],