@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.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 +60 -3
- package/dist/cli.js +841 -803
- package/dist/types/async/index.d.ts +0 -1
- package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
- package/dist/types/config/keybindings.d.ts +6 -1
- package/dist/types/config/settings-schema.d.ts +56 -33
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/history-protocol.d.ts +14 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/types.d.ts +1 -1
- package/dist/types/irc/bus.d.ts +66 -0
- package/dist/types/modes/components/agent-hub.d.ts +30 -0
- package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
- package/dist/types/modes/components/welcome.d.ts +3 -9
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +3 -2
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +3 -2
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/registry/agent-lifecycle.d.ts +51 -0
- package/dist/types/registry/agent-registry.d.ts +16 -5
- package/dist/types/session/agent-session.d.ts +35 -30
- package/dist/types/session/messages.d.ts +2 -4
- package/dist/types/session/session-history-format.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +21 -3
- package/dist/types/session/streaming-output.d.ts +23 -0
- package/dist/types/task/executor.d.ts +11 -2
- package/dist/types/task/index.d.ts +11 -4
- package/dist/types/task/output-manager.d.ts +0 -7
- package/dist/types/task/repair-args.d.ts +8 -7
- package/dist/types/task/types.d.ts +55 -51
- package/dist/types/tools/browser/tab-worker.d.ts +3 -1
- package/dist/types/tools/find.d.ts +0 -11
- package/dist/types/tools/grouped-file-output.d.ts +0 -49
- package/dist/types/tools/index.d.ts +1 -3
- package/dist/types/tools/irc.d.ts +76 -38
- package/dist/types/tools/job.d.ts +7 -1
- package/examples/extensions/with-deps/package.json +1 -0
- package/package.json +11 -10
- package/scripts/bundle-dist.ts +28 -19
- package/src/async/index.ts +0 -1
- package/src/cli/gallery-cli.ts +1 -1
- package/src/cli/gallery-fixtures/agentic.ts +230 -115
- package/src/cli/gallery-fixtures/types.ts +5 -0
- package/src/cli.ts +20 -6
- package/src/commit/agentic/tools/analyze-file.ts +38 -19
- package/src/config/keybindings.ts +6 -1
- package/src/config/settings-schema.ts +56 -40
- package/src/config/settings.ts +7 -0
- package/src/eval/__tests__/agent-bridge.test.ts +5 -3
- package/src/eval/agent-bridge.ts +3 -16
- package/src/eval/js/shared/prelude.txt +1 -1
- package/src/eval/py/prelude.py +5 -6
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +38 -13
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +8 -8
- package/src/internal-urls/history-protocol.ts +113 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +3 -1
- package/src/internal-urls/types.ts +1 -1
- package/src/irc/bus.ts +292 -0
- package/src/main.ts +8 -60
- package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
- package/src/modes/components/compaction-summary-message.ts +68 -32
- package/src/modes/components/custom-editor.ts +10 -0
- package/src/modes/components/tool-execution.ts +31 -1
- package/src/modes/components/ttsr-notification.ts +72 -30
- package/src/modes/components/welcome.ts +9 -33
- package/src/modes/controllers/event-controller.ts +65 -0
- package/src/modes/controllers/extension-ui-controller.ts +8 -8
- package/src/modes/controllers/input-controller.ts +18 -2
- package/src/modes/controllers/selector-controller.ts +21 -17
- package/src/modes/interactive-mode.ts +8 -13
- package/src/modes/theme/theme.ts +18 -5
- package/src/modes/types.ts +3 -5
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +51 -49
- package/src/prompts/system/irc-incoming.md +3 -4
- package/src/prompts/system/orchestrate-notice.md +2 -2
- package/src/prompts/system/subagent-system-prompt.md +0 -5
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/system/workflow-notice.md +2 -2
- package/src/prompts/tools/eval.md +3 -3
- package/src/prompts/tools/irc.md +29 -19
- package/src/prompts/tools/read.md +2 -2
- package/src/prompts/tools/task-summary.md +5 -16
- package/src/prompts/tools/task.md +38 -29
- package/src/registry/agent-lifecycle.ts +218 -0
- package/src/registry/agent-registry.ts +16 -5
- package/src/sdk.ts +29 -9
- package/src/session/agent-session.ts +243 -237
- package/src/session/messages.ts +11 -78
- package/src/session/session-history-format.ts +246 -0
- package/src/session/session-manager.ts +59 -5
- package/src/session/streaming-output.ts +60 -0
- package/src/task/executor.ts +855 -466
- package/src/task/index.ts +718 -794
- package/src/task/output-manager.ts +0 -11
- package/src/task/render.ts +133 -63
- package/src/task/repair-args.ts +21 -9
- package/src/task/types.ts +73 -66
- package/src/tools/ask.ts +4 -2
- package/src/tools/bash.ts +15 -5
- package/src/tools/browser/tab-worker.ts +26 -7
- package/src/tools/browser.ts +28 -1
- package/src/tools/find.ts +2 -27
- package/src/tools/grouped-file-output.ts +1 -118
- package/src/tools/index.ts +4 -12
- package/src/tools/irc.ts +596 -171
- package/src/tools/job.ts +41 -7
- package/src/tools/read.ts +57 -1
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resolve.ts +4 -1
- package/dist/types/async/support.d.ts +0 -2
- package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
- package/dist/types/task/simple-mode.d.ts +0 -8
- package/src/async/support.ts +0 -5
- package/src/task/simple-mode.ts +0 -27
|
@@ -1,51 +1,87 @@
|
|
|
1
|
-
import { Box,
|
|
1
|
+
import { Box, type Component, Markdown } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
3
3
|
import type { CompactionSummaryMessage } from "../../session/messages";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Compaction point in the transcript, rendered as a slim horizontal divider:
|
|
7
|
+
*
|
|
8
|
+
* ──────── 📷 compacted · ctrl+o ────────
|
|
9
|
+
*
|
|
10
|
+
* The conversation above the divider stays visible (display transcript keeps
|
|
11
|
+
* full history); only the LLM context was reset. Expanding (ctrl+o) reveals
|
|
12
|
+
* the compaction summary below the divider.
|
|
8
13
|
*/
|
|
9
|
-
export class CompactionSummaryMessageComponent
|
|
14
|
+
export class CompactionSummaryMessageComponent implements Component {
|
|
10
15
|
#expanded = false;
|
|
16
|
+
#cache?: { width: number; lines: string[] };
|
|
17
|
+
#detail?: Box;
|
|
11
18
|
|
|
12
|
-
constructor(private readonly message: CompactionSummaryMessage) {
|
|
13
|
-
super(1, 1, t => theme.bg("customMessageBg", t));
|
|
14
|
-
this.#updateDisplay();
|
|
15
|
-
}
|
|
19
|
+
constructor(private readonly message: CompactionSummaryMessage) {}
|
|
16
20
|
|
|
17
21
|
setExpanded(expanded: boolean): void {
|
|
22
|
+
if (this.#expanded === expanded) return;
|
|
18
23
|
this.#expanded = expanded;
|
|
19
|
-
this.#
|
|
24
|
+
this.#cache = undefined;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
invalidate(): void {
|
|
28
|
+
this.#cache = undefined;
|
|
29
|
+
// Theme may have changed — rebuild the detail box lazily on next render.
|
|
30
|
+
this.#detail = undefined;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
render(width: number): readonly string[] {
|
|
34
|
+
width = Math.max(1, width);
|
|
35
|
+
if (this.#cache?.width === width) {
|
|
36
|
+
return this.#cache.lines;
|
|
37
|
+
}
|
|
38
|
+
const lines = this.#expanded
|
|
39
|
+
? ["", this.#divider(width), "", ...this.#detailBox().render(width)]
|
|
40
|
+
: ["", this.#divider(width), ""];
|
|
41
|
+
this.#cache = { width, lines };
|
|
42
|
+
return lines;
|
|
43
|
+
}
|
|
29
44
|
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
#divider(width: number): string {
|
|
46
|
+
const rule = theme.tree.horizontal;
|
|
47
|
+
const label = `${theme.icon.camera} compacted`;
|
|
48
|
+
// sep.dot ships pre-padded (" · "); trim so the hint joins with single spaces.
|
|
49
|
+
const hint = `${theme.sep.dot.trim()} ctrl+o`;
|
|
50
|
+
const plainWidth = Bun.stringWidth(`${label} ${hint}`, { countAnsiEscapeCodes: false });
|
|
51
|
+
// ` label hint ` framed by rules on both sides.
|
|
52
|
+
const remaining = width - plainWidth - 2;
|
|
53
|
+
if (remaining < 4) {
|
|
54
|
+
// Too narrow for a framed rule — emit the bare label.
|
|
55
|
+
return theme.fg("muted", label);
|
|
56
|
+
}
|
|
57
|
+
const left = Math.floor(remaining / 2);
|
|
58
|
+
const right = remaining - left;
|
|
59
|
+
return (
|
|
60
|
+
theme.fg("dim", rule.repeat(left)) +
|
|
61
|
+
` ${theme.fg("muted", label)} ${theme.fg("dim", hint)} ` +
|
|
62
|
+
theme.fg("dim", rule.repeat(right))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
34
65
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
66
|
+
#detailBox(): Box {
|
|
67
|
+
if (this.#detail) return this.#detail;
|
|
68
|
+
const box = new Box(1, 1, t => theme.bg("customMessageBg", t));
|
|
69
|
+
const tokenStr = this.message.tokensBefore.toLocaleString();
|
|
70
|
+
const frameCount = this.message.images?.length ?? 0;
|
|
71
|
+
const frameNote =
|
|
72
|
+
frameCount > 0 ? `\n\n_${frameCount} snapcompact frame${frameCount === 1 ? "" : "s"} attached_` : "";
|
|
73
|
+
box.addChild(
|
|
74
|
+
new Markdown(
|
|
75
|
+
`**Compacted from ${tokenStr} tokens**\n\n${this.message.summary}${frameNote}`,
|
|
76
|
+
0,
|
|
77
|
+
0,
|
|
78
|
+
getMarkdownTheme(),
|
|
79
|
+
{
|
|
39
80
|
color: (text: string) => theme.fg("customMessageText", text),
|
|
40
|
-
}
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
if (this.message.shortSummary) {
|
|
47
|
-
this.addChild(new Text(theme.fg("customMessageText", this.message.shortSummary), 0, 1));
|
|
48
|
-
}
|
|
49
|
-
}
|
|
81
|
+
},
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
this.#detail = box;
|
|
85
|
+
return box;
|
|
50
86
|
}
|
|
51
87
|
}
|
|
@@ -175,6 +175,8 @@ export class CustomEditor extends Editor {
|
|
|
175
175
|
onDequeue?: () => void;
|
|
176
176
|
/** Called when Caps Lock is pressed. */
|
|
177
177
|
onCapsLock?: () => void;
|
|
178
|
+
/** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
|
|
179
|
+
onLeftAtStart?: () => void;
|
|
178
180
|
|
|
179
181
|
/** Custom key handlers from extensions and non-built-in app actions. */
|
|
180
182
|
#customKeyHandlers = new Map<KeyId, () => void>();
|
|
@@ -257,6 +259,14 @@ export class CustomEditor extends Editor {
|
|
|
257
259
|
const parsedKey = parseKey(data);
|
|
258
260
|
const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
|
|
259
261
|
|
|
262
|
+
// Left-arrow on an empty editor: surface for the agent-hub double-tap
|
|
263
|
+
// gesture. Plain "left" only — modified arrows and any in-text cursor
|
|
264
|
+
// movement fall through to normal handling.
|
|
265
|
+
if (canonical === "left" && this.onLeftAtStart && this.getText().trim() === "") {
|
|
266
|
+
this.onLeftAtStart();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
260
270
|
if (canonical !== undefined) {
|
|
261
271
|
// Intercept configured image paste (async - fires and handles result)
|
|
262
272
|
if (this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
|
|
@@ -19,6 +19,7 @@ import type { Theme } from "../../modes/theme/theme";
|
|
|
19
19
|
import { theme } from "../../modes/theme/theme";
|
|
20
20
|
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
|
|
21
21
|
import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
|
|
22
|
+
import { isWaitingPollDetails } from "../../tools/job";
|
|
22
23
|
import {
|
|
23
24
|
formatArgsInline,
|
|
24
25
|
JSON_TREE_MAX_DEPTH_COLLAPSED,
|
|
@@ -194,6 +195,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
194
195
|
// sealed the block stays in the transcript's repaintable live region so a
|
|
195
196
|
// late result still repaints instead of stranding the streaming preview.
|
|
196
197
|
#sealed = false;
|
|
198
|
+
// A `job` poll result whose watched jobs are all still running. Such a
|
|
199
|
+
// block never finalizes (stays in the transcript live region) so a
|
|
200
|
+
// follow-up `job` call can displace it instead of stacking another
|
|
201
|
+
// "waiting on N jobs" frame. Cleared by `seal()`.
|
|
202
|
+
#displaceable = false;
|
|
197
203
|
#renderState: {
|
|
198
204
|
spinnerFrame?: number;
|
|
199
205
|
expanded: boolean;
|
|
@@ -359,6 +365,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
359
365
|
): void {
|
|
360
366
|
this.#result = result;
|
|
361
367
|
this.#isPartial = isPartial;
|
|
368
|
+
// A `job` poll that found every watched job still running is transient
|
|
369
|
+
// "still waiting" chrome; keep the block displaceable so the next `job`
|
|
370
|
+
// call replaces it instead of stacking another waiting frame (see the
|
|
371
|
+
// event controller's displaceable-poll bookkeeping).
|
|
372
|
+
this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
|
|
362
373
|
// When tool is complete, ensure args are marked complete so spinner stops
|
|
363
374
|
if (!isPartial) {
|
|
364
375
|
this.#argsComplete = true;
|
|
@@ -425,7 +436,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
425
436
|
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
|
426
437
|
const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
|
|
427
438
|
const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
|
|
428
|
-
|
|
439
|
+
// A displaceable waiting poll keeps its spinner ticking: it reads as one
|
|
440
|
+
// persistent live poll, and the changing leading glyph keeps the
|
|
441
|
+
// transcript's stable-prefix ratchet from committing rows of a block
|
|
442
|
+
// that a follow-up `job` call may remove.
|
|
443
|
+
const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
|
|
429
444
|
if (needsSpinner && !this.#spinnerInterval) {
|
|
430
445
|
const now = performance.now();
|
|
431
446
|
const frameCount = theme.spinnerFrames.length;
|
|
@@ -513,6 +528,9 @@ export class ToolExecutionComponent extends Container {
|
|
|
513
528
|
isTranscriptBlockFinalized(): boolean {
|
|
514
529
|
if (this.#sealed) return true;
|
|
515
530
|
if (this.#result === undefined) return false;
|
|
531
|
+
// A displaceable waiting poll stays live: its rows are kept out of
|
|
532
|
+
// native scrollback so a follow-up `job` call can remove the block.
|
|
533
|
+
if (this.#displaceable) return false;
|
|
516
534
|
if (!this.#isPartial) return true;
|
|
517
535
|
// Partial result: a background async tool is accepted to freeze (the agent
|
|
518
536
|
// continues while it runs and would otherwise pin an unbounded live region);
|
|
@@ -528,11 +546,23 @@ export class ToolExecutionComponent extends Container {
|
|
|
528
546
|
seal(): void {
|
|
529
547
|
if (this.#sealed) return;
|
|
530
548
|
this.#sealed = true;
|
|
549
|
+
this.#displaceable = false;
|
|
531
550
|
this.stopAnimation();
|
|
532
551
|
this.#updateDisplay();
|
|
533
552
|
this.#ui.requestRender();
|
|
534
553
|
}
|
|
535
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Whether this block is a waiting `job` poll (every watched job still
|
|
557
|
+
* running) that has not been sealed. Such a block never finalized, so none
|
|
558
|
+
* of its rows entered native scrollback (the ticking spinner keeps the
|
|
559
|
+
* stable-prefix ratchet at zero) and the whole block can be removed when a
|
|
560
|
+
* follow-up `job` call supersedes it.
|
|
561
|
+
*/
|
|
562
|
+
isDisplaceableBlock(): boolean {
|
|
563
|
+
return this.#displaceable && !this.#sealed;
|
|
564
|
+
}
|
|
565
|
+
|
|
536
566
|
/**
|
|
537
567
|
* Stop spinner animation and cleanup resources.
|
|
538
568
|
*/
|
|
@@ -2,16 +2,24 @@ import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
|
2
2
|
import type { Rule } from "../../capability/rule";
|
|
3
3
|
import { theme } from "../../modes/theme/theme";
|
|
4
4
|
|
|
5
|
+
/** Collapsed view shows at most this many rules before eliding the rest. */
|
|
6
|
+
const MAX_COLLAPSED_RULES = 4;
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
9
|
* Component that renders a TTSR (Time Traveling Stream Rules) notification.
|
|
7
10
|
* Shows when a rule violation is detected and the stream is being rewound.
|
|
11
|
+
* One block can carry several rules: a single event may match multiple rules,
|
|
12
|
+
* and consecutive notifications merge into the previous block via
|
|
13
|
+
* {@link addRules} while it is still the live transcript tail.
|
|
8
14
|
*/
|
|
9
15
|
export class TtsrNotificationComponent extends Container {
|
|
10
16
|
#box: Box;
|
|
11
17
|
#expanded = false;
|
|
18
|
+
#rules: Rule[];
|
|
12
19
|
|
|
13
|
-
constructor(
|
|
20
|
+
constructor(rules: Rule[]) {
|
|
14
21
|
super();
|
|
22
|
+
this.#rules = [...rules];
|
|
15
23
|
|
|
16
24
|
this.addChild(new Spacer(1));
|
|
17
25
|
|
|
@@ -22,6 +30,17 @@ export class TtsrNotificationComponent extends Container {
|
|
|
22
30
|
this.#rebuild();
|
|
23
31
|
}
|
|
24
32
|
|
|
33
|
+
/** Merge additional rules into this block (deduped by rule name). */
|
|
34
|
+
addRules(rules: Rule[]): void {
|
|
35
|
+
let changed = false;
|
|
36
|
+
for (const rule of rules) {
|
|
37
|
+
if (this.#rules.some(existing => existing.name === rule.name)) continue;
|
|
38
|
+
this.#rules.push(rule);
|
|
39
|
+
changed = true;
|
|
40
|
+
}
|
|
41
|
+
if (changed) this.#rebuild();
|
|
42
|
+
}
|
|
43
|
+
|
|
25
44
|
setExpanded(expanded: boolean): void {
|
|
26
45
|
if (this.#expanded !== expanded) {
|
|
27
46
|
this.#expanded = expanded;
|
|
@@ -35,46 +54,69 @@ export class TtsrNotificationComponent extends Container {
|
|
|
35
54
|
|
|
36
55
|
#rebuild(): void {
|
|
37
56
|
this.#box.clear();
|
|
57
|
+
// fg colors conflict with inverse, so styling inside the block is limited
|
|
58
|
+
// to bold (names) and italic (descriptions).
|
|
59
|
+
if (this.#rules.length === 1) {
|
|
60
|
+
this.#rebuildSingle(this.#rules[0]!);
|
|
61
|
+
} else {
|
|
62
|
+
this.#rebuildMulti();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
38
65
|
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const header = `${theme.icon.warning} Injecting ${label}: ${ruleNames}`;
|
|
66
|
+
#rebuildSingle(rule: Rule): void {
|
|
67
|
+
const header = `${theme.icon.warning} Injecting rule: ${theme.bold(rule.name)} ${theme.icon.rewind}`;
|
|
68
|
+
this.#box.addChild(new Text(header, 0, 0));
|
|
43
69
|
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
const desc = (rule.description || rule.content)?.trim();
|
|
71
|
+
if (!desc) return;
|
|
46
72
|
|
|
47
|
-
|
|
73
|
+
let displayText = desc;
|
|
74
|
+
let truncated = false;
|
|
75
|
+
if (!this.#expanded) {
|
|
76
|
+
const lines = desc.split("\n");
|
|
77
|
+
if (lines.length > 2) {
|
|
78
|
+
displayText = `${lines.slice(0, 2).join("\n")}…`;
|
|
79
|
+
truncated = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
48
82
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
83
|
+
this.#box.addChild(new Spacer(1));
|
|
84
|
+
this.#box.addChild(new Text(theme.italic(displayText), 0, 0));
|
|
85
|
+
if (truncated) {
|
|
86
|
+
this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#rebuildMulti(): void {
|
|
91
|
+
const header = `${theme.icon.warning} Injecting ${this.#rules.length} rules: ${theme.icon.rewind}`;
|
|
92
|
+
this.#box.addChild(new Text(header, 0, 0));
|
|
93
|
+
this.#box.addChild(new Spacer(1));
|
|
54
94
|
|
|
55
|
-
|
|
95
|
+
const visible = this.#expanded ? this.#rules : this.#rules.slice(0, MAX_COLLAPSED_RULES);
|
|
96
|
+
let elidedDetail = false;
|
|
97
|
+
for (const rule of visible) {
|
|
98
|
+
const desc = (rule.description || rule.content)?.trim();
|
|
99
|
+
let line = theme.bold(rule.name);
|
|
100
|
+
if (desc) {
|
|
101
|
+
let displayText = desc;
|
|
56
102
|
if (!this.#expanded) {
|
|
57
|
-
//
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
displayText = `${
|
|
103
|
+
// One line per rule when collapsed; full description when expanded.
|
|
104
|
+
const newline = desc.indexOf("\n");
|
|
105
|
+
if (newline !== -1) {
|
|
106
|
+
displayText = `${desc.slice(0, newline).trimEnd()}…`;
|
|
107
|
+
elidedDetail = true;
|
|
61
108
|
}
|
|
62
109
|
}
|
|
63
|
-
|
|
64
|
-
// Use italic for subtle distinction (fg colors conflict with inverse)
|
|
65
|
-
this.#box.addChild(new Text(theme.italic(displayText), 0, 0));
|
|
110
|
+
line += `: ${theme.italic(displayText)}`;
|
|
66
111
|
}
|
|
112
|
+
this.#box.addChild(new Text(line, 0, 0));
|
|
67
113
|
}
|
|
68
114
|
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
});
|
|
75
|
-
if (hasMoreContent) {
|
|
76
|
-
this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
|
|
77
|
-
}
|
|
115
|
+
const hidden = this.#rules.length - visible.length;
|
|
116
|
+
if (hidden > 0) {
|
|
117
|
+
this.#box.addChild(new Text(theme.italic(`… +${hidden} more (ctrl+o to expand)`), 0, 0));
|
|
118
|
+
} else if (elidedDetail) {
|
|
119
|
+
this.#box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
|
|
78
120
|
}
|
|
79
121
|
}
|
|
80
122
|
}
|
|
@@ -18,14 +18,8 @@ const TIPS: readonly string[] = tipsText
|
|
|
18
18
|
.filter(line => line.length > 0);
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*/
|
|
24
|
-
const PROCESS_TIP: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Fixed number of session rows in the welcome box so its height doesn't shift
|
|
28
|
-
* between the pre-TUI splash (loading placeholder) and the loaded state.
|
|
21
|
+
* Fixed number of session rows in the welcome box so its height stays stable
|
|
22
|
+
* across recent-session updates.
|
|
29
23
|
*/
|
|
30
24
|
export const WELCOME_SESSION_SLOTS = 4;
|
|
31
25
|
|
|
@@ -76,10 +70,8 @@ export interface LspServerInfo {
|
|
|
76
70
|
export class WelcomeComponent implements Component {
|
|
77
71
|
#animStart: number | null = null;
|
|
78
72
|
#animTimer: ReturnType<typeof setInterval> | null = null;
|
|
79
|
-
/**
|
|
80
|
-
#
|
|
81
|
-
/** Per-process tip so re-renders (intro, LSP updates, splash swap) don't shuffle it. */
|
|
82
|
-
readonly #tip: string | undefined = PROCESS_TIP;
|
|
73
|
+
/** Tip chosen once per instance so re-renders (intro, LSP updates) don't shuffle it. */
|
|
74
|
+
readonly #tip: string | undefined = TIPS.length > 0 ? TIPS[Math.floor(Math.random() * TIPS.length)] : undefined;
|
|
83
75
|
// Render cache: the welcome box is the first transcript-area component, so
|
|
84
76
|
// returning a stable array reference keeps the whole frame prefix stable.
|
|
85
77
|
// Bypassed while the intro animation runs (every frame differs).
|
|
@@ -90,7 +82,7 @@ export class WelcomeComponent implements Component {
|
|
|
90
82
|
private readonly version: string,
|
|
91
83
|
private modelName: string,
|
|
92
84
|
private providerName: string,
|
|
93
|
-
private recentSessions: RecentSession[]
|
|
85
|
+
private recentSessions: RecentSession[] = [],
|
|
94
86
|
private lspServers: LspServerInfo[] = [],
|
|
95
87
|
) {}
|
|
96
88
|
|
|
@@ -99,16 +91,6 @@ export class WelcomeComponent implements Component {
|
|
|
99
91
|
this.#cachedLines = undefined;
|
|
100
92
|
}
|
|
101
93
|
|
|
102
|
-
/**
|
|
103
|
-
* Freeze the logo on the intro animation's first frame. The pre-TUI startup
|
|
104
|
-
* splash uses this so the in-TUI intro — which starts at that exact frame —
|
|
105
|
-
* picks up seamlessly from the splash's static box.
|
|
106
|
-
*/
|
|
107
|
-
holdIntroFirstFrame(): void {
|
|
108
|
-
this.#holdIntroFirstFrame = true;
|
|
109
|
-
this.invalidate();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
94
|
/**
|
|
113
95
|
* Play a one-shot intro that sweeps the gradient through every phase
|
|
114
96
|
* before settling on the resting frame. Safe to call multiple times —
|
|
@@ -116,7 +98,6 @@ export class WelcomeComponent implements Component {
|
|
|
116
98
|
*/
|
|
117
99
|
playIntro(requestRender: () => void): void {
|
|
118
100
|
this.#stopAnimation();
|
|
119
|
-
this.#holdIntroFirstFrame = false;
|
|
120
101
|
this.#animStart = performance.now();
|
|
121
102
|
requestRender();
|
|
122
103
|
this.#animTimer = setInterval(() => {
|
|
@@ -217,9 +198,7 @@ export class WelcomeComponent implements Component {
|
|
|
217
198
|
|
|
218
199
|
// Recent sessions content
|
|
219
200
|
const sessionLines: string[] = [];
|
|
220
|
-
if (this.recentSessions ===
|
|
221
|
-
sessionLines.push(` ${theme.fg("dim", "Loading…")}`);
|
|
222
|
-
} else if (this.recentSessions.length === 0) {
|
|
201
|
+
if (this.recentSessions.length === 0) {
|
|
223
202
|
sessionLines.push(` ${theme.fg("dim", "No recent sessions")}`);
|
|
224
203
|
} else {
|
|
225
204
|
// Reserve width for the bullet prefix (" • ") and the trailing " (timeAgo)"
|
|
@@ -238,7 +217,7 @@ export class WelcomeComponent implements Component {
|
|
|
238
217
|
);
|
|
239
218
|
}
|
|
240
219
|
}
|
|
241
|
-
// Pad to the fixed slot count so the box doesn't
|
|
220
|
+
// Pad to the fixed slot count so the box height doesn't depend on session count.
|
|
242
221
|
while (sessionLines.length < WELCOME_SESSION_SLOTS) {
|
|
243
222
|
sessionLines.push("");
|
|
244
223
|
}
|
|
@@ -377,9 +356,9 @@ export class WelcomeComponent implements Component {
|
|
|
377
356
|
return str + padding(width - visLen);
|
|
378
357
|
}
|
|
379
358
|
|
|
380
|
-
/** Pick the logo frame for the current intro phase, or the resting
|
|
359
|
+
/** Pick the logo frame for the current intro phase, or the resting frame. */
|
|
381
360
|
#currentLogoFrame(): readonly string[] {
|
|
382
|
-
if (this.#animStart == null) return
|
|
361
|
+
if (this.#animStart == null) return REST_FRAME;
|
|
383
362
|
const elapsed = performance.now() - this.#animStart;
|
|
384
363
|
if (elapsed >= INTRO_MS) return REST_FRAME;
|
|
385
364
|
return introLogoFrame(elapsed / INTRO_MS);
|
|
@@ -510,8 +489,5 @@ function introLogoFrame(progress: number): string[] {
|
|
|
510
489
|
return gradientLogo(PI_LOGO, phase, { strength: shineStrength, pos: shinePos });
|
|
511
490
|
}
|
|
512
491
|
|
|
513
|
-
/** First intro frame, cached for splash-held renders (resize re-renders reuse it). */
|
|
514
|
-
const INTRO_FIRST_FRAME = introLogoFrame(0);
|
|
515
|
-
|
|
516
492
|
/** Resting gradient frame, cached for re-renders outside of the intro. */
|
|
517
493
|
const REST_FRAME = gradientLogo(PI_LOGO, 0);
|
|
@@ -77,6 +77,14 @@ export class EventController {
|
|
|
77
77
|
// Insertion-ordered IRC cards not yet retired; values are the transcript
|
|
78
78
|
// components each card contributed (see #retireIrcCard for the guard).
|
|
79
79
|
#liveIrcCards = new Map<string, Component[]>();
|
|
80
|
+
// Most recent `job` tool block whose result still had every watched job
|
|
81
|
+
// running. Kept un-finalized (live) so the next `job` call displaces it —
|
|
82
|
+
// one persistent poll instead of a stack of "waiting on N jobs" frames —
|
|
83
|
+
// and sealed in place the moment anything else lands below it.
|
|
84
|
+
#displaceablePollComponent: ToolExecutionComponent | undefined = undefined;
|
|
85
|
+
// Most recent TTSR notification block. A new ttsr_triggered event merges its
|
|
86
|
+
// rules into this block while it is still the (live-region) transcript tail.
|
|
87
|
+
#lastTtsrNotification: TtsrNotificationComponent | undefined = undefined;
|
|
80
88
|
#streamingReveal: StreamingRevealController;
|
|
81
89
|
#handlers: AgentSessionEventHandlers;
|
|
82
90
|
|
|
@@ -282,6 +290,7 @@ export class EventController {
|
|
|
282
290
|
const signature = `${textContent}\u0000${imageCount}`;
|
|
283
291
|
|
|
284
292
|
this.#resetReadGroup();
|
|
293
|
+
this.#resolveDisplaceablePoll();
|
|
285
294
|
const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
|
|
286
295
|
const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
|
|
287
296
|
if (!wasOptimistic) {
|
|
@@ -389,6 +398,28 @@ export class EventController {
|
|
|
389
398
|
}
|
|
390
399
|
}
|
|
391
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Resolve the pending displaceable poll block before the next block lands.
|
|
403
|
+
* A follow-up `job` call displaces it — the stale "waiting on N jobs" frame
|
|
404
|
+
* is removed so repeated polls read as one persistent poll — while anything
|
|
405
|
+
* else seals it in place as final history. Removal is safe only because a
|
|
406
|
+
* displaceable block never finalizes: commits stop at the first live block,
|
|
407
|
+
* so none of its rows have entered native scrollback (see
|
|
408
|
+
* ToolExecutionComponent.isDisplaceableBlock).
|
|
409
|
+
*/
|
|
410
|
+
#resolveDisplaceablePoll(nextToolName?: string): void {
|
|
411
|
+
const previous = this.#displaceablePollComponent;
|
|
412
|
+
if (!previous) return;
|
|
413
|
+
this.#displaceablePollComponent = undefined;
|
|
414
|
+
if (nextToolName === "job" && previous.isDisplaceableBlock()) {
|
|
415
|
+
this.ctx.chatContainer.removeChild(previous);
|
|
416
|
+
}
|
|
417
|
+
// Sealing stops the waiting-poll spinner and freezes the block (for a
|
|
418
|
+
// just-removed component it only clears the animation timer).
|
|
419
|
+
previous.seal();
|
|
420
|
+
this.ctx.ui.requestRender();
|
|
421
|
+
}
|
|
422
|
+
|
|
392
423
|
async #handleNotice(event: Extract<AgentSessionEvent, { type: "notice" }>): Promise<void> {
|
|
393
424
|
const message = event.source ? `${event.source}: ${event.message}` : event.message;
|
|
394
425
|
if (event.level === "error") {
|
|
@@ -444,6 +475,7 @@ export class EventController {
|
|
|
444
475
|
continue;
|
|
445
476
|
}
|
|
446
477
|
if (!readArgsTargetInternalUrl(content.arguments)) {
|
|
478
|
+
if (!this.ctx.pendingTools.has(content.id)) this.#resolveDisplaceablePoll(content.name);
|
|
447
479
|
this.#trackReadToolCall(content.id, content.arguments);
|
|
448
480
|
const component = this.ctx.pendingTools.get(content.id);
|
|
449
481
|
if (component) {
|
|
@@ -465,6 +497,7 @@ export class EventController {
|
|
|
465
497
|
? { ...content.arguments, __partialJson: content.partialJson }
|
|
466
498
|
: content.arguments;
|
|
467
499
|
if (!this.ctx.pendingTools.has(content.id)) {
|
|
500
|
+
this.#resolveDisplaceablePoll(content.name);
|
|
468
501
|
this.#resetReadGroup();
|
|
469
502
|
const tool = this.ctx.session.getToolByName(content.name);
|
|
470
503
|
const component = new ToolExecutionComponent(
|
|
@@ -561,6 +594,9 @@ export class EventController {
|
|
|
561
594
|
component.seal();
|
|
562
595
|
}
|
|
563
596
|
}
|
|
597
|
+
// These calls will never produce a result either, so the tracked
|
|
598
|
+
// waiting poll cannot be displaced anymore — freeze it in place.
|
|
599
|
+
this.#resolveDisplaceablePoll();
|
|
564
600
|
}
|
|
565
601
|
this.#lastAssistantComponent = this.ctx.streamingComponent;
|
|
566
602
|
this.#lastAssistantComponent.setUsageInfo(event.message.usage);
|
|
@@ -589,6 +625,7 @@ export class EventController {
|
|
|
589
625
|
async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
|
|
590
626
|
this.#updateWorkingMessageFromIntent(event.intent);
|
|
591
627
|
if (!this.ctx.pendingTools.has(event.toolCallId)) {
|
|
628
|
+
this.#resolveDisplaceablePoll(event.toolName);
|
|
592
629
|
if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
|
|
593
630
|
this.#trackReadToolCall(event.toolCallId, event.args);
|
|
594
631
|
const component = this.ctx.pendingTools.get(event.toolCallId);
|
|
@@ -697,6 +734,14 @@ export class EventController {
|
|
|
697
734
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
698
735
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
699
736
|
}
|
|
737
|
+
if (
|
|
738
|
+
event.toolName === "job" &&
|
|
739
|
+
component instanceof ToolExecutionComponent &&
|
|
740
|
+
component.isDisplaceableBlock()
|
|
741
|
+
) {
|
|
742
|
+
// Remember the waiting poll so the next `job` call can displace it.
|
|
743
|
+
this.#displaceablePollComponent = component;
|
|
744
|
+
}
|
|
700
745
|
this.ctx.ui.requestRender();
|
|
701
746
|
}
|
|
702
747
|
}
|
|
@@ -759,6 +804,9 @@ export class EventController {
|
|
|
759
804
|
this.#readToolCallArgs.clear();
|
|
760
805
|
this.#readToolCallAssistantComponents.clear();
|
|
761
806
|
this.#resetReadGroup();
|
|
807
|
+
// The turn is over: nothing else lands this turn, so the waiting poll is
|
|
808
|
+
// final history — seal it instead of letting its spinner tick while idle.
|
|
809
|
+
this.#resolveDisplaceablePoll();
|
|
762
810
|
this.#lastAssistantComponent = undefined;
|
|
763
811
|
this.ctx.ui.requestRender();
|
|
764
812
|
this.#scheduleIdleCompaction();
|
|
@@ -908,9 +956,26 @@ export class EventController {
|
|
|
908
956
|
}
|
|
909
957
|
|
|
910
958
|
async #handleTtsrTriggered(event: Extract<AgentSessionEvent, { type: "ttsr_triggered" }>): Promise<void> {
|
|
959
|
+
// Consecutive notifications (e.g. per-tool matches from one assistant
|
|
960
|
+
// message) merge into the previous block instead of stacking. Mutating an
|
|
961
|
+
// existing block is only safe while it sits inside the live region — a
|
|
962
|
+
// still-mutating block above it means none of its rows have been committed
|
|
963
|
+
// to native scrollback yet (commits are prefix-only and stop at the first
|
|
964
|
+
// live block), so the grown block still repaints.
|
|
965
|
+
const previous = this.#lastTtsrNotification;
|
|
966
|
+
if (
|
|
967
|
+
previous &&
|
|
968
|
+
this.ctx.chatContainer.children.at(-1) === previous &&
|
|
969
|
+
this.ctx.chatContainer.isWithinLiveRegion(previous)
|
|
970
|
+
) {
|
|
971
|
+
previous.addRules(event.rules);
|
|
972
|
+
this.ctx.ui.requestRender();
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
911
975
|
const component = new TtsrNotificationComponent(event.rules);
|
|
912
976
|
component.setExpanded(this.ctx.toolOutputExpanded);
|
|
913
977
|
this.ctx.present(component);
|
|
978
|
+
this.#lastTtsrNotification = component;
|
|
914
979
|
}
|
|
915
980
|
|
|
916
981
|
async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
|