@oh-my-pi/pi-coding-agent 15.11.7 → 15.12.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 +63 -1
- package/dist/cli.js +8106 -7708
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/collab/crypto.d.ts +7 -0
- package/dist/types/collab/guest.d.ts +23 -0
- package/dist/types/collab/host.d.ts +29 -0
- package/dist/types/collab/protocol.d.ts +113 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +60 -5
- package/dist/types/export/custom-share.d.ts +1 -2
- package/dist/types/export/html/index.d.ts +39 -1
- package/dist/types/export/share.d.ts +43 -0
- package/dist/types/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/agent-hub.d.ts +32 -1
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/hook-selector.d.ts +4 -6
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/status-line/component.d.ts +10 -2
- package/dist/types/modes/components/status-line/types.d.ts +11 -0
- package/dist/types/modes/controllers/event-controller.d.ts +7 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
- package/dist/types/modes/interactive-mode.d.ts +16 -0
- package/dist/types/modes/session-observer-registry.d.ts +7 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +20 -0
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/codex-auto-reset.d.ts +8 -4
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/snapcompact-inline.d.ts +6 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/types.d.ts +9 -0
- package/package.json +14 -13
- package/scripts/bench-guard.ts +71 -0
- package/scripts/build-binary.ts +4 -0
- package/scripts/bundle-dist.ts +4 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/src/cli/args.ts +2 -0
- package/src/cli-commands.ts +1 -0
- package/src/collab/crypto.ts +63 -0
- package/src/collab/guest.ts +450 -0
- package/src/collab/host.ts +556 -0
- package/src/collab/protocol.ts +232 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/join.ts +39 -0
- package/src/config/model-registry.ts +22 -14
- package/src/config/settings-schema.ts +67 -5
- package/src/config/settings.ts +12 -0
- package/src/export/custom-share.ts +1 -1
- package/src/export/html/index.ts +122 -17
- package/src/export/html/share-loader.js +102 -0
- package/src/export/html/template.css +745 -459
- package/src/export/html/template.html +6 -3
- package/src/export/html/template.js +240 -915
- package/src/export/html/tool-views.generated.js +38 -0
- package/src/export/share.ts +268 -0
- package/src/extensibility/slash-commands.ts +1 -97
- package/src/internal-urls/docs-index.generated.ts +74 -73
- package/src/main.ts +33 -11
- package/src/modes/components/agent-hub.ts +659 -431
- package/src/modes/components/assistant-message.ts +126 -6
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/hook-selector.ts +4 -5
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/status-line/component.ts +59 -6
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line/segments.ts +18 -1
- package/src/modes/components/status-line/types.ts +12 -0
- package/src/modes/components/tips.txt +4 -1
- package/src/modes/controllers/command-controller.ts +55 -96
- package/src/modes/controllers/event-controller.ts +45 -16
- package/src/modes/controllers/input-controller.ts +175 -9
- package/src/modes/controllers/selector-controller.ts +13 -15
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/controllers/streaming-reveal.ts +7 -0
- package/src/modes/interactive-mode.ts +56 -6
- package/src/modes/session-observer-registry.ts +11 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +20 -0
- package/src/modes/utils/ui-helpers.ts +23 -13
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk.ts +239 -36
- package/src/session/agent-session.ts +82 -7
- package/src/session/codex-auto-reset.ts +23 -11
- package/src/session/session-manager.ts +44 -0
- package/src/session/snapcompact-inline.ts +9 -3
- package/src/slash-commands/builtin-registry.ts +261 -24
- package/src/task/executor.ts +14 -0
- package/src/task/index.ts +5 -1
- package/src/task/render.ts +76 -5
- package/src/task/types.ts +9 -0
- package/src/tiny/worker.ts +17 -95
- package/src/tools/job.ts +6 -9
- package/src/tools/read.ts +38 -5
- package/src/tools/write.ts +13 -42
- package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
- package/dist/types/export/html/template.generated.d.ts +0 -1
- package/dist/types/export/html/template.macro.d.ts +0 -5
- package/dist/types/tiny/compiled-runtime.d.ts +0 -35
- package/scripts/generate-template.ts +0 -33
- package/src/bun-imports.d.ts +0 -28
- package/src/export/html/template.generated.ts +0 -2
- package/src/export/html/template.macro.ts +0 -25
- package/src/tiny/compiled-runtime.ts +0 -179
|
@@ -49,6 +49,11 @@ export class AssistantMessageComponent extends Container {
|
|
|
49
49
|
/** Whether the last updateContent carried an in-flight streaming partial; such
|
|
50
50
|
* renders bypass the markdown module LRU (see Markdown.transientRenderCache). */
|
|
51
51
|
#lastUpdateTransient = false;
|
|
52
|
+
// Fast-path state: reuse Markdown children when message shape is stable during streaming.
|
|
53
|
+
#fastPathKey: string | undefined;
|
|
54
|
+
#fastPathItems:
|
|
55
|
+
| Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
|
|
56
|
+
| undefined;
|
|
52
57
|
|
|
53
58
|
constructor(
|
|
54
59
|
message?: AssistantMessage,
|
|
@@ -71,6 +76,12 @@ export class AssistantMessageComponent extends Container {
|
|
|
71
76
|
|
|
72
77
|
override invalidate(): void {
|
|
73
78
|
super.invalidate();
|
|
79
|
+
// Theme/symbol changes arrive via invalidate(). Fast-path children captured
|
|
80
|
+
// getMarkdownTheme() at construction, so drop them and force the teardown
|
|
81
|
+
// path to rebuild with the current theme. Streaming updates call
|
|
82
|
+
// updateContent() directly and keep the fast path.
|
|
83
|
+
this.#fastPathKey = undefined;
|
|
84
|
+
this.#fastPathItems = undefined;
|
|
74
85
|
if (this.#lastMessage) {
|
|
75
86
|
this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
|
|
76
87
|
}
|
|
@@ -228,14 +239,111 @@ export class AssistantMessageComponent extends Container {
|
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
241
|
|
|
242
|
+
#computeShapeKey(message: AssistantMessage): string {
|
|
243
|
+
const parts: string[] = [`htb:${this.hideThinkingBlock ? 1 : 0}`];
|
|
244
|
+
for (const content of message.content) {
|
|
245
|
+
if (content.type === "text") {
|
|
246
|
+
parts.push(content.text.trim() ? "T1" : "T0");
|
|
247
|
+
} else if (content.type === "thinking") {
|
|
248
|
+
if (!content.thinking.trim()) parts.push("K0");
|
|
249
|
+
else if (this.hideThinkingBlock) parts.push("KH");
|
|
250
|
+
else parts.push("KV");
|
|
251
|
+
} else {
|
|
252
|
+
// Non-rendered blocks (toolCall, redactedThinking, …) still occupy a
|
|
253
|
+
// content index. Encode their position so an inserted/removed one shifts
|
|
254
|
+
// the key and forces the teardown path instead of mis-indexing children.
|
|
255
|
+
parts.push(`O:${content.type}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (settings.get("display.showTokenUsage") && this.#usageInfo) {
|
|
259
|
+
const u = this.#usageInfo;
|
|
260
|
+
parts.push(`u:${u.input + u.cacheWrite}:${u.output}:${u.cacheRead}`);
|
|
261
|
+
} else {
|
|
262
|
+
parts.push("u:");
|
|
263
|
+
}
|
|
264
|
+
return parts.join("|");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#canFastPath(message: AssistantMessage): boolean {
|
|
268
|
+
for (const content of message.content) {
|
|
269
|
+
if (content.type === "toolCall") return false;
|
|
270
|
+
}
|
|
271
|
+
if (this.#toolImagesByCallId.size > 0) return false;
|
|
272
|
+
if (message.stopReason === "aborted" && shouldRenderAbortReason(message.errorMessage)) return false;
|
|
273
|
+
if (message.stopReason === "error" && !this.#errorPinned) return false;
|
|
274
|
+
if (
|
|
275
|
+
message.errorMessage &&
|
|
276
|
+
shouldRenderAbortReason(message.errorMessage) &&
|
|
277
|
+
message.stopReason !== "aborted" &&
|
|
278
|
+
message.stopReason !== "error"
|
|
279
|
+
)
|
|
280
|
+
return false;
|
|
281
|
+
// Extension stability: if thinking renderers exist and any tracked thinking
|
|
282
|
+
// block's text changed, extensions may produce a different child count.
|
|
283
|
+
if (this.thinkingRenderers.length > 0 && this.#fastPathItems) {
|
|
284
|
+
for (const item of this.#fastPathItems) {
|
|
285
|
+
if (item.blockType === "thinking") {
|
|
286
|
+
const content = message.content[item.contentIndex];
|
|
287
|
+
if (content?.type === "thinking" && content.thinking.trim() !== item.lastText) return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
#tryFastPathUpdate(message: AssistantMessage, opts?: { transient?: boolean }): boolean {
|
|
295
|
+
if (!this.#fastPathKey || !this.#fastPathItems) return false;
|
|
296
|
+
if (!this.#canFastPath(message)) {
|
|
297
|
+
this.#fastPathKey = undefined;
|
|
298
|
+
this.#fastPathItems = undefined;
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
if (this.#computeShapeKey(message) !== this.#fastPathKey) {
|
|
302
|
+
this.#fastPathKey = undefined;
|
|
303
|
+
this.#fastPathItems = undefined;
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
const transient = opts?.transient === true;
|
|
307
|
+
// Shape is identical — setText only on Markdown children whose source changed.
|
|
308
|
+
for (const item of this.#fastPathItems) {
|
|
309
|
+
item.md.transientRenderCache = transient;
|
|
310
|
+
const content = message.content[item.contentIndex];
|
|
311
|
+
let newText: string;
|
|
312
|
+
if (item.blockType === "text" && content?.type === "text") {
|
|
313
|
+
newText = content.text.trim();
|
|
314
|
+
} else if (item.blockType === "thinking" && content?.type === "thinking") {
|
|
315
|
+
newText = content.thinking.trim();
|
|
316
|
+
} else {
|
|
317
|
+
// Block at this index is gone or changed type (index shift) — fail closed.
|
|
318
|
+
this.#fastPathKey = undefined;
|
|
319
|
+
this.#fastPathItems = undefined;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (newText !== item.lastText) {
|
|
323
|
+
item.md.setText(newText);
|
|
324
|
+
item.lastText = newText;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
231
330
|
updateContent(message: AssistantMessage, opts?: { transient?: boolean }): void {
|
|
232
331
|
this.#blockVersion++;
|
|
233
332
|
this.#lastMessage = message;
|
|
234
333
|
this.#lastUpdateTransient = opts?.transient === true;
|
|
235
334
|
|
|
335
|
+
// Fast path: reuse Markdown children when shape is stable during streaming
|
|
336
|
+
if (this.#tryFastPathUpdate(message)) return;
|
|
337
|
+
|
|
236
338
|
// Clear content container
|
|
237
339
|
this.#contentContainer.clear();
|
|
238
340
|
|
|
341
|
+
// Determine if we should capture Markdown instances for next fast path
|
|
342
|
+
const shouldCapture = this.#canFastPath(message);
|
|
343
|
+
const captureItems:
|
|
344
|
+
| Array<{ md: Markdown; contentIndex: number; blockType: "text" | "thinking"; lastText: string }>
|
|
345
|
+
| undefined = shouldCapture ? [] : undefined;
|
|
346
|
+
|
|
239
347
|
const hasVisibleContent = message.content.some(
|
|
240
348
|
c =>
|
|
241
349
|
(c.type === "text" && c.text.trim()) ||
|
|
@@ -249,9 +357,11 @@ export class AssistantMessageComponent extends Container {
|
|
|
249
357
|
if (content.type === "text" && content.text.trim()) {
|
|
250
358
|
// Assistant text messages with no background - trim the text
|
|
251
359
|
// Set paddingY=0 to avoid extra spacing before tool executions
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
this.#
|
|
360
|
+
const trimmed = content.text.trim();
|
|
361
|
+
const md = new Markdown(trimmed, 1, 0, getMarkdownTheme());
|
|
362
|
+
md.transientRenderCache = this.#lastUpdateTransient;
|
|
363
|
+
this.#contentContainer.addChild(md);
|
|
364
|
+
captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
|
|
255
365
|
} else if (content.type === "thinking" && content.thinking.trim()) {
|
|
256
366
|
if (this.hideThinkingBlock) {
|
|
257
367
|
thinkingIndex += 1;
|
|
@@ -265,12 +375,13 @@ export class AssistantMessageComponent extends Container {
|
|
|
265
375
|
|
|
266
376
|
const thinkingText = content.thinking.trim();
|
|
267
377
|
// Thinking traces in thinkingText color, italic
|
|
268
|
-
const
|
|
378
|
+
const md = new Markdown(thinkingText, 1, 0, getMarkdownTheme(), {
|
|
269
379
|
color: (text: string) => theme.fg("thinkingText", text),
|
|
270
380
|
italic: true,
|
|
271
381
|
});
|
|
272
|
-
|
|
273
|
-
this.#contentContainer.addChild(
|
|
382
|
+
md.transientRenderCache = this.#lastUpdateTransient;
|
|
383
|
+
this.#contentContainer.addChild(md);
|
|
384
|
+
captureItems?.push({ md, contentIndex: i, blockType: "thinking", lastText: thinkingText });
|
|
274
385
|
this.#appendThinkingExtensions(i, thinkingIndex, thinkingText);
|
|
275
386
|
thinkingIndex += 1;
|
|
276
387
|
if (hasVisibleContentAfter) {
|
|
@@ -318,5 +429,14 @@ export class AssistantMessageComponent extends Container {
|
|
|
318
429
|
this.#contentContainer.addChild(new Spacer(1));
|
|
319
430
|
this.#contentContainer.addChild(new Text(theme.fg("dim", parts.join(" ")), 1, 0));
|
|
320
431
|
}
|
|
432
|
+
|
|
433
|
+
// Store fast-path state for next call
|
|
434
|
+
if (shouldCapture) {
|
|
435
|
+
this.#fastPathItems = captureItems;
|
|
436
|
+
this.#fastPathKey = this.#computeShapeKey(message);
|
|
437
|
+
} else {
|
|
438
|
+
this.#fastPathKey = undefined;
|
|
439
|
+
this.#fastPathItems = undefined;
|
|
440
|
+
}
|
|
321
441
|
}
|
|
322
442
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { TextContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { Container, Markdown, Text } from "@oh-my-pi/pi-tui";
|
|
3
|
+
import type { CollabPromptDetails } from "../../collab/protocol";
|
|
4
|
+
import type { CustomMessage } from "../../session/messages";
|
|
5
|
+
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a collab guest prompt on every participant's transcript: a
|
|
9
|
+
* user-message-styled bubble prefixed with the author's name.
|
|
10
|
+
*/
|
|
11
|
+
export class CollabPromptMessageComponent extends Container {
|
|
12
|
+
constructor(message: CustomMessage<CollabPromptDetails>) {
|
|
13
|
+
super();
|
|
14
|
+
const from = message.details?.from?.trim() || "guest";
|
|
15
|
+
this.addChild(new Text(theme.fg("accent", `\x1b[1m«${from}»\x1b[22m ›`), 1, 0));
|
|
16
|
+
const text =
|
|
17
|
+
typeof message.content === "string"
|
|
18
|
+
? message.content
|
|
19
|
+
: message.content
|
|
20
|
+
.filter((content): content is TextContent => content.type === "text")
|
|
21
|
+
.map(content => content.text)
|
|
22
|
+
.join("");
|
|
23
|
+
this.addChild(
|
|
24
|
+
new Markdown(text, 1, 1, getMarkdownTheme(), {
|
|
25
|
+
bgColor: (value: string) => theme.bg("userMessageBg", value),
|
|
26
|
+
color: (value: string) => theme.fg("userMessageText", value),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -31,13 +31,12 @@ import { CountdownTimer } from "./countdown-timer";
|
|
|
31
31
|
import { DynamicBorder } from "./dynamic-border";
|
|
32
32
|
import { renderSegmentTrack } from "./segment-track";
|
|
33
33
|
|
|
34
|
-
/** One segment of a {@link HookSelectorSlider} — a label
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
/** One segment of a {@link HookSelectorSlider} — a label and an optional
|
|
35
|
+
* detail line (e.g. the resolved model name) shown beneath the track while
|
|
36
|
+
* the segment is active. Segment colors come from the track's theme palette,
|
|
37
|
+
* assigned by position. */
|
|
37
38
|
export interface HookSelectorSliderSegment {
|
|
38
39
|
label: string;
|
|
39
|
-
/** Theme color for the segment label; defaults to `accent`. */
|
|
40
|
-
color?: ThemeColor;
|
|
41
40
|
/** Secondary line rendered under the track when this segment is selected. */
|
|
42
41
|
detail?: string;
|
|
43
42
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared renderer for a horizontal row of colored "segments" styled after the
|
|
3
|
-
* status line: each segment
|
|
4
|
-
* as a powerline chip (its
|
|
5
|
-
* flanked by triangle caps) and the
|
|
6
|
-
* thin separator.
|
|
3
|
+
* status line: each segment is colored by its track position from the theme's
|
|
4
|
+
* own palette, the active one is filled as a powerline chip (its color as the
|
|
5
|
+
* background, a luminance-matched label, flanked by triangle caps) and the
|
|
6
|
+
* rest are plain colored labels joined by a thin separator.
|
|
7
7
|
*
|
|
8
8
|
* Used by the plan-mode model-tier slider ({@link HookSelectorComponent}) and
|
|
9
9
|
* the ctrl+p role-cycle status so both surfaces read identically.
|
|
@@ -12,13 +12,49 @@ import { type ThemeColor, theme } from "../theme/theme";
|
|
|
12
12
|
|
|
13
13
|
export interface TrackSegment {
|
|
14
14
|
label: string;
|
|
15
|
-
/** Theme color for the segment; defaults to `accent`. */
|
|
16
|
-
color?: ThemeColor;
|
|
17
15
|
}
|
|
18
16
|
|
|
19
17
|
const FG_RESET = "\x1b[39m";
|
|
20
18
|
const BG_RESET = "\x1b[49m";
|
|
21
19
|
|
|
20
|
+
/** Vivid theme colors for position-based segment coloring, in preference
|
|
21
|
+
* order. Themes alias many of these to the same value (titanium maps most of
|
|
22
|
+
* the syntax set onto its accent), so {@link resolveSegmentPalette} dedupes
|
|
23
|
+
* by resolved escape and hands position i the i-th distinct color. */
|
|
24
|
+
const SEGMENT_COLOR_CANDIDATES: ThemeColor[] = [
|
|
25
|
+
"accent",
|
|
26
|
+
"success",
|
|
27
|
+
"warning",
|
|
28
|
+
"error",
|
|
29
|
+
"mdCode",
|
|
30
|
+
"mdLink",
|
|
31
|
+
"syntaxString",
|
|
32
|
+
"syntaxKeyword",
|
|
33
|
+
"syntaxFunction",
|
|
34
|
+
"syntaxNumber",
|
|
35
|
+
"syntaxOperator",
|
|
36
|
+
"syntaxVariable",
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve up to `count` theme colors that render distinctly under the active
|
|
41
|
+
* theme, in candidate preference order. May return fewer than `count` when the
|
|
42
|
+
* theme has fewer distinct hues (e.g. monochrome themes) — callers wrap with
|
|
43
|
+
* modulo. Never returns an empty array: `accent` always resolves.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveSegmentPalette(count: number): ThemeColor[] {
|
|
46
|
+
const palette: ThemeColor[] = [];
|
|
47
|
+
const seen = new Set<string>();
|
|
48
|
+
for (const color of SEGMENT_COLOR_CANDIDATES) {
|
|
49
|
+
const ansi = theme.getFgAnsi(color);
|
|
50
|
+
if (seen.has(ansi)) continue;
|
|
51
|
+
seen.add(ansi);
|
|
52
|
+
palette.push(color);
|
|
53
|
+
if (palette.length >= count) break;
|
|
54
|
+
}
|
|
55
|
+
return palette;
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
/**
|
|
23
59
|
* Render `segments` as a colored chip track with `activeIndex` filled. Returns
|
|
24
60
|
* a single line of styled text with no surrounding caption or arrows — callers
|
|
@@ -30,6 +66,7 @@ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number
|
|
|
30
66
|
const capLeft = theme.sep.powerlineRight;
|
|
31
67
|
const capRight = theme.sep.powerlineLeft;
|
|
32
68
|
const thinSep = theme.fg("statusLineSep", theme.sep.powerlineThin);
|
|
69
|
+
const palette = resolveSegmentPalette(segments.length);
|
|
33
70
|
|
|
34
71
|
let track = "";
|
|
35
72
|
segments.forEach((segment, i) => {
|
|
@@ -38,7 +75,7 @@ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number
|
|
|
38
75
|
// caps already delimit the active segment, so pad around it instead.
|
|
39
76
|
track += i === activeIndex || i - 1 === activeIndex ? " " : ` ${thinSep} `;
|
|
40
77
|
}
|
|
41
|
-
const color =
|
|
78
|
+
const color = palette[i % palette.length];
|
|
42
79
|
const fg = theme.getFgAnsi(color);
|
|
43
80
|
if (i !== activeIndex) {
|
|
44
81
|
track += `${fg}${segment.label}${FG_RESET}`;
|
|
@@ -18,6 +18,7 @@ import { renderSegment, type SegmentContext } from "./segments";
|
|
|
18
18
|
import { getSeparator } from "./separators";
|
|
19
19
|
import { calculateTokensPerSecond } from "./token-rate";
|
|
20
20
|
import type {
|
|
21
|
+
CollabStatus,
|
|
21
22
|
EffectiveStatusLineSettings,
|
|
22
23
|
StatusLineSegmentId,
|
|
23
24
|
StatusLineSegmentOptions,
|
|
@@ -152,6 +153,8 @@ export class StatusLineComponent implements Component {
|
|
|
152
153
|
#planModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
153
154
|
#loopModeStatus: { enabled: boolean } | null = null;
|
|
154
155
|
#goalModeStatus: { enabled: boolean; paused: boolean } | null = null;
|
|
156
|
+
#collabStatus: CollabStatus | null = null;
|
|
157
|
+
#focusedAgentId: string | undefined;
|
|
155
158
|
|
|
156
159
|
// Git status caching (1s TTL)
|
|
157
160
|
#cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
@@ -187,7 +190,7 @@ export class StatusLineComponent implements Component {
|
|
|
187
190
|
#nonMessageInputsKey: string | undefined;
|
|
188
191
|
#messageTokenTotalsCache: MessageTokenTotalsCache | undefined;
|
|
189
192
|
|
|
190
|
-
constructor(private
|
|
193
|
+
constructor(private session: AgentSession) {
|
|
191
194
|
this.#settings = {
|
|
192
195
|
preset: settings.get("statusLine.preset"),
|
|
193
196
|
leftSegments: settings.get("statusLine.leftSegments"),
|
|
@@ -200,6 +203,19 @@ export class StatusLineComponent implements Component {
|
|
|
200
203
|
};
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive
|
|
208
|
+
* from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main.
|
|
209
|
+
*/
|
|
210
|
+
setSession(session: AgentSession, focusedAgentId?: string): void {
|
|
211
|
+
const sessionChanged = this.session !== session;
|
|
212
|
+
if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return;
|
|
213
|
+
this.session = session;
|
|
214
|
+
this.#focusedAgentId = focusedAgentId;
|
|
215
|
+
if (sessionChanged) this.#invalidateSessionCaches();
|
|
216
|
+
this.invalidate();
|
|
217
|
+
}
|
|
218
|
+
|
|
203
219
|
updateSettings(settings: StatusLineSettings): void {
|
|
204
220
|
this.#settings = settings;
|
|
205
221
|
this.#effectiveSettings = undefined;
|
|
@@ -217,6 +233,11 @@ export class StatusLineComponent implements Component {
|
|
|
217
233
|
this.#subagentCount = count;
|
|
218
234
|
}
|
|
219
235
|
|
|
236
|
+
/** Active subagent count as currently displayed (collab state mirroring). */
|
|
237
|
+
get subagentCount(): number {
|
|
238
|
+
return this.#subagentCount;
|
|
239
|
+
}
|
|
240
|
+
|
|
220
241
|
setSessionStartTime(time: number): void {
|
|
221
242
|
this.#sessionStartTime = time;
|
|
222
243
|
}
|
|
@@ -233,6 +254,10 @@ export class StatusLineComponent implements Component {
|
|
|
233
254
|
this.#goalModeStatus = status ?? null;
|
|
234
255
|
}
|
|
235
256
|
|
|
257
|
+
setCollabStatus(status: CollabStatus | null): void {
|
|
258
|
+
this.#collabStatus = status;
|
|
259
|
+
}
|
|
260
|
+
|
|
236
261
|
setHookStatus(key: string, text: string | undefined): void {
|
|
237
262
|
if (text === undefined) {
|
|
238
263
|
this.#hookStatuses.delete(key);
|
|
@@ -281,6 +306,16 @@ export class StatusLineComponent implements Component {
|
|
|
281
306
|
invalidate(): void {
|
|
282
307
|
this.#invalidateGitCaches();
|
|
283
308
|
}
|
|
309
|
+
#invalidateSessionCaches(): void {
|
|
310
|
+
this.#cachedUsage = null;
|
|
311
|
+
this.#usageFetchedAt = 0;
|
|
312
|
+
this.#usageInFlight = false;
|
|
313
|
+
this.#nonMessageTokensCache = undefined;
|
|
314
|
+
this.#nonMessageInputsKey = undefined;
|
|
315
|
+
this.#messageTokenTotalsCache = undefined;
|
|
316
|
+
this.#lastTokensPerSecond = null;
|
|
317
|
+
this.#lastTokensPerSecondTimestamp = null;
|
|
318
|
+
}
|
|
284
319
|
|
|
285
320
|
#invalidateGitCaches(): void {
|
|
286
321
|
this.#cachedBranch = undefined;
|
|
@@ -441,16 +476,19 @@ export class StatusLineComponent implements Component {
|
|
|
441
476
|
const now = Date.now();
|
|
442
477
|
if (this.#usageInFlight) return;
|
|
443
478
|
if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return;
|
|
444
|
-
const
|
|
479
|
+
const session = this.session;
|
|
480
|
+
const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).fetchUsageReports;
|
|
445
481
|
if (typeof fetcher !== "function") return;
|
|
446
482
|
this.#usageInFlight = true;
|
|
447
483
|
void fetcher
|
|
448
|
-
.call(
|
|
484
|
+
.call(session)
|
|
449
485
|
.then(reports => {
|
|
486
|
+
if (this.session !== session) return;
|
|
450
487
|
this.#cachedUsage = this.#normalizeUsageReports(reports);
|
|
451
488
|
this.#usageFetchedAt = Date.now();
|
|
452
489
|
})
|
|
453
490
|
.catch(() => {
|
|
491
|
+
if (this.session !== session) return;
|
|
454
492
|
// Backoff on error: stamp the fetch time so the 5-min TTL guard
|
|
455
493
|
// also acts as an error budget. Without this, every render
|
|
456
494
|
// kicks off another fetch (gated only by #usageInFlight),
|
|
@@ -458,7 +496,7 @@ export class StatusLineComponent implements Component {
|
|
|
458
496
|
this.#usageFetchedAt = Date.now();
|
|
459
497
|
})
|
|
460
498
|
.finally(() => {
|
|
461
|
-
this.#usageInFlight = false;
|
|
499
|
+
if (this.session === session) this.#usageInFlight = false;
|
|
462
500
|
});
|
|
463
501
|
}
|
|
464
502
|
|
|
@@ -642,15 +680,25 @@ export class StatusLineComponent implements Component {
|
|
|
642
680
|
contextTokens = breakdown.usedTokens;
|
|
643
681
|
contextWindow = breakdown.contextWindow || contextWindow;
|
|
644
682
|
}
|
|
645
|
-
|
|
683
|
+
let contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
684
|
+
|
|
685
|
+
// Collab guest: context comes from the host's state frames — the local
|
|
686
|
+
// replica does no accounting of its own.
|
|
687
|
+
const collabState = this.#collabStatus?.stateOverride;
|
|
688
|
+
if (collabState?.contextUsage) {
|
|
689
|
+
contextWindow = collabState.contextUsage.contextWindow || contextWindow;
|
|
690
|
+
contextPercent = collabState.contextUsage.percent ?? contextPercent;
|
|
691
|
+
}
|
|
646
692
|
|
|
647
693
|
return {
|
|
648
694
|
session: this.session,
|
|
695
|
+
focusedAgentId: this.#focusedAgentId,
|
|
649
696
|
width,
|
|
650
697
|
options: segmentOptions ?? {},
|
|
651
698
|
planMode: this.#planModeStatus,
|
|
652
699
|
loopMode: this.#loopModeStatus,
|
|
653
700
|
goalMode: this.#goalModeStatus,
|
|
701
|
+
collab: this.#collabStatus,
|
|
654
702
|
usageStats,
|
|
655
703
|
contextPercent,
|
|
656
704
|
contextWindow,
|
|
@@ -858,7 +906,12 @@ export class StatusLineComponent implements Component {
|
|
|
858
906
|
}
|
|
859
907
|
|
|
860
908
|
getTopBorder(width: number): { content: string; width: number } {
|
|
861
|
-
|
|
909
|
+
let content = this.#buildStatusLine(width);
|
|
910
|
+
if (this.#focusedAgentId && content) {
|
|
911
|
+
// Dim the whole bar while focus-proxied. Group/cap terminators emit full
|
|
912
|
+
// `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each.
|
|
913
|
+
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
|
|
914
|
+
}
|
|
862
915
|
return {
|
|
863
916
|
content,
|
|
864
917
|
width: visibleWidth(content),
|
|
@@ -2,7 +2,7 @@ import type { PresetDef, StatusLinePreset } from "./types";
|
|
|
2
2
|
|
|
3
3
|
export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
4
4
|
default: {
|
|
5
|
-
leftSegments: ["pi", "model", "mode", "path", "git", "pr", "context_pct", "cost"],
|
|
5
|
+
leftSegments: ["pi", "model", "mode", "collab", "path", "git", "pr", "context_pct", "cost"],
|
|
6
6
|
rightSegments: ["session_name"],
|
|
7
7
|
separator: "powerline-thin",
|
|
8
8
|
segmentOptions: {
|
|
@@ -65,7 +65,11 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string |
|
|
|
65
65
|
|
|
66
66
|
const piSegment: StatusLineSegment = {
|
|
67
67
|
id: "pi",
|
|
68
|
-
render(
|
|
68
|
+
render(ctx) {
|
|
69
|
+
if (ctx.focusedAgentId) {
|
|
70
|
+
const icon = theme.icon.ghost ? `${theme.icon.ghost} ` : "";
|
|
71
|
+
return { content: theme.fg("warning", `${icon}${ctx.focusedAgentId} `), visible: true };
|
|
72
|
+
}
|
|
69
73
|
const content = theme.icon.pi ? `${theme.icon.pi} ` : "";
|
|
70
74
|
return { content: theme.fg("accent", content), visible: true };
|
|
71
75
|
},
|
|
@@ -493,6 +497,18 @@ const sessionNameSegment: StatusLineSegment = {
|
|
|
493
497
|
},
|
|
494
498
|
};
|
|
495
499
|
|
|
500
|
+
const collabSegment: StatusLineSegment = {
|
|
501
|
+
id: "collab",
|
|
502
|
+
render(ctx) {
|
|
503
|
+
if (!ctx.collab) return { content: "", visible: false };
|
|
504
|
+
const label =
|
|
505
|
+
ctx.collab.role === "host"
|
|
506
|
+
? `⇄ collab:${ctx.collab.participantCount}`
|
|
507
|
+
: `⇄ collab guest:${ctx.collab.participantCount}`;
|
|
508
|
+
return { content: theme.fg("accent", label), visible: true };
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
|
|
496
512
|
function pickUsageColor(percent: number): "muted" | "warning" | "error" {
|
|
497
513
|
if (percent >= 80) return "error";
|
|
498
514
|
if (percent >= 50) return "warning";
|
|
@@ -573,6 +589,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
573
589
|
cache_hit: cacheHitSegment,
|
|
574
590
|
session_name: sessionNameSegment,
|
|
575
591
|
usage: usageSegment,
|
|
592
|
+
collab: collabSegment,
|
|
576
593
|
};
|
|
577
594
|
|
|
578
595
|
export function renderSegment(id: StatusLineSegmentId, ctx: SegmentContext): RenderedSegment {
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
+
import type { CollabSessionState } from "../../../collab/protocol";
|
|
1
2
|
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
|
|
2
3
|
import type { AgentSession } from "../../../session/agent-session";
|
|
3
4
|
|
|
4
5
|
export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
|
|
5
6
|
|
|
7
|
+
/** Collab session indicator + (guest-only) host-state override for segments. */
|
|
8
|
+
export interface CollabStatus {
|
|
9
|
+
role: "host" | "guest";
|
|
10
|
+
participantCount: number;
|
|
11
|
+
/** Guest only: host footer snapshot that overrides locally computed values. */
|
|
12
|
+
stateOverride?: CollabSessionState | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export interface StatusLineSegmentOptions {
|
|
7
16
|
model?: { showThinkingLevel?: boolean };
|
|
8
17
|
path?: { abbreviate?: boolean; maxLength?: number; stripWorkPrefix?: boolean };
|
|
@@ -36,6 +45,8 @@ export type RGB = readonly [number, number, number];
|
|
|
36
45
|
|
|
37
46
|
export interface SegmentContext {
|
|
38
47
|
session: AgentSession;
|
|
48
|
+
/** Focused subagent id while the view is proxied at its session, undefined otherwise. */
|
|
49
|
+
focusedAgentId?: string | undefined;
|
|
39
50
|
width: number;
|
|
40
51
|
options: StatusLineSegmentOptions;
|
|
41
52
|
planMode: {
|
|
@@ -49,6 +60,7 @@ export interface SegmentContext {
|
|
|
49
60
|
enabled: boolean;
|
|
50
61
|
paused: boolean;
|
|
51
62
|
} | null;
|
|
63
|
+
collab: CollabStatus | null;
|
|
52
64
|
// Cached values for performance (computed once per render)
|
|
53
65
|
usageStats: {
|
|
54
66
|
input: number;
|
|
@@ -16,4 +16,7 @@ Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smo
|
|
|
16
16
|
Press ctrl+r to search your prompt history and reuse a past message
|
|
17
17
|
`/force read` pins the next turn to one specific tool when the model keeps reaching for the wrong one
|
|
18
18
|
`/copy code` grabs the last code block to your clipboard — `/copy cmd` grabs the last shell/python command
|
|
19
|
-
`/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
|
|
19
|
+
`/shake` rips heavy tool results out of context to reclaim tokens without a full /compact — `/shake images` drops just images
|
|
20
|
+
Pair up live: `/collab` shares your session through an end-to-end encrypted relay link — a teammate runs `/join <link>` to watch tool calls stream and prompt the agent from their own omp
|
|
21
|
+
Press ← ← to drill into a running or finished agent and inspect its tool calls and transcript
|
|
22
|
+
Hit a Codex rate limit? `/usage reset` spends a saved reset credit to immediately restore your quota
|