@luan.sh/pi-libtui 0.3.8

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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,175 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { rgb } from "../src/color/palette.ts";
4
+ import { createTuiThemeVariation, parseBackgroundAnsi, tuiTheme, tuiThemeAppearance } from "../src/color/theme.ts";
5
+ import { terminalColorsRegistry } from "../src/terminal-colors.ts";
6
+
7
+ afterEach(() => terminalColorsRegistry().publish(undefined));
8
+
9
+ describe("TUI theme colors", () => {
10
+ test("derives an adjacent TUI theme by shifting surfaces and accent", () => {
11
+ const theme = {
12
+ name: "parent",
13
+ getFgAnsi: (token: string) => (token === "mdHeading" ? "\x1b[38;2;220;120;40m" : "\x1b[38;2;120;160;220m"),
14
+ getBgAnsi: (token: string) => (token === "toolPendingBg" ? "\x1b[48;2;10;20;30m" : "\x1b[48;2;40;50;60m"),
15
+ } as never as Theme;
16
+ const variation = createTuiThemeVariation(theme, "side");
17
+
18
+ expect(variation.name).toBe("side");
19
+ expect(variation.colors.accent).toBe("#dc7828");
20
+ expect(variation.colors.borderAccent).toBe("#dc7828");
21
+ expect(variation.colors.userMessageBg).toBe("#0a141e");
22
+ expect(variation.colors.toolPendingBg).toBe("#28323c");
23
+ expect(variation.colors.syntaxString).toBe("#78a0dc");
24
+ });
25
+
26
+ test("derives adjacent application appearance from the active Pi surface", () => {
27
+ const themed = (background: string) =>
28
+ ({
29
+ name: "surface",
30
+ getBgAnsi: () => background,
31
+ }) as never as Theme;
32
+
33
+ expect(tuiThemeAppearance(themed("\x1b[48;2;20;24;30m"))).toBe("dark");
34
+ expect(tuiThemeAppearance(themed("\x1b[48;2;238;240;245m"))).toBe("light");
35
+ });
36
+
37
+ test("exposes generated semantic roles and stable hue swatches", () => {
38
+ const theme = {
39
+ name: "tokyo-night",
40
+ getColorMode: () => "truecolor",
41
+ getFgAnsi: (token: string) =>
42
+ ({
43
+ error: "\x1b[38;2;247;118;142m",
44
+ success: "\x1b[38;2;158;206;106m",
45
+ warning: "\x1b[38;2;224;175;104m",
46
+ accent: "\x1b[38;2;122;162;247m",
47
+ border: "\x1b[38;2;59;66;97m",
48
+ mdHeading: "\x1b[38;2;255;158;100m",
49
+ syntaxKeyword: "\x1b[38;2;187;154;247m",
50
+ mdLink: "\x1b[38;2;125;207;255m",
51
+ muted: "\x1b[38;2;86;95;137m",
52
+ dim: "\x1b[38;2;59;66;97m",
53
+ text: "\x1b[39m",
54
+ })[token] ?? "\x1b[39m",
55
+ getBgAnsi: (token: string) => (token === "selectedBg" ? "\x1b[48;2;41;46;66m" : "\x1b[48;2;26;27;38m"),
56
+ } as never as Theme;
57
+ const colors = tuiTheme(theme);
58
+ const green = colors.color({ hue: "green", shade: 5 });
59
+ expect(green).toBe(colors.color({ hue: "green", shade: 5 }));
60
+ expect(colors.bgAnsi(green)).toBe("\x1b[48;2;158;206;106m");
61
+ expect(colors.fgAnsi("border")).toBe("\x1b[38;2;59;66;97m");
62
+ expect(colors.fgAnsi("heading")).toBe("\x1b[38;2;255;158;100m");
63
+ expect(colors.fgAnsi("accent")).toBe("\x1b[38;2;122;162;247m");
64
+ expect(colors.fgAnsi(colors.mixForeground("text.muted", "accent", 0))).toBe(colors.fgAnsi("text.muted"));
65
+ expect(colors.fgAnsi(colors.mixForeground("text.muted", "accent", 1))).toBe(colors.fgAnsi("accent"));
66
+ expect(colors.fgAnsi(colors.adjustForegroundBrightness("accent", 0))).toBe(colors.fgAnsi("accent"));
67
+ const indexedColors = tuiTheme({ ...theme, getColorMode: () => "256color" } as Theme);
68
+ expect(indexedColors.bgAnsi("diff.hunkHover")).not.toBe(indexedColors.bgAnsi("diff.hunk"));
69
+ expect(indexedColors.bgAnsi("diff.hunkGutterHover")).not.toBe(indexedColors.bgAnsi("diff.hunkGutter"));
70
+ const bg = (token: Parameters<typeof colors.bgAnsi>[0]) => parseBackgroundAnsi(colors.bgAnsi(token))!;
71
+ const added = bg("diff.added");
72
+ const removed = bg("diff.removed");
73
+ const addedGutter = bg("diff.addedGutter");
74
+ const removedGutter = bg("diff.removedGutter");
75
+ expect(added.g).toBeGreaterThan(added.r);
76
+ expect(removed.r).toBeGreaterThan(removed.g);
77
+ expect(addedGutter).not.toEqual(added);
78
+ expect(removedGutter).not.toEqual(removed);
79
+ expect(colors.bgAnsi("badge.positive")).toMatch(/^\x1b\[48;2;/);
80
+ expect(colors.fg("positive", "done")).toEndWith("done\x1b[39m");
81
+ const logical = colors.color("accent");
82
+ expect(colors.fg(logical, "custom")).toBe(`${colors.fgAnsi("accent")}custom\x1b[39m`);
83
+ expect(colors.bg(logical, "custom")).toBe(`${colors.bgAnsi(logical)}custom\x1b[49m`);
84
+ expect(() => colors.bgAnsi({} as never)).toThrow("Color must come from a pi-libtui TuiTheme");
85
+ });
86
+
87
+ test("re-resolves semantic color handles in the consuming theme", () => {
88
+ const hostTheme = (accent: string): Theme =>
89
+ ({
90
+ name: `host-${accent}`,
91
+ getColorMode: () => "truecolor",
92
+ getFgAnsi: (token: string) =>
93
+ token === "accent"
94
+ ? accent
95
+ : token === "border" || token === "mdHeading"
96
+ ? "\x1b[38;2;70;80;90m"
97
+ : "\x1b[39m",
98
+ getBgAnsi: () => "\x1b[49m",
99
+ }) as never as Theme;
100
+ const first = tuiTheme(hostTheme("\x1b[38;2;10;20;30m"));
101
+ const accent = first.color("accent");
102
+ expect(first.color("border")).not.toBe(first.color("heading"));
103
+ const second = tuiTheme(hostTheme("\x1b[38;2;40;50;60m"));
104
+ expect(second.fg(accent, "accent")).toBe("\x1b[38;2;40;50;60maccent\x1b[39m");
105
+ });
106
+
107
+ test("reinterprets indexed opaque colors through the current terminal palette", () => {
108
+ const firstPalette = Array.from({ length: 16 }, (_, index) => rgb(index * 12, index * 6, index * 3));
109
+ const secondPalette = Array.from({ length: 16 }, (_, index) =>
110
+ rgb(255 - index * 12, 255 - index * 6, 255 - index * 3),
111
+ );
112
+ const theme = { name: "harmonious", getColorMode: () => "truecolor" } as Theme;
113
+ terminalColorsRegistry().publish({
114
+ scheme: "dark",
115
+ indexedPalette: "custom",
116
+ defaultBackground: firstPalette[0],
117
+ defaultForeground: firstPalette[15],
118
+ ansiBase16: firstPalette,
119
+ });
120
+ const first = tuiTheme(theme);
121
+ const accent = first.color("accent");
122
+ const firstAnsi = first.fgAnsi("accent");
123
+
124
+ terminalColorsRegistry().publish({
125
+ scheme: "light",
126
+ indexedPalette: "custom",
127
+ defaultBackground: secondPalette[0],
128
+ defaultForeground: secondPalette[15],
129
+ ansiBase16: secondPalette,
130
+ });
131
+ const second = tuiTheme(theme);
132
+ expect(second.fgAnsi("accent")).not.toBe(firstAnsi);
133
+ expect(second.fg(accent, "accent")).toBe(`${second.fgAnsi("accent")}accent\x1b[39m`);
134
+ });
135
+
136
+ test("derives cursor surfaces away from both dark and light backgrounds", () => {
137
+ const darkTheme = {
138
+ name: "dark-test",
139
+ getColorMode: () => "truecolor",
140
+ getFgAnsi: (token: string) => (token === "text" ? "\x1b[38;2;240;240;240m" : "\x1b[38;2;100;120;140m"),
141
+ getBgAnsi: () => "\x1b[48;2;24;28;36m",
142
+ } as never as Theme;
143
+ const lightTheme = {
144
+ name: "light-test",
145
+ getColorMode: () => "truecolor",
146
+ getFgAnsi: (token: string) => (token === "text" ? "\x1b[38;2;25;25;25m" : "\x1b[38;2;100;120;140m"),
147
+ getBgAnsi: (token: string) => (token === "selectedBg" ? "\x1b[48;2;210;215;225m" : "\x1b[48;2;235;238;242m"),
148
+ } as never as Theme;
149
+ const darkBase = parseBackgroundAnsi(tuiTheme(darkTheme).bgAnsi("surface.base"))!;
150
+ const darkCursor = parseBackgroundAnsi(tuiTheme(darkTheme).bgAnsi("surface.cursor"))!;
151
+ const lightBase = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("surface.base"))!;
152
+ const lightCursor = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("surface.cursor"))!;
153
+ const lightAdded = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("diff.added"))!;
154
+ const lightAddedGutter = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("diff.addedGutter"))!;
155
+ const lightRemoved = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("diff.removed"))!;
156
+ const lightRemovedGutter = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("diff.removedGutter"))!;
157
+ const lightHunk = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("diff.hunk"))!;
158
+ const lightHunkGutter = parseBackgroundAnsi(tuiTheme(lightTheme).bgAnsi("diff.hunkGutter"))!;
159
+ expect(darkCursor.r + darkCursor.g + darkCursor.b).toBeGreaterThan(darkBase.r + darkBase.g + darkBase.b);
160
+ expect(lightCursor.r + lightCursor.g + lightCursor.b).toBeLessThan(lightBase.r + lightBase.g + lightBase.b);
161
+ expect(lightAddedGutter).not.toEqual(lightAdded);
162
+ expect(lightRemovedGutter).not.toEqual(lightRemoved);
163
+ expect(lightHunk.b - Math.max(lightHunk.r, lightHunk.g)).toBeGreaterThan(0);
164
+ expect(lightHunkGutter.b - Math.max(lightHunkGutter.r, lightHunkGutter.g)).toBeGreaterThan(0);
165
+ expect(tuiTheme(lightTheme).fgAnsi("border")).toBe("\x1b[38;2;100;120;140m");
166
+ expect(tuiTheme(darkTheme).bgAnsi("surface.editor")).toBe("\x1b[48;2;19;22;28m");
167
+ expect(tuiTheme(lightTheme).bgAnsi("surface.editor")).toBe("\x1b[48;2;235;238;242m");
168
+ expect(tuiTheme(lightTheme).bgAnsi("surface.raised")).toBe("\x1b[48;2;235;238;242m");
169
+ expect(tuiTheme(lightTheme).bgAnsi("surface.selected")).toBe("\x1b[48;2;210;215;225m");
170
+ expect(tuiTheme(lightTheme).bgAnsi("surface.inset")).toMatch(/^\x1b\[48;2;/);
171
+ expect(tuiTheme(lightTheme).bgAnsi("surface.inset")).not.toBe("\x1b[48;5;0m");
172
+ const semanticAccent = tuiTheme(darkTheme).color("accent");
173
+ expect(tuiTheme(lightTheme).fg(semanticAccent, "accent")).toBe("\x1b[38;2;100;120;140maccent\x1b[39m");
174
+ });
175
+ });
@@ -0,0 +1,160 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { ToolOutput } from "../src/tool/output.ts";
5
+
6
+ const theme = {
7
+ name: "output-test",
8
+ getColorMode: () => "truecolor",
9
+ getFgAnsi: () => "\x1b[38;2;180;180;180m",
10
+ getBgAnsi: () => "\x1b[48;2;20;20;20m",
11
+ } as never as Theme;
12
+
13
+ describe("ToolOutput", () => {
14
+ test("makes append and replace explicit while caching exact revisions", () => {
15
+ const output = new ToolOutput({ theme, initial: { text: "one", revision: 1 } });
16
+ const first = output.render(20);
17
+ expect(output.render(20)).toBe(first);
18
+ output.append(" two", 2);
19
+ expect(stripTerminalSequences(output.render(20).join("\n"))).toContain("one two");
20
+ output.replace({ text: "replacement", revision: 3 });
21
+ expect(stripTerminalSequences(output.render(20).join("\n"))).toBe("replacement");
22
+ });
23
+
24
+ test("applies caller-owned bounded viewports without breaking ANSI width", () => {
25
+ const text = Array.from({ length: 100 }, (_, index) => `\x1b[3${index % 8}mline-${index}-long\x1b[39m`).join("\n");
26
+ const output = new ToolOutput({
27
+ theme,
28
+ initial: { text, revision: 1 },
29
+ viewport: { maxRows: 5 },
30
+ });
31
+ const collapsed = output.render(8);
32
+ expect(collapsed).toHaveLength(5);
33
+ expect(collapsed.every((line) => visibleWidth(line) <= 8)).toBe(true);
34
+ output.setViewport({ maxRows: 12, selection: "tail" });
35
+ expect(output.render(8)).toHaveLength(12);
36
+ });
37
+
38
+ test("keeps an exact head-tail omission row inside the bounded output", () => {
39
+ const output = new ToolOutput({
40
+ theme,
41
+ initial: { text: "line 0\nline 1\nline 2\nline 3\nline 4\nline 5", revision: 1 },
42
+ viewport: { maxRows: 4, selection: "head-tail" },
43
+ });
44
+
45
+ expect(stripTerminalSequences(output.render(20).join("\n"))).toBe("line 0\n… 3 rows omitted …\nline 4\nline 5");
46
+ expect(output.getOmissionRow()).toBe(1);
47
+ });
48
+
49
+ test("bounds retained append content under stress", () => {
50
+ const output = new ToolOutput({ theme, maxCharacters: 128, viewport: { maxRows: 20, selection: "tail" } });
51
+ for (let revision = 1; revision <= 10_000; revision++) output.append(`${revision},`, revision);
52
+ const rendered = stripTerminalSequences(output.render(80).join("\n"));
53
+ expect(rendered).toContain("earlier characters discarded");
54
+ expect(rendered.length).toBeLessThan(300);
55
+ });
56
+
57
+ test("keeps the retention marker inside a one-row viewport", () => {
58
+ const output = new ToolOutput({
59
+ theme,
60
+ initial: { text: "discard this and retain the tail", revision: 1 },
61
+ maxCharacters: 8,
62
+ viewport: { maxRows: 1 },
63
+ });
64
+
65
+ const rendered = output.render(80);
66
+ expect(rendered).toHaveLength(1);
67
+ expect(stripTerminalSequences(rendered[0]!)).toContain("earlier characters discarded");
68
+ });
69
+
70
+ test("never retains the tail half of an ANSI control sequence", () => {
71
+ const output = new ToolOutput({ theme, maxCharacters: 8, viewport: { maxRows: 20 } });
72
+ output.append(`before\x1b[38;2;100;120;140mcolored\x1b[39m`, 1);
73
+ const plain = stripTerminalSequences(output.render(80).join("\n"));
74
+ expect(plain).not.toMatch(/\[\d+(?:;\d+)*m/);
75
+ });
76
+
77
+ test("preserves SGR styling but removes terminal side-effect controls", () => {
78
+ const output = new ToolOutput({
79
+ theme,
80
+ initial: {
81
+ text: "\x1b[31mred\x1b[0m\x1b]52;c;Y2xpcGJvYXJk\x07\x1bPpayload\x1b\\safe",
82
+ revision: 1,
83
+ },
84
+ });
85
+ const rendered = output.render(80).join("\n");
86
+ expect(rendered).toContain("\x1b[31mred\x1b[0m");
87
+ expect(rendered).not.toContain("]52");
88
+ expect(rendered).not.toContain("payload");
89
+ expect(stripTerminalSequences(rendered)).toBe("redsafe");
90
+ });
91
+
92
+ test("removes eight-bit OSC and DCS controls with their payloads", () => {
93
+ const output = new ToolOutput({
94
+ theme,
95
+ initial: { text: "before\u009d52;c;secret\u0007middle\u0090device\u009cafter", revision: 1 },
96
+ });
97
+ expect(stripTerminalSequences(output.render(80).join("\n"))).toBe("beforemiddleafter");
98
+ });
99
+
100
+ test("carries split SGR sequences across cumulative snapshots", () => {
101
+ const output = new ToolOutput({ theme });
102
+ output.appendCumulative("\x1b[31", 1);
103
+ expect(output.render(80)).toEqual([]);
104
+ output.appendCumulative("\x1b[31mred", 2);
105
+ const rendered = output.render(80).join("\n");
106
+ expect(rendered).toContain("\x1b[31mred");
107
+ expect(stripTerminalSequences(rendered)).toBe("red");
108
+ });
109
+
110
+ test("bounds unterminated terminal controls without leaking their payload", () => {
111
+ const output = new ToolOutput({ theme, maxCharacters: 16 });
112
+ output.append(`\x1b]52;c;${"secret".repeat(20_000)}`, 1);
113
+ expect(output.render(80)).toEqual([]);
114
+ output.append("\x07safe", 2);
115
+ expect(stripTerminalSequences(output.render(80).join("\n"))).toBe("safe");
116
+ });
117
+
118
+ test("bounds unterminated CSI state even below its introducer width", () => {
119
+ const output = new ToolOutput({ theme, maxCharacters: 1 });
120
+ output.append(`\x1b[${"1".repeat(100_000)}`, 1);
121
+ expect(output.render(80)).toEqual([]);
122
+ output.append("msafe", 2);
123
+ const rendered = stripTerminalSequences(output.render(80).join("\n"));
124
+ expect(rendered).toEndWith("e");
125
+ expect(rendered).not.toContain("1111");
126
+ });
127
+
128
+ test("bounds sanitization work before retaining large resumed output", () => {
129
+ const source = "x".repeat(100_000);
130
+ const output = new ToolOutput({ theme, initial: { text: source, revision: 0 }, maxCharacters: 1_000 });
131
+ expect(stripTerminalSequences(output.render(80).join("\n"))).toContain("earlier characters discarded");
132
+ });
133
+
134
+ test("clips pathological single lines before wrapping", () => {
135
+ const output = new ToolOutput({
136
+ theme,
137
+ initial: { text: "x".repeat(1_000_000), revision: 1 },
138
+ maxCharacters: 1_000_000,
139
+ viewport: { maxRows: 20 },
140
+ });
141
+ const rendered = stripTerminalSequences(output.render(1).join("\n"));
142
+
143
+ expect(rendered).toContain("…");
144
+ expect(output.render(1)).toHaveLength(20);
145
+ expect(rendered.length).toBeLessThan(100);
146
+ });
147
+
148
+ test("normalizes non-finite retention limits and appends cumulative snapshots by delta", () => {
149
+ const output = new ToolOutput({
150
+ theme,
151
+ maxCharacters: Number.POSITIVE_INFINITY,
152
+ viewport: { maxRows: 4, selection: "tail" },
153
+ });
154
+ output.appendCumulative("one", 1);
155
+ output.appendCumulative("one\ntwo", 2);
156
+ expect(stripTerminalSequences(output.render(40).join("\n"))).toBe("one\ntwo");
157
+ output.append("x".repeat(1_000_001), 3);
158
+ expect(stripTerminalSequences(output.render(40)[0] ?? "")).toContain("earlier characters discarded");
159
+ });
160
+ });