pi-aside 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - The panel is never an empty box: before the first words it shows Pi's spinner with `working` and the seconds so
6
+ far, then `thinking` with the last lines of the model's thinking.
7
+ - Asides answer sooner on models that take effort changes mid-conversation (the Claude models with
8
+ `supportsMidConvoEffort`, Responses models with `configuration_update`): they think at a `low` effort, sent as the
9
+ trailing configuration item, so the cache read is unchanged. A second dry run proves that only that item differs;
10
+ otherwise the main session's level is kept.
11
+ - Answers, thinking and the `/aside` reader render as Markdown.
12
+ - Enter on an empty editor puts the panel away: the frame collapses to one line, and a second Enter clears it.
13
+ The frame shows `↵ hides` while the editor is empty.
14
+ - ctrl+o (Pi's "more") expands a long answer to the terminal's room and reopens a collapsed panel; Pi still
15
+ toggles its tool output. A truncated answer ends with `… ctrl+o more`.
16
+ - The `/aside` reader opens at the latest question, and its lines wrap at the frame instead of being cut.
17
+ - `aside:done` carries `effort`, `firstTextMs` and `totalMs`.
18
+
3
19
  ## 0.1.0
4
20
 
5
21
  First release.
package/README.md CHANGED
@@ -42,8 +42,10 @@ or from a checkout: `pi install /path/to/pi-aside`.
42
42
  | Alt+Enter | Pi's meaning: a follow-up. |
43
43
  | Another `?` message while the answer is shown | Continues the side conversation. |
44
44
  | Send anything to the agent | The answer collapses to one dim line. |
45
+ | Enter on an empty editor | Puts the answer away: first to one dim line, then gone. The frame shows `↵ hides` while the editor is empty. |
46
+ | ctrl+o (Pi's "more") | Expands a long answer, or reopens a collapsed one. Pi still toggles its tool output. |
45
47
  | `/aside <question>` | Asks on the side, busy or not. |
46
- | `/aside` | Opens the latest side conversation in a reader. Esc closes it. |
48
+ | `/aside` | Opens the latest side conversation in a reader, at the latest question. Esc closes it. |
47
49
  | Tab on an empty editor when `⇥ suggestion` is shown | Accepts the aside's suggested instruction for the agent. Enter sends it, with the side Q&A attached as a note. |
48
50
  | Esc | Pi's meaning: aborts the agent. pi-aside never takes focus. |
49
51
 
@@ -72,12 +72,48 @@ as cache read plus cache write. OpenAI APIs report no cache writes, so the prefi
72
72
  plus cache read). Anthropic reports the counts in its first stream event, so the figure is available while the main
73
73
  reply still streams.
74
74
 
75
- ### 2.6 Not implemented
75
+ ### 2.6 Asides think at a low effort when that keeps the cache (amends design §4.2 C2, C3)
76
+
77
+ An aside inherited the main session's thinking level. At `xhigh` the panel stayed empty for seconds while the
78
+ model thought about a side question. Some models take effort changes in the middle of a conversation
79
+ (`compat.supportsMidConvoEffort`): pi-ai then sends the level as a trailing configuration item (E: an Anthropic
80
+ `system` item with `output_config.effort`, a Responses `configuration_update`), after the cached prefix, and keeps
81
+ the top-level fields unchanged.
82
+
83
+ On such models the aside asks at `low` when the main level is higher (`asideLevel` in `src/fork.ts`; a level the
84
+ model's `thinkingLevelMap` lacks or maps to nothing is not used). The change is proved, not assumed: when the fork
85
+ is made, a second dry run of T at the lower level must differ from B0 in E only (same core, same other top-level
86
+ fields). Then C2 compares B1 with that dry run, and C3 compares E(F) with its E instead of E(P). Every other check
87
+ is unchanged, so the request still extends P byte for byte up to E. Without the proof the aside keeps the main
88
+ level. T1 uses the same rule on its own requests.
89
+
90
+ Measured on the Anthropic relay (Claude Opus 5.5, main at `xhigh`, idle aside, two runs each): the first words came
91
+ after 3.8 s at the main level and after 1.7–1.9 s at `low`; the cache read was the same (3,557 tokens, `exact`).
92
+
93
+ ### 2.7 The panel while an answer has no words yet, Markdown, and putting it away (amends ux §3.2)
94
+
95
+ - Before the first words the panel shows Pi's spinner with `working` and the seconds so far. Once the model
96
+ thinks, the line says `thinking` and the last three lines of its (summarized) thinking follow, dim and italic.
97
+ Tool calls show as before (`reading src/a.ts`), with the spinner. While the answer streams, the spinner sits in
98
+ the title.
99
+ - Answers, thinking and the reader are rendered with Pi's Markdown component and Markdown theme.
100
+ - Enter on an empty editor puts the panel away: the frame collapses to its one dim line, and the next such Enter
101
+ clears that line. Pi does nothing on an empty Enter, so no meaning changes. While the editor is empty, the
102
+ frame's lower edge says `↵ hides`. A handoff suggestion goes away with the panel. `/aside` still rereads the
103
+ thread.
104
+ - Pi's "more" key (`app.tools.expand`, ctrl+o, which Pi's own header teaches as `ctrl+o more`) also expands the
105
+ aside: a long answer gets the terminal's room (its height minus 14 lines) instead of 10 lines, and a collapsed
106
+ panel opens again. Pi still toggles its tool output, so the key keeps its meaning: show more. A truncated answer
107
+ ends with `… ctrl+o more` (the key as bound); an answer longer than the room ends with `… /aside to read all`.
108
+ - The `/aside` reader opens at the latest question instead of the first.
109
+
110
+ ### 2.8 Not implemented
76
111
 
77
112
  - **The fork-point breakpoint (ux §5.5).** The aside's markers are pi-ai's defaults. When the main cache has
78
113
  expired, the aside pays the cold write, and the money line says so.
79
114
  - **A `background-work` emitter in pi-durable-subagents.** Until an emitter exists, "busy" means running, as ux §3.1
80
- specifies for that case.
115
+ specifies for that case. So while the agent is idle and only subagents run, a `?` message goes to the agent as a
116
+ prompt (seen in real use, dsa 1.2.0); `/aside <question>` asks on the side meanwhile.
81
117
  - **The user studies of ux §6 (U1) and the 40-message misroute set (U8).** Both need real users and a real model.
82
118
  The route check is implemented and tested with scripted replies.
83
119
 
@@ -96,7 +132,8 @@ interaction, in a private tmux server. Nothing touches `~/.pi` or the network.
96
132
  | Fallbacks | A handler that rewrites the transcript, which gives T1 with a fresh session id; no main request yet, which gives T1 with the session's system prompt. |
97
133
  | Order | A payload replacement by an extension loaded after pi-aside (the tap). Loading as an installed package. |
98
134
  | Follow-ups | The fork continues; after the main session moves, it forks again with the earlier exchange quoted. |
99
- | TUI | The hint, a busy `?` asked on the side and never seen by the main session, collapse on the next send, CJK `?`, a steer without `?`, an idle `?` as a prompt, Esc aborting with the panel visible, Tab accepting the suggestion and Enter sending it with the side note, `route: agent` sent as a steer, a failed aside sent as a steer, a bracketed paste, and the waiting state. |
135
+ | TUI | The hint, a busy `?` asked on the side and never seen by the main session, collapse on the next send, CJK `?`, a steer without `?`, an idle `?` as a prompt, Esc aborting with the panel visible, Tab accepting the suggestion and Enter sending it with the side note, `route: agent` sent as a steer, a failed aside sent as a steer, a bracketed paste, the waiting state, the spinner and thinking before the first words, Markdown, and Enter on an empty editor putting the panel away. |
136
+ | Effort | On models with mid-conversation effort, the aside's E carries `low` and everything else matches the main request (Anthropic and Responses); other models keep the main level. |
100
137
  | Units | The text rules (71 gate cases), strip, lists, C1–C3, the observer state machine, the usage queue, background work, placeholders, the notice, money. |
101
138
 
102
139
  ### 3.2 Real provider (2026-10-21, a few cents in total)
package/docs/ux.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-aside: interaction design
2
2
 
3
- Status: implemented, revision 5 (§4: Enter is routed through Pi's `input` event, and the ghost text is a line below the editor; §3.2: an answer still streaming does not collapse; see `implementation.md` §2). Revision 4: busy includes waiting for subagents (§3.1, §3.8), and follow-ups see the latest main state (§3.2). This document covers only what the user sees and does. The engine in `design.md`
3
+ Status: implemented, revision 6 (§3.2: the panel is never an empty box, renders Markdown, and an Enter on an empty editor puts it away). Revision 5 (§4: Enter is routed through Pi's `input` event, and the ghost text is a line below the editor; §3.2: an answer still streaming does not collapse; see `implementation.md` §2). Revision 4: busy includes waiting for subagents (§3.1, §3.8), and follow-ups see the latest main state (§3.2). This document covers only what the user sees and does. The engine in `design.md`
4
4
  (observation, T0/T1, C1–C3, routing, accounting) changes in two places only: follow-ups fork again when the main session has moved (§3.2), and a fork-point breakpoint is proposed (§5.5). Revision 1 is critiqued in §7.
5
5
 
6
6
  ## 1. The goal
@@ -102,9 +102,18 @@ question mark is a steer, exactly as today.
102
102
  `T ++ G ++ Q ++ A1 ++ Q2`.
103
103
  - **Dismissal needs no key.** When the user next sends anything to the agent, the panel collapses to one dim line
104
104
  (`▸ why did it pick B?`). An answer still streaming stays expanded: the user is waiting for it. The next aside replaces it, and a session switch clears it.
105
+ - **Putting it away by hand is the same Enter.** Enter on an empty editor sends nothing, so it collapses the panel
106
+ the same way, and a second one clears the line (revision 6). While the editor is empty, the frame's lower edge
107
+ says `↵ hides`. `/aside` still rereads the thread.
108
+ - **Never an empty box.** Before the first words, the panel shows Pi's spinner with `working` and the seconds so
109
+ far; once the model thinks, `thinking` and the last lines of its thinking, dim. Answers render as Markdown.
110
+ On models that take effort changes mid-conversation, an aside thinks at a low effort, which keeps the cache
111
+ (`implementation.md` §2.6), so its first words come sooner.
105
112
  - **Long answers.** While streaming, the panel shows the last 10 lines. When the answer is done, it shows the
106
- first 10 lines and `… /aside to read all`. `/aside` with no argument opens the latest aside in a reader overlay.
107
- The reader is an explicit modal with its own footer, and Esc closes it, as in every Pi selector.
113
+ first 10 lines and `… ctrl+o more`: Pi's own "more" key expands the aside with everything else (revision 6),
114
+ up to the terminal's room, and reopens a collapsed panel. Beyond that, `… /aside to read all`. `/aside` with no argument opens the latest aside in a reader overlay.
115
+ The reader is an explicit modal with its own footer, opens at the latest question, and Esc closes it, as in
116
+ every Pi selector.
108
117
  - Read-only tools run silently. A dim line such as `reading src/adapter.ts` appears while one runs.
109
118
  - **The session does not fork.** The side thread is an in-memory continuation of the main request
110
119
  (`design.md` §4.2: `T ++ G ++ Q`, where G is what the branch gained since the observed request). It sees the
@@ -203,10 +212,11 @@ The aside request itself answers the routing question, so no extra request is ne
203
212
 
204
213
  | Key | Meaning | New? |
205
214
  |---|---|---|
206
- | Enter | Send. While busy, a question-mark ending asks on the side unless the route check finds a request or correction (shown before sending) | Extended, visibly |
215
+ | Enter | Send. While busy, a question-mark ending asks on the side unless the route check finds a request or correction (shown before sending). On an empty editor, put the aside panel away (`↵ hides` on its frame) | Extended, visibly |
207
216
  | Alt+Enter | Queue a follow-up | Pi |
208
217
  | Esc | Abort the agent; close the reader overlay | Pi |
209
218
  | Tab | Autocomplete, which now also accepts the handoff ghost text in an empty editor | Pi |
219
+ | ctrl+o | Show more: Pi's tool output, and now the aside panel too | Pi |
210
220
 
211
221
  Revision 1 had Tab with three meanings, Esc meaning "dismiss", and a focus mode. Revision 2 has no new key.
212
222
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-aside",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension: ask a side question about a running session without touching it, at the cache-read price.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/fork.ts CHANGED
@@ -14,7 +14,9 @@ import {
14
14
  type Delta,
15
15
  deepEqual,
16
16
  diffTopLevel,
17
+ differsOnlyInConfig,
17
18
  type Payload,
19
+ splitList,
18
20
  T0_APIS,
19
21
  } from "./payload.ts";
20
22
  import { OWN_REQUEST } from "./tap.ts";
@@ -131,8 +133,12 @@ export interface Fork {
131
133
  thinkingBudgets: unknown;
132
134
  blockImages: boolean;
133
135
  };
136
+ /** The thinking level the aside sends with, when lower than the main session's (see `asideLevel`). */
137
+ effort?: string;
134
138
  /** T0 only. */
135
139
  b0?: Payload;
140
+ /** T0 with `effort`: the dry run of T at the aside's effort; it differs from B0 only in E. */
141
+ b0e?: Payload;
136
142
  delta?: Delta;
137
143
  modelSnapshot?: Any;
138
144
  /** Number of entries in the session branch when the fork was made. */
@@ -151,11 +157,36 @@ export interface SendResult {
151
157
  sent?: Payload;
152
158
  }
153
159
 
154
- function baseOptions(env: ForkEnv, fork: Fork, model: Any, signal: AbortSignal | undefined): Any {
160
+ /** The effort an aside asks for: quick answers, so its first words arrive soon. */
161
+ export const ASIDE_LEVEL = "low";
162
+ const LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
163
+
164
+ /**
165
+ * The thinking level for an aside: `ASIDE_LEVEL` when the main session's is higher and the model takes effort
166
+ * changes mid-conversation (`compat.supportsMidConvoEffort`: pi-ai sends the level as a trailing configuration item,
167
+ * after the cached prefix), otherwise the main session's level. A level the model maps to nothing is not used.
168
+ */
169
+ export function asideLevel(model: Any, level: string | undefined): string | undefined {
170
+ if (!level || model?.compat?.supportsMidConvoEffort !== true) return level;
171
+ const map = model.thinkingLevelMap;
172
+ if (map && typeof map === "object" && (map[ASIDE_LEVEL] === null || (Object.keys(map).length > 0 && !(ASIDE_LEVEL in map)))) {
173
+ return level;
174
+ }
175
+ const i = LEVELS.indexOf(level);
176
+ return i > LEVELS.indexOf(ASIDE_LEVEL) ? ASIDE_LEVEL : level;
177
+ }
178
+
179
+ function baseOptions(
180
+ env: ForkEnv,
181
+ fork: Fork,
182
+ model: Any,
183
+ signal: AbortSignal | undefined,
184
+ reasoning: string | undefined = fork.effort ?? fork.recipe.reasoning,
185
+ ): Any {
155
186
  const settings = env.settings();
156
187
  const opts = requestOptions(settings);
157
188
  return {
158
- reasoning: fork.recipe.reasoning,
189
+ reasoning,
159
190
  sessionId: fork.recipe.sessionId,
160
191
  transport: fork.recipe.transport,
161
192
  thinkingBudgets: fork.recipe.thinkingBudgets,
@@ -179,9 +210,15 @@ async function finish(stream: Any): Promise<Any> {
179
210
  }
180
211
 
181
212
  /** B0: the dry conversion of T; onPayload records the payload and aborts before any request is sent. */
182
- export async function dryRun(env: ForkEnv, fork: Fork, model: Any, messages: unknown[]): Promise<Payload> {
213
+ export async function dryRun(
214
+ env: ForkEnv,
215
+ fork: Fork,
216
+ model: Any,
217
+ messages: unknown[],
218
+ reasoning: string | undefined = fork.recipe.reasoning,
219
+ ): Promise<Payload> {
183
220
  let captured: Payload | undefined;
184
- const options = baseOptions(env, fork, model, undefined);
221
+ const options = baseOptions(env, fork, model, undefined, reasoning);
185
222
  options.onPayload = (payload: unknown) => {
186
223
  captured = structuredClone(payload) as Payload;
187
224
  throw new Fallback("dry run");
@@ -248,6 +285,19 @@ async function prepareExact(env: ForkEnv, observation: Observation, branchLength
248
285
  fork.b0 = b0;
249
286
  fork.delta = delta;
250
287
  fork.pHash = await sha256(canonical(p));
288
+ // A lower effort is used only when the dry conversion proves that it changes nothing but E.
289
+ const lower = asideLevel(snapshot, recipe.reasoning);
290
+ if (lower !== recipe.reasoning) {
291
+ try {
292
+ const b0e = await dryRun(env, fork, structuredClone(snapshot), observation.transcript, lower);
293
+ if (differsOnlyInConfig(recipe.api, b0e, b0) && !deepEqual(b0e, b0)) {
294
+ fork.effort = lower;
295
+ fork.b0e = b0e;
296
+ }
297
+ } catch {
298
+ // the main session's level
299
+ }
300
+ }
251
301
  return fork;
252
302
  }
253
303
 
@@ -312,11 +362,13 @@ export function makeRebuilt(env: ForkEnv, observation: Observation | undefined,
312
362
  }
313
363
  const level = recipe?.reasoning ?? (env.thinkingLevel() === "off" ? undefined : env.thinkingLevel());
314
364
  const settings = env.settings();
365
+ const lower = asideLevel(env.registry.find(provider, modelId), level);
315
366
  return {
316
367
  tier: "rebuilt",
317
368
  reason,
318
369
  base,
319
370
  observation,
371
+ ...(lower !== level ? { effort: lower } : {}),
320
372
  recipe: {
321
373
  provider,
322
374
  modelId,
@@ -364,7 +416,9 @@ async function demote(
364
416
  fork.tier = "rebuilt";
365
417
  fork.reason = reason;
366
418
  fork.recipe = rebuilt.recipe;
419
+ fork.effort = rebuilt.effort;
367
420
  fork.b0 = undefined;
421
+ fork.b0e = undefined;
368
422
  fork.delta = undefined;
369
423
  return sendRebuilt(env, fork, messages, signal, onEvent);
370
424
  }
@@ -409,13 +463,13 @@ async function sendExact(
409
463
  restart = true;
410
464
  throw new Fallback("restart");
411
465
  }
412
- const c2 = checkC2(recipe.api, fork.b0!, b1 as Payload, p);
466
+ const c2 = checkC2(recipe.api, fork.b0e ?? fork.b0!, b1 as Payload, p);
413
467
  if (!c2.ok) {
414
468
  fallback = c2.reason;
415
469
  throw new Fallback(c2.reason);
416
470
  }
417
471
  const f = applyDelta(b1 as Payload, fork.delta!);
418
- const c3 = checkC3(recipe.api, f, p);
472
+ const c3 = checkC3(recipe.api, f, p, fork.b0e ? splitList(recipe.api, fork.b0e)?.config : undefined);
419
473
  if (!c3.ok) {
420
474
  fallback = c3.reason;
421
475
  throw new Fallback(c3.reason);
package/src/index.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  // pi-aside: ask a side question about a running Pi session without touching it, at the cache-read price.
2
2
  // Engine: docs/design.md. Interaction: docs/ux.md.
3
3
 
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { getKeybindings } from "@earendil-works/pi-tui";
4
+ import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, keyText } from "@earendil-works/pi-coding-agent";
5
+ import { getKeybindings, Markdown } from "@earendil-works/pi-tui";
6
6
  import { BackgroundWork } from "./busy.ts";
7
7
  import type { ForkEnv } from "./fork.ts";
8
8
  import { Observer, type Recipe } from "./observe.ts";
9
- import { MONEY_THRESHOLD, type PanelState, Reader, type ReaderTurn, renderBelow, renderPanel } from "./panel.ts";
9
+ import {
10
+ type MarkdownRenderer,
11
+ MONEY_THRESHOLD,
12
+ PANEL_LINES,
13
+ type PanelState,
14
+ plainText,
15
+ Reader,
16
+ type ReaderTurn,
17
+ renderBelow,
18
+ renderPanel,
19
+ SPIN_MS,
20
+ } from "./panel.ts";
10
21
  import { endsWithQuestionMark, routeEnter } from "./qmark.ts";
11
22
  import { parseReply } from "./reply.ts";
12
23
  import { AsideThread, type TurnView } from "./thread.ts";
@@ -23,6 +34,28 @@ const STATUS_KEY = "pi-aside";
23
34
  const SIDE_NOTE_TYPE = "aside";
24
35
  const REBUILT_NOTICE_AFTER = 3;
25
36
 
37
+ /** Pi's Markdown component with Pi's Markdown theme; the last few renders are kept (the spinner redraws often). */
38
+ function markdownRenderer(theme: Any): MarkdownRenderer {
39
+ const cache = new Map<string, string[]>();
40
+ return (text, width, style) => {
41
+ const key = `${style ?? ""}|${width}|${text}`;
42
+ const hit = cache.get(key);
43
+ if (hit) return hit;
44
+ let lines: string[];
45
+ try {
46
+ const textStyle = style === "thinking" ? { color: (s: string) => theme.fg("dim", s), italic: true } : undefined;
47
+ lines = new Markdown(text, 0, 0, getMarkdownTheme(), textStyle).render(Math.max(10, width)).map((line) => line.replace(/\s+$/u, ""));
48
+ while (lines.length > 0 && lines.at(-1) === "") lines.pop();
49
+ while (lines.length > 0 && lines[0] === "") lines.shift();
50
+ } catch {
51
+ lines = plainText(text, width);
52
+ }
53
+ if (cache.size >= 8) cache.delete(cache.keys().next().value!);
54
+ cache.set(key, lines);
55
+ return lines;
56
+ };
57
+ }
58
+
26
59
  export default function piAside(pi: ExtensionAPI): void {
27
60
  const observer = new Observer();
28
61
  let ctxRef: ExtensionContext | undefined;
@@ -36,6 +69,7 @@ export default function piAside(pi: ExtensionAPI): void {
36
69
  let rebuiltInARow = 0;
37
70
  let rebuiltNoticeShown = false;
38
71
  let unsubscribeInput: (() => void) | undefined;
72
+ let ticker: ReturnType<typeof setInterval> | undefined;
39
73
 
40
74
  const usage = new UsageQueue({
41
75
  state: () => (ctxRef ? { idle: ctxRef.isIdle(), sessionId: ctxRef.sessionManager.getSessionId() } : undefined),
@@ -131,7 +165,7 @@ export default function piAside(pi: ExtensionAPI): void {
131
165
  function resetSession(ctx: ExtensionContext | undefined): void {
132
166
  thread?.abort();
133
167
  thread = undefined;
134
- panel = { turns: [], collapsed: false };
168
+ panel = { turns: [], collapsed: false, expanded: panel.expanded };
135
169
  handoff = undefined;
136
170
  acceptedHandoff = undefined;
137
171
  rebuiltInARow = 0;
@@ -263,7 +297,7 @@ export default function piAside(pi: ExtensionAPI): void {
263
297
  if (!continuing) {
264
298
  thread?.abort();
265
299
  thread = new AsideThread(threadHost());
266
- panel = { turns: thread.turns, collapsed: false };
300
+ panel = { turns: thread.turns, collapsed: false, expanded: panel.expanded };
267
301
  } else if (thread!.busy) {
268
302
  thread!.abort();
269
303
  }
@@ -271,6 +305,7 @@ export default function piAside(pi: ExtensionAPI): void {
271
305
  handoff = undefined;
272
306
  acceptedHandoff = undefined;
273
307
  panel.collapsed = false;
308
+ panel.hidden = false;
274
309
  panel.sentToAgent = undefined;
275
310
  showPanel(true);
276
311
  refreshBelow();
@@ -338,6 +373,7 @@ export default function piAside(pi: ExtensionAPI): void {
338
373
  parts.push(`rebuilt: not expected to hit${turn.reason ? ` (${turn.reason})` : ""}`);
339
374
  parts.push(`read ${fmt(turn.read)}, wrote ${fmt(turn.written)}`);
340
375
  }
376
+ if (turn.effort) parts.push(`thinking ${turn.effort}`);
341
377
  parts.push(`$${turn.cost.toFixed(turn.cost < MONEY_THRESHOLD ? 3 : 2)}`);
342
378
  return parts.join(" · ");
343
379
  }
@@ -351,6 +387,9 @@ export default function piAside(pi: ExtensionAPI): void {
351
387
  read: turn.read,
352
388
  written: turn.written,
353
389
  predicted: turn.predicted,
390
+ effort: turn.effort,
391
+ firstTextMs: turn.firstTextAt === undefined ? undefined : turn.firstTextAt - turn.startedAt,
392
+ totalMs: Date.now() - turn.startedAt,
354
393
  cost: turn.cost,
355
394
  error: turn.error,
356
395
  aborted: turn.aborted === true,
@@ -401,7 +440,14 @@ export default function piAside(pi: ExtensionAPI): void {
401
440
  async function openReader(ctx: ExtensionContext): Promise<void> {
402
441
  await ctx.ui.custom<void>(
403
442
  (tui: Any, theme: Any, _keybindings: Any, done: (result: void) => void) =>
404
- new Reader(readerTurns, theme, () => done(undefined), () => tui.requestRender(), () => tui.terminal?.rows ?? process.stdout.rows ?? 40),
443
+ new Reader(
444
+ readerTurns,
445
+ theme,
446
+ () => done(undefined),
447
+ () => tui.requestRender(),
448
+ () => tui.terminal?.rows ?? process.stdout.rows ?? 40,
449
+ markdownRenderer(theme),
450
+ ),
405
451
  { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } as Any },
406
452
  );
407
453
  }
@@ -410,7 +456,8 @@ export default function piAside(pi: ExtensionAPI): void {
410
456
 
411
457
  function renderNow(): void {
412
458
  if (ctxRef?.mode !== "tui") return;
413
- showPanel(panel.turns.length > 0);
459
+ showPanel(panel.turns.length > 0 && !panel.hidden);
460
+ animate();
414
461
  try {
415
462
  tuiRef?.requestRender();
416
463
  } catch {
@@ -418,6 +465,54 @@ export default function piAside(pi: ExtensionAPI): void {
418
465
  }
419
466
  }
420
467
 
468
+ /** Whether the panel shows an answer still running (its spinner turns). */
469
+ function spinning(): boolean {
470
+ const turn = panel.turns.at(-1);
471
+ return !!turn && !turn.done && !panel.hidden && !panel.sentToAgent;
472
+ }
473
+
474
+ /** Redraw at the spinner's pace while an answer runs. */
475
+ function animate(): void {
476
+ if (ticker || !spinning()) return;
477
+ ticker = setInterval(() => {
478
+ if (!spinning() || ctxRef?.mode !== "tui") {
479
+ clearInterval(ticker);
480
+ ticker = undefined;
481
+ }
482
+ try {
483
+ tuiRef?.requestRender();
484
+ } catch {
485
+ // not mounted
486
+ }
487
+ }, SPIN_MS);
488
+ ticker.unref?.();
489
+ }
490
+
491
+ /** ctrl+o (`app.tools.expand`): a collapsed panel opens again, expanded; otherwise expanded and compact toggle. */
492
+ function toggleMore(): void {
493
+ if (panel.collapsed && !panel.hidden && panel.turns.length > 0) {
494
+ panel.collapsed = false;
495
+ panel.expanded = true;
496
+ } else {
497
+ panel.expanded = !panel.expanded;
498
+ }
499
+ renderNow();
500
+ }
501
+
502
+ /**
503
+ * Enter on an empty editor puts the panel away (docs/ux.md §3.2): the frame collapses to one dim line, and the
504
+ * next such Enter clears that line. The thread stays readable with `/aside`.
505
+ */
506
+ function putAway(): boolean {
507
+ if (panel.turns.length === 0 || panel.hidden) return false;
508
+ if (panel.collapsed) panel.hidden = true;
509
+ else panel.collapsed = true;
510
+ handoff = undefined;
511
+ renderNow();
512
+ refreshBelow();
513
+ return true;
514
+ }
515
+
421
516
  function showPanel(show: boolean): void {
422
517
  const ctx = ctxRef;
423
518
  if (!ctx || ctx.mode !== "tui") return;
@@ -432,7 +527,20 @@ export default function piAside(pi: ExtensionAPI): void {
432
527
  PANEL_WIDGET,
433
528
  (tui: Any, theme: Any) => {
434
529
  tuiRef = tui;
435
- return { render: (width: number) => renderPanel(panel, width, theme), invalidate() {} };
530
+ const md = markdownRenderer(theme);
531
+ return {
532
+ render: (width: number) => {
533
+ const rows = tui?.terminal?.rows ?? process.stdout.rows ?? 40;
534
+ panel.maxLines = Math.max(PANEL_LINES, rows - 14);
535
+ try {
536
+ panel.moreKey = keyText("app.tools.expand") || undefined;
537
+ } catch {
538
+ panel.moreKey = undefined;
539
+ }
540
+ return renderPanel(panel, width, theme, md, Date.now());
541
+ },
542
+ invalidate() {},
543
+ };
436
544
  },
437
545
  { placement: "aboveEditor" },
438
546
  );
@@ -462,6 +570,7 @@ export default function piAside(pi: ExtensionAPI): void {
462
570
  if (!ctx || ctx.mode !== "tui") return;
463
571
  const state = belowState();
464
572
  belowCache = state;
573
+ panel.editorEmpty = editorText().trim() === "";
465
574
  const show = state.hint || state.ghost !== undefined;
466
575
  try {
467
576
  if (show !== belowShown) {
@@ -483,6 +592,22 @@ export default function piAside(pi: ExtensionAPI): void {
483
592
  }
484
593
  }
485
594
 
595
+ function editorText(): string {
596
+ try {
597
+ return ctxRef?.ui.getEditorText() ?? "";
598
+ } catch {
599
+ return "";
600
+ }
601
+ }
602
+
603
+ /** Whether Pi's input editor has the focus (not a selector, a dialog or another extension's overlay). */
604
+ function editorFocused(): boolean {
605
+ const focus = tuiRef?.getFocusedComponent;
606
+ if (typeof focus !== "function") return true;
607
+ const focused = focus.call(tuiRef);
608
+ return !!focused && typeof focused === "object" && focused.actionHandlers instanceof Map;
609
+ }
610
+
486
611
  function installTui(ctx: ExtensionContext): void {
487
612
  unsubscribeInput?.();
488
613
  unsubscribeInput = undefined;
@@ -491,6 +616,11 @@ export default function piAside(pi: ExtensionAPI): void {
491
616
  if (ctx.mode !== "tui") return;
492
617
  unsubscribeInput = ctx.ui.onTerminalInput((data) => {
493
618
  const kb = getKeybindings();
619
+ if (kb.matches(data, "tui.input.submit") && editorText().trim() === "" && editorFocused() && putAway()) {
620
+ return { consume: true };
621
+ }
622
+ // Pi's "more" (ctrl+o) expands the aside with everything else; Pi still toggles its tool output.
623
+ if (kb.matches(data, "app.tools.expand") && editorFocused()) toggleMore();
494
624
  if (handoff && kb.matches(data, "tui.input.tab")) {
495
625
  let text = "";
496
626
  try {
package/src/panel.ts CHANGED
@@ -10,16 +10,54 @@ import type { TurnView } from "./thread.ts";
10
10
  type Theme = any;
11
11
 
12
12
  export const PANEL_LINES = 10;
13
+ /** Lines of the model's thinking shown while no answer text has arrived. */
14
+ export const THINKING_LINES = 3;
13
15
  export const MONEY_THRESHOLD = 0.25;
14
16
  export const HINT = "↵ asks on the side · corrections still reach the agent";
17
+ /** On the panel's lower edge while the editor is empty: Enter there puts the panel away (docs/ux.md §3.2). */
18
+ export const HIDE_HINT = "↵ hides";
19
+
20
+ /** Pi's own loader frames. */
21
+ export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
+ export const SPIN_MS = 80;
23
+
24
+ export function spinnerFrame(now: number): string {
25
+ return SPINNER[Math.floor(now / SPIN_MS) % SPINNER.length]!;
26
+ }
15
27
 
16
28
  export interface PanelState {
17
29
  turns: TurnView[];
30
+ /** One dim line instead of the frame. */
18
31
  collapsed: boolean;
32
+ /** Not shown at all (`/aside` still rereads the thread). */
33
+ hidden?: boolean;
34
+ /** Whether the editor is empty: the lower edge then shows `HIDE_HINT`. */
35
+ editorEmpty?: boolean;
36
+ /** Pi's "more" toggle (`app.tools.expand`, ctrl+o) is on: the answer gets up to `maxLines` lines. */
37
+ expanded?: boolean;
38
+ /** Lines an expanded answer may take (from the terminal height). */
39
+ maxLines?: number;
40
+ /** The key that toggles "more", as Pi shows it (`ctrl+o`). */
41
+ moreKey?: string;
19
42
  /** Set when the route check sent the text to the agent. */
20
43
  sentToAgent?: { text: string; waiting: boolean };
21
44
  }
22
45
 
46
+ /** Markdown to terminal lines; `thinking` text is rendered dim and italic. */
47
+ export type MarkdownRenderer = (text: string, width: number, style?: "thinking") => string[];
48
+
49
+ function wrap(text: string, width: number): string[] {
50
+ const out: string[] = [];
51
+ for (const line of text.split("\n")) {
52
+ if (line === "") out.push("");
53
+ else out.push(...wrapTextWithAnsi(line, Math.max(10, width)));
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Plain wrapping, for tests and when Pi's Markdown component is unavailable. */
59
+ export const plainText: MarkdownRenderer = (text, width) => wrap(text, width);
60
+
23
61
  function formatCost(cost: number): string {
24
62
  return `$${cost.toFixed(2)}`;
25
63
  }
@@ -32,17 +70,37 @@ export function moneyLine(turn: TurnView): string | undefined {
32
70
  return undefined;
33
71
  }
34
72
 
35
- function wrap(text: string, width: number): string[] {
36
- const out: string[] = [];
37
- for (const line of text.split("\n")) {
38
- if (line === "") out.push("");
39
- else out.push(...wrapTextWithAnsi(line, Math.max(10, width)));
73
+ function elapsed(turn: TurnView, now: number): string {
74
+ const seconds = Math.floor((now - turn.startedAt) / 1000);
75
+ return seconds >= 1 ? ` · ${seconds}s` : "";
76
+ }
77
+
78
+ /** The end of a long thinking text, cut at a paragraph or line start so that its Markdown stays whole. */
79
+ function thinkingTail(text: string): string {
80
+ const trimmed = text.trimEnd();
81
+ if (trimmed.length <= 600) return trimmed;
82
+ const tail = trimmed.slice(-600);
83
+ const cut = tail.indexOf("\n");
84
+ return cut >= 0 && cut < tail.length - 1 ? tail.slice(cut + 1) : tail;
85
+ }
86
+
87
+ /**
88
+ * The live line of an answer in progress: a spinner, what is happening and for how long, and while the model thinks,
89
+ * the last lines of its thinking. Shown until the answer's first words, and during a tool call.
90
+ */
91
+ export function progressLines(turn: TurnView, width: number, theme: Theme, md: MarkdownRenderer, now: number): string[] {
92
+ const spin = theme.fg("accent", spinnerFrame(now));
93
+ const dim = (s: string) => theme.fg("dim", s);
94
+ if (turn.activity) return [`${spin} ${dim(`${turn.activity}${elapsed(turn, now)}`)}`];
95
+ if (turn.thinking?.trim()) {
96
+ const tail = md(thinkingTail(turn.thinking), Math.max(10, width - 2), "thinking").filter((line) => line.trim() !== "");
97
+ return [`${spin} ${dim(`thinking${elapsed(turn, now)}`)}`, ...tail.slice(-THINKING_LINES).map((line) => ` ${line}`)];
40
98
  }
41
- return out;
99
+ return [`${spin} ${dim(`working${elapsed(turn, now)}`)}`];
42
100
  }
43
101
 
44
102
  /** Body lines of the panel for its current state (without the frame). */
45
- export function panelBody(state: PanelState, width: number, theme: Theme): string[] {
103
+ export function panelBody(state: PanelState, width: number, theme: Theme, md: MarkdownRenderer = plainText, now = Date.now()): string[] {
46
104
  const turn = state.turns.at(-1);
47
105
  if (!turn) return [];
48
106
  const dim = (s: string) => theme.fg("dim", s);
@@ -51,15 +109,16 @@ export function panelBody(state: PanelState, width: number, theme: Theme): strin
51
109
  }
52
110
  const lines: string[] = [];
53
111
  const parsed = parseReply(turn.raw, turn.done);
54
- const body = wrap(parsed.body, width);
112
+ const body = parsed.body ? md(parsed.body, width) : [];
113
+ const limit = state.expanded ? Math.max(PANEL_LINES, state.maxLines ?? PANEL_LINES) : PANEL_LINES;
55
114
  if (!turn.done) {
56
- lines.push(...body.slice(-PANEL_LINES));
57
- if (turn.activity) lines.push(dim(turn.activity));
58
- else if (body.length === 0) lines.push(dim("…"));
115
+ lines.push(...body.slice(-limit));
116
+ if (turn.activity || body.length === 0) lines.push(...progressLines(turn, width, theme, md, now));
59
117
  } else {
60
- if (body.length > PANEL_LINES) {
61
- lines.push(...body.slice(0, PANEL_LINES));
62
- lines.push(dim("… /aside to read all"));
118
+ if (body.length > limit) {
119
+ lines.push(...body.slice(0, limit));
120
+ const more = !state.expanded && state.moreKey ? `${state.moreKey} more` : "/aside to read all";
121
+ lines.push(dim(`… ${more}`));
63
122
  } else {
64
123
  lines.push(...body);
65
124
  }
@@ -71,21 +130,37 @@ export function panelBody(state: PanelState, width: number, theme: Theme): strin
71
130
  return lines;
72
131
  }
73
132
 
74
- /** The framed panel (rounded box with the question as title), or one dim line when collapsed. */
75
- export function renderPanel(state: PanelState, width: number, theme: Theme): string[] {
133
+ /**
134
+ * The framed panel (rounded box with the question as its title, a spinner before it while the answer streams, and
135
+ * `↵ hides` on the lower edge while the editor is empty), one dim line when collapsed, nothing when hidden.
136
+ */
137
+ export function renderPanel(state: PanelState, width: number, theme: Theme, md: MarkdownRenderer = plainText, now = Date.now()): string[] {
76
138
  const turn = state.turns.at(-1);
77
- if (!turn) return [];
139
+ if (!turn || state.hidden) return [];
78
140
  const border = (s: string) => theme.fg("borderMuted", s);
79
- if (state.collapsed) return [truncateToWidth(theme.fg("dim", `▸ ${turn.question.replace(/\s+/g, " ")}`), width)];
141
+ const question = turn.question.replace(/\s+/g, " ");
142
+ const running = !turn.done && !state.sentToAgent;
143
+ if (state.collapsed) {
144
+ const mark = running ? theme.fg("accent", spinnerFrame(now)) : theme.fg("dim", "▸");
145
+ return [truncateToWidth(`${mark} ${theme.fg("dim", question)}`, width)];
146
+ }
80
147
  const inner = Math.max(10, width - 4);
81
- const title = truncateToWidth(` ${turn.question.replace(/\s+/g, " ")} `, Math.max(4, width - 6), "…");
148
+ // The spinner sits in the title while the answer streams, and on the progress line before it (never both).
149
+ const progress = !!turn.activity || parseReply(turn.raw, turn.done).body === "";
150
+ const spin = running && !progress ? `${spinnerFrame(now)} ` : "";
151
+ const title = truncateToWidth(` ${spin}${question} `, Math.max(4, width - 6), "…");
82
152
  const top = border("╭─") + theme.fg("muted", title) + border(`${"─".repeat(Math.max(0, width - 3 - visibleWidth(title)))}╮`);
83
153
  const out = [truncateToWidth(top, width)];
84
- for (const line of panelBody(state, inner, theme)) {
85
- const text = truncateToWidth(line, inner);
154
+ for (const line of panelBody(state, inner, theme, md, now)) {
155
+ const text = truncateToWidth(line.replace(/\s+$/u, ""), inner);
86
156
  out.push(`${border("│")} ${text}${" ".repeat(Math.max(0, inner - visibleWidth(text)))} ${border("│")}`);
87
157
  }
88
- out.push(border(`╰${"─".repeat(Math.max(0, width - 2))}╯`));
158
+ const hint = state.editorEmpty ? ` ${HIDE_HINT} ` : "";
159
+ if (hint && width >= visibleWidth(hint) + 6) {
160
+ out.push(border(`╰${"─".repeat(Math.max(0, width - 3 - visibleWidth(hint)))}`) + theme.fg("dim", hint) + border("─╯"));
161
+ } else {
162
+ out.push(border(`╰${"─".repeat(Math.max(0, width - 2))}╯`));
163
+ }
89
164
  return out;
90
165
  }
91
166
 
@@ -103,7 +178,7 @@ export interface ReaderTurn {
103
178
  detail: string;
104
179
  }
105
180
 
106
- /** The `/aside` reader: every question and answer of the latest thread, scrollable; Esc closes. */
181
+ /** The `/aside` reader: every question and answer of the latest thread, opened at the latest question; Esc closes. */
107
182
  export class Reader {
108
183
  private offset = 0;
109
184
  private lastHeight = 20;
@@ -114,15 +189,21 @@ export class Reader {
114
189
  private readonly done: () => void,
115
190
  private readonly requestRender: () => void,
116
191
  private readonly rows: () => number,
192
+ private readonly md: MarkdownRenderer = plainText,
117
193
  ) {}
118
194
 
195
+ /** Where the latest question starts: the reader opens there, like a conversation opens at its end. */
196
+ private latestStart = 0;
197
+ private opened = false;
198
+
119
199
  private lines(width: number): string[] {
120
200
  const out: string[] = [];
121
201
  const inner = Math.max(10, width - 2);
122
202
  for (const turn of this.turns()) {
203
+ this.latestStart = out.length;
123
204
  out.push(...wrap(this.theme.fg("accent", `? ${turn.question}`), inner));
124
205
  out.push("");
125
- out.push(...wrap(turn.answer, inner));
206
+ out.push(...this.md(turn.answer, inner).map((line) => line.replace(/\s+$/u, "")));
126
207
  out.push(...wrap(this.theme.fg("dim", turn.detail), inner));
127
208
  out.push("");
128
209
  }
@@ -132,8 +213,12 @@ export class Reader {
132
213
  render(width: number): string[] {
133
214
  const height = Math.max(5, Math.floor(this.rows() * 0.8) - 2);
134
215
  this.lastHeight = height;
135
- const all = this.lines(width);
216
+ const all = this.lines(width - 2);
136
217
  const max = Math.max(0, all.length - height);
218
+ if (!this.opened) {
219
+ this.opened = true;
220
+ this.offset = this.latestStart;
221
+ }
137
222
  if (this.offset > max) this.offset = max;
138
223
  const view = all.slice(this.offset, this.offset + height);
139
224
  const border = (s: string) => this.theme.fg("borderMuted", s);
package/src/payload.ts CHANGED
@@ -223,12 +223,27 @@ export function checkC2(api: string, b0: Payload, b1: Payload, p: Payload): Chec
223
223
  return ok;
224
224
  }
225
225
 
226
- /** C3, on the bytes sent: F's top-level fields equal P's, and strip(core(F)) extends strip(core(P)). */
227
- export function checkC3(api: string, f: Payload, p: Payload): CheckResult {
226
+ /**
227
+ * Whether two dry conversions of the same transcript differ only in their trailing configuration items E: the same
228
+ * core and the same other top-level fields. This is what proves that a lower aside effort keeps the cache prefix.
229
+ */
230
+ export function differsOnlyInConfig(api: string, a: Payload, b: Payload): boolean {
231
+ const sa = splitList(api, a);
232
+ const sb = splitList(api, b);
233
+ if (!sa || !sb) return false;
234
+ if (!deepEqual(strip(sa.core), strip(sb.core))) return false;
235
+ return otherFieldsEqual(api, a, b) === undefined;
236
+ }
237
+
238
+ /**
239
+ * C3, on the bytes sent: F's top-level fields equal P's, and strip(core(F)) extends strip(core(P)). E(F) equals E(P),
240
+ * or `expectedConfig` when the aside runs at its own effort (proved by `differsOnlyInConfig` on the dry runs).
241
+ */
242
+ export function checkC3(api: string, f: Payload, p: Payload, expectedConfig?: unknown[]): CheckResult {
228
243
  const sf = splitList(api, f);
229
244
  const sp = splitList(api, p);
230
245
  if (!sf || !sp) return fail("no content list");
231
- if (!deepEqual(strip(sf.config), strip(sp.config))) return fail("the trailing configuration differs from the observed request");
246
+ if (!deepEqual(strip(sf.config), strip(expectedConfig ?? sp.config))) return fail("the trailing configuration differs from the observed request");
232
247
  if (!extendsList(sf.core, sp.core)) return fail("the request does not extend the observed request");
233
248
  const field = otherFieldsEqual(api, f, p);
234
249
  if (field) return fail(`\`${field}\` differs from the observed request`);
package/src/thread.ts CHANGED
@@ -39,6 +39,13 @@ export interface TurnView {
39
39
  error?: string;
40
40
  /** A dim activity line, such as `reading src/a.ts`. */
41
41
  activity?: string;
42
+ /** The model's (summarized) thinking in the current request, while no answer text has arrived. */
43
+ thinking?: string;
44
+ /** When the question was asked, and when the answer's first text arrived (ms since the epoch). */
45
+ startedAt: number;
46
+ firstTextAt?: number;
47
+ /** The thinking level the aside used, when lower than the main session's. */
48
+ effort?: string;
42
49
  tier?: Tier;
43
50
  reason?: string;
44
51
  cost: number;
@@ -66,6 +73,14 @@ function textOf(message: Any): string {
66
73
  .join("");
67
74
  }
68
75
 
76
+ function thinkingOf(message: Any): string {
77
+ if (!message || !Array.isArray(message.content)) return "";
78
+ return message.content
79
+ .filter((c: Any) => c?.type === "thinking" && typeof c.thinking === "string")
80
+ .map((c: Any) => c.thinking)
81
+ .join("\n\n");
82
+ }
83
+
69
84
  function activityFor(call: Any): string {
70
85
  const args = call.arguments ?? {};
71
86
  switch (call.name) {
@@ -140,7 +155,7 @@ export class AsideThread {
140
155
 
141
156
  /** Ask a question in this thread. Resolves when the answer is complete, failed or aborted. */
142
157
  async ask(question: string, options: { routeCheck: boolean; onRoute?: (route: "side" | "agent") => void }): Promise<TurnView> {
143
- const turn: TurnView = { question, raw: "", done: false, cost: 0, read: 0, written: 0 };
158
+ const turn: TurnView = { question, raw: "", done: false, startedAt: Date.now(), cost: 0, read: 0, written: 0 };
144
159
  this.turns.push(turn);
145
160
  const controller = new AbortController();
146
161
  this.controller = controller;
@@ -175,6 +190,7 @@ export class AsideThread {
175
190
  const fork = this.fork!;
176
191
  turn.tier = fork.tier;
177
192
  turn.reason = fork.reason;
193
+ turn.effort = fork.effort;
178
194
  turn.predicted = fork.observation?.cacheablePrefix;
179
195
  const q = {
180
196
  role: "user",
@@ -188,6 +204,8 @@ export class AsideThread {
188
204
  const result: SendResult = await send(this.host.env, fork, [...this.exchange, ...added], signal, (event: Any) => {
189
205
  if (event?.partial) {
190
206
  turn.raw = textBefore + textOf(event.partial);
207
+ turn.thinking = thinkingOf(event.partial) || undefined;
208
+ if (turn.firstTextAt === undefined && turn.raw.trim() !== "") turn.firstTextAt = Date.now();
191
209
  reportRoute();
192
210
  update();
193
211
  }
@@ -195,6 +213,8 @@ export class AsideThread {
195
213
  const message = result.message;
196
214
  turn.tier = result.tier;
197
215
  turn.reason = result.reason ?? turn.reason;
216
+ turn.effort = fork.effort;
217
+ turn.thinking = undefined;
198
218
  this.account(fork, result, turn);
199
219
  added.push(message);
200
220
  turn.raw = textBefore + textOf(message);
@@ -231,6 +251,7 @@ export class AsideThread {
231
251
  } finally {
232
252
  turn.done = true;
233
253
  turn.activity = undefined;
254
+ turn.thinking = undefined;
234
255
  if (this.controller === controller) this.controller = undefined;
235
256
  reportRoute();
236
257
  update();