@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.6

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 (55) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
  3. package/dist/cli.js +3378 -3333
  4. package/dist/types/cli/gc-cli.d.ts +1 -0
  5. package/dist/types/launch/client.d.ts +9 -1
  6. package/dist/types/launch/protocol.d.ts +17 -0
  7. package/dist/types/modes/components/btw-panel.d.ts +3 -0
  8. package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
  9. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  10. package/dist/types/modes/interactive-mode.d.ts +4 -1
  11. package/dist/types/modes/types.d.ts +3 -1
  12. package/dist/types/security/contracts/schemas.d.ts +405 -403
  13. package/dist/types/session/agent-session-types.d.ts +5 -0
  14. package/dist/types/session/agent-session.d.ts +26 -2
  15. package/dist/types/session/launch-completion.d.ts +10 -0
  16. package/dist/types/session/session-entries.d.ts +15 -1
  17. package/dist/types/session/session-manager.d.ts +7 -0
  18. package/dist/types/session/yield-queue.d.ts +6 -1
  19. package/dist/types/tools/index.d.ts +9 -0
  20. package/package.json +12 -12
  21. package/src/cli/gc-cli.ts +641 -21
  22. package/src/config/model-discovery.ts +14 -14
  23. package/src/config/model-registry.ts +10 -8
  24. package/src/config/settings.ts +1 -1
  25. package/src/export/html/index.ts +10 -3
  26. package/src/export/share.ts +4 -0
  27. package/src/launch/broker.ts +222 -6
  28. package/src/launch/client.ts +161 -9
  29. package/src/launch/protocol.ts +52 -0
  30. package/src/main.ts +13 -6
  31. package/src/mcp/config-writer.ts +1 -1
  32. package/src/modes/components/btw-panel.ts +41 -4
  33. package/src/modes/controllers/btw-controller.ts +55 -7
  34. package/src/modes/controllers/command-controller.ts +31 -0
  35. package/src/modes/controllers/input-controller.ts +24 -7
  36. package/src/modes/interactive-mode.ts +16 -2
  37. package/src/modes/types.ts +8 -1
  38. package/src/prompts/session/launch-completion.md +1 -0
  39. package/src/sdk.ts +15 -0
  40. package/src/security/contracts/schemas.ts +205 -183
  41. package/src/security/contracts/validation.ts +5 -1
  42. package/src/security/store.ts +2 -2
  43. package/src/session/agent-session-types.ts +6 -0
  44. package/src/session/agent-session.ts +191 -14
  45. package/src/session/launch-completion.ts +37 -0
  46. package/src/session/session-context.ts +26 -0
  47. package/src/session/session-entries.ts +17 -1
  48. package/src/session/session-manager.ts +21 -0
  49. package/src/session/yield-queue.ts +121 -16
  50. package/src/slash-commands/builtin-registry.ts +10 -0
  51. package/src/tools/browser/cmux/cmux-tab.ts +92 -4
  52. package/src/tools/hub/launch.ts +122 -6
  53. package/src/tools/index.ts +9 -0
  54. package/dist/types/config/file-lock.d.ts +0 -29
  55. package/src/config/file-lock.ts +0 -164
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as net from "node:net";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
5
+ import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
6
6
  import { hostHasInheritableConsole } from "../eval/py/spawn-options";
7
7
  import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
8
8
  import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
@@ -11,11 +11,12 @@ import {
11
11
  DAEMON_IDLE_GRACE_ENV,
12
12
  DAEMON_PROJECT_DIR_ENV,
13
13
  DAEMON_RUNTIME_DIR_ENV,
14
+ type DaemonCompletionNotification,
14
15
  type DaemonOperation,
15
16
  type DaemonRpcResult,
16
- type DaemonWireResponse,
17
+ type DaemonWireMessage,
17
18
  parseDaemonRpcResult,
18
- parseDaemonWireResponse,
19
+ parseDaemonWireMessage,
19
20
  } from "./protocol";
20
21
  import { resolveDaemonSpawnOptions } from "./spawn-options";
21
22
 
@@ -43,14 +44,26 @@ export interface DaemonBrokerClientOptions {
43
44
  idleGraceMs?: number;
44
45
  }
45
46
 
47
+ export interface DaemonCompletionUnregisterOptions {
48
+ /** Detach this process without deleting broker-persisted pending notifications. */
49
+ preservePending?: boolean;
50
+ }
51
+
46
52
  /** Persistent per-process connection to one project or global daemon broker. */
47
53
  export interface DaemonBrokerClient {
54
+ onCompletion(
55
+ owner: string,
56
+ sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
57
+ ): (options?: DaemonCompletionUnregisterOptions) => void;
48
58
  /** Canonical project directory or synthetic directory identifying a global scope. */
49
59
  readonly projectDir: string;
50
60
  request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
51
61
  close(): void;
52
62
  }
53
63
 
64
+ /** A request reached the broker and the broker rejected the operation. */
65
+ export class DaemonBrokerRejectedError extends Error {}
66
+
54
67
  async function canonicalProjectDir(projectDir: string): Promise<string> {
55
68
  const resolved = path.resolve(projectDir);
56
69
  try {
@@ -134,12 +147,20 @@ class SocketDaemonClient implements DaemonBrokerClient {
134
147
  readonly #runtimeDir: string;
135
148
  readonly #endpoint: string;
136
149
  readonly #token: string;
150
+ readonly #seenCompletionIds = new Set<string>();
137
151
  readonly #idleGraceMs: number | undefined;
138
152
  readonly #pending = new Map<string, PendingRequest>();
153
+ readonly #completionSinks = new Map<string, (notification: DaemonCompletionNotification) => Promise<void> | void>();
154
+ readonly #completionUnsubscribes = new Set<string>();
155
+ readonly #preservedCompletionOwners = new Set<string>();
156
+ readonly #completionReplays = new Set<string>();
157
+ readonly #inFlightCompletionIds = new Set<string>();
158
+ readonly #completionSubscriptionId = crypto.randomUUID();
139
159
  #socket: net.Socket | undefined;
140
160
  #connectPromise: Promise<void> | undefined;
141
161
  #buffer = "";
142
162
  #closed = false;
163
+ #completionReconnectTimer: NodeJS.Timeout | undefined;
143
164
 
144
165
  constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
145
166
  this.projectDir = projectDir;
@@ -156,6 +177,8 @@ class SocketDaemonClient implements DaemonBrokerClient {
156
177
  const socket = this.#socket;
157
178
  if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
158
179
 
180
+ const completionUnsubscribes = [...this.#completionUnsubscribes];
181
+ const completionReplays = [...this.#completionReplays];
159
182
  const id = crypto.randomUUID();
160
183
  const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
161
184
  const timer = setTimeout(() => {
@@ -176,18 +199,88 @@ class SocketDaemonClient implements DaemonBrokerClient {
176
199
  pending.removeAbort = () => signal.removeEventListener("abort", abort);
177
200
  }
178
201
  this.#pending.set(id, pending);
179
- socket.write(`${JSON.stringify({ id, token: this.#token, operation })}\n`);
180
- return promise;
202
+ socket.write(
203
+ `${JSON.stringify({
204
+ id,
205
+ token: this.#token,
206
+ owners: [...this.#completionSinks.keys()],
207
+ detachedOwners: [...this.#preservedCompletionOwners],
208
+ completionEvents: true,
209
+ completionUnsubscribes,
210
+ completionReplays,
211
+ completionSubscriptionId: this.#completionSubscriptionId,
212
+ operation,
213
+ })}\n`,
214
+ );
215
+ const result = await promise;
216
+ for (const owner of completionUnsubscribes) {
217
+ if (!this.#completionSinks.has(owner)) this.#completionUnsubscribes.delete(owner);
218
+ }
219
+ for (const owner of completionReplays) {
220
+ if (this.#completionSinks.has(owner)) this.#completionReplays.delete(owner);
221
+ }
222
+ return result;
181
223
  }
182
224
 
183
225
  close(): void {
184
226
  if (this.#closed) return;
185
227
  this.#closed = true;
228
+ clearTimeout(this.#completionReconnectTimer);
229
+ this.#completionReconnectTimer = undefined;
186
230
  this.#socket?.destroy();
231
+ this.#completionSinks.clear();
232
+ this.#preservedCompletionOwners.clear();
233
+ this.#completionReplays.clear();
187
234
  this.#socket = undefined;
188
235
  this.#rejectPending(new Error("Daemon broker client closed"));
189
236
  }
190
237
 
238
+ onCompletion(
239
+ owner: string,
240
+ sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
241
+ ): (options?: DaemonCompletionUnregisterOptions) => void {
242
+ this.#completionUnsubscribes.delete(owner);
243
+ if (this.#preservedCompletionOwners.delete(owner)) this.#completionReplays.add(owner);
244
+ this.#completionSinks.set(owner, sink);
245
+ this.#publishCompletionOwners();
246
+ return options => {
247
+ if (this.#completionSinks.get(owner) !== sink) return;
248
+ this.#completionSinks.delete(owner);
249
+ if (options?.preservePending) {
250
+ this.#preservedCompletionOwners.add(owner);
251
+ } else {
252
+ this.#preservedCompletionOwners.delete(owner);
253
+ this.#completionUnsubscribes.add(owner);
254
+ }
255
+ if (this.#completionSinks.size === 0 && this.#completionReconnectTimer) {
256
+ clearTimeout(this.#completionReconnectTimer);
257
+ this.#completionReconnectTimer = undefined;
258
+ }
259
+ this.#publishCompletionOwners();
260
+ };
261
+ }
262
+
263
+ #publishCompletionOwners(): void {
264
+ if (this.#closed) return;
265
+ void this.request({ op: "ping" }).catch(() => this.#scheduleCompletionReconnect());
266
+ }
267
+
268
+ #scheduleCompletionReconnect(): void {
269
+ if (
270
+ this.#closed ||
271
+ this.#completionSinks.size === 0 ||
272
+ this.#completionReconnectTimer !== undefined ||
273
+ (this.#socket !== undefined && !this.#socket.destroyed)
274
+ ) {
275
+ return;
276
+ }
277
+ this.#completionReconnectTimer = setTimeout(() => {
278
+ this.#completionReconnectTimer = undefined;
279
+ this.#publishCompletionOwners();
280
+ }, CONNECT_RETRY_MS);
281
+ this.#completionReconnectTimer.unref();
282
+ }
283
+
191
284
  async #connect(): Promise<void> {
192
285
  if (this.#socket && !this.#socket.destroyed) return;
193
286
  if (this.#connectPromise) return this.#connectPromise;
@@ -251,6 +344,7 @@ class SocketDaemonClient implements DaemonBrokerClient {
251
344
  socket.on("close", () => {
252
345
  if (this.#socket === socket) this.#socket = undefined;
253
346
  this.#rejectPending(new Error("Daemon broker connection closed"));
347
+ this.#scheduleCompletionReconnect();
254
348
  });
255
349
  }
256
350
 
@@ -262,21 +356,32 @@ class SocketDaemonClient implements DaemonBrokerClient {
262
356
  const line = this.#buffer.slice(0, newline);
263
357
  this.#buffer = this.#buffer.slice(newline + 1);
264
358
  if (line.length === 0) continue;
265
- let response: DaemonWireResponse;
359
+ let decoded: unknown;
360
+ try {
361
+ decoded = JSON.parse(line);
362
+ } catch (error) {
363
+ this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
364
+ continue;
365
+ }
366
+ let message: DaemonWireMessage;
266
367
  try {
267
- const decoded: unknown = JSON.parse(line);
268
- response = parseDaemonWireResponse(decoded);
368
+ message = parseDaemonWireMessage(decoded);
269
369
  } catch (error) {
270
370
  this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
271
371
  continue;
272
372
  }
373
+ if ("event" in message) {
374
+ void this.#deliverCompletion(message);
375
+ continue;
376
+ }
377
+ const response = message;
273
378
  const pending = this.#pending.get(response.id);
274
379
  if (!pending) continue;
275
380
  this.#pending.delete(response.id);
276
381
  clearTimeout(pending.timer);
277
382
  pending.removeAbort?.();
278
383
  if (!response.ok) {
279
- pending.reject(new Error(response.error));
384
+ pending.reject(new DaemonBrokerRejectedError(response.error));
280
385
  continue;
281
386
  }
282
387
  try {
@@ -287,6 +392,53 @@ class SocketDaemonClient implements DaemonBrokerClient {
287
392
  }
288
393
  }
289
394
 
395
+ async #deliverCompletion(message: DaemonCompletionNotification): Promise<void> {
396
+ if (this.#seenCompletionIds.has(message.completionId)) {
397
+ this.#ackCompletion(message.completionId);
398
+ return;
399
+ }
400
+ if (this.#inFlightCompletionIds.has(message.completionId)) return;
401
+ const sink = this.#completionSinks.get(message.owner);
402
+ if (!sink) return;
403
+ this.#inFlightCompletionIds.add(message.completionId);
404
+ try {
405
+ await sink(message);
406
+ if (this.#seenCompletionIds.size >= 512) {
407
+ const oldest = this.#seenCompletionIds.values().next().value;
408
+ if (oldest !== undefined) this.#seenCompletionIds.delete(oldest);
409
+ }
410
+ this.#seenCompletionIds.add(message.completionId);
411
+ this.#ackCompletion(message.completionId);
412
+ } catch (error) {
413
+ logger.warn("Daemon completion sink failed", {
414
+ owner: message.owner,
415
+ completionId: message.completionId,
416
+ error: error instanceof Error ? error.message : String(error),
417
+ });
418
+ this.#socket?.destroy();
419
+ } finally {
420
+ this.#inFlightCompletionIds.delete(message.completionId);
421
+ }
422
+ }
423
+
424
+ #ackCompletion(completionId: string): void {
425
+ const socket = this.#socket;
426
+ if (!socket || socket.destroyed) return;
427
+ socket.write(
428
+ `${JSON.stringify({
429
+ id: crypto.randomUUID(),
430
+ token: this.#token,
431
+ owners: [...this.#completionSinks.keys()],
432
+ detachedOwners: [...this.#preservedCompletionOwners],
433
+ completionEvents: true,
434
+ completionAcks: [completionId],
435
+ completionUnsubscribes: [...this.#completionUnsubscribes],
436
+ completionSubscriptionId: this.#completionSubscriptionId,
437
+ operation: { op: "ping" },
438
+ })}\n`,
439
+ );
440
+ }
441
+
290
442
  #rejectPending(error: Error): void {
291
443
  for (const pending of this.#pending.values()) {
292
444
  clearTimeout(pending.timer);
@@ -122,12 +122,29 @@ export type DaemonRpcResult =
122
122
  export interface DaemonWireRequest {
123
123
  id: string;
124
124
  token: string;
125
+ owners?: string[];
126
+ detachedOwners?: string[];
127
+ completionEvents?: boolean;
128
+ completionAcks?: string[];
129
+ completionUnsubscribes?: string[];
130
+ completionReplays?: string[];
131
+ completionSubscriptionId?: string;
125
132
  operation: DaemonOperation;
126
133
  }
127
134
 
128
135
  /** Response envelope kept raw until matched with its pending operation. */
129
136
  export type DaemonWireResponse = { id: string; ok: true; result: unknown } | { id: string; ok: false; error: string };
130
137
 
138
+ /** Unsolicited terminal completion sent to the socket that owns a daemon. */
139
+ export interface DaemonCompletionNotification {
140
+ event: "daemon-completed";
141
+ completionId: string;
142
+ owner: string;
143
+ daemon: DaemonSnapshot;
144
+ }
145
+
146
+ export type DaemonWireMessage = DaemonWireResponse | DaemonCompletionNotification;
147
+
131
148
  function isRecord(value: unknown): value is Record<string, unknown> {
132
149
  return typeof value === "object" && value !== null && !Array.isArray(value);
133
150
  }
@@ -268,6 +285,27 @@ export function parseDaemonWireRequest(value: unknown): DaemonWireRequest {
268
285
  return {
269
286
  id: stringValue(source.id, "request.id"),
270
287
  token: stringValue(source.token, "request.token"),
288
+ owners: source.owners === undefined ? undefined : stringArray(source.owners, "request.owners"),
289
+ detachedOwners:
290
+ source.detachedOwners === undefined ? undefined : stringArray(source.detachedOwners, "request.detachedOwners"),
291
+ completionEvents:
292
+ source.completionEvents === undefined
293
+ ? undefined
294
+ : booleanValue(source.completionEvents, "request.completionEvents"),
295
+ completionAcks:
296
+ source.completionAcks === undefined ? undefined : stringArray(source.completionAcks, "request.completionAcks"),
297
+ completionUnsubscribes:
298
+ source.completionUnsubscribes === undefined
299
+ ? undefined
300
+ : stringArray(source.completionUnsubscribes, "request.completionUnsubscribes"),
301
+ completionReplays:
302
+ source.completionReplays === undefined
303
+ ? undefined
304
+ : stringArray(source.completionReplays, "request.completionReplays"),
305
+ completionSubscriptionId:
306
+ source.completionSubscriptionId === undefined
307
+ ? undefined
308
+ : stringValue(source.completionSubscriptionId, "request.completionSubscriptionId"),
271
309
  operation: parseDaemonOperation(source.operation),
272
310
  };
273
311
  }
@@ -281,6 +319,20 @@ export function parseDaemonWireResponse(value: unknown): DaemonWireResponse {
281
319
  throw new Error("response.ok must be a boolean");
282
320
  }
283
321
 
322
+ /** Decode one broker response or unsolicited completion notification. */
323
+ export function parseDaemonWireMessage(value: unknown): DaemonWireMessage {
324
+ const source = record(value, "daemon message");
325
+ if (source.event === "daemon-completed") {
326
+ return {
327
+ event: "daemon-completed",
328
+ completionId: stringValue(source.completionId, "completion.id"),
329
+ owner: stringValue(source.owner, "completion.owner"),
330
+ daemon: parseDaemonSnapshot(source.daemon),
331
+ };
332
+ }
333
+ return parseDaemonWireResponse(value);
334
+ }
335
+
284
336
  function parseDaemonOperation(value: unknown): DaemonOperation {
285
337
  const source = record(value, "daemon operation");
286
338
  const op = stringValue(source.op, "operation.op");
package/src/main.ts CHANGED
@@ -1597,6 +1597,18 @@ export async function runRootCommand(
1597
1597
  stdoutIsTTY: process.stdout.isTTY,
1598
1598
  });
1599
1599
 
1600
+ // Startup changelog is only consumed by interactive mode below; kick the
1601
+ // CHANGELOG.md parse off now so it overlaps session creation instead of
1602
+ // serializing after it.
1603
+ const startupChangelogPromise = isInteractive
1604
+ ? logger.time(
1605
+ "main:getChangelogForDisplay",
1606
+ getChangelogForDisplay,
1607
+ parsedArgs,
1608
+ settingsInstance.get("startup.changelogMode"),
1609
+ )
1610
+ : undefined;
1611
+
1600
1612
  const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = await createSession({
1601
1613
  ...sessionOptions,
1602
1614
  eventBus,
@@ -1656,12 +1668,7 @@ export async function runRootCommand(
1656
1668
  await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, eventBus, rpcInput);
1657
1669
  } else if (isInteractive) {
1658
1670
  const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
1659
- const startupChangelog = await logger.time(
1660
- "main:getChangelogForDisplay",
1661
- getChangelogForDisplay,
1662
- parsedArgs,
1663
- settingsInstance.get("startup.changelogMode"),
1664
- );
1671
+ const startupChangelog = await startupChangelogPromise;
1665
1672
 
1666
1673
  const modelScopeNotification = buildModelScopeNotification(
1667
1674
  scopedModels,
@@ -7,8 +7,8 @@ import { randomUUID } from "node:crypto";
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
9
  import { isEnoent } from "@oh-my-pi/pi-utils";
10
+ import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
10
11
  import { invalidate as invalidateFsCache } from "../capability/fs";
11
- import { withFileLock } from "../config/file-lock";
12
12
 
13
13
  import { validateServerConfig } from "./config";
14
14
  import { MCP_CONFIG_SCHEMA_URL, type MCPConfigFile, type MCPServerConfig } from "./types";
@@ -3,16 +3,37 @@ import { replaceTabs } from "../../tools/render-utils";
3
3
  import { getMarkdownTheme, theme } from "../theme/theme";
4
4
  import { DynamicBorder } from "./dynamic-border";
5
5
 
6
- type BtwPanelState = "running" | "complete" | "aborted" | "error";
6
+ type BtwPanelState = "running" | "complete" | "branching" | "aborted" | "error";
7
7
 
8
8
  interface BtwPanelComponentOptions {
9
9
  question: string;
10
10
  tui: TUI;
11
+ canBranch?: () => boolean;
12
+ }
13
+
14
+ class BtwFooter implements Component {
15
+ #getLine: () => string;
16
+ #line: string | undefined;
17
+ #text: Text | undefined;
18
+
19
+ constructor(getLine: () => string) {
20
+ this.#getLine = getLine;
21
+ }
22
+
23
+ render(width: number): readonly string[] {
24
+ const line = this.#getLine();
25
+ if (line !== this.#line || !this.#text) {
26
+ this.#line = line;
27
+ this.#text = new Text(line, 1, 0);
28
+ }
29
+ return this.#text.render(width);
30
+ }
11
31
  }
12
32
 
13
33
  export class BtwPanelComponent extends Container {
14
34
  #question: string;
15
35
  #tui: TUI;
36
+ #canBranch: (() => boolean) | undefined;
16
37
  #state: BtwPanelState = "running";
17
38
  #answer = "";
18
39
  #errorMessage: string | undefined;
@@ -23,6 +44,7 @@ export class BtwPanelComponent extends Container {
23
44
  super();
24
45
  this.#question = options.question;
25
46
  this.#tui = options.tui;
47
+ this.#canBranch = options.canBranch;
26
48
  this.#rebuild();
27
49
  }
28
50
 
@@ -47,6 +69,14 @@ export class BtwPanelComponent extends Container {
47
69
  this.#rebuild();
48
70
  }
49
71
 
72
+ /** Shows that the completed answer is being promoted into the chat session. */
73
+ markBranching(): void {
74
+ if (this.#closed) return;
75
+ this.#state = "branching";
76
+ this.#errorMessage = undefined;
77
+ this.#rebuild();
78
+ }
79
+
50
80
  markAborted(): void {
51
81
  if (this.#closed) return;
52
82
  this.#state = "aborted";
@@ -86,7 +116,7 @@ export class BtwPanelComponent extends Container {
86
116
  this.addChild(new Spacer(1));
87
117
  this.addChild(this.#contentComponent());
88
118
  this.addChild(new Spacer(1));
89
- this.addChild(new Text(this.#footerLine(), 1, 0));
119
+ this.addChild(new BtwFooter(() => this.#footerLine()));
90
120
  this.addChild(new Spacer(1));
91
121
  this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
92
122
  // Component-scoped: a rebuild replaces only this panel's own children
@@ -100,8 +130,15 @@ export class BtwPanelComponent extends Container {
100
130
  switch (this.#state) {
101
131
  case "running":
102
132
  return theme.fg("muted", "Esc cancel /btw");
103
- case "complete":
104
- return theme.fg("muted", this.isCopyable() ? "c copy · b branch to chat · Esc dismiss" : "Esc dismiss");
133
+ case "complete": {
134
+ if (!this.isCopyable()) return theme.fg("muted", "Esc dismiss");
135
+ const actions = ["c copy"];
136
+ if (this.#canBranch?.() ?? this.isBranchable()) actions.push("b branch to chat");
137
+ actions.push("Esc dismiss");
138
+ return theme.fg("muted", actions.join(" · "));
139
+ }
140
+ case "branching":
141
+ return theme.fg("muted", `${theme.status.pending} Branching to chat…`);
105
142
  case "aborted":
106
143
  return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
107
144
  case "error":
@@ -10,6 +10,7 @@ interface BtwRequest {
10
10
  abortController: AbortController;
11
11
  question: string;
12
12
  leafId: string | null;
13
+ sessionId: string;
13
14
  }
14
15
 
15
16
  function assistantMessageWithReplyText(assistantMessage: AssistantMessage, replyText: string): AssistantMessage {
@@ -39,6 +40,7 @@ export class BtwController {
39
40
  #lastReplyText: string | undefined;
40
41
  #lastAssistantMessage: AssistantMessage | undefined;
41
42
  #lastLeafId: string | null | undefined;
43
+ #lastSessionId: string | undefined;
42
44
  #branchInFlight = false;
43
45
  #lastCopyText: string | undefined;
44
46
  #copyInFlight = false;
@@ -50,17 +52,39 @@ export class BtwController {
50
52
  }
51
53
 
52
54
  canBranch(): boolean {
55
+ return this.#branchUnavailableReason() === undefined;
56
+ }
57
+
58
+ /** Whether plain `b` is currently reserved for a completed or pending branch action. */
59
+ handlesBranchKey(): boolean {
60
+ if (this.#branchInFlight) return true;
61
+ if (this.#activeRequest?.component.isBranchable() !== true) return false;
53
62
  return (
54
- !this.#branchInFlight &&
55
- this.#activeRequest?.component.isBranchable() === true &&
56
63
  this.#lastQuestion !== undefined &&
57
64
  this.#lastReplyText !== undefined &&
58
65
  this.#lastAssistantMessage !== undefined &&
59
- this.#lastLeafId !== null &&
60
- this.#lastLeafId === this.ctx.sessionManager.getLeafId()
66
+ this.#lastLeafId !== undefined &&
67
+ this.#lastSessionId !== undefined
61
68
  );
62
69
  }
63
70
 
71
+ #branchUnavailableReason(): string | undefined {
72
+ if (this.#branchInFlight) return "a branch is already in progress";
73
+ if (this.#activeRequest?.component.isBranchable() !== true) return "the answer is not ready";
74
+ if (!this.#lastQuestion || !this.#lastReplyText || !this.#lastAssistantMessage) {
75
+ return "the answer is unavailable";
76
+ }
77
+ if (!this.#lastLeafId) return "the session has no branch point";
78
+ if (
79
+ this.#lastSessionId !== this.ctx.sessionManager.getSessionId() ||
80
+ this.#lastLeafId !== this.ctx.sessionManager.getLeafId()
81
+ ) {
82
+ return "the session changed since /btw started";
83
+ }
84
+ if (this.ctx.session.isStreaming) return "a turn is still running";
85
+ return undefined;
86
+ }
87
+
64
88
  canCopy(): boolean {
65
89
  return (
66
90
  !this.#copyInFlight && this.#activeRequest?.component.isCopyable() === true && this.#lastCopyText !== undefined
@@ -83,17 +107,34 @@ export class BtwController {
83
107
  }
84
108
 
85
109
  async handleBranch(): Promise<boolean> {
86
- if (!this.canBranch() || !this.#lastQuestion || !this.#lastAssistantMessage) return false;
110
+ const unavailableReason = this.#branchUnavailableReason();
111
+ if (unavailableReason) {
112
+ this.ctx.showStatus(`/btw branch unavailable: ${unavailableReason}`, { dim: true });
113
+ return false;
114
+ }
115
+ const request = this.#activeRequest;
116
+ const question = this.#lastQuestion;
117
+ const assistantMessage = this.#lastAssistantMessage;
118
+ const leafId = this.#lastLeafId;
119
+ const sessionId = this.#lastSessionId;
120
+ if (!request || !question || !assistantMessage || !leafId || !sessionId) return false;
121
+
87
122
  this.#branchInFlight = true;
123
+ request.component.markBranching();
88
124
  try {
89
- await this.ctx.handleBtwBranch(this.#lastQuestion, this.#lastAssistantMessage);
125
+ await this.ctx.handleBtwBranch(question, assistantMessage, leafId, sessionId);
90
126
  return true;
91
127
  } finally {
92
128
  this.#branchInFlight = false;
129
+ if (this.#activeRequest === request) request.component.markComplete();
93
130
  }
94
131
  }
95
132
 
96
133
  handleEscape(): boolean {
134
+ if (this.#branchInFlight) {
135
+ this.ctx.showStatus("/btw branch is in progress", { dim: true });
136
+ return true;
137
+ }
97
138
  if (!this.#activeRequest) return false;
98
139
  this.#closeActiveRequest({ abort: this.#activeRequest.abortController.signal.aborted === false });
99
140
  return true;
@@ -119,10 +160,15 @@ export class BtwController {
119
160
  this.#closeActiveRequest({ abort: true });
120
161
 
121
162
  const request: BtwRequest = {
122
- component: new BtwPanelComponent({ question: trimmedQuestion, tui: this.ctx.ui }),
163
+ component: new BtwPanelComponent({
164
+ question: trimmedQuestion,
165
+ tui: this.ctx.ui,
166
+ canBranch: () => this.canBranch(),
167
+ }),
123
168
  abortController: new AbortController(),
124
169
  question: trimmedQuestion,
125
170
  leafId: this.ctx.sessionManager.getLeafId(),
171
+ sessionId: this.ctx.sessionManager.getSessionId(),
126
172
  };
127
173
  this.ctx.btwContainer.clear();
128
174
  this.ctx.btwContainer.addChild(request.component);
@@ -156,6 +202,7 @@ export class BtwController {
156
202
  this.#lastCopyText = copyText;
157
203
  this.#lastAssistantMessage = assistantMessageWithReplyText(assistantMessage, replyText);
158
204
  this.#lastLeafId = request.leafId;
205
+ this.#lastSessionId = request.sessionId;
159
206
  } else {
160
207
  this.#clearCompletedState();
161
208
  }
@@ -190,6 +237,7 @@ export class BtwController {
190
237
  this.#lastAssistantMessage = undefined;
191
238
  this.#lastCopyText = undefined;
192
239
  this.#lastLeafId = undefined;
240
+ this.#lastSessionId = undefined;
193
241
  }
194
242
 
195
243
  #isActiveRequest(request: BtwRequest): boolean {
@@ -961,6 +961,37 @@ export class CommandController {
961
961
  this.ctx.showStatus(`Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`);
962
962
  }
963
963
 
964
+ async handleResetContextCommand(): Promise<void> {
965
+ if (this.ctx.session.isCompacting) {
966
+ this.ctx.session.abortCompaction();
967
+ while (this.ctx.session.isCompacting) {
968
+ await Bun.sleep(10);
969
+ }
970
+ }
971
+ const result = await this.ctx.session.resetSessionContext();
972
+ if (!result) {
973
+ this.ctx.showWarning("Wait for the current response to finish or abort it before resetting the context.");
974
+ return;
975
+ }
976
+ // Drop the rendered transcript so the UI matches the now-empty model
977
+ // context (mirrors #runNewSessionFlow's teardown, minus the new session —
978
+ // the session id, title, and transcript file all survive).
979
+ this.ctx.clearTransientSessionUi();
980
+ this.ctx.resetTranscript();
981
+ this.ctx.statusLine.invalidate();
982
+ this.ctx.updateEditorBorderColor();
983
+ const noun = result.droppedCount === 1 ? "message" : "messages";
984
+ this.ctx.present([
985
+ new Spacer(1),
986
+ new Text(
987
+ `${theme.fg("accent", `${theme.status.success} Context reset — ${result.droppedCount} ${noun} dropped; session continues.`)}`,
988
+ 1,
989
+ 1,
990
+ ),
991
+ ]);
992
+ this.ctx.ui.requestRender(true, { clearScrollback: true });
993
+ }
994
+
964
995
  async handleDropCommand(): Promise<void> {
965
996
  if (!this.ctx.sessionManager.getSessionFile()) {
966
997
  this.ctx.showError("Nothing to drop (in-memory session)");