gsd-pi 2.73.1-dev.6f61020 → 2.73.1-dev.a2eb797

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.
Files changed (109) hide show
  1. package/dist/cli-web-branch.d.ts +4 -3
  2. package/dist/cli-web-branch.js +10 -7
  3. package/dist/cli.js +99 -206
  4. package/dist/logo.d.ts +1 -1
  5. package/dist/logo.js +1 -1
  6. package/dist/onboarding.js +59 -53
  7. package/dist/resource-loader.js +2 -2
  8. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +59 -1
  9. package/dist/resources/extensions/gsd/auto/phases.js +15 -9
  10. package/dist/resources/extensions/gsd/auto-dispatch.js +11 -3
  11. package/dist/resources/extensions/gsd/auto-post-unit.js +41 -1
  12. package/dist/resources/extensions/gsd/auto-timeout-recovery.js +13 -0
  13. package/dist/resources/extensions/gsd/auto-verification.js +88 -3
  14. package/dist/resources/extensions/gsd/auto.js +21 -8
  15. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  16. package/dist/resources/extensions/gsd/notification-widget.js +2 -2
  17. package/dist/resources/extensions/gsd/state.js +61 -14
  18. package/dist/web/standalone/.next/BUILD_ID +1 -1
  19. package/dist/web/standalone/.next/app-path-routes-manifest.json +14 -14
  20. package/dist/web/standalone/.next/build-manifest.json +2 -2
  21. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  22. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.html +1 -1
  39. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app-paths-manifest.json +14 -14
  46. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  47. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  48. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  49. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  50. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  51. package/package.json +1 -2
  52. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +138 -0
  53. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
  55. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js +8 -2
  56. package/packages/pi-coding-agent/dist/modes/interactive/components/assistant-message.js.map +1 -1
  57. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +2 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  59. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +9 -3
  60. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  61. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts +2 -0
  62. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.d.ts.map +1 -0
  63. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js +52 -0
  64. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.test.js.map +1 -0
  65. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +21 -4
  67. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  69. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +5 -1
  70. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  71. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +157 -0
  72. package/packages/pi-coding-agent/src/modes/interactive/components/assistant-message.ts +10 -6
  73. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.test.ts +73 -0
  74. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +9 -3
  75. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +21 -4
  76. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +5 -1
  77. package/packages/pi-tui/dist/__tests__/tui.test.js +30 -0
  78. package/packages/pi-tui/dist/__tests__/tui.test.js.map +1 -1
  79. package/packages/pi-tui/dist/tui.d.ts +1 -0
  80. package/packages/pi-tui/dist/tui.d.ts.map +1 -1
  81. package/packages/pi-tui/dist/tui.js +22 -3
  82. package/packages/pi-tui/dist/tui.js.map +1 -1
  83. package/packages/pi-tui/src/__tests__/tui.test.ts +38 -0
  84. package/packages/pi-tui/src/tui.ts +20 -3
  85. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +95 -1
  86. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +88 -0
  87. package/src/resources/extensions/gsd/auto/phases.ts +22 -9
  88. package/src/resources/extensions/gsd/auto-dispatch.ts +10 -4
  89. package/src/resources/extensions/gsd/auto-post-unit.ts +47 -1
  90. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +17 -0
  91. package/src/resources/extensions/gsd/auto-verification.ts +98 -3
  92. package/src/resources/extensions/gsd/auto.ts +26 -14
  93. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -1
  94. package/src/resources/extensions/gsd/notification-widget.ts +2 -2
  95. package/src/resources/extensions/gsd/state.ts +71 -15
  96. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +2 -2
  97. package/src/resources/extensions/gsd/tests/auto-post-unit-step-message.test.ts +53 -0
  98. package/src/resources/extensions/gsd/tests/complete-milestone-false-merge.test.ts +142 -0
  99. package/src/resources/extensions/gsd/tests/completed-at-reconcile.test.ts +42 -0
  100. package/src/resources/extensions/gsd/tests/derive-state-crossval.test.ts +3 -2
  101. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +3 -2
  102. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +68 -8
  103. package/src/resources/extensions/gsd/tests/derive-state.test.ts +3 -3
  104. package/src/resources/extensions/gsd/tests/integration/state-machine-edge-cases.test.ts +4 -2
  105. package/src/resources/extensions/gsd/tests/state-machine-full-walkthrough.test.ts +5 -7
  106. package/src/resources/extensions/gsd/tests/token-profile.test.ts +1 -1
  107. package/src/resources/extensions/gsd/tests/validate-milestone-stuck-guard.test.ts +179 -0
  108. /package/dist/web/standalone/.next/static/{xEc_9MXTYTFRfqJP-SGqp → rCeJUc4hHW3VT0ARiIinf}/_buildManifest.js +0 -0
  109. /package/dist/web/standalone/.next/static/{xEc_9MXTYTFRfqJP-SGqp → rCeJUc4hHW3VT0ARiIinf}/_ssgManifest.js +0 -0
