@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67
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 +74 -1
- package/dist/types/cli/classify-install-target.d.ts +5 -1
- package/dist/types/config/keybindings.d.ts +4 -1
- package/dist/types/config/settings-schema.d.ts +24 -5
- package/dist/types/edit/file-snapshot-store.d.ts +1 -1
- package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
- package/dist/types/eval/backend.d.ts +6 -6
- package/dist/types/eval/bridge-timeout.d.ts +27 -0
- package/dist/types/eval/idle-timeout.d.ts +16 -14
- package/dist/types/eval/js/executor.d.ts +3 -3
- package/dist/types/eval/py/executor.d.ts +2 -2
- package/dist/types/eval/py/spawn-options.d.ts +58 -0
- package/dist/types/modes/components/assistant-message.d.ts +16 -0
- package/dist/types/modes/components/copy-selector.d.ts +22 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -1
- package/dist/types/modes/components/error-banner.d.ts +11 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/components/user-message.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/image-references.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +8 -1
- package/dist/types/modes/types.d.ts +8 -1
- package/dist/types/modes/utils/copy-targets.d.ts +53 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/session/blob-store.d.ts +12 -11
- package/dist/types/session/session-manager.d.ts +5 -3
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/tiny/title-client.d.ts +16 -1
- package/dist/types/tool-discovery/mode.d.ts +8 -0
- package/dist/types/tools/archive-reader.d.ts +5 -1
- package/dist/types/tools/eval-render.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +25 -0
- package/dist/types/tui/code-cell.d.ts +6 -0
- package/dist/types/tui/hyperlink.d.ts +12 -0
- package/dist/types/tui/output-block.d.ts +11 -0
- package/dist/types/web/search/render.d.ts +1 -2
- package/package.json +9 -9
- package/src/autoresearch/dashboard.ts +11 -21
- package/src/cli/classify-install-target.ts +31 -5
- package/src/cli/claude-trace-cli.ts +13 -1
- package/src/cli/plugin-cli.ts +45 -0
- package/src/cli/web-search-cli.ts +0 -1
- package/src/config/keybindings.ts +58 -1
- package/src/config/model-registry.ts +54 -4
- package/src/config/settings-schema.ts +25 -5
- package/src/debug/raw-sse.ts +18 -4
- package/src/edit/file-snapshot-store.ts +1 -1
- package/src/edit/index.ts +1 -1
- package/src/edit/renderer.ts +7 -7
- package/src/edit/streaming.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +100 -27
- package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
- package/src/eval/__tests__/idle-timeout.test.ts +26 -12
- package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
- package/src/eval/__tests__/llm-bridge.test.ts +10 -10
- package/src/eval/__tests__/shared-executors.test.ts +2 -2
- package/src/eval/agent-bridge.ts +4 -5
- package/src/eval/backend.ts +6 -6
- package/src/eval/bridge-timeout.ts +44 -0
- package/src/eval/idle-timeout.ts +33 -15
- package/src/eval/js/executor.ts +10 -10
- package/src/eval/llm-bridge.ts +4 -5
- package/src/eval/py/executor.ts +6 -6
- package/src/eval/py/kernel.ts +11 -1
- package/src/eval/py/spawn-options.ts +126 -0
- package/src/eval/py/tool-bridge.ts +43 -5
- package/src/export/ttsr.ts +9 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
- package/src/extensibility/extensions/runner.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +9 -8
- package/src/lsp/client.ts +80 -2
- package/src/lsp/index.ts +38 -4
- package/src/lsp/render.ts +3 -3
- package/src/main.ts +8 -2
- package/src/modes/components/agent-dashboard.ts +13 -4
- package/src/modes/components/assistant-message.ts +44 -1
- package/src/modes/components/copy-selector.ts +249 -0
- package/src/modes/components/custom-editor.ts +14 -2
- package/src/modes/components/error-banner.ts +33 -0
- package/src/modes/components/extensions/extension-list.ts +17 -8
- package/src/modes/components/history-search.ts +19 -11
- package/src/modes/components/model-selector.ts +125 -29
- package/src/modes/components/oauth-selector.ts +28 -12
- package/src/modes/components/session-observer-overlay.ts +13 -15
- package/src/modes/components/session-selector.ts +24 -13
- package/src/modes/components/tool-execution.ts +71 -13
- package/src/modes/components/transcript-container.ts +93 -32
- package/src/modes/components/tree-selector.ts +19 -7
- package/src/modes/components/user-message-selector.ts +25 -14
- package/src/modes/components/user-message.ts +9 -2
- package/src/modes/controllers/command-controller.ts +0 -116
- package/src/modes/controllers/event-controller.ts +67 -12
- package/src/modes/controllers/input-controller.ts +33 -1
- package/src/modes/controllers/selector-controller.ts +38 -1
- package/src/modes/image-references.ts +111 -0
- package/src/modes/interactive-mode.ts +52 -17
- package/src/modes/theme/theme.ts +46 -10
- package/src/modes/types.ts +11 -2
- package/src/modes/utils/copy-targets.ts +254 -0
- package/src/modes/utils/ui-helpers.ts +23 -2
- package/src/prompts/ci-green-request.md +5 -3
- package/src/prompts/system/project-prompt.md +1 -0
- package/src/prompts/tools/ast-edit.md +1 -1
- package/src/prompts/tools/ast-grep.md +1 -1
- package/src/prompts/tools/read.md +1 -1
- package/src/prompts/tools/search.md +1 -1
- package/src/sdk.ts +17 -9
- package/src/session/agent-session.ts +43 -14
- package/src/session/blob-store.ts +96 -9
- package/src/session/session-manager.ts +19 -10
- package/src/slash-commands/builtin-registry.ts +3 -11
- package/src/system-prompt.ts +4 -0
- package/src/task/render.ts +38 -11
- package/src/tiny/title-client.ts +7 -1
- package/src/tool-discovery/mode.ts +24 -0
- package/src/tools/archive-reader.ts +339 -31
- package/src/tools/bash.ts +18 -8
- package/src/tools/browser/render.ts +5 -4
- package/src/tools/debug.ts +3 -3
- package/src/tools/eval-render.ts +24 -9
- package/src/tools/eval.ts +14 -19
- package/src/tools/fetch.ts +34 -14
- package/src/tools/gh.ts +65 -11
- package/src/tools/index.ts +6 -8
- package/src/tools/read.ts +65 -19
- package/src/tools/render-utils.ts +46 -0
- package/src/tools/search-tool-bm25.ts +4 -6
- package/src/tools/search.ts +60 -11
- package/src/tools/ssh.ts +21 -8
- package/src/tools/write.ts +17 -8
- package/src/tui/code-cell.ts +19 -4
- package/src/tui/hyperlink.ts +42 -7
- package/src/tui/output-block.ts +14 -0
- package/src/web/search/index.ts +2 -2
- package/src/web/search/render.ts +23 -55
- package/dist/types/eval/heartbeat.d.ts +0 -45
- package/src/eval/__tests__/heartbeat.test.ts +0 -84
- package/src/eval/heartbeat.ts +0 -74
- /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
|
|
34
34
|
import { toolRenderers } from "../../tools/renderers";
|
|
35
35
|
import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
|
|
36
|
-
import { renderStatusLine } from "../../tui";
|
|
36
|
+
import { isFramedBlockComponent, renderStatusLine } from "../../tui";
|
|
37
37
|
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
|
38
38
|
import { renderDiff } from "./diff";
|
|
39
39
|
|
|
@@ -45,6 +45,12 @@ function ensureInvalidate(component: unknown): Component {
|
|
|
45
45
|
return c as Component;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function addBoxChild(box: Box, component: unknown): boolean {
|
|
49
|
+
const child = ensureInvalidate(component);
|
|
50
|
+
box.addChild(child);
|
|
51
|
+
return isFramedBlockComponent(child);
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
/**
|
|
49
55
|
* Drop trailing removal/hunk-header lines that appear in a streaming diff
|
|
50
56
|
* before the matching `+added` lines have arrived. Without this, a partial
|
|
@@ -107,7 +113,7 @@ function rawTextInputFromPartialJson(partialJson: unknown): string | undefined {
|
|
|
107
113
|
// Function-tool arguments stream as JSON. Custom/free-form tools stream raw
|
|
108
114
|
// text in the same transport field; only the raw form is a valid fallback for
|
|
109
115
|
// the conventional `input` parameter.
|
|
110
|
-
if (first === "{" || first ===
|
|
116
|
+
if (first === "{" || first === '"') return undefined;
|
|
111
117
|
return partialJson;
|
|
112
118
|
}
|
|
113
119
|
|
|
@@ -197,6 +203,11 @@ export class ToolExecutionComponent extends Container {
|
|
|
197
203
|
#todoStrikeInterval?: NodeJS.Timeout;
|
|
198
204
|
// Track if args are still being streamed (for edit/write spinner)
|
|
199
205
|
#argsComplete = false;
|
|
206
|
+
// Sealed once the tool reaches a terminal state (result delivered, or the
|
|
207
|
+
// turn abandoned it without one). Drives `isTranscriptBlockFinalized`: until
|
|
208
|
+
// sealed the block stays in the transcript's repaintable live region so a
|
|
209
|
+
// late result still repaints instead of stranding the streaming preview.
|
|
210
|
+
#sealed = false;
|
|
200
211
|
#renderState: {
|
|
201
212
|
spinnerFrame?: number;
|
|
202
213
|
expanded: boolean;
|
|
@@ -448,6 +459,13 @@ export class ToolExecutionComponent extends Container {
|
|
|
448
459
|
} else if (!needsSpinner && this.#spinnerInterval) {
|
|
449
460
|
clearInterval(this.#spinnerInterval);
|
|
450
461
|
this.#spinnerInterval = undefined;
|
|
462
|
+
// Clear the last drawn frame so a non-live renderCall (e.g. a write whose
|
|
463
|
+
// args just completed) stops showing a frozen spinner glyph. Skip when a
|
|
464
|
+
// todo strike owns the frame — it sets its own value right after this.
|
|
465
|
+
if (!this.#todoStrikeInterval) {
|
|
466
|
+
this.#spinnerFrame = undefined;
|
|
467
|
+
this.#renderState.spinnerFrame = undefined;
|
|
468
|
+
}
|
|
451
469
|
}
|
|
452
470
|
}
|
|
453
471
|
|
|
@@ -488,6 +506,37 @@ export class ToolExecutionComponent extends Container {
|
|
|
488
506
|
}
|
|
489
507
|
}
|
|
490
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Whether this block has reached a terminal state for transcript freezing.
|
|
511
|
+
* Reports `false` while it can still visually change so the
|
|
512
|
+
* {@link TranscriptContainer} keeps it inside the repaintable live region:
|
|
513
|
+
* a foreground tool awaiting its result, or one streaming partial output.
|
|
514
|
+
* A final (non-partial) result, a background-async tool the agent has moved
|
|
515
|
+
* past, or an explicit {@link seal} flips it to `true`.
|
|
516
|
+
*/
|
|
517
|
+
isTranscriptBlockFinalized(): boolean {
|
|
518
|
+
if (this.#sealed) return true;
|
|
519
|
+
if (this.#result === undefined) return false;
|
|
520
|
+
if (!this.#isPartial) return true;
|
|
521
|
+
// Partial result: a background async tool is accepted to freeze (the agent
|
|
522
|
+
// continues while it runs and would otherwise pin an unbounded live region);
|
|
523
|
+
// a foreground tool streaming partial output stays live until it finishes.
|
|
524
|
+
return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Mark the tool terminal even though no result arrived (the turn aborted or
|
|
529
|
+
* abandoned it) and stop animating, so it can freeze and stops pinning the
|
|
530
|
+
* transcript live region.
|
|
531
|
+
*/
|
|
532
|
+
seal(): void {
|
|
533
|
+
if (this.#sealed) return;
|
|
534
|
+
this.#sealed = true;
|
|
535
|
+
this.stopAnimation();
|
|
536
|
+
this.#updateDisplay();
|
|
537
|
+
this.#ui.requestRender();
|
|
538
|
+
}
|
|
539
|
+
|
|
491
540
|
/**
|
|
492
541
|
* Stop spinner animation and cleanup resources.
|
|
493
542
|
*/
|
|
@@ -496,6 +545,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
496
545
|
clearInterval(this.#spinnerInterval);
|
|
497
546
|
this.#spinnerInterval = undefined;
|
|
498
547
|
this.#spinnerFrame = undefined;
|
|
548
|
+
this.#renderState.spinnerFrame = undefined;
|
|
499
549
|
}
|
|
500
550
|
this.#stopTodoStrikeAnimation();
|
|
501
551
|
this.#editDiffAbort?.abort();
|
|
@@ -538,6 +588,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
538
588
|
const inline = Boolean((tool as { inline?: boolean }).inline);
|
|
539
589
|
this.#contentBox.setBgFn(inline ? undefined : bgFn);
|
|
540
590
|
this.#contentBox.clear();
|
|
591
|
+
let contentBoxHasFramedBlock = false;
|
|
541
592
|
// Mirror the built-in renderer branch so custom renderers (notably the
|
|
542
593
|
// task tool, whose live instance routes through here) receive the same
|
|
543
594
|
// render context — e.g. the `hasResult` flag that suppresses the task
|
|
@@ -550,16 +601,16 @@ export class ToolExecutionComponent extends Container {
|
|
|
550
601
|
try {
|
|
551
602
|
const callComponent = tool.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
|
|
552
603
|
if (callComponent) {
|
|
553
|
-
this.#contentBox
|
|
604
|
+
contentBoxHasFramedBlock = addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
|
|
554
605
|
}
|
|
555
606
|
} catch (err) {
|
|
556
607
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|
|
557
608
|
// Fall back to default on error
|
|
558
|
-
this.#contentBox
|
|
609
|
+
addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
|
|
559
610
|
}
|
|
560
611
|
} else {
|
|
561
612
|
// No custom renderCall, show tool name
|
|
562
|
-
this.#contentBox
|
|
613
|
+
addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
|
|
563
614
|
}
|
|
564
615
|
|
|
565
616
|
// Render result component if we have a result
|
|
@@ -582,23 +633,24 @@ export class ToolExecutionComponent extends Container {
|
|
|
582
633
|
this.#args,
|
|
583
634
|
);
|
|
584
635
|
if (resultComponent) {
|
|
585
|
-
this.#contentBox
|
|
636
|
+
contentBoxHasFramedBlock = addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
|
|
586
637
|
}
|
|
587
638
|
} catch (err) {
|
|
588
639
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|
|
589
640
|
// Fall back to showing raw output on error
|
|
590
641
|
const output = this.#getTextOutput();
|
|
591
642
|
if (output) {
|
|
592
|
-
this.#contentBox
|
|
643
|
+
addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
|
|
593
644
|
}
|
|
594
645
|
}
|
|
595
646
|
} else if (this.#result) {
|
|
596
647
|
// Has result but no custom renderResult
|
|
597
648
|
const output = this.#getTextOutput();
|
|
598
649
|
if (output) {
|
|
599
|
-
this.#contentBox
|
|
650
|
+
addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
|
|
600
651
|
}
|
|
601
652
|
}
|
|
653
|
+
this.#contentBox.setPaddingX(contentBoxHasFramedBlock ? 0 : 1);
|
|
602
654
|
} else if (this.#toolName in toolRenderers) {
|
|
603
655
|
// Built-in tools with renderers
|
|
604
656
|
const renderer = toolRenderers[this.#toolName];
|
|
@@ -617,6 +669,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
617
669
|
// Multi-file: render each file as its own Box (identical to separate tool calls)
|
|
618
670
|
this.#contentBox.setBgFn(undefined);
|
|
619
671
|
this.#contentBox.clear();
|
|
672
|
+
this.#contentBox.setPaddingX(1);
|
|
620
673
|
|
|
621
674
|
const renderContext = this.#buildRenderContext();
|
|
622
675
|
this.#renderState.renderContext = renderContext;
|
|
@@ -639,7 +692,8 @@ export class ToolExecutionComponent extends Container {
|
|
|
639
692
|
theme,
|
|
640
693
|
);
|
|
641
694
|
if (resultComponent) {
|
|
642
|
-
fileBox
|
|
695
|
+
const fileBoxHasFramedBlock = addBoxChild(fileBox, resultComponent);
|
|
696
|
+
fileBox.setPaddingX(fileBoxHasFramedBlock ? 0 : 1);
|
|
643
697
|
}
|
|
644
698
|
} catch (err) {
|
|
645
699
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|
|
@@ -675,6 +729,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
675
729
|
// Inline renderers skip background styling
|
|
676
730
|
this.#contentBox.setBgFn(renderer.inline ? undefined : bgFn);
|
|
677
731
|
this.#contentBox.clear();
|
|
732
|
+
let contentBoxHasFramedBlock = false;
|
|
678
733
|
|
|
679
734
|
const renderContext = this.#buildRenderContext();
|
|
680
735
|
this.#renderState.renderContext = renderContext;
|
|
@@ -685,12 +740,13 @@ export class ToolExecutionComponent extends Container {
|
|
|
685
740
|
try {
|
|
686
741
|
const callComponent = renderer.renderCall(this.#getCallArgsForRender(), this.#renderState, theme);
|
|
687
742
|
if (callComponent) {
|
|
688
|
-
|
|
743
|
+
contentBoxHasFramedBlock =
|
|
744
|
+
addBoxChild(this.#contentBox, callComponent) || contentBoxHasFramedBlock;
|
|
689
745
|
}
|
|
690
746
|
} catch (err) {
|
|
691
747
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|
|
692
748
|
// Fall back to default on error
|
|
693
|
-
this.#contentBox
|
|
749
|
+
addBoxChild(this.#contentBox, new Text(theme.fg("toolTitle", theme.bold(this.#toolLabel)), 0, 0));
|
|
694
750
|
}
|
|
695
751
|
}
|
|
696
752
|
|
|
@@ -708,17 +764,19 @@ export class ToolExecutionComponent extends Container {
|
|
|
708
764
|
this.#getCallArgsForRender(),
|
|
709
765
|
);
|
|
710
766
|
if (resultComponent) {
|
|
711
|
-
|
|
767
|
+
contentBoxHasFramedBlock =
|
|
768
|
+
addBoxChild(this.#contentBox, resultComponent) || contentBoxHasFramedBlock;
|
|
712
769
|
}
|
|
713
770
|
} catch (err) {
|
|
714
771
|
logger.warn("Tool renderer failed", { tool: this.#toolName, error: String(err) });
|
|
715
772
|
// Fall back to showing raw output on error
|
|
716
773
|
const output = this.#getTextOutput();
|
|
717
774
|
if (output) {
|
|
718
|
-
this.#contentBox
|
|
775
|
+
addBoxChild(this.#contentBox, new Text(theme.fg("toolOutput", replaceTabs(output)), 0, 0));
|
|
719
776
|
}
|
|
720
777
|
}
|
|
721
778
|
}
|
|
779
|
+
this.#contentBox.setPaddingX(contentBoxHasFramedBlock ? 0 : 1);
|
|
722
780
|
}
|
|
723
781
|
} else {
|
|
724
782
|
// Other built-in tools: use Text directly with caching
|
|
@@ -12,6 +12,33 @@ interface SnapshotCarrier {
|
|
|
12
12
|
[kSnapshot]?: FrozenRender;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A transcript block that is still mutating (a foreground tool awaiting its
|
|
17
|
+
* result, an assistant message mid-stream) reports `false` so the container
|
|
18
|
+
* keeps it inside the live (repaintable) region instead of freezing it. Blocks
|
|
19
|
+
* without the method are treated as finalized — the default, stable behavior.
|
|
20
|
+
*
|
|
21
|
+
* `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
|
|
22
|
+
* only grow at the bottom and never re-layout (a streaming assistant reply).
|
|
23
|
+
* Such a block's scrolled-off head is safe to commit to native scrollback even
|
|
24
|
+
* while live; blocks that omit it (tool previews that collapse to a compact
|
|
25
|
+
* result) keep their mutable rows deferred. Default is `false`.
|
|
26
|
+
*/
|
|
27
|
+
interface FinalizableBlock {
|
|
28
|
+
isTranscriptBlockFinalized?(): boolean;
|
|
29
|
+
isTranscriptBlockAppendOnly?(): boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isBlockFinalized(child: Component): boolean {
|
|
33
|
+
const fn = (child as Component & FinalizableBlock).isTranscriptBlockFinalized;
|
|
34
|
+
return fn ? fn.call(child) : true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isBlockAppendOnly(child: Component): boolean {
|
|
38
|
+
const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
|
|
39
|
+
return fn ? fn.call(child) : false;
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
/**
|
|
16
43
|
* Transcript container that freezes the rendered output of every block except
|
|
17
44
|
* the bottom-most (live) one on terminals where committed native scrollback is
|
|
@@ -38,15 +65,24 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
38
65
|
// Bumped to invalidate every block's snapshot at once; a snapshot is only
|
|
39
66
|
// honored when its stored generation still matches.
|
|
40
67
|
#generation = 0;
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
68
|
+
// Line index where the live (repaintable) region began on the previous
|
|
69
|
+
// render — the start of the earliest still-mutating block, or the bottom
|
|
70
|
+
// block when everything is finalized. A block leaves the live region only
|
|
71
|
+
// once it has finalized AND a finalized block sits below it; the frame it
|
|
72
|
+
// crosses out is recomputed so it freezes at its true final content, not the
|
|
73
|
+
// mid-stream snapshot it last rendered while live (TUI render coalescing can
|
|
74
|
+
// advance a block's content in the very frame it stops being live).
|
|
75
|
+
#prevLiveStartIndex = 0;
|
|
76
|
+
// Local line index where the current live region begins in the most recent
|
|
77
|
+
// render. TUI extends the native-scrollback pinned region from this point
|
|
78
|
+
// through the live blocks and the root chrome rendered below them.
|
|
49
79
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
80
|
+
// Local line index up to which the leading run of live blocks is append-only
|
|
81
|
+
// (a streaming assistant reply): everything in [liveRegionStart,
|
|
82
|
+
// commitSafeEnd) only grows at the bottom and never re-layouts, so its
|
|
83
|
+
// scrolled-off head is safe to commit to native scrollback. `undefined` when
|
|
84
|
+
// the first live block is volatile (a tool preview).
|
|
85
|
+
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
50
86
|
|
|
51
87
|
override invalidate(): void {
|
|
52
88
|
// A theme/global invalidation forces a full recompute on the rebuild that
|
|
@@ -64,6 +100,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
64
100
|
return this.#nativeScrollbackLiveRegionStart;
|
|
65
101
|
}
|
|
66
102
|
|
|
103
|
+
getNativeScrollbackCommitSafeEnd(): number | undefined {
|
|
104
|
+
return this.#nativeScrollbackCommitSafeEnd;
|
|
105
|
+
}
|
|
106
|
+
|
|
67
107
|
/**
|
|
68
108
|
* Retire all frozen snapshots so the next render reflects each block's current
|
|
69
109
|
* state. Call at reconciliation checkpoints (prompt submit) where the whole
|
|
@@ -77,41 +117,62 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
77
117
|
override render(width: number): string[] {
|
|
78
118
|
width = Math.max(1, width);
|
|
79
119
|
this.#nativeScrollbackLiveRegionStart = undefined;
|
|
120
|
+
this.#nativeScrollbackCommitSafeEnd = undefined;
|
|
80
121
|
if (!TERMINAL.eagerEraseScrollbackRisk) return super.render(width);
|
|
81
122
|
|
|
123
|
+
const count = this.children.length;
|
|
124
|
+
// The live region spans from the earliest still-mutating block through the
|
|
125
|
+
// bottom. A block that has not finalized must stay repaintable: out-of-band
|
|
126
|
+
// inserts (TTSR/todo cards) can append a finalized block *below* a tool that
|
|
127
|
+
// is still awaiting its result, and freezing the tool there would strand its
|
|
128
|
+
// committed rows on the mid-stream preview the late result never reaches.
|
|
129
|
+
let liveStartIndex = count - 1;
|
|
130
|
+
for (let i = 0; i < count; i++) {
|
|
131
|
+
if (!isBlockFinalized(this.children[i]!)) {
|
|
132
|
+
liveStartIndex = i;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Blocks at [prevLiveStart, liveStart) just crossed out of the live region;
|
|
137
|
+
// recompute them so they freeze at their final content. Everything below
|
|
138
|
+
// the lower of the two cutoffs was already frozen last frame and replays.
|
|
139
|
+
const replayCutoff = Math.min(liveStartIndex, this.#prevLiveStartIndex);
|
|
140
|
+
this.#prevLiveStartIndex = liveStartIndex;
|
|
141
|
+
|
|
82
142
|
const lines: string[] = [];
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
for (let i = 0; i <
|
|
143
|
+
// Tracks whether we are still inside the leading run of append-only live
|
|
144
|
+
// blocks. The first non-append-only live block (or a finalized block below
|
|
145
|
+
// the live region's start, which cannot happen for a leading run) closes it.
|
|
146
|
+
let commitSafeOpen = true;
|
|
147
|
+
for (let i = 0; i < count; i++) {
|
|
88
148
|
const child = this.children[i]! as Component & SnapshotCarrier;
|
|
89
|
-
if (
|
|
90
|
-
this.#nativeScrollbackLiveRegionStart = lines.length;
|
|
149
|
+
if (i >= liveStartIndex) {
|
|
150
|
+
if (i === liveStartIndex) this.#nativeScrollbackLiveRegionStart = lines.length;
|
|
91
151
|
} else {
|
|
92
152
|
const snapshot = child[kSnapshot];
|
|
93
|
-
// Replay
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
// here: TUI render coalescing can advance its content (final streamed
|
|
98
|
-
// tokens) in the very frame that appends the block now below it, so its
|
|
99
|
-
// cached snapshot predates that final content. Recomputing on the
|
|
100
|
-
// transition seals the block at its true final state, not a mid-stream one.
|
|
101
|
-
if (
|
|
102
|
-
child !== prevLiveChild &&
|
|
103
|
-
snapshot &&
|
|
104
|
-
snapshot.generation === this.#generation &&
|
|
105
|
-
snapshot.width === width
|
|
106
|
-
) {
|
|
153
|
+
// Replay a frozen block's last live render. A stale generation
|
|
154
|
+
// (post-thaw) or width mismatch (resize, explicit rebuild) recomputes
|
|
155
|
+
// instead, as does a block that was still live last frame (i >= cutoff).
|
|
156
|
+
if (i < replayCutoff && snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
|
|
107
157
|
lines.push(...snapshot.lines);
|
|
108
158
|
continue;
|
|
109
159
|
}
|
|
110
160
|
}
|
|
111
161
|
const rendered = child.render(width);
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
162
|
+
// Extend the commit-safe boundary through each leading append-only live
|
|
163
|
+
// block. `lines.length` here is this block's start offset; the boundary
|
|
164
|
+
// runs to the end of its rendered rows. The first volatile live block
|
|
165
|
+
// closes the run so its mutable rows stay deferred.
|
|
166
|
+
if (i >= liveStartIndex && commitSafeOpen) {
|
|
167
|
+
if (isBlockAppendOnly(child)) {
|
|
168
|
+
this.#nativeScrollbackCommitSafeEnd = lines.length + rendered.length;
|
|
169
|
+
} else {
|
|
170
|
+
commitSafeOpen = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Cache every block's latest render. While a block is in the live region
|
|
174
|
+
// this keeps its snapshot current; on the frame it crosses out, the
|
|
175
|
+
// recompute above refreshes it to the final state before it freezes.
|
|
115
176
|
child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
|
|
116
177
|
lines.push(...rendered);
|
|
117
178
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
fuzzyMatch,
|
|
7
7
|
Input,
|
|
8
8
|
matchesKey,
|
|
9
|
+
ScrollView,
|
|
9
10
|
Spacer,
|
|
10
11
|
Text,
|
|
11
12
|
TruncatedText,
|
|
@@ -492,6 +493,10 @@ class TreeList implements Component {
|
|
|
492
493
|
const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
|
|
493
494
|
const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
|
|
494
495
|
|
|
496
|
+
const overflow = this.#filteredNodes.length > this.maxVisibleLines;
|
|
497
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
498
|
+
const rows: string[] = [];
|
|
499
|
+
|
|
495
500
|
for (let i = startIndex; i < endIndex; i++) {
|
|
496
501
|
const flatNode = this.#filteredNodes[i];
|
|
497
502
|
const entry = flatNode.node.entry;
|
|
@@ -560,15 +565,22 @@ class TreeList implements Component {
|
|
|
560
565
|
if (isSelected) {
|
|
561
566
|
line = theme.bg("selectedBg", line);
|
|
562
567
|
}
|
|
563
|
-
|
|
568
|
+
rows.push(truncateToWidth(line, rowWidth));
|
|
564
569
|
}
|
|
565
570
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
),
|
|
571
|
-
);
|
|
571
|
+
const sv = new ScrollView(rows, {
|
|
572
|
+
height: rows.length,
|
|
573
|
+
scrollbar: "auto",
|
|
574
|
+
totalRows: this.#filteredNodes.length,
|
|
575
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
576
|
+
});
|
|
577
|
+
sv.setScrollOffset(startIndex);
|
|
578
|
+
lines.push(...sv.render(width));
|
|
579
|
+
|
|
580
|
+
const filterLabel = this.#getFilterLabel();
|
|
581
|
+
if (filterLabel) {
|
|
582
|
+
lines.push(truncateToWidth(theme.fg("muted", ` ${filterLabel.trim()}`), width));
|
|
583
|
+
}
|
|
572
584
|
|
|
573
585
|
return lines;
|
|
574
586
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
extractPrintableText,
|
|
5
5
|
fuzzyFilter,
|
|
6
6
|
matchesKey,
|
|
7
|
+
ScrollView,
|
|
7
8
|
Spacer,
|
|
8
9
|
Text,
|
|
9
10
|
truncateToWidth,
|
|
@@ -48,14 +49,10 @@ class UserMessageList implements Component {
|
|
|
48
49
|
return this.#isSearchEnabled() || this.#searchQuery.length > 0;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
#renderStatusLine(
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
? `${selectedCount}/${total} of ${this.messages.length}`
|
|
56
|
-
: `${selectedCount}/${total}`;
|
|
57
|
-
const suffix = this.#searchQuery.trim() ? ` Search: ${this.#searchQuery}` : " Type to search";
|
|
58
|
-
return theme.fg("muted", ` (${count})${suffix}`);
|
|
52
|
+
#renderStatusLine(_total: number): string {
|
|
53
|
+
const query = this.#searchQuery.trim();
|
|
54
|
+
const suffix = query ? `Search: ${this.#searchQuery}` : "Type to search";
|
|
55
|
+
return theme.fg("muted", ` ${suffix}`);
|
|
59
56
|
}
|
|
60
57
|
|
|
61
58
|
#setSearchQuery(query: string): void {
|
|
@@ -103,6 +100,9 @@ class UserMessageList implements Component {
|
|
|
103
100
|
const endIndex = Math.min(startIndex + this.#maxVisible, total);
|
|
104
101
|
|
|
105
102
|
// Render visible messages (2 lines per message + blank line)
|
|
103
|
+
const overflow = total > this.#maxVisible;
|
|
104
|
+
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
105
|
+
const messageLines: string[] = [];
|
|
106
106
|
for (let i = startIndex; i < endIndex; i++) {
|
|
107
107
|
const message = this.#filteredMessages[i];
|
|
108
108
|
if (!message) continue;
|
|
@@ -113,26 +113,37 @@ class UserMessageList implements Component {
|
|
|
113
113
|
|
|
114
114
|
// First line: cursor + message
|
|
115
115
|
const cursor = isSelected ? theme.fg("accent", "› ") : " ";
|
|
116
|
-
const maxMsgWidth =
|
|
116
|
+
const maxMsgWidth = rowWidth - 2; // Account for cursor (2 chars)
|
|
117
117
|
const truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth);
|
|
118
118
|
const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
messageLines.push(messageLine);
|
|
121
121
|
|
|
122
122
|
// Second line: metadata (position in history)
|
|
123
123
|
const position = this.messages.indexOf(message) + 1;
|
|
124
124
|
const metadata = ` Message ${position} of ${this.messages.length}`;
|
|
125
125
|
const metadataLine = theme.fg("muted", metadata);
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
messageLines.push(metadataLine);
|
|
127
|
+
messageLines.push(""); // Blank line between messages
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
if (total === 0) {
|
|
131
131
|
lines.push(theme.fg("muted", " No matching messages"));
|
|
132
|
+
} else {
|
|
133
|
+
const visibleCount = endIndex - startIndex;
|
|
134
|
+
const linesPerItem = visibleCount > 0 ? messageLines.length / visibleCount : 1;
|
|
135
|
+
const sv = new ScrollView(messageLines, {
|
|
136
|
+
height: messageLines.length,
|
|
137
|
+
scrollbar: "auto",
|
|
138
|
+
totalRows: Math.round(total * linesPerItem),
|
|
139
|
+
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
140
|
+
});
|
|
141
|
+
sv.setScrollOffset(Math.round(startIndex * linesPerItem));
|
|
142
|
+
lines.push(...sv.render(width));
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
// Add
|
|
135
|
-
if (
|
|
145
|
+
// Add search indicator if needed
|
|
146
|
+
if (this.#shouldRenderSearchStatus()) {
|
|
136
147
|
lines.push(this.#renderStatusLine(total));
|
|
137
148
|
}
|
|
138
149
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
|
3
|
+
import { imageReferenceHyperlink, renderImageReferences } from "../image-references";
|
|
3
4
|
import { highlightMagicKeywords } from "../magic-keywords";
|
|
4
5
|
|
|
5
6
|
// OSC 133 shell integration: marks prompt zones for terminal multiplexers
|
|
@@ -11,7 +12,7 @@ const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
|
|
11
12
|
* Component that renders a user message
|
|
12
13
|
*/
|
|
13
14
|
export class UserMessageComponent extends Container {
|
|
14
|
-
constructor(text: string, synthetic = false) {
|
|
15
|
+
constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
|
|
15
16
|
super();
|
|
16
17
|
const bgColor = (value: string) => theme.bg("userMessageBg", value);
|
|
17
18
|
// Paint the magic keywords ("ultrathink"/"orchestrate"/"workflow") inside the rendered
|
|
@@ -20,9 +21,15 @@ export class UserMessageComponent extends Container {
|
|
|
20
21
|
// `highlightMagicKeywords` additionally restores the bubble's own foreground after each
|
|
21
22
|
// painted keyword so the gradient never bleeds into the rest of the line.
|
|
22
23
|
const keywordReset = theme.getFgAnsi("userMessageText") || "\x1b[39m";
|
|
23
|
-
const
|
|
24
|
+
const baseText = synthetic
|
|
24
25
|
? (value: string) => theme.fg("dim", value)
|
|
25
26
|
: (value: string) => theme.fg("userMessageText", highlightMagicKeywords(value, keywordReset));
|
|
27
|
+
const imageLabel = (value: string) => theme.fg("accent", `\x1b[1m\x1b[4m${value}\x1b[24m\x1b[22m`);
|
|
28
|
+
const color = (value: string) =>
|
|
29
|
+
renderImageReferences(value, {
|
|
30
|
+
renderText: baseText,
|
|
31
|
+
renderReference: (label, index) => imageReferenceHyperlink(label, index, imageLinks, imageLabel),
|
|
32
|
+
});
|
|
26
33
|
this.addChild(new Spacer(1));
|
|
27
34
|
this.addChild(
|
|
28
35
|
new Markdown(text, 1, 1, getMarkdownTheme(), {
|