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 +16 -0
- package/README.md +3 -1
- package/docs/implementation.md +40 -3
- package/docs/ux.md +14 -4
- package/package.json +1 -1
- package/src/fork.ts +60 -6
- package/src/index.ts +138 -8
- package/src/panel.ts +110 -25
- package/src/payload.ts +18 -3
- package/src/thread.ts +22 -1
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
|
|
package/docs/implementation.md
CHANGED
|
@@ -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
|
|
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
|
|
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 `…
|
|
107
|
-
|
|
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
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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 =
|
|
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(-
|
|
57
|
-
if (turn.activity) lines.push(
|
|
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 >
|
|
61
|
-
lines.push(...body.slice(0,
|
|
62
|
-
|
|
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
|
-
/**
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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(...
|
|
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
|
-
/**
|
|
227
|
-
|
|
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();
|