@herbertgao/pi-extensions 2026.8.1 → 2026.8.2

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 (162) hide show
  1. package/README.md +5 -2
  2. package/node_modules/@czottmann/pi-automode/README.md +16 -2
  3. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +35 -0
  4. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +18 -9
  5. package/node_modules/@czottmann/pi-automode/docs/defaults.md +6 -2
  6. package/node_modules/@czottmann/pi-automode/examples/automode.local.json +6 -0
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +11 -7
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +100 -0
  9. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +32 -0
  10. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +78 -3
  11. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +16 -4
  12. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/paths.ts +23 -1
  13. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/permissions.ts +22 -1
  14. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/types.ts +18 -2
  15. package/node_modules/@czottmann/pi-automode/package.json +4 -4
  16. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +5 -0
  17. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +5 -0
  18. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +5 -0
  19. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-help.md +5 -0
  20. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +5 -0
  21. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +5 -0
  22. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail-frontmatter.cjs +23 -0
  23. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail.mjs +99 -0
  24. package/node_modules/@dietrichgebert/ponytail/.qoder/rules/ponytail.md +30 -0
  25. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +16 -0
  26. package/node_modules/@dietrichgebert/ponytail/AGENTS.md +32 -0
  27. package/node_modules/@dietrichgebert/ponytail/LICENSE +21 -0
  28. package/node_modules/@dietrichgebert/ponytail/README.es.md +298 -0
  29. package/node_modules/@dietrichgebert/ponytail/README.ko.md +315 -0
  30. package/node_modules/@dietrichgebert/ponytail/README.md +351 -0
  31. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-3model.svg +21 -0
  32. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-agentic.svg +62 -0
  33. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.png +0 -0
  34. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.svg +115 -0
  35. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt-dark.svg +27 -0
  36. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt.svg +27 -0
  37. package/node_modules/@dietrichgebert/ponytail/assets/logo.png +0 -0
  38. package/node_modules/@dietrichgebert/ponytail/assets/social-preview.png +0 -0
  39. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-es.png +0 -0
  40. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-ko.png +0 -0
  41. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner.png +0 -0
  42. package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +44 -0
  43. package/node_modules/@dietrichgebert/ponytail/hooks/copilot-hooks.json +21 -0
  44. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +96 -0
  45. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-config.js +169 -0
  46. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-instructions.js +98 -0
  47. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +130 -0
  48. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +85 -0
  49. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-statusline.ps1 +24 -0
  50. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-statusline.sh +18 -0
  51. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-subagent.js +77 -0
  52. package/node_modules/@dietrichgebert/ponytail/hooks/qoder-hooks.json +26 -0
  53. package/node_modules/@dietrichgebert/ponytail/package.json +47 -0
  54. package/node_modules/@dietrichgebert/ponytail/pi-extension/index.js +211 -0
  55. package/node_modules/@dietrichgebert/ponytail/pi-extension/package.json +8 -0
  56. package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +60 -0
  57. package/node_modules/@dietrichgebert/ponytail/skills/ponytail/SKILL.md +120 -0
  58. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +41 -0
  59. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +44 -0
  60. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +50 -0
  61. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +71 -0
  62. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +57 -0
  63. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -3
  64. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -3
  65. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +10 -6
  66. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/agent-summary.ts +6 -2
  67. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +24 -83
  68. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +1 -1
  69. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/markdown-enhance.ts +119 -0
  70. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +73 -9
  71. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +2 -2
  72. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-hover.ts +130 -0
  73. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-interaction.ts +65 -542
  74. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-layout.ts +160 -0
  75. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-packets.ts +143 -0
  76. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse-scroll.ts +184 -0
  77. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool-diff/diff-renderer.ts +2 -2
  78. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool-grouping.ts +9 -2
  79. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/transcript-refresh.ts +91 -0
  80. package/node_modules/@herbertgao/pi-cc-extensions/extensions/utils/agent-summary.ts +1 -1
  81. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  82. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-dark.json +1 -0
  83. package/node_modules/@herbertgao/pi-cc-extensions/themes/cc-light.json +2 -2
  84. package/node_modules/@herbertgao/pi-stash/README.md +9 -9
  85. package/node_modules/@herbertgao/pi-stash/package.json +1 -1
  86. package/node_modules/@herbertgao/pi-stash/src/index.ts +2 -1
  87. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +14 -0
  88. package/node_modules/@herbertgao/pi-subagents/README.md +6 -2
  89. package/node_modules/@herbertgao/pi-subagents/package.json +3 -3
  90. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +85 -11
  91. package/node_modules/@herbertgao/pi-subagents/src/index.ts +28 -4
  92. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +12 -0
  93. package/node_modules/@herbertgao/pi-subagents/src/types.ts +2 -0
  94. package/node_modules/@narumitw/pi-btw/LICENSE +21 -0
  95. package/node_modules/@narumitw/pi-btw/README.md +210 -0
  96. package/node_modules/@narumitw/pi-btw/package.json +53 -0
  97. package/node_modules/@narumitw/pi-btw/src/bring-to-main.ts +632 -0
  98. package/node_modules/@narumitw/pi-btw/src/btw.ts +996 -0
  99. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +339 -0
  100. package/node_modules/@narumitw/pi-btw/src/index.ts +1 -0
  101. package/node_modules/@narumitw/pi-btw/src/menu.ts +296 -0
  102. package/node_modules/@narumitw/pi-btw/src/settings.ts +237 -0
  103. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +238 -0
  104. package/node_modules/@narumitw/pi-btw/src/text.ts +10 -0
  105. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +620 -0
  106. package/node_modules/@pi-plugins/fast-mode/LICENSE +21 -0
  107. package/node_modules/@pi-plugins/fast-mode/README.md +92 -0
  108. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts +6 -0
  109. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -0
  110. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +2026 -0
  111. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -0
  112. package/node_modules/@pi-plugins/fast-mode/package.json +73 -0
  113. package/node_modules/pi-mcp-adapter/CHANGELOG.md +59 -0
  114. package/node_modules/pi-mcp-adapter/README.md +62 -13
  115. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +377 -0
  116. package/node_modules/pi-mcp-adapter/commands.ts +9 -7
  117. package/node_modules/pi-mcp-adapter/config.ts +110 -49
  118. package/node_modules/pi-mcp-adapter/direct-tools.ts +34 -19
  119. package/node_modules/pi-mcp-adapter/elicitation-handler.ts +37 -36
  120. package/node_modules/pi-mcp-adapter/errors.ts +21 -17
  121. package/node_modules/pi-mcp-adapter/host-html-template.ts +13 -7
  122. package/node_modules/pi-mcp-adapter/index.ts +37 -14
  123. package/node_modules/pi-mcp-adapter/init.ts +9 -7
  124. package/node_modules/pi-mcp-adapter/json-schema-validator.ts +2 -2
  125. package/node_modules/pi-mcp-adapter/lifecycle.ts +8 -8
  126. package/node_modules/pi-mcp-adapter/logger.ts +1 -1
  127. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +36 -22
  128. package/node_modules/pi-mcp-adapter/mcp-auth.ts +120 -2
  129. package/node_modules/pi-mcp-adapter/mcp-code.ts +13 -11
  130. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +19 -13
  131. package/node_modules/pi-mcp-adapter/mcp-output-guard.ts +15 -11
  132. package/node_modules/pi-mcp-adapter/mcp-panel.ts +34 -15
  133. package/node_modules/pi-mcp-adapter/mcp-probe.ts +131 -34
  134. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +2 -2
  135. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +7 -4
  136. package/node_modules/pi-mcp-adapter/mcp-trace.ts +59 -64
  137. package/node_modules/pi-mcp-adapter/metadata-cache.ts +34 -26
  138. package/node_modules/pi-mcp-adapter/npx-resolver.ts +6 -4
  139. package/node_modules/pi-mcp-adapter/oauth-handler.ts +1 -1
  140. package/node_modules/pi-mcp-adapter/onboarding-state.ts +19 -11
  141. package/node_modules/pi-mcp-adapter/package.json +13 -8
  142. package/node_modules/pi-mcp-adapter/panel-keys.ts +20 -4
  143. package/node_modules/pi-mcp-adapter/prompts.ts +2 -2
  144. package/node_modules/pi-mcp-adapter/proxy-modes.ts +48 -11
  145. package/node_modules/pi-mcp-adapter/sampling-handler.ts +22 -15
  146. package/node_modules/pi-mcp-adapter/search-ranking.ts +2 -1
  147. package/node_modules/pi-mcp-adapter/server-manager.ts +165 -113
  148. package/node_modules/pi-mcp-adapter/session-recovery.ts +11 -12
  149. package/node_modules/pi-mcp-adapter/skills/mcp-scripting/SKILL.md +3 -3
  150. package/node_modules/pi-mcp-adapter/state.ts +3 -1
  151. package/node_modules/pi-mcp-adapter/tool-approval.ts +67 -4
  152. package/node_modules/pi-mcp-adapter/tool-metadata.ts +6 -5
  153. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +14 -2
  154. package/node_modules/pi-mcp-adapter/ts-shape.ts +9 -2
  155. package/node_modules/pi-mcp-adapter/types.ts +109 -22
  156. package/node_modules/pi-mcp-adapter/ui-app-bridge-helpers.ts +40 -0
  157. package/node_modules/pi-mcp-adapter/ui-resource-handler.ts +28 -19
  158. package/node_modules/pi-mcp-adapter/ui-server.ts +90 -58
  159. package/node_modules/pi-mcp-adapter/ui-session.ts +16 -14
  160. package/node_modules/pi-mcp-adapter/unix-socket-transport.ts +4 -4
  161. package/node_modules/pi-mcp-adapter/utils.ts +23 -9
  162. package/package.json +22 -7
@@ -0,0 +1,620 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import {
3
+ AssistantMessageComponent,
4
+ getMarkdownTheme,
5
+ type KeybindingsManager,
6
+ type Theme,
7
+ UserMessageComponent,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ type Component,
11
+ CURSOR_MARKER,
12
+ Editor,
13
+ type EditorTheme,
14
+ type Focusable,
15
+ Key,
16
+ Loader,
17
+ Markdown,
18
+ matchesKey,
19
+ type TUI,
20
+ truncateToWidth,
21
+ visibleWidth,
22
+ } from "@earendil-works/pi-tui";
23
+ import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
24
+ import { sanitizeSingleLine } from "./text.js";
25
+
26
+ const TRANSCRIPT_CHROME_LINES = 2;
27
+ const MAX_STEERING_DISPLAY_LINES = 3;
28
+ const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
29
+ // Pi renders a spacer above the custom component and a two-line built-in footer below it.
30
+ const RESERVED_APP_LINES = 3;
31
+
32
+ export type TranscriptPagerAction =
33
+ | { kind: "submit"; question: string }
34
+ | { kind: "bringToMain"; questionDraft: string }
35
+ | { kind: "close" };
36
+
37
+ export interface BtwThinkingControl {
38
+ level: BtwThinkingLevel;
39
+ levels: readonly BtwThinkingLevel[];
40
+ keybindings: KeybindingsManager;
41
+ onChange: (level: BtwThinkingLevel) => void;
42
+ }
43
+
44
+ export interface BtwAnsweringViewOptions {
45
+ steering?: {
46
+ questions: readonly string[];
47
+ onSubmit: (question: string) => void;
48
+ thinking?: BtwThinkingControl;
49
+ };
50
+ }
51
+
52
+ export class BtwTranscriptPager implements Component, Focusable {
53
+ private readonly transcriptComponents: Component[];
54
+ private readonly editor: Editor;
55
+ private readonly canBringToMain: boolean;
56
+ private scrollOffset = 0;
57
+ private lastContentLineCount = 0;
58
+ private lastViewportHeight = 1;
59
+ private followBottom: boolean;
60
+ private warning: string | undefined;
61
+ private finished = false;
62
+ private isFocused = false;
63
+ private thinkingLevel: BtwThinkingLevel | undefined;
64
+
65
+ constructor(
66
+ private readonly tui: TUI,
67
+ private readonly theme: Theme,
68
+ turns: readonly SideThreadTurn[],
69
+ private readonly onAction: (action: TranscriptPagerAction) => void,
70
+ private readonly options: {
71
+ startAtBottom?: boolean;
72
+ initialQuestion?: string;
73
+ thinking?: BtwThinkingControl;
74
+ } = {},
75
+ ) {
76
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
77
+ this.canBringToMain = turns.some((turn) => turn.kind === "answered");
78
+ this.followBottom = options.startAtBottom ?? false;
79
+ this.thinkingLevel = options.thinking?.level;
80
+ const editorTheme: EditorTheme = {
81
+ borderColor: (text) => this.theme.fg("accent", text),
82
+ selectList: {
83
+ selectedPrefix: (text) => this.theme.fg("accent", text),
84
+ selectedText: (text) => this.theme.fg("accent", text),
85
+ description: (text) => this.theme.fg("muted", text),
86
+ scrollInfo: (text) => this.theme.fg("dim", text),
87
+ noMatch: (text) => this.theme.fg("warning", text),
88
+ },
89
+ };
90
+ this.editor = new Editor(this.tui, editorTheme);
91
+ if (options.initialQuestion) this.editor.setText(options.initialQuestion);
92
+ this.editor.onChange = () => {
93
+ this.warning = undefined;
94
+ };
95
+ this.editor.onSubmit = (text) => {
96
+ const question = text.trim();
97
+ if (!question) {
98
+ this.warning = "Question cannot be empty";
99
+ return;
100
+ }
101
+ this.finished = true;
102
+ this.onAction({ kind: "submit", question });
103
+ };
104
+ }
105
+
106
+ get focused(): boolean {
107
+ return this.isFocused;
108
+ }
109
+
110
+ set focused(value: boolean) {
111
+ this.isFocused = value;
112
+ this.editor.focused = value;
113
+ }
114
+
115
+ render(width: number): string[] {
116
+ const safeWidth = Math.max(1, width);
117
+ const editorLines = this.editor.render(safeWidth);
118
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
119
+ const viewportHeight = Math.max(
120
+ 0,
121
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
122
+ );
123
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
124
+ this.lastContentLineCount = contentLines.length;
125
+ this.lastViewportHeight = viewportHeight;
126
+ if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
127
+ this.clampScrollOffset();
128
+
129
+ return fitComposerLayout(
130
+ renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
131
+ contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
132
+ this.renderFooter(safeWidth),
133
+ editorLines,
134
+ availableRows,
135
+ );
136
+ }
137
+
138
+ handleInput(data: string): void {
139
+ if (this.finished) return;
140
+ if (matchesKey(data, Key.ctrl("c"))) {
141
+ this.finished = true;
142
+ this.onAction({ kind: "close" });
143
+ return;
144
+ }
145
+ if (this.canBringToMain && matchesKey(data, Key.ctrl("r"))) {
146
+ this.finished = true;
147
+ this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
148
+ return;
149
+ }
150
+ const thinking = this.options.thinking;
151
+ if (
152
+ thinking &&
153
+ thinking.levels.length > 1 &&
154
+ thinking.keybindings.matches(data, "app.thinking.cycle")
155
+ ) {
156
+ const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
157
+ const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
158
+ if (nextLevel) {
159
+ this.thinkingLevel = nextLevel;
160
+ thinking.onChange(nextLevel);
161
+ this.warning = undefined;
162
+ this.tui.requestRender();
163
+ }
164
+ return;
165
+ }
166
+ if (matchesKey(data, Key.pageUp)) {
167
+ const previousOffset = this.scrollOffset;
168
+ this.scrollBy(-this.lastViewportHeight);
169
+ if (this.scrollOffset < previousOffset) this.followBottom = false;
170
+ this.tui.requestRender();
171
+ return;
172
+ }
173
+ if (matchesKey(data, Key.pageDown)) {
174
+ this.scrollBy(this.lastViewportHeight);
175
+ this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
176
+ this.tui.requestRender();
177
+ return;
178
+ }
179
+ this.editor.handleInput(data);
180
+ if (!this.finished) this.tui.requestRender();
181
+ }
182
+
183
+ invalidate(): void {
184
+ for (const component of this.transcriptComponents) component.invalidate();
185
+ this.editor.invalidate();
186
+ }
187
+
188
+ dispose(): void {
189
+ if (this.finished) return;
190
+ this.finished = true;
191
+ this.onAction({ kind: "close" });
192
+ }
193
+
194
+ private renderFooter(width: number): string {
195
+ if (this.warning) {
196
+ const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
197
+ return truncateToWidth(this.theme.fg("warning", warning), width);
198
+ }
199
+ const scrollable = this.getMaxScrollOffset() > 0;
200
+ const thinking = this.options.thinking;
201
+ const cycleHint =
202
+ thinking && thinking.levels.length > 1 && this.thinkingLevel
203
+ ? ` • thinking ${this.thinkingLevel} • ${thinkingKeyLabel(thinking.keybindings)} cycle`
204
+ : "";
205
+ const base = this.canBringToMain
206
+ ? "btw • Enter send • Ctrl+R bring to main • Ctrl+C exit"
207
+ : "btw • Enter send • Ctrl+C exit";
208
+ const fullBase = `${base}${cycleHint}`;
209
+ const fallbackBase = "btw • Enter • Ctrl+C";
210
+ const compactBase = this.canBringToMain ? "btw • Enter • Ctrl+R • Ctrl+C" : fallbackBase;
211
+ const compactWithThinking = `${compactBase}${cycleHint}`;
212
+ let hints =
213
+ visibleWidth(fullBase) <= width
214
+ ? fullBase
215
+ : visibleWidth(compactWithThinking) <= width
216
+ ? compactWithThinking
217
+ : visibleWidth(compactBase) <= width
218
+ ? compactBase
219
+ : fallbackBase;
220
+ if (scrollable) {
221
+ const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
222
+ const compactHistory = " • PgUp/PgDn";
223
+ const compactScrollable = this.canBringToMain
224
+ ? "Enter • Ctrl+R • Ctrl+C • PgUp/PgDn"
225
+ : `${fallbackBase}${compactHistory}`;
226
+ if (visibleWidth(`${hints}${history}`) <= width) {
227
+ hints += history;
228
+ } else if (visibleWidth(`${compactBase}${history}`) <= width) {
229
+ hints = `${compactBase}${history}`;
230
+ } else if (visibleWidth(`${hints}${compactHistory}`) <= width) {
231
+ hints += compactHistory;
232
+ } else if (visibleWidth(`${compactBase}${compactHistory}`) <= width) {
233
+ hints = `${compactBase}${compactHistory}`;
234
+ } else if (visibleWidth(compactScrollable) <= width) {
235
+ hints = compactScrollable;
236
+ }
237
+ }
238
+ return truncateToWidth(this.theme.fg("muted", hints), width);
239
+ }
240
+
241
+ private scrollBy(delta: number): void {
242
+ this.scrollOffset += delta;
243
+ this.clampScrollOffset();
244
+ }
245
+
246
+ private clampScrollOffset(): void {
247
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
248
+ }
249
+
250
+ private getMaxScrollOffset(): number {
251
+ return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
252
+ }
253
+ }
254
+
255
+ export class BtwAnsweringView implements Component, Focusable {
256
+ private readonly transcriptComponents: Component[];
257
+ private readonly loader: Loader;
258
+ private readonly editor: Editor | undefined;
259
+ private readonly controller = new AbortController();
260
+ private scrollOffset = 0;
261
+ private lastContentLineCount = 0;
262
+ private lastViewportHeight = 1;
263
+ private followBottom = true;
264
+ private warning: string | undefined;
265
+ private finished = false;
266
+ private isFocused = false;
267
+ private thinkingLevel: BtwThinkingLevel | undefined;
268
+
269
+ constructor(
270
+ private readonly tui: TUI,
271
+ private readonly theme: Theme,
272
+ turns: readonly SideThreadTurn[],
273
+ pendingQuestion: string,
274
+ private readonly onCancel: () => void,
275
+ thinkingLevel?: BtwThinkingLevel,
276
+ private readonly options: BtwAnsweringViewOptions = {},
277
+ ) {
278
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
279
+ this.thinkingLevel = options.steering?.thinking?.level ?? thinkingLevel;
280
+ this.loader = new Loader(
281
+ this.tui,
282
+ (text) => this.theme.fg("accent", text),
283
+ (text) => this.theme.fg("muted", text),
284
+ "Answering…",
285
+ );
286
+ if (options.steering) {
287
+ const editorTheme: EditorTheme = {
288
+ borderColor: (text) => this.theme.fg("accent", text),
289
+ selectList: {
290
+ selectedPrefix: (text) => this.theme.fg("accent", text),
291
+ selectedText: (text) => this.theme.fg("accent", text),
292
+ description: (text) => this.theme.fg("muted", text),
293
+ scrollInfo: (text) => this.theme.fg("dim", text),
294
+ noMatch: (text) => this.theme.fg("warning", text),
295
+ },
296
+ };
297
+ this.editor = new Editor(this.tui, editorTheme);
298
+ this.editor.onChange = () => {
299
+ this.warning = undefined;
300
+ };
301
+ this.editor.onSubmit = (text) => {
302
+ const question = text.trim();
303
+ if (!question) {
304
+ this.warning = "Question cannot be empty";
305
+ return;
306
+ }
307
+ options.steering?.onSubmit(question);
308
+ this.warning = undefined;
309
+ };
310
+ }
311
+ }
312
+
313
+ get focused(): boolean {
314
+ return this.isFocused;
315
+ }
316
+
317
+ set focused(value: boolean) {
318
+ this.isFocused = value;
319
+ if (this.editor) this.editor.focused = value;
320
+ }
321
+
322
+ get signal(): AbortSignal {
323
+ return this.controller.signal;
324
+ }
325
+
326
+ render(width: number): string[] {
327
+ const safeWidth = Math.max(1, width);
328
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
329
+ const editorLines = this.editor?.render(safeWidth) ?? [];
330
+ const steeringCapacity = Math.max(
331
+ 0,
332
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
333
+ );
334
+ const steeringLines = renderSteeringLines(
335
+ this.options.steering?.questions ?? [],
336
+ safeWidth,
337
+ this.theme,
338
+ Math.min(MAX_STEERING_DISPLAY_LINES, steeringCapacity),
339
+ );
340
+ const viewportHeight = Math.max(
341
+ 0,
342
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES - steeringLines.length,
343
+ );
344
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
345
+ this.lastContentLineCount = contentLines.length;
346
+ this.lastViewportHeight = viewportHeight;
347
+ if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
348
+ this.clampScrollOffset();
349
+
350
+ return fitComposerLayout(
351
+ renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
352
+ contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
353
+ this.renderFooter(safeWidth),
354
+ editorLines,
355
+ availableRows,
356
+ steeringLines,
357
+ );
358
+ }
359
+
360
+ handleInput(data: string): void {
361
+ if (this.finished) return;
362
+ if (matchesKey(data, Key.ctrl("c"))) {
363
+ this.finished = true;
364
+ this.loader.stop();
365
+ this.controller.abort();
366
+ this.onCancel();
367
+ return;
368
+ }
369
+ const thinking = this.options.steering?.thinking;
370
+ if (
371
+ thinking &&
372
+ thinking.levels.length > 1 &&
373
+ thinking.keybindings.matches(data, "app.thinking.cycle")
374
+ ) {
375
+ const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
376
+ const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
377
+ if (nextLevel) {
378
+ this.thinkingLevel = nextLevel;
379
+ thinking.onChange(nextLevel);
380
+ this.warning = undefined;
381
+ this.tui.requestRender();
382
+ }
383
+ return;
384
+ }
385
+ if (matchesKey(data, Key.pageUp)) {
386
+ const previousOffset = this.scrollOffset;
387
+ this.scrollBy(-this.lastViewportHeight);
388
+ if (this.scrollOffset < previousOffset) this.followBottom = false;
389
+ this.tui.requestRender();
390
+ return;
391
+ }
392
+ if (matchesKey(data, Key.pageDown)) {
393
+ this.scrollBy(this.lastViewportHeight);
394
+ this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
395
+ this.tui.requestRender();
396
+ return;
397
+ }
398
+ this.editor?.handleInput(data);
399
+ this.tui.requestRender();
400
+ }
401
+
402
+ invalidate(): void {
403
+ for (const component of this.transcriptComponents) component.invalidate();
404
+ this.loader.invalidate();
405
+ this.editor?.invalidate();
406
+ }
407
+
408
+ finish(): void {
409
+ this.finished = true;
410
+ this.loader.stop();
411
+ }
412
+
413
+ dispose(): void {
414
+ if (this.finished) {
415
+ this.loader.stop();
416
+ this.controller.abort();
417
+ return;
418
+ }
419
+ this.finished = true;
420
+ this.loader.stop();
421
+ this.controller.abort();
422
+ this.onCancel();
423
+ }
424
+
425
+ private renderFooter(width: number): string {
426
+ if (this.warning) {
427
+ const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C cancel`;
428
+ return truncateToWidth(this.theme.fg("warning", warning), width);
429
+ }
430
+ const baseHint = this.editor ? "Enter steer • Ctrl+C cancel" : "Ctrl+C cancel";
431
+ const thinking = this.options.steering?.thinking;
432
+ const cycleHint =
433
+ thinking && thinking.levels.length > 1 && this.thinkingLevel
434
+ ? ` • thinking ${this.thinkingLevel} • ${thinkingKeyLabel(thinking.keybindings)} cycle`
435
+ : "";
436
+ const scrollHint = this.getMaxScrollOffset() > 0 ? " • PgUp/PgDn history" : "";
437
+ const hints = `${baseHint}${cycleHint}${scrollHint}`;
438
+ const compactHints = this.editor ? "Enter • Ctrl+C" : "Ctrl+C";
439
+ const selectedHints = visibleWidth(hints) <= width ? hints : compactHints;
440
+ const loaderWidth = Math.max(1, width - visibleWidth(selectedHints) - 3);
441
+ const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
442
+ return truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", selectedHints)}`, width);
443
+ }
444
+
445
+ private scrollBy(delta: number): void {
446
+ this.scrollOffset += delta;
447
+ this.clampScrollOffset();
448
+ }
449
+
450
+ private clampScrollOffset(): void {
451
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
452
+ }
453
+
454
+ private getMaxScrollOffset(): number {
455
+ return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
456
+ }
457
+ }
458
+
459
+ export function formatSideTranscript(turns: readonly SideThreadTurn[]): string {
460
+ return turns
461
+ .map((turn) => {
462
+ const question = escapeTerminalControls(turn.question);
463
+ const rawAnswer = escapeTerminalControls(turn.answer);
464
+ const answer = turn.kind === "error" ? `Error: ${rawAnswer}` : rawAnswer;
465
+ return `${question}\n\n${answer}`;
466
+ })
467
+ .join("\n\n");
468
+ }
469
+
470
+ function buildTranscriptComponents(
471
+ turns: readonly SideThreadTurn[],
472
+ theme: Theme,
473
+ pendingQuestion?: string,
474
+ ): Component[] {
475
+ const components = turns.flatMap((turn): Component[] => {
476
+ const question = new UserMessageComponent(
477
+ escapeTerminalControls(turn.question),
478
+ getMarkdownTheme(),
479
+ 1,
480
+ );
481
+ if (turn.kind === "error") {
482
+ const error = new Markdown(
483
+ `Error: ${escapeTerminalControls(turn.answer)}`,
484
+ 1,
485
+ 1,
486
+ getMarkdownTheme(),
487
+ { color: (text) => theme.fg("error", text) },
488
+ );
489
+ return [question, error];
490
+ }
491
+ const response: AssistantMessage = {
492
+ ...turn.response,
493
+ content: [{ type: "text", text: escapeTerminalControls(turn.answer) }],
494
+ stopReason: "stop",
495
+ errorMessage: undefined,
496
+ };
497
+ return [question, new AssistantMessageComponent(response, true, getMarkdownTheme(), "", 1)];
498
+ });
499
+ if (pendingQuestion) {
500
+ components.push(
501
+ new UserMessageComponent(escapeTerminalControls(pendingQuestion), getMarkdownTheme(), 1),
502
+ );
503
+ }
504
+ return components;
505
+ }
506
+
507
+ function renderTranscriptLines(components: readonly Component[], width: number): string[] {
508
+ return components
509
+ .flatMap((component) => component.render(width))
510
+ .map(stripShellIntegrationMarkers);
511
+ }
512
+
513
+ function renderSideThreadHeader(
514
+ width: number,
515
+ theme: Theme,
516
+ thinkingLevel?: BtwThinkingLevel,
517
+ ): string {
518
+ const thinking = thinkingLevel ? ` · thinking ${thinkingLevel}` : "";
519
+ const title = truncateToWidth(`─ btw · side thread${thinking} `, width);
520
+ const ruleWidth = Math.max(0, width - visibleWidth(title));
521
+ return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
522
+ }
523
+
524
+ function thinkingKeyLabel(keybindings: KeybindingsManager): string {
525
+ const key =
526
+ sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
527
+ "Shift+Tab";
528
+ return key
529
+ .split("+")
530
+ .map((part) => {
531
+ const lower = part.toLowerCase();
532
+ if (lower === "shift") return "Shift";
533
+ if (lower === "ctrl") return "Ctrl";
534
+ if (lower === "alt") return "Alt";
535
+ return part.length === 1
536
+ ? part.toUpperCase()
537
+ : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
538
+ })
539
+ .join("+");
540
+ }
541
+
542
+ function fitComposerLayout(
543
+ header: string,
544
+ contentLines: string[],
545
+ footer: string,
546
+ editorLines: string[],
547
+ availableRows: number,
548
+ statusLines: string[] = [],
549
+ ): string[] {
550
+ const lines = [header, ...contentLines, ...statusLines, footer, ...editorLines];
551
+ if (lines.length <= availableRows) return lines;
552
+ if (availableRows <= 1) return [header];
553
+ const editorBudget = Math.max(0, availableRows - 2);
554
+ return [header, footer, ...fitEditorLines(editorLines, editorBudget)];
555
+ }
556
+
557
+ function fitEditorLines(editorLines: string[], budget: number): string[] {
558
+ if (budget <= 0) return [];
559
+ if (editorLines.length <= budget) return editorLines;
560
+ const cursorIndex = editorLines.findIndex((line) => line.includes(CURSOR_MARKER));
561
+ if (cursorIndex < 0) return editorLines.slice(-budget);
562
+ const start = Math.min(cursorIndex, editorLines.length - budget);
563
+ return editorLines.slice(start, start + budget);
564
+ }
565
+
566
+ function renderSteeringLines(
567
+ questions: readonly string[],
568
+ width: number,
569
+ theme: Theme,
570
+ maxLines: number,
571
+ ): string[] {
572
+ if (questions.length === 0 || maxLines <= 0) return [];
573
+ const formatQuestion = (question: string) =>
574
+ sanitizeSingleLine(question) || "(non-printing message)";
575
+ if (maxLines === 1 && questions.length > 1) {
576
+ return [
577
+ truncateToWidth(
578
+ theme.fg(
579
+ "dim",
580
+ `Steering (+${questions.length - 1} more): ${formatQuestion(questions[0] ?? "")}`,
581
+ ),
582
+ width,
583
+ ),
584
+ ];
585
+ }
586
+ const hasOverflow = questions.length > maxLines;
587
+ const questionLimit = hasOverflow ? Math.max(1, maxLines - 1) : maxLines;
588
+ const lines = questions
589
+ .slice(0, questionLimit)
590
+ .map((question) =>
591
+ truncateToWidth(theme.fg("dim", `Steering: ${formatQuestion(question)}`), width),
592
+ );
593
+ if (hasOverflow) {
594
+ lines.push(
595
+ truncateToWidth(
596
+ theme.fg("dim", `Steering: … +${questions.length - questionLimit} more`),
597
+ width,
598
+ ),
599
+ );
600
+ }
601
+ return lines;
602
+ }
603
+
604
+ function stripShellIntegrationMarkers(line: string): string {
605
+ return OSC133_MARKERS.reduce((result, marker) => result.replaceAll(marker, ""), line);
606
+ }
607
+
608
+ function escapeTerminalControls(text: string): string {
609
+ return [...text]
610
+ .map((character) => {
611
+ if (character === "\n") return character;
612
+ if (character === "\t") return " ";
613
+ const code = character.charCodeAt(0);
614
+ if (code <= 31 || (code >= 127 && code <= 159)) {
615
+ return `\\x${code.toString(16).padStart(2, "0")}`;
616
+ }
617
+ return character;
618
+ })
619
+ .join("");
620
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kedom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.