@@ -656,3 +656,160 @@ test("chat-controller does not duplicate text when content is [text, tool, text]
656
656
 
657
657
  assert.deepEqual((secondText as any).range, { startIndex: 2, endIndex: 2 }, "range must not be cleared on message_end (would cause duplication)");
658
658
  });
659
+
660
+ // Regression for the claude-code sub-turn bug that followed #4144:
661
+ // an adapter can reset content[] back to 0/1 mid-lifecycle when a new provider
662
+ // sub-turn begins. The segment walker must NOT update prior-sub-turn text-run
663
+ // components in place (which would destroy earlier history) and must NOT reuse
664
+ // stale tool registrations for a new tool at the same contentIndex. Prior
665
+ // sub-turn children must stay frozen; new sub-turn segments must append after
666
+ // them, and the pinned "Latest Output" mirror must re-evaluate for the new sub-turn.
667
+ test("chat-controller freezes prior sub-turn and appends new segments when content shrinks", async () => {
668
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
669
+ fg: (_key: string, text: string) => text,
670
+ bg: (_key: string, text: string) => text,
671
+ bold: (text: string) => text,
672
+ italic: (text: string) => text,
673
+ truncate: (text: string) => text,
674
+ };
675
+
676
+ const host = createHost();
677
+ host.getMarkdownThemeWithSettings = () => ({});
678
+
679
+ const t1 = { type: "toolCall", id: "t1", name: "tool_one", arguments: {} };
680
+ const t2 = { type: "toolCall", id: "t2", name: "tool_two", arguments: {} };
681
+
682
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
683
+
684
+ // Sub-turn 1: grow to [A, T1, B]
685
+ await handleAgentEvent(host, {
686
+ type: "message_update",
687
+ message: makeAssistant([{ type: "text", text: "A" }]),
688
+ assistantMessageEvent: {
689
+ type: "text_delta", contentIndex: 0, delta: "A",
690
+ partial: makeAssistant([{ type: "text", text: "A" }]),
691
+ },
692
+ } as any);
693
+ await handleAgentEvent(host, {
694
+ type: "message_update",
695
+ message: makeAssistant([{ type: "text", text: "A" }, t1]),
696
+ assistantMessageEvent: {
697
+ type: "toolcall_end", contentIndex: 1,
698
+ toolCall: { ...t1, externalResult: { content: [{ type: "text", text: "r1" }], details: {}, isError: false } },
699
+ partial: makeAssistant([{ type: "text", text: "A" }, t1]),
700
+ },
701
+ } as any);
702
+ await handleAgentEvent(host, {
703
+ type: "message_update",
704
+ message: makeAssistant([{ type: "text", text: "A" }, t1, { type: "text", text: "B" }]),
705
+ assistantMessageEvent: {
706
+ type: "text_delta", contentIndex: 2, delta: "B",
707
+ partial: makeAssistant([{ type: "text", text: "A" }, t1, { type: "text", text: "B" }]),
708
+ },
709
+ } as any);
710
+
711
+ assert.equal(host.chatContainer.children.length, 3, "sub-turn 1 renders 3 children");
712
+ const priorA = host.chatContainer.children[0];
713
+ const priorT1 = host.chatContainer.children[1];
714
+ const priorB = host.chatContainer.children[2];
715
+
716
+ // Sub-turn boundary: adapter resets content[] to [C]
717
+ await handleAgentEvent(host, {
718
+ type: "message_update",
719
+ message: makeAssistant([{ type: "text", text: "C" }]),
720
+ assistantMessageEvent: {
721
+ type: "text_delta", contentIndex: 0, delta: "C",
722
+ partial: makeAssistant([{ type: "text", text: "C" }]),
723
+ },
724
+ } as any);
725
+
726
+ // Prior 3 children must still exist in DOM — and a NEW text-run for "C" appended after them.
727
+ assert.equal(host.chatContainer.children.length, 4, "shrink must append new segment, not replace prior history");
728
+ assert.equal(host.chatContainer.children[0], priorA, "prior A component stays at index 0");
729
+ assert.equal(host.chatContainer.children[1], priorT1, "prior T1 component stays at index 1");
730
+ assert.equal(host.chatContainer.children[2], priorB, "prior B component stays at index 2");
731
+ assert.notEqual(host.chatContainer.children[3], priorA, "new C text-run must be a different component from prior A");
732
+ assert.equal(host.chatContainer.children[3]?.constructor?.name, "AssistantMessageComponent");
733
+
734
+ // Prior A component must still render "A", not be overwritten with "C".
735
+ function getRenderedTexts(comp: any): string[] {
736
+ const contentContainer = comp.children?.[0];
737
+ if (!contentContainer) return [];
738
+ return (contentContainer.children ?? [])
739
+ .filter((c: any) => c.constructor?.name === "Markdown")
740
+ .map((c: any) => (c as any).text as string);
741
+ }
742
+ assert.deepEqual(getRenderedTexts(priorA), ["A"], "prior A text-run must still contain 'A' after shrink");
743
+ assert.deepEqual(getRenderedTexts(priorB), ["B"], "prior B text-run must still contain 'B' after shrink");
744
+ assert.deepEqual(getRenderedTexts(host.chatContainer.children[3]), ["C"], "new text-run must contain only 'C'");
745
+
746
+ // Sub-turn 2 grows with a new tool T2 at contentIndex=1.
747
+ await handleAgentEvent(host, {
748
+ type: "message_update",
749
+ message: makeAssistant([{ type: "text", text: "C" }, t2]),
750
+ assistantMessageEvent: {
751
+ type: "toolcall_end", contentIndex: 1,
752
+ toolCall: { ...t2, externalResult: { content: [{ type: "text", text: "r2" }], details: {}, isError: false } },
753
+ partial: makeAssistant([{ type: "text", text: "C" }, t2]),
754
+ },
755
+ } as any);
756
+
757
+ // T2 must be appended after the new C text-run, not conflated with the stale T1 registration.
758
+ assert.equal(host.chatContainer.children.length, 5, "new tool appends after new text-run");
759
+ assert.equal(host.chatContainer.children[4]?.constructor?.name, "ToolExecutionComponent");
760
+ assert.notEqual(host.chatContainer.children[4], priorT1, "new T2 must be a different component from prior T1");
761
+
762
+ // Finalize so the module-level pinned spinner (setInterval) is torn down and the test process can exit.
763
+ await handleAgentEvent(host, { type: "message_end", message: makeAssistant([{ type: "text", text: "C" }, t2]) } as any);
764
+ });
765
+
766
+ // Regression: after a sub-turn shrink, lastPinnedText must be cleared so the
767
+ // pinned "Latest Output" mirror can display text from the new sub-turn instead
768
+ // of staying frozen on a stale snapshot (the "bottom green stays" symptom).
769
+ test("chat-controller updates pinned zone after sub-turn shrink", async () => {
770
+ (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = {
771
+ fg: (_key: string, text: string) => text,
772
+ bg: (_key: string, text: string) => text,
773
+ bold: (text: string) => text,
774
+ italic: (text: string) => text,
775
+ truncate: (text: string) => text,
776
+ };
777
+
778
+ const host = createHost();
779
+ host.getMarkdownThemeWithSettings = () => ({});
780
+
781
+ const t1 = { type: "toolCall", id: "t1", name: "tool_one", arguments: {} };
782
+ const t2 = { type: "toolCall", id: "t2", name: "tool_two", arguments: {} };
783
+
784
+ await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any);
785
+
786
+ // Sub-turn 1 with pinnable text before a tool → populates pinned zone with "first".
787
+ await handleAgentEvent(host, {
788
+ type: "message_update",
789
+ message: makeAssistant([{ type: "text", text: "first" }, t1]),
790
+ assistantMessageEvent: {
791
+ type: "toolcall_end", contentIndex: 1,
792
+ toolCall: { ...t1, externalResult: { content: [{ type: "text", text: "r1" }], details: {}, isError: false } },
793
+ partial: makeAssistant([{ type: "text", text: "first" }, t1]),
794
+ },
795
+ } as any);
796
+ const pinnedMarkdown = host.pinnedMessageContainer.children[1];
797
+ assert.equal((pinnedMarkdown as any)?.text, "first", "pinned zone seeded with sub-turn 1 text");
798
+
799
+ // Sub-turn boundary: content resets to [second, t2].
800
+ await handleAgentEvent(host, {
801
+ type: "message_update",
802
+ message: makeAssistant([{ type: "text", text: "second" }, t2]),
803
+ assistantMessageEvent: {
804
+ type: "toolcall_end", contentIndex: 1,
805
+ toolCall: { ...t2, externalResult: { content: [{ type: "text", text: "r2" }], details: {}, isError: false } },
806
+ partial: makeAssistant([{ type: "text", text: "second" }, t2]),
807
+ },
808
+ } as any);
809
+
810
+ // Pinned markdown must now reflect the new sub-turn's text, not stay frozen on "first".
811
+ assert.equal((pinnedMarkdown as any)?.text, "second", "pinned zone must update after sub-turn shrink (#4144 regression)");
812
+
813
+ // Finalize so the module-level pinned spinner (setInterval) is torn down and the test process can exit.
814
+ await handleAgentEvent(host, { type: "message_end", message: makeAssistant([{ type: "text", text: "second" }, t2]) } as any);
815
+ });
@@ -87,6 +87,7 @@ export class AssistantMessageComponent extends Container {
87
87
  const hasVisibleContent = slice.some(
88
88
  (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
89
89
  );
90
+ const hasTextContent = message.content.some((c) => c.type === "text" && c.text.trim().length > 0);
90
91
 
91
92
  if (hasVisibleContent) {
92
93
  this.contentContainer.addChild(new Spacer(1));
@@ -114,12 +115,15 @@ export class AssistantMessageComponent extends Container {
114
115
  }
115
116
  } else {
116
117
  // Thinking traces in thinkingText color, italic
117
- this.contentContainer.addChild(
118
- new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, {
119
- color: (text: string) => theme.fg("thinkingText", text),
120
- italic: true,
121
- }),
122
- );
118
+ const thinkingMarkdown = new Markdown(content.thinking.trim(), 1, 0, this.markdownTheme, {
119
+ color: (text: string) => theme.fg("thinkingText", text),
120
+ italic: true,
121
+ });
122
+ // Keep assistant text/questions visible even when thinking traces are long.
123
+ if (hasTextContent) {
124
+ thinkingMarkdown.maxLines = 8;
125
+ }
126
+ this.contentContainer.addChild(thinkingMarkdown);
123
127
  if (hasVisibleContentAfter) {
124
128
  this.contentContainer.addChild(new Spacer(1));
125
129
  }
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it, mock } from "node:test";
3
+
4
+ import { DynamicBorder } from "./dynamic-border.js";
5
+
6
+ function makeTUI() {
7
+ return {
8
+ renderCount: 0,
9
+ requestRender() {
10
+ this.renderCount++;
11
+ },
12
+ };
13
+ }
14
+
15
+ describe("DynamicBorder spinner", () => {
16
+ it("suppresses standalone render when an external render occurred recently", () => {
17
+ const border = new DynamicBorder((s) => s);
18
+ const tui = makeTUI();
19
+
20
+ border.startSpinner(tui as any, (s) => s);
21
+ // startSpinner calls requestRender once immediately
22
+ assert.equal(tui.renderCount, 1, "initial render on startSpinner");
23
+
24
+ // Simulate an externally-triggered render (e.g. from streaming)
25
+ border.render(80);
26
+
27
+ // Access the private interval callback by advancing the timer
28
+ // Instead, we directly test the render-batching logic:
29
+ // After render() sets lastExternalRender, a spinner tick within 200ms
30
+ // should NOT call requestRender.
31
+ const anyBorder = border as any;
32
+ assert.ok(
33
+ Date.now() - anyBorder.lastExternalRender < 200,
34
+ "lastExternalRender should be recent after render()",
35
+ );
36
+
37
+ border.stopSpinner();
38
+ });
39
+
40
+ it("triggers standalone render when no external render occurred recently", async () => {
41
+ const border = new DynamicBorder((s) => s);
42
+ const tui = makeTUI();
43
+
44
+ // Set lastExternalRender to a time well in the past
45
+ const anyBorder = border as any;
46
+ anyBorder.lastExternalRender = 0;
47
+
48
+ border.startSpinner(tui as any, (s) => s);
49
+ const initialCount = tui.renderCount;
50
+
51
+ // Wait for one spinner tick (200ms interval + buffer)
52
+ await new Promise((r) => setTimeout(r, 250));
53
+
54
+ assert.ok(
55
+ tui.renderCount > initialCount,
56
+ "spinner should trigger requestRender when no recent external render",
57
+ );
58
+
59
+ border.stopSpinner();
60
+ });
61
+
62
+ it("updates lastExternalRender on each render() call", () => {
63
+ const border = new DynamicBorder((s) => s);
64
+ const anyBorder = border as any;
65
+
66
+ const before = Date.now();
67
+ border.render(80);
68
+ const after = Date.now();
69
+
70
+ assert.ok(anyBorder.lastExternalRender >= before);
71
+ assert.ok(anyBorder.lastExternalRender <= after);
72
+ });
73
+ });
@@ -17,6 +17,7 @@ export class DynamicBorder implements Component {
17
17
  private spinnerIndex = 0;
18
18
  private spinnerInterval: NodeJS.Timeout | null = null;
19
19
  private spinnerColorFn?: (str: string) => string;
20
+ private lastExternalRender = 0;
20
21
 
21
22
  constructor(color: (str: string) => string = (str) => {
22
23
  try { return theme.fg("border", str); } catch { return str; }
@@ -31,7 +32,7 @@ export class DynamicBorder implements Component {
31
32
 
32
33
  /**
33
34
  * Start an animated spinner that prepends to the label.
34
- * The spinner rotates every 80ms and triggers a re-render via the TUI.
35
+ * The spinner rotates every 200ms and triggers a re-render via the TUI.
35
36
  */
36
37
  startSpinner(ui: TUI, colorFn: (str: string) => string): void {
37
38
  this.stopSpinner();
@@ -39,8 +40,12 @@ export class DynamicBorder implements Component {
39
40
  this.spinnerIndex = 0;
40
41
  this.spinnerInterval = setInterval(() => {
41
42
  this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
42
- ui.requestRender();
43
- }, 80);
43
+ // Only trigger standalone render if no other source rendered recently.
44
+ // During active streaming, message_update already calls requestRender().
45
+ if (Date.now() - this.lastExternalRender > 200) {
46
+ ui.requestRender();
47
+ }
48
+ }, 200);
44
49
  ui.requestRender();
45
50
  }
46
51
 
@@ -64,6 +69,7 @@ export class DynamicBorder implements Component {
64
69
  }
65
70
 
66
71
  render(width: number): string[] {
72
+ this.lastExternalRender = Date.now();
67
73
  const spinnerPrefix = this.spinnerInterval && this.spinnerColorFn
68
74
  ? this.spinnerColorFn(this.spinnerFrames[this.spinnerIndex]) + " "
69
75
  : "";
@@ -10,6 +10,10 @@ import { appKey } from "../components/keybinding-hints.js";
10
10
  // Tracks the last processed content index to avoid re-scanning all blocks on every message_update
11
11
  let lastProcessedContentIndex = 0;
12
12
 
13
+ // Tracks the previous content[] length so we can detect when an adapter resets
14
+ // the assistant content array for a new provider sub-turn within one lifecycle.
15
+ let lastContentLength = 0;
16
+
13
17
  // --- Segment walker state (per streaming assistant turn) ---
14
18
  type RenderedSegment =
15
19
  | { kind: "text-run"; startIndex: number; endIndex: number; component: AssistantMessageComponent }
@@ -85,6 +89,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
85
89
  // Reset content index tracker and pinned state when a new assistant message starts
86
90
  if (event.type === "message_start" && event.message.role === "assistant") {
87
91
  lastProcessedContentIndex = 0;
92
+ lastContentLength = 0;
88
93
  lastPinnedText = "";
89
94
  hasToolsInTurn = false;
90
95
  renderedSegments = [];
@@ -108,6 +113,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
108
113
  lastPinnedText = "";
109
114
  hasToolsInTurn = false;
110
115
  renderedSegments = [];
116
+ lastContentLength = 0;
111
117
  if (pinnedBorder) pinnedBorder.stopSpinner();
112
118
  pinnedBorder = undefined;
113
119
  pinnedTextComponent = undefined;
@@ -211,12 +217,22 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
211
217
  }
212
218
 
213
219
  const contentBlocks = host.streamingMessage.content;
214
- // Some adapters reuse a single assistant lifecycle while internally
215
- // spanning multiple provider turns. When a new turn starts, content
216
- // length can shrink back to 0/1; reset scan index to avoid skipping.
217
- if (lastProcessedContentIndex >= contentBlocks.length) {
220
+ // Some adapters (notably claude-code) reuse a single assistant
221
+ // lifecycle while internally spanning multiple provider sub-turns.
222
+ // When a new sub-turn starts, content[] length shrinks back to 0/1.
223
+ // The scan loop needs its index reset, AND the segment walker's
224
+ // renderedSegments map must be cleared so existing text-run
225
+ // components don't get overwritten in place with new sub-turn
226
+ // content (#4144 regression). Prior sub-turn children stay in
227
+ // chatContainer as frozen history; new segments append after them.
228
+ if (contentBlocks.length < lastContentLength) {
229
+ renderedSegments = [];
230
+ lastPinnedText = "";
231
+ lastProcessedContentIndex = 0;
232
+ } else if (lastProcessedContentIndex >= contentBlocks.length) {
218
233
  lastProcessedContentIndex = 0;
219
234
  }
235
+ lastContentLength = contentBlocks.length;
220
236
  for (let i = lastProcessedContentIndex; i < contentBlocks.length; i++) {
221
237
  const content = contentBlocks[i];
222
238
  if (content.type === "toolCall") {
@@ -474,6 +490,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
474
490
  host.streamingComponent = undefined;
475
491
  host.streamingMessage = undefined;
476
492
  renderedSegments = [];
493
+ lastContentLength = 0;
477
494
  // Clear pinned output once the message is finalized in the chat
478
495
  // container — prevents duplicate display when the agent continues
479
496
  // (e.g. form elicitation) after the assistant message ends.
@@ -2306,9 +2306,13 @@ export class InteractiveMode {
2306
2306
 
2307
2307
  private rebuildChatFromMessages(): void {
2308
2308
  this.chatContainer.clear();
2309
+ this.pinnedMessageContainer.clear();
2309
2310
  const context = this.sessionManager.buildSessionContext();
2310
2311
  this.renderSessionContext(context);
2311
- this.populatePinnedFromMessages(context.messages);
2312
+ // Pinned content NOT re-populated here — the streaming lifecycle in
2313
+ // chat-controller.ts manages the pinned zone during active work.
2314
+ // populatePinnedFromMessages() remains in renderInitialMessages()
2315
+ // for the session-resume case at startup.
2312
2316
  }
2313
2317
 
2314
2318
  /**
@@ -20,6 +20,36 @@ function makeTerminal() {
20
20
  setTitle() { },
21
21
  };
22
22
  }
23
+ describe("TUI clearOnShrink debounce", () => {
24
+ it("defers full redraw on first shrink and commits on second", () => {
25
+ const tui = new TUI(makeTerminal());
26
+ const anyTui = tui;
27
+ // Enable clearOnShrink and simulate prior rendering state
28
+ anyTui.clearOnShrink = true;
29
+ anyTui.maxLinesRendered = 10;
30
+ anyTui._shrinkDebounceActive = false;
31
+ // Simulate a shrink: newLines has fewer lines than maxLinesRendered
32
+ // First shrink should set debounce flag but NOT reset maxLinesRendered
33
+ anyTui._shrinkDebounceActive = false;
34
+ // Verify the flag exists and is initially false
35
+ assert.equal(anyTui._shrinkDebounceActive, false);
36
+ // After setting it to true (simulating first shrink detection),
37
+ // maxLinesRendered should remain at the old value so the condition
38
+ // triggers again on the next render
39
+ anyTui._shrinkDebounceActive = true;
40
+ assert.equal(anyTui.maxLinesRendered, 10, "maxLinesRendered must not change during deferred shrink");
41
+ });
42
+ it("resets debounce flag when content grows back", () => {
43
+ const tui = new TUI(makeTerminal());
44
+ const anyTui = tui;
45
+ anyTui.clearOnShrink = true;
46
+ anyTui._shrinkDebounceActive = true;
47
+ // Simulating the else branch: content grew back or no shrink
48
+ // The code sets _shrinkDebounceActive = false in the else branch
49
+ anyTui._shrinkDebounceActive = false;
50
+ assert.equal(anyTui._shrinkDebounceActive, false);
51
+ });
52
+ });
23
53
  describe("TUI", () => {
24
54
  it("does not swallow a bare Escape keypress while waiting for the cell-size response", () => {
25
55
  const tui = new TUI(makeTerminal());
@@ -1 +1 @@
1
- {"version":3,"file":"tui.test.js","sourceRoot":"","sources":["../../src/__tests__/tui.test.ts"],"names":[],"mappings":"AAAA,OAAO,MAAM,MAAM,oBAAoB,CAAC;AACxC,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,WAAW,CAAC;AAEzC,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAI3C,SAAS,YAAY;IACpB,OAAO;QACN,KAAK,EAAE,IAAI;QACX,OAAO,EAAE,EAAE;QACX,IAAI,EAAE,EAAE;QACR,mBAAmB,EAAE,KAAK;QAC1B,KAAK,KAAI,CAAC;QACV,IAAI,KAAI,CAAC;QACT,UAAU,EAAE,KAAK,IAAI,EAAE,GAAE,CAAC;QAC1B,KAAK,KAAI,CAAC;QACV,MAAM,KAAI,CAAC;QACX,UAAU,KAAI,CAAC;QACf,UAAU,KAAI,CAAC;QACf,SAAS,KAAI,CAAC;QACd,eAAe,KAAI,CAAC;QACpB,WAAW,KAAI,CAAC;QAChB,QAAQ,KAAI,CAAC;KACb,CAAC;AACH,CAAC;AAED,QAAQ,CAAC,KAAK,EAAE,GAAG,EAAE;IACpB,EAAE,CAAC,kFAAkF,EAAE,GAAG,EAAE;QAC3F,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,YAAY,EAAE,CAAC,CAAC;QACpC,MAAM,QAAQ,GAAa,EAAE,CAAC;QAE9B,GAAG,CAAC,QAAQ,CAAC;YACZ,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE;YAChB,WAAW,EAAE,CAAC,IAAY,EAAE,EAAE;gBAC7B,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACrB,CAAC;YACD,UAAU,KAAI,CAAC;SACf,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,GAAU,CAAC;QAC1B,MAAM,CAAC,oBAAoB,GAAG,IAAI,CAAC;QACnC,MAAM,CAAC,WAAW,GAAG,EAAE,CAAC;QAExB,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QAE3B,MAAM,CAAC,SAAS,CAAC,QAAQ,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC;QACrC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,oBAAoB,EAAE,KAAK,CAAC,CAAC;QACjD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;IACtC,CAAC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,WAAW,EAAE,GAAG,EAAE;IAC1B,SAAS,mBAAmB,CAAC,OAA6B;QACzD,OAAO;YACN,MAAM,EAAE,GAAG,EAAE,CAAC,EAAE;YAChB,UAAU,KAAI,CAAC;YACf,OAAO;gBACN,OAAO,CAAC,QAAQ,EAAE,CAAC;YACpB,CAAC;SACD,CAAC;IACH,CAAC;IAED,EAAE,CAAC,0DAA0D,EAAE,GAAG,EAAE;QACnE,MAAM,CAAC,GAAG,IAAI,SAAS,EAAE,CAAC;QAC1B,MAAM,OAAO,GAAG,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;QAChC,CAAC,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC,CAAC;QACzC,CAAC,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC,CAAC;QAEzC,CAAC,CAAC,cAAc,EAAE,CAAC;QAEnB,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACnC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,6EAA6E,EAAE,GAAG,EAAE;QACtF,MAAM,CAAC,GAAG,IAAI,SAAS,EAAE,CAAC;QAC1B,MAAM,OAAO,GAAG,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;QAChC,CAAC,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC,CAAC;QACzC,CAAC,CAAC,QAAQ,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAC,CAAC;QAEzC,CAAC,CAAC,KAAK,EAAE,CAAC;QAEV,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC;QACnC,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC;IACnC,CAAC,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC","sourcesContent":["import assert from \"node:assert/strict\";\nimport { describe, it } from \"node:test\";\n\nimport { Container, TUI } from \"../tui.js\";\nimport type { Component } from \"../tui.js\";\nimport type { Terminal } from \"../terminal.js\";\n\nfunction makeTerminal(): Terminal {\n\treturn {\n\t\tisTTY: true,\n\t\tcolumns: 80,\n\t\trows: 24,\n\t\tkittyProtocolActive: false,\n\t\tstart() {},\n\t\tstop() {},\n\t\tdrainInput: async () => {},\n\t\twrite() {},\n\t\tmoveBy() {},\n\t\thideCursor() {},\n\t\tshowCursor() {},\n\t\tclearLine() {},\n\t\tclearFromCursor() {},\n\t\tclearScreen() {},\n\t\tsetTitle() {},\n\t};\n}\n\ndescribe(\"TUI\", () => {\n\tit(\"does not swallow a bare Escape keypress while waiting for the cell-size response\", () => {\n\t\tconst tui = new TUI(makeTerminal());\n\t\tconst received: string[] = [];\n\n\t\ttui.setFocus({\n\t\t\trender: () => [],\n\t\t\thandleInput: (data: string) => {\n\t\t\t\treceived.push(data);\n\t\t\t},\n\t\t\tinvalidate() {},\n\t\t});\n\n\t\tconst anyTui = tui as any;\n\t\tanyTui.cellSizeQueryPending = true;\n\t\tanyTui.inputBuffer = \"\";\n\n\t\tanyTui.handleInput(\"\\x1b\");\n\n\t\tassert.deepEqual(received, [\"\\x1b\"]);\n\t\tassert.equal(anyTui.cellSizeQueryPending, false);\n\t\tassert.equal(anyTui.inputBuffer, \"\");\n\t});\n});\n\ndescribe(\"Container\", () => {\n\tfunction makeDisposableChild(counter: { disposed: number }): Component & { dispose(): void } {\n\t\treturn {\n\t\t\trender: () => [],\n\t\t\tinvalidate() {},\n\t\t\tdispose() {\n\t\t\t\tcounter.disposed++;\n\t\t\t},\n\t\t};\n\t}\n\n\tit(\"detachChildren() removes children without disposing them\", () => {\n\t\tconst c = new Container();\n\t\tconst counter = { disposed: 0 };\n\t\tc.addChild(makeDisposableChild(counter));\n\t\tc.addChild(makeDisposableChild(counter));\n\n\t\tc.detachChildren();\n\n\t\tassert.equal(c.children.length, 0);\n\t\tassert.equal(counter.disposed, 0);\n\t});\n\n\tit(\"clear() still disposes children (regression guard for detach/dispose split)\", () => {\n\t\tconst c = new Container();\n\t\tconst counter = { disposed: 0 };\n\t\tc.addChild(makeDisposableChild(counter));\n\t\tc.addChild(makeDisposableChild(counter));\n\n\t\tc.clear();\n\n\t\tassert.equal(c.children.length, 0);\n\t\tassert.equal(counter.disposed, 2);\n\t});\n});\n"]}
1
+ {"version":3,"file":"tui.test.js","sourceRoot":"","sources":["../../src/__tests__/tui.test.ts"],"names":[],"mappings":"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","sourcesContent":["import assert from \"node:assert/strict\";\nimport { describe, it } from \"node:test\";\n\nimport { Container, TUI } from \"../tui.js\";\nimport type { Component } from \"../tui.js\";\nimport type { Terminal } from \"../terminal.js\";\n\nfunction makeTerminal(): Terminal {\n\treturn {\n\t\tisTTY: true,\n\t\tcolumns: 80,\n\t\trows: 24,\n\t\tkittyProtocolActive: false,\n\t\tstart() {},\n\t\tstop() {},\n\t\tdrainInput: async () => {},\n\t\twrite() {},\n\t\tmoveBy() {},\n\t\thideCursor() {},\n\t\tshowCursor() {},\n\t\tclearLine() {},\n\t\tclearFromCursor() {},\n\t\tclearScreen() {},\n\t\tsetTitle() {},\n\t};\n}\n\ndescribe(\"TUI clearOnShrink debounce\", () => {\n\tit(\"defers full redraw on first shrink and commits on second\", () => {\n\t\tconst tui = new TUI(makeTerminal());\n\t\tconst anyTui = tui as any;\n\n\t\t// Enable clearOnShrink and simulate prior rendering state\n\t\tanyTui.clearOnShrink = true;\n\t\tanyTui.maxLinesRendered = 10;\n\t\tanyTui._shrinkDebounceActive = false;\n\n\t\t// Simulate a shrink: newLines has fewer lines than maxLinesRendered\n\t\t// First shrink should set debounce flag but NOT reset maxLinesRendered\n\t\tanyTui._shrinkDebounceActive = false;\n\n\t\t// Verify the flag exists and is initially false\n\t\tassert.equal(anyTui._shrinkDebounceActive, false);\n\n\t\t// After setting it to true (simulating first shrink detection),\n\t\t// maxLinesRendered should remain at the old value so the condition\n\t\t// triggers again on the next render\n\t\tanyTui._shrinkDebounceActive = true;\n\t\tassert.equal(anyTui.maxLinesRendered, 10, \"maxLinesRendered must not change during deferred shrink\");\n\t});\n\n\tit(\"resets debounce flag when content grows back\", () => {\n\t\tconst tui = new TUI(makeTerminal());\n\t\tconst anyTui = tui as any;\n\n\t\tanyTui.clearOnShrink = true;\n\t\tanyTui._shrinkDebounceActive = true;\n\n\t\t// Simulating the else branch: content grew back or no shrink\n\t\t// The code sets _shrinkDebounceActive = false in the else branch\n\t\tanyTui._shrinkDebounceActive = false;\n\t\tassert.equal(anyTui._shrinkDebounceActive, false);\n\t});\n});\n\ndescribe(\"TUI\", () => {\n\tit(\"does not swallow a bare Escape keypress while waiting for the cell-size response\", () => {\n\t\tconst tui = new TUI(makeTerminal());\n\t\tconst received: string[] = [];\n\n\t\ttui.setFocus({\n\t\t\trender: () => [],\n\t\t\thandleInput: (data: string) => {\n\t\t\t\treceived.push(data);\n\t\t\t},\n\t\t\tinvalidate() {},\n\t\t});\n\n\t\tconst anyTui = tui as any;\n\t\tanyTui.cellSizeQueryPending = true;\n\t\tanyTui.inputBuffer = \"\";\n\n\t\tanyTui.handleInput(\"\\x1b\");\n\n\t\tassert.deepEqual(received, [\"\\x1b\"]);\n\t\tassert.equal(anyTui.cellSizeQueryPending, false);\n\t\tassert.equal(anyTui.inputBuffer, \"\");\n\t});\n});\n\ndescribe(\"Container\", () => {\n\tfunction makeDisposableChild(counter: { disposed: number }): Component & { dispose(): void } {\n\t\treturn {\n\t\t\trender: () => [],\n\t\t\tinvalidate() {},\n\t\t\tdispose() {\n\t\t\t\tcounter.disposed++;\n\t\t\t},\n\t\t};\n\t}\n\n\tit(\"detachChildren() removes children without disposing them\", () => {\n\t\tconst c = new Container();\n\t\tconst counter = { disposed: 0 };\n\t\tc.addChild(makeDisposableChild(counter));\n\t\tc.addChild(makeDisposableChild(counter));\n\n\t\tc.detachChildren();\n\n\t\tassert.equal(c.children.length, 0);\n\t\tassert.equal(counter.disposed, 0);\n\t});\n\n\tit(\"clear() still disposes children (regression guard for detach/dispose split)\", () => {\n\t\tconst c = new Container();\n\t\tconst counter = { disposed: 0 };\n\t\tc.addChild(makeDisposableChild(counter));\n\t\tc.addChild(makeDisposableChild(counter));\n\n\t\tc.clear();\n\n\t\tassert.equal(c.children.length, 0);\n\t\tassert.equal(counter.disposed, 2);\n\t});\n});\n"]}
@@ -157,6 +157,7 @@ export declare class TUI extends Container {
157
157
  private cellSizeQueryPending;
158
158
  private showHardwareCursor;
159
159
  private clearOnShrink;
160
+ private _shrinkDebounceActive;
160
161
  private maxLinesRendered;
161
162
  private previousViewportTop;
162
163
  private fullRedrawCount;
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"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"}
@@ -110,6 +110,7 @@ export class TUI extends Container {
110
110
  this.cellSizeQueryPending = false;
111
111
  this.showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1" || process.env.TERM_PROGRAM === "WarpTerminal";
112
112
  this.clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
113
+ this._shrinkDebounceActive = false;
113
114
  this.maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
114
115
  this.previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
115
116
  this.fullRedrawCount = 0;
@@ -537,9 +538,27 @@ export class TUI extends Container {
537
538
  // (overlays need the padding, so only do this when no overlays are active)
538
539
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
539
540
  if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
540
- logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
541
- fullRender(true);
542
- return;
541
+ if (!this._shrinkDebounceActive) {
542
+ // First shrink detection: defer the full redraw by one tick.
543
+ // If content grows back immediately (pinned clear → new streaming),
544
+ // the full redraw is avoided.
545
+ this._shrinkDebounceActive = true;
546
+ // Do NOT update maxLinesRendered here — keep the old value so the
547
+ // condition `newLines.length < maxLinesRendered` still triggers on
548
+ // the next render if content stays shrunk.
549
+ logRedraw(`clearOnShrink deferred (maxLinesRendered=${this.maxLinesRendered})`);
550
+ // Fall through to differential render for this frame
551
+ }
552
+ else {
553
+ // Still shrunk on second render — commit the full redraw
554
+ this._shrinkDebounceActive = false;
555
+ logRedraw(`clearOnShrink committed (maxLinesRendered=${this.maxLinesRendered})`);
556
+ fullRender(true);
557
+ return;
558
+ }
559
+ }
560
+ else {
561
+ this._shrinkDebounceActive = false;
543
562
  }
544
563
  // Find first and last changed lines
545
564
  let firstChanged = -1;