@gavin-hu/my-pi-agent 0.16.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
package/lib/report.ts CHANGED
@@ -2,23 +2,29 @@
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.
14
17
  *
15
18
  * Delivery is one shared transcript notice per burst, and a burst is marked
16
19
  * reported only after the send succeeded, so a throw leaves it for the next
17
- * turn. Value-only: every flag lives in the returned closure, never in a module
18
- * variable, because each extension gets its own module instance.
20
+ * turn. `registerPendingChannel` is the other half: the seven host hooks that
21
+ * drive the machine, registered once for `job` and `watch` rather than copied
22
+ * into each factory. Value-only: every flag lives in the returned closure, never
23
+ * in a module variable, because each extension gets its own module instance.
19
24
  */
20
25
 
21
26
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
27
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
22
28
  import { pruneStaleNotices, type NoticeMessage } from "./notice.ts";
23
29
 
24
30
  export interface PendingReporterOptions<T> {
@@ -30,6 +36,11 @@ export interface PendingReporterOptions<T> {
30
36
  markReported(items: T[]): void;
31
37
  /** Build the notice for one burst: model text plus renderer details. */
32
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;
33
44
  /** The notice's source, so only its own stale entries are pruned. */
34
45
  source: string;
35
46
  /** Runs after a wake turn was successfully requested, for an extra announcement. */
@@ -44,8 +55,8 @@ export interface PendingReporter {
44
55
  sessionStart(): void;
45
56
  /** A turn began: outcomes queue up, and none of them triggers a turn. */
46
57
  agentStart(): void;
47
- /** The turn settled: a later outcome may wake again. */
48
- agentSettled(): void;
58
+ /** The turn settled: retry a wake it suppressed, unless it was aborted. */
59
+ agentSettled(event?: { aborted?: boolean }): void;
49
60
  /** Report what is queued at the start of a turn, without triggering one. */
50
61
  beforeAgentStart(): { message: NoticeMessage } | undefined;
51
62
  /** Drop this source's older notices from the outgoing context. */
@@ -53,26 +64,29 @@ export interface PendingReporter {
53
64
  }
54
65
 
55
66
  export function createPendingReporter<T>(options: PendingReporterOptions<T>): PendingReporter {
56
- const { wake, pending, markReported, buildNotice, source, onWoke } = options;
67
+ const { wake, pending, markReported, buildNotice, wakeRequested, source, onWoke } = options;
57
68
  let busy = false;
58
69
  let waking = false;
59
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
+
60
88
  return {
61
- report: () => {
62
- if (busy || waking) return;
63
- const items = pending();
64
- if (items.length === 0) return;
65
- try {
66
- wake(buildNotice(items));
67
- } catch {
68
- // Reporting is best-effort; the next turn reports the same items.
69
- return;
70
- }
71
- // Mark only after the send succeeded, so a throw changes nothing.
72
- markReported(items);
73
- waking = true;
74
- onWoke?.(items);
75
- },
89
+ report,
76
90
  sessionStart: () => {
77
91
  busy = false;
78
92
  waking = false;
@@ -80,9 +94,13 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
80
94
  agentStart: () => {
81
95
  busy = true;
82
96
  },
83
- agentSettled: () => {
97
+ agentSettled: (event) => {
84
98
  busy = false;
85
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();
86
104
  },
87
105
  beforeAgentStart: () => {
88
106
  const items = pending();
@@ -97,3 +115,77 @@ export function createPendingReporter<T>(options: PendingReporterOptions<T>): Pe
97
115
  },
98
116
  };
99
117
  }
118
+
119
+ /** The session-scoped state a channel drives, narrowed to what its hooks need. */
120
+ export interface PendingChannelRuntime {
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;
129
+ /** Release the session's resources; idempotent. */
130
+ shutdown(): Promise<void>;
131
+ }
132
+
133
+ export interface PendingChannelOptions {
134
+ /** The reporter that turns pending outcomes into notices. */
135
+ reporter: PendingReporter;
136
+ /** The runtime whose pending outcomes the notices describe. */
137
+ runtime: PendingChannelRuntime;
138
+ /** Runs on every session start, after the runtime loads: arm the report trigger. */
139
+ onSessionStart?(ctx: ExtensionContext): void;
140
+ /** Runs before the runtime releases: clear what `onSessionStart` armed. */
141
+ onSessionShutdown?(): void;
142
+ }
143
+
144
+ /**
145
+ * Wire a reporter to the host's session lifecycle.
146
+ *
147
+ * The order inside `session_start` is the contract: the reporter forgets the
148
+ * previous session's busy window, the runtime loads the records, and only then
149
+ * is the report trigger armed — a reload or a replaced session reuses the same
150
+ * runtime. A shutdown clears the trigger before the runtime releases the table it
151
+ * was calling into.
152
+ */
153
+ export function registerPendingChannel(
154
+ pi: Pick<ExtensionAPI, "on">,
155
+ { reporter, runtime, onSessionStart, onSessionShutdown }: PendingChannelOptions,
156
+ ): void {
157
+ pi.on("session_start", async (_event, ctx) => {
158
+ reporter.sessionStart();
159
+ await runtime.load(ctx);
160
+ onSessionStart?.(ctx);
161
+ });
162
+
163
+ // Tree navigation does not change the pending records; only repaint.
164
+ pi.on("session_tree", (_event, ctx) => {
165
+ runtime.setStatus?.(ctx);
166
+ });
167
+
168
+ pi.on("agent_start", () => {
169
+ reporter.agentStart();
170
+ });
171
+ pi.on("agent_settled", (event) => {
172
+ reporter.agentSettled(event);
173
+ });
174
+
175
+ // Report what is queued at the start of the next turn, without triggering one.
176
+ pi.on("before_agent_start", (_event, ctx) => {
177
+ const reported = reporter.beforeAgentStart();
178
+ if (!reported) return undefined;
179
+ runtime.setStatus?.(ctx);
180
+ return reported;
181
+ });
182
+
183
+ // Keep stale notices out of later turns. Only this source's notices are
184
+ // pruned; another source's are left for its own handler.
185
+ pi.on("context", (event) => reporter.filterContext(event.messages));
186
+
187
+ pi.on("session_shutdown", async () => {
188
+ onSessionShutdown?.();
189
+ await runtime.shutdown();
190
+ });
191
+ }
@@ -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
  {
@@ -1,17 +1,67 @@
1
1
  /**
2
- * The shared shape of a rejected tool call.
2
+ * The shared shape of a tool result.
3
3
  *
4
- * Extension tools return an `isError: true` result carrying their own `details`
5
- * type. `toolError` builds the content and the flag so the extensions that
6
- * reject a call (`job` and `watch`, plus any future tool) agree on the shape.
4
+ * Every extension tool returns the same envelope: one text block the model
5
+ * reads, the extension's own `details` for the renderer and the session branch,
6
+ * and — where the tool declares an `outputSchema` — the JSON mirror Pi calls
7
+ * `structuredContent`. A rejected call is that envelope marked `isError`, still
8
+ * carrying the previous state so the model can retry.
9
+ *
10
+ * `textResult` and `failedResult` build the two, so the flag and the absent
11
+ * `structuredContent` are decided once. What stays with the extension is the
12
+ * text and the mirror: `structuredContent` drops `undefined` field by field and
13
+ * projects nested objects, so it is written per `details` type rather than
14
+ * derived. `toolError` / `errorResult` keep the shorthand the
15
+ * action-and-message tools (`job` and `watch`) share.
7
16
  */
8
17
 
18
+ import type { JsonValue } from "@earendil-works/pi-ai";
19
+
9
20
  /** One text block in a tool result. */
10
21
  export interface ToolErrorContent {
11
22
  type: "text";
12
23
  text: string;
13
24
  }
14
25
 
26
+ /** A tool result: what the model reads, the extension's details, and its JSON mirror. */
27
+ export interface ToolResult<Details> {
28
+ content: ToolErrorContent[];
29
+ details: Details;
30
+ structuredContent?: Record<string, JsonValue>;
31
+ }
32
+
33
+ /** A rejected call: the result, marked so the host reports it as an error. */
34
+ export interface ToolFailure<Details> extends ToolResult<Details> {
35
+ isError: true;
36
+ }
37
+
38
+ /** The content and details both results share. */
39
+ function envelope<Details>(text: string, details: Details): ToolResult<Details> {
40
+ return { content: [{ type: "text", text }], details };
41
+ }
42
+
43
+ /** A successful call. */
44
+ export function textResult<Details>(
45
+ text: string,
46
+ details: Details,
47
+ structuredContent?: Record<string, JsonValue>,
48
+ ): ToolResult<Details> {
49
+ const result = envelope(text, details);
50
+ return structuredContent === undefined ? result : { ...result, structuredContent };
51
+ }
52
+
53
+ /** A rejected call, keeping the state the caller had before it. */
54
+ export function failedResult<Details>(
55
+ text: string,
56
+ details: Details,
57
+ structuredContent?: Record<string, JsonValue>,
58
+ ): ToolFailure<Details> {
59
+ const result = envelope(text, details);
60
+ return structuredContent === undefined
61
+ ? { ...result, isError: true }
62
+ : { ...result, structuredContent, isError: true };
63
+ }
64
+
15
65
  /** A rejected tool call: the message as content, the extension's details, and the error flag. */
16
66
  export function toolError<Details extends { action: string; error: string }>(
17
67
  message: string,
package/lib/tui/README.md CHANGED
@@ -35,17 +35,24 @@ 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, 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.
39
40
 
40
41
  ## Docked list screens
41
42
 
42
43
  Every list screen is `dockListFrame`: the header rule, an optional summary and
43
- its blank line, the body, an optional range row, an optional detail block, and
44
- the blank-padded hint. The frame owns that spacing, so a screen never hand-rolls
45
- it — including the blank line above the detail block, which keeps a footnote
46
- from reading as one more row. A screen that sizes its body through `fitRows`
47
- counts the block with `detailBlockRows`, or the hint row is pushed off the
48
- bottom of a short terminal.
44
+ its blank line, the body, an optional range row, an optional detail block, the
45
+ key-hint row, and the closing rule. The frame owns that spacing, so a screen
46
+ never hand-rolls it — including the blank line above the detail block, which
47
+ keeps a footnote from reading as one more row. A screen that sizes its body
48
+ through `fitRows` counts the block with `detailBlockRows`, or the hint row is
49
+ pushed off the bottom of a short terminal.
50
+
51
+ The closing rule is not decoration: Pi's built-in selectors draw one under the
52
+ list and over the footer, and the editor a docked screen replaces draws its own,
53
+ so a screen that ends on a blank removes the dock's bottom edge. A screen that
54
+ lays out its own rows closes its block with `screenFooter(theme, width, hint)`;
55
+ a screen whose row above the hint varies places `plainRule` itself.
49
56
 
50
57
  A list that **opens a pane over one row** — `/jobs`, `/watch`, `/agents`,
51
58
  `/check` — subclasses [`dock-screen.ts`](./dock-screen.ts) instead of
@@ -74,6 +81,20 @@ what a plain list wants — do not hand-roll the frame to get one of them:
74
81
  (`c` collect, `x` cancel). Return `true` for the keys it claimed; everything
75
82
  else falls through to the pane's scroll keys.
76
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
+
77
98
  A destructive action asks through [`confirm.ts`](./confirm.ts):
78
99
  `ConfirmState.ask(prompt, accept)` holds the question, `resolve(data)` accepts on
79
100
  `y`/`Y`/Enter, dismisses on `n`/`N`/Esc/`q`, and returns `undefined` for any
@@ -92,7 +113,8 @@ Resolve colors through a [`tokens.ts`](./tokens.ts) role
92
113
  (`paint(theme, role, text)`), never a literal ANSI sequence, hex value, or Pi
93
114
  token named inline; a theme swap must re-skin every surface. Use the shared
94
115
  `selectionMarker`, `GLYPHS`, `SEPARATORS`, and `STATUS_KEYS` instead of retyping
95
- their literals, and `screenHint` / Pi's `keyHint` for the hint row.
116
+ their literals, `screenHint` / Pi's `keyHint` for the hint row, and
117
+ `screenFooter` for the hint row with its blank and closing rule.
96
118
 
97
119
  A list accents the focused row's label (`labelRole: selected ? "accent" :
98
120
  "label"`); the `❯` marker alone is not enough to tell which row is live. The
package/lib/tui/chrome.ts CHANGED
@@ -8,9 +8,9 @@
8
8
  * the transcript visible.
9
9
  *
10
10
  * Screens are full-width, terminal-height-aware components. These helpers build
11
- * the top rule, clamp a screen's body to the terminal, and render the key-hint
12
- * footer, so each screen keeps its own layout but not its own copy of the
13
- * arithmetic or hint vocabulary.
11
+ * the top rule and the closing rule, clamp a screen's body to the terminal, and
12
+ * render the key-hint footer, so each screen keeps its own layout but not its
13
+ * own copy of the arithmetic or hint vocabulary.
14
14
  *
15
15
  * No runtime dependencies beyond `@earendil-works/pi-tui`: the theme is passed
16
16
  * in, and the viewport options are explicit.
@@ -20,6 +20,7 @@ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
20
20
  import { Text, truncateToWidth, visibleWidth, type EditorTheme, type OverlayOptions } from "@earendil-works/pi-tui";
21
21
  import { SEPARATORS } from "./glyphs.ts";
22
22
  import { BODY_INDENT } from "./indent.ts";
23
+ import { plainRule } from "./rules.ts";
23
24
 
24
25
  /**
25
26
  * `ctx.ui.custom` options that give a screen the whole terminal.
@@ -88,12 +89,29 @@ export function screenHint(theme: Theme, width: number, hints: ScreenHint[], ind
88
89
  return truncateToWidth(`${lead}${kept.map(render).join(separator)}`, target);
89
90
  }
90
91
 
92
+ /**
93
+ * The bottom of a screen: a blank row, its key-hint row, and the closing rule.
94
+ *
95
+ * {@link dockListFrame} ends a docked list screen with this, and a screen that
96
+ * lays out its own rows — `/context`, the WeChat login and control screens —
97
+ * closes its block with it too, so no screen spells the three rows out alone. A
98
+ * screen whose row above the hint varies (a scroll gauge or a blank) places its
99
+ * own rule with `plainRule` instead.
100
+ */
101
+ export function screenFooter(theme: Theme, width: number, hint: string): string[] {
102
+ return ["", hint, plainRule(theme, width)];
103
+ }
104
+
91
105
  /**
92
106
  * The sections of a docked list screen, assembled by {@link dockListFrame}.
93
107
  * `body`, `range`, and `detail` lines are built (and truncated) by the caller;
94
108
  * the frame only places them.
95
109
  */
96
110
  export interface DockListFrame {
111
+ /** The active theme, used for the closing rule. */
112
+ theme: Theme;
113
+ /** The screen width in columns, used for the closing rule. */
114
+ width: number;
97
115
  /** The pre-rendered header line, usually `titleRule(theme, width, title)`. */
98
116
  header: string;
99
117
  /** A summary line under the header; a blank line follows it. */
@@ -132,11 +150,14 @@ export function detailBlockRows(detail: readonly string[] | undefined, blank = t
132
150
  /**
133
151
  * Assemble a docked list screen: the caller's header rule, an optional summary
134
152
  * and its blank line, the body, an optional range row, optional detail rows after
135
- * their own blank line, and the blank-line-padded key-hint footer.
153
+ * their own blank line, the key-hint row, and the closing rule.
136
154
  *
137
155
  * This is the convention every docked list screen shares — the bytefly panel,
138
156
  * `/jobs`, `/watch`, `/todos`, `/memory`, and `/rewind`. The header line is built
139
- * by the caller (usually `titleRule`), so the frame never picks a rule style.
157
+ * by the caller (usually `titleRule`), so the frame never picks the *opening*
158
+ * rule style; the closing rule is always the muted fill, matching the closing
159
+ * rule Pi's built-in selectors draw and the bottom border of the editor slot the
160
+ * screen replaces.
140
161
  */
141
162
  export function dockListFrame(frame: DockListFrame): string[] {
142
163
  const lines: string[] = [frame.header];
@@ -147,7 +168,7 @@ export function dockListFrame(frame: DockListFrame): string[] {
147
168
  if (frame.detailBlank !== false) lines.push("");
148
169
  lines.push(...frame.detail);
149
170
  }
150
- lines.push("", frame.hint, "");
171
+ lines.push(...screenFooter(frame.theme, frame.width, frame.hint));
151
172
  return lines;
152
173
  }
153
174
 
@@ -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";
@@ -33,22 +35,33 @@ import { paint } from "./tokens.ts";
33
35
  /** Rows the body shows when the terminal height is unknown. */
34
36
  const SCREEN_DEFAULT_ROWS = 14;
35
37
 
36
- /** Fixed rows around the list body: header, summary, blank, blank, hint, blank. */
38
+ /** Fixed rows around the list body: header, summary, blank, blank, hint, closing rule. */
37
39
  const LIST_CHROME_ROWS = 6;
38
40
 
39
- /** Fixed rows around a detail body: header, subtitle, blank, blank, hint, blank. */
41
+ /** Fixed rows around a detail body: header, subtitle, blank, blank, hint, closing rule. */
40
42
  const DETAIL_CHROME_ROWS = 6;
41
43
 
42
44
  /** What a detail pane shows: its title, lines, and the one-line subtitle. */
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));
@@ -353,12 +407,14 @@ export abstract class DockListScreen<T> implements Component {
353
407
  }
354
408
 
355
409
  return dockListFrame({
356
- header: titleRule(this.theme, w, this.options.title),
410
+ theme: this.theme,
411
+ width: w,
412
+ header: titleRule(this.theme, w, this.headerLabel()),
357
413
  summary: this.summary(rows, w),
358
414
  body,
359
415
  range,
360
416
  detail: block.map((line) => clip(` ${paint(this.theme, "meta", line)}`, w)),
361
- hint: this.hint(w, rows),
417
+ hint: this.footer(w, this.listHints(rows)),
362
418
  });
363
419
  }
364
420
 
@@ -366,6 +422,7 @@ export abstract class DockListScreen<T> implements Component {
366
422
  this.syncDetail();
367
423
  // A live pane whose row is gone has already closed itself: draw the list.
368
424
  if (this.mode !== "detail") return this.renderList(w);
425
+ this.syncText(w);
369
426
  const facts = this.detailFacts ? this.detailFacts(w) : [];
370
427
  const visible = fitRows(
371
428
  this.options.viewportRowsSource,
@@ -389,18 +446,21 @@ export abstract class DockListScreen<T> implements Component {
389
446
  }
390
447
 
391
448
  return dockListFrame({
449
+ theme: this.theme,
450
+ width: w,
392
451
  header: titleRule(this.theme, w, this.pane.title(this.options.detailTitle)),
393
452
  summary: clip(` ${paint(this.theme, "faint", this.pane.subtitleLine())}`, w),
394
453
  body,
395
454
  range,
396
- hint: screenHint(this.theme, w, this.detailHints(this.detailRow)),
455
+ hint: this.footer(w, this.detailHints(this.detailRow)),
397
456
  });
398
457
  }
399
458
 
400
459
  /**
401
460
  * Re-read a live pane in place. This runs inside a render, so it must not ask
402
461
  * for another frame; a source that disappeared closes the pane instead of
403
- * 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.
404
464
  */
405
465
  private syncDetail(): void {
406
466
  if (!this.liveDetail()) return;
@@ -408,11 +468,23 @@ export abstract class DockListScreen<T> implements Component {
408
468
  if (!id) return;
409
469
  const source = this.detailById?.(id);
410
470
  if (!source) {
411
- this.mode = "list";
412
- this.pane.close();
413
- this.detailFacts = undefined;
471
+ this.closeDetail();
414
472
  return;
415
473
  }
416
- 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 });
417
489
  }
418
490
  }