@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,53 @@
1
+ {
2
+ "name": "@narumitw/pi-btw",
3
+ "version": "0.50.0",
4
+ "description": "Pi extension that adds a /btw side-question command.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "btw",
13
+ "side-question"
14
+ ],
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./src/index.ts"
23
+ ]
24
+ },
25
+ "piExtension": {
26
+ "lifecycle": "stable"
27
+ },
28
+ "scripts": {
29
+ "check": "biome check . && npm run typecheck",
30
+ "format": "biome check --write .",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "devDependencies": {
34
+ "@biomejs/biome": "2.5.7",
35
+ "@earendil-works/pi-ai": "0.84.1",
36
+ "@earendil-works/pi-coding-agent": "0.84.1",
37
+ "@earendil-works/pi-tui": "0.84.1",
38
+ "typescript": "7.0.2"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "https://github.com/narumiruna/pi-extensions",
43
+ "directory": "packages/pi-btw"
44
+ },
45
+ "dependencies": {
46
+ "@narumitw/pi-tui-kit": "^0.49.1"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-ai": "*",
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@earendil-works/pi-tui": "*"
52
+ }
53
+ }
@@ -0,0 +1,632 @@
1
+ import type { KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ type TUI,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import type { SideThreadTurn } from "./side-thread.js";
11
+
12
+ const RESERVED_APP_ROWS = 3;
13
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
14
+
15
+ export interface BtwBringToMainSegment {
16
+ role: "user" | "assistant";
17
+ text: string;
18
+ }
19
+
20
+ export interface BtwBringToMainSummary {
21
+ lines: number;
22
+ messages: number;
23
+ tokens: number;
24
+ }
25
+
26
+ export interface BtwSelectionLine {
27
+ role: BtwBringToMainSegment["role"];
28
+ text: string;
29
+ }
30
+
31
+ export interface BtwTextPosition {
32
+ line: number;
33
+ column: number;
34
+ }
35
+
36
+ export interface BtwTextRangeSelectorState {
37
+ cursor: BtwTextPosition;
38
+ anchor?: BtwTextPosition;
39
+ lineAnchor?: number;
40
+ preferredColumn: number;
41
+ scrollOffset: number;
42
+ horizontalOffset: number;
43
+ }
44
+
45
+ export type BtwQuickBringToMainScope =
46
+ | { kind: "latest" }
47
+ | { kind: "from"; answeredTurnIndex: number }
48
+ | { kind: "entire" };
49
+
50
+ export type BtwTextRangeSelectorAction =
51
+ | { kind: "confirm"; segments: BtwBringToMainSegment[] }
52
+ | { kind: "back" }
53
+ | { kind: "close" };
54
+
55
+ export function getAnsweredTurns(
56
+ turns: readonly SideThreadTurn[],
57
+ ): Array<Extract<SideThreadTurn, { kind: "answered" }>> {
58
+ return turns.filter(
59
+ (turn): turn is Extract<SideThreadTurn, { kind: "answered" }> => turn.kind === "answered",
60
+ );
61
+ }
62
+
63
+ export function buildQuickBringToMainSegments(
64
+ turns: readonly SideThreadTurn[],
65
+ scope: BtwQuickBringToMainScope,
66
+ ): BtwBringToMainSegment[] {
67
+ const answered = getAnsweredTurns(turns);
68
+ const selected =
69
+ scope.kind === "latest"
70
+ ? answered.slice(-1)
71
+ : scope.kind === "from"
72
+ ? answered.slice(Math.max(0, scope.answeredTurnIndex))
73
+ : answered;
74
+ return selected.flatMap((turn) => [
75
+ { role: "user" as const, text: turn.question },
76
+ { role: "assistant" as const, text: turn.answer },
77
+ ]);
78
+ }
79
+
80
+ export function buildBtwSelectionLines(turns: readonly SideThreadTurn[]): BtwSelectionLine[] {
81
+ return buildQuickBringToMainSegments(turns, { kind: "entire" }).flatMap((segment) =>
82
+ segment.text.split("\n").map((text) => ({ role: segment.role, text })),
83
+ );
84
+ }
85
+
86
+ export function segmentsFromLineRange(
87
+ lines: readonly BtwSelectionLine[],
88
+ anchor: number,
89
+ cursor: number,
90
+ ): BtwBringToMainSegment[] {
91
+ if (lines.length === 0) return [];
92
+ const start = Math.max(0, Math.min(anchor, cursor, lines.length - 1));
93
+ const end = Math.max(0, Math.min(Math.max(anchor, cursor), lines.length - 1));
94
+ const segments: BtwBringToMainSegment[] = [];
95
+ for (const line of lines.slice(start, end + 1)) {
96
+ const previous = segments.at(-1);
97
+ if (previous?.role === line.role) {
98
+ previous.text += `\n${line.text}`;
99
+ } else {
100
+ segments.push({ role: line.role, text: line.text });
101
+ }
102
+ }
103
+ return segments;
104
+ }
105
+
106
+ export function segmentsFromTextRange(
107
+ lines: readonly BtwSelectionLine[],
108
+ anchor: BtwTextPosition,
109
+ cursor: BtwTextPosition,
110
+ ): BtwBringToMainSegment[] {
111
+ if (lines.length === 0) return [];
112
+ const first = clampTextPosition(lines, anchor);
113
+ const second = clampTextPosition(lines, cursor);
114
+ const [start, end] = compareTextPositions(first, second) <= 0 ? [first, second] : [second, first];
115
+ if (compareTextPositions(start, end) === 0) return [];
116
+
117
+ const segments: BtwBringToMainSegment[] = [];
118
+ for (let lineIndex = start.line; lineIndex <= end.line; lineIndex += 1) {
119
+ const line = lines[lineIndex];
120
+ if (!line) continue;
121
+ const characters = splitGraphemes(line.text);
122
+ const from = lineIndex === start.line ? start.column : 0;
123
+ const to = lineIndex === end.line ? end.column : characters.length;
124
+ const text = characters.slice(from, to).join("");
125
+ if (text) {
126
+ const previous = segments.at(-1);
127
+ if (previous?.role === line.role) previous.text += text;
128
+ else segments.push({ role: line.role, text });
129
+ }
130
+ const crossesSameRoleLine = lineIndex < end.line && lines[lineIndex + 1]?.role === line.role;
131
+ if (crossesSameRoleLine) {
132
+ const current = segments.at(-1);
133
+ if (current?.role === line.role) current.text += "\n";
134
+ else segments.push({ role: line.role, text: "\n" });
135
+ }
136
+ }
137
+ return segments;
138
+ }
139
+
140
+ export function estimateBringToMainTokens(segments: readonly BtwBringToMainSegment[]): number {
141
+ return Math.ceil(
142
+ Buffer.byteLength(segments.map((segment) => segment.text).join("\n"), "utf8") / 4,
143
+ );
144
+ }
145
+
146
+ export function summarizeBringToMain(
147
+ segments: readonly BtwBringToMainSegment[],
148
+ ): BtwBringToMainSummary {
149
+ return {
150
+ lines: segments.reduce((count, segment) => count + segment.text.split("\n").length, 0),
151
+ messages: segments.length,
152
+ tokens: estimateBringToMainTokens(segments),
153
+ };
154
+ }
155
+
156
+ export function formatBtwBringToMain(segments: readonly BtwBringToMainSegment[]): string {
157
+ const body = segments
158
+ .map(
159
+ (segment) =>
160
+ `${segment.role === "user" ? "User" : "Assistant"}:\n${escapeBringToMainText(segment.text)}`,
161
+ )
162
+ .join("\n\n");
163
+ return [
164
+ "The following context was brought back from a /btw side discussion.",
165
+ "Treat it as discussion context, not as work already completed.",
166
+ "",
167
+ "<btw_context>",
168
+ body,
169
+ "</btw_context>",
170
+ ].join("\n");
171
+ }
172
+
173
+ export class BtwTextRangeSelector implements Component {
174
+ private readonly lines: BtwSelectionLine[];
175
+ private cursor: BtwTextPosition = { line: 0, column: 0 };
176
+ private anchor: BtwTextPosition | undefined;
177
+ private lineAnchor: number | undefined;
178
+ private preferredColumn = 0;
179
+ private scrollOffset = 0;
180
+ private horizontalOffset = 0;
181
+ private warning: string | undefined;
182
+ private finished = false;
183
+
184
+ constructor(
185
+ private readonly tui: TUI,
186
+ private readonly theme: Theme,
187
+ private readonly keybindings: KeybindingsManager,
188
+ turns: readonly SideThreadTurn[],
189
+ private readonly onAction: (action: BtwTextRangeSelectorAction) => void,
190
+ initialState?: BtwTextRangeSelectorState,
191
+ ) {
192
+ this.lines = buildBtwSelectionLines(turns);
193
+ if (initialState) {
194
+ this.cursor = clampTextPosition(this.lines, initialState.cursor);
195
+ this.anchor = initialState.anchor
196
+ ? clampTextPosition(this.lines, initialState.anchor)
197
+ : undefined;
198
+ this.lineAnchor =
199
+ initialState.lineAnchor === undefined
200
+ ? undefined
201
+ : Math.max(0, Math.min(this.lines.length - 1, initialState.lineAnchor));
202
+ this.preferredColumn = Math.max(0, initialState.preferredColumn);
203
+ this.scrollOffset = Math.max(0, initialState.scrollOffset);
204
+ this.horizontalOffset = Math.max(0, initialState.horizontalOffset);
205
+ }
206
+ }
207
+
208
+ getState(): BtwTextRangeSelectorState {
209
+ return {
210
+ cursor: { ...this.cursor },
211
+ anchor: this.anchor ? { ...this.anchor } : undefined,
212
+ lineAnchor: this.lineAnchor,
213
+ preferredColumn: this.preferredColumn,
214
+ scrollOffset: this.scrollOffset,
215
+ horizontalOffset: this.horizontalOffset,
216
+ };
217
+ }
218
+
219
+ render(width: number): string[] {
220
+ const safeWidth = Math.max(1, width);
221
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
222
+ const showStatus = availableRows >= 4;
223
+ const showFooter = availableRows >= 3;
224
+ const viewportHeight = Math.max(
225
+ 1,
226
+ availableRows - 1 - (showStatus ? 1 : 0) - (showFooter ? 1 : 0),
227
+ );
228
+ this.keepCursorVisible(viewportHeight);
229
+ const textWidth = Math.max(1, safeWidth - visibleWidth("●> Assistant │ "));
230
+ this.keepCursorHorizontallyVisible(textWidth);
231
+ const range = this.getSelectionRange();
232
+ const lineRange = this.getLineSelectionRange();
233
+ const visible = this.lines.slice(this.scrollOffset, this.scrollOffset + viewportHeight);
234
+ const rows = visible.map((line, visibleIndex) => {
235
+ const lineIndex = this.scrollOffset + visibleIndex;
236
+ const role = line.role === "user" ? "User" : "Assistant";
237
+ const lineSelected = lineRange
238
+ ? lineIndex >= lineRange.start && lineIndex <= lineRange.end
239
+ : false;
240
+ const prefix = `${lineSelected ? "●" : " "}${lineIndex === this.cursor.line ? ">" : " "} ${role.padEnd(9)} │ `;
241
+ const text = this.renderTextLine(line, lineIndex, range, lineSelected);
242
+ return truncateToWidth(
243
+ lineIndex === this.cursor.line ? this.theme.fg("accent", prefix) + text : prefix + text,
244
+ safeWidth,
245
+ "",
246
+ );
247
+ });
248
+ const selected = this.getSelectedSegments();
249
+ const summary = summarizeBringToMain(selected);
250
+ const status =
251
+ selected.length === 0
252
+ ? "Selected: none"
253
+ : `Selected: ${summary.lines} ${summary.lines === 1 ? "line" : "lines"} · ${summary.messages} ${summary.messages === 1 ? "message" : "messages"} · ~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"}`;
254
+ const confirm = confirmKeyLabel(this.keybindings);
255
+ const back = keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"]);
256
+ const vertical = `${keybindingLabel(this.keybindings, "tui.select.up")}/${keybindingLabel(this.keybindings, "tui.select.down")}`;
257
+ const confirmUsesSpace = this.keybindings.matches(" ", "tui.select.confirm");
258
+ const detailedFooter = this.warning
259
+ ? `${this.warning} • ${confirmUsesSpace ? "Shift+Arrows select" : "Space lines • Shift+Arrows text"} • ${back} back • Ctrl+C close`
260
+ : this.lineAnchor !== undefined
261
+ ? `${confirmUsesSpace ? `${vertical} extend lines` : `Space clear • ${vertical} extend lines`} • Shift+Arrows text • ${confirm} bring • ${back} back • Ctrl+C close`
262
+ : `Shift+Arrows select • Arrows move${confirmUsesSpace ? "" : " • Space lines"} • ${confirm} bring • ${back} back • Ctrl+C close`;
263
+ const criticalFooter = `${confirm} bring • ${back} back • Ctrl+C close`;
264
+ const footer = visibleWidth(detailedFooter) <= safeWidth ? detailedFooter : criticalFooter;
265
+ return fitRows(
266
+ [
267
+ truncateToWidth(
268
+ this.theme.fg("accent", this.theme.bold("Select text to bring to main")),
269
+ safeWidth,
270
+ "",
271
+ ),
272
+ ...(showStatus ? [truncateToWidth(this.theme.fg("muted", status), safeWidth, "")] : []),
273
+ ...rows,
274
+ ...(showFooter
275
+ ? [
276
+ truncateToWidth(
277
+ this.theme.fg(this.warning ? "warning" : "muted", footer),
278
+ safeWidth,
279
+ "",
280
+ ),
281
+ ]
282
+ : []),
283
+ ],
284
+ availableRows,
285
+ );
286
+ }
287
+
288
+ handleInput(data: string): void {
289
+ if (this.finished) return;
290
+ if (matchesKey(data, Key.ctrl("c"))) {
291
+ this.finish({ kind: "close" });
292
+ return;
293
+ }
294
+ if (this.keybindings.matches(data, "tui.select.cancel")) {
295
+ this.finish({ kind: "back" });
296
+ return;
297
+ }
298
+ if (matchesConfirm(data, this.keybindings)) {
299
+ if (this.lines.length > 0) {
300
+ const segments = this.getSelectedSegments();
301
+ if (segments.length === 0) {
302
+ this.warning = "Select text first";
303
+ this.tui.requestRender();
304
+ } else {
305
+ this.finish({ kind: "confirm", segments });
306
+ }
307
+ }
308
+ return;
309
+ }
310
+ if (matchesKey(data, Key.space)) {
311
+ this.anchor = undefined;
312
+ this.lineAnchor = this.lineAnchor === undefined ? this.cursor.line : undefined;
313
+ this.afterMove();
314
+ return;
315
+ }
316
+ if (matchesKey(data, Key.shift("left"))) {
317
+ this.moveHorizontal(-1, true);
318
+ return;
319
+ }
320
+ if (matchesKey(data, Key.shift("right"))) {
321
+ this.moveHorizontal(1, true);
322
+ return;
323
+ }
324
+ if (matchesKey(data, Key.shift("up"))) {
325
+ this.moveVertical(-1, true);
326
+ return;
327
+ }
328
+ if (matchesKey(data, Key.shift("down"))) {
329
+ this.moveVertical(1, true);
330
+ return;
331
+ }
332
+ if (matchesKey(data, Key.left)) {
333
+ this.moveHorizontal(-1, false);
334
+ return;
335
+ }
336
+ if (matchesKey(data, Key.right)) {
337
+ this.moveHorizontal(1, false);
338
+ return;
339
+ }
340
+ if (this.keybindings.matches(data, "tui.select.up")) {
341
+ this.moveVertical(-1, false);
342
+ return;
343
+ }
344
+ if (this.keybindings.matches(data, "tui.select.down")) {
345
+ this.moveVertical(1, false);
346
+ return;
347
+ }
348
+ if (this.keybindings.matches(data, "tui.select.pageUp")) {
349
+ this.moveVertical(-10, false);
350
+ return;
351
+ }
352
+ if (this.keybindings.matches(data, "tui.select.pageDown")) {
353
+ this.moveVertical(10, false);
354
+ return;
355
+ }
356
+ }
357
+
358
+ invalidate(): void {}
359
+
360
+ private renderTextLine(
361
+ line: BtwSelectionLine,
362
+ lineIndex: number,
363
+ range: { start: BtwTextPosition; end: BtwTextPosition } | undefined,
364
+ lineSelected: boolean,
365
+ ): string {
366
+ const characters = splitGraphemes(line.text);
367
+ let rendered = this.horizontalOffset > 0 ? this.theme.fg("muted", "…") : "";
368
+ let buffer = "";
369
+ let bufferSelected = false;
370
+ const flush = () => {
371
+ if (!buffer) return;
372
+ rendered += bufferSelected
373
+ ? this.theme.bg("selectedBg", this.theme.fg("text", buffer))
374
+ : buffer;
375
+ buffer = "";
376
+ };
377
+ for (let column = this.horizontalOffset; column <= characters.length; column += 1) {
378
+ if (range && lineIndex === range.start.line && column === range.start.column) {
379
+ flush();
380
+ rendered += this.theme.fg("accent", "[");
381
+ }
382
+ if (lineIndex === this.cursor.line && column === this.cursor.column) {
383
+ flush();
384
+ rendered += this.theme.fg("accent", "│");
385
+ }
386
+ if (range && lineIndex === range.end.line && column === range.end.column) {
387
+ flush();
388
+ rendered += this.theme.fg("accent", "]");
389
+ }
390
+ const character = characters[column];
391
+ if (character === undefined) continue;
392
+ const selected =
393
+ lineSelected || (range ? positionFallsInside(lineIndex, column, range) : false);
394
+ if (buffer && selected !== bufferSelected) flush();
395
+ bufferSelected = selected;
396
+ buffer += escapeTerminalControls(character);
397
+ }
398
+ flush();
399
+ return rendered;
400
+ }
401
+
402
+ private getSelectionRange(): { start: BtwTextPosition; end: BtwTextPosition } | undefined {
403
+ if (!this.anchor || compareTextPositions(this.anchor, this.cursor) === 0) return undefined;
404
+ return compareTextPositions(this.anchor, this.cursor) < 0
405
+ ? { start: this.anchor, end: this.cursor }
406
+ : { start: this.cursor, end: this.anchor };
407
+ }
408
+
409
+ private getLineSelectionRange(): { start: number; end: number } | undefined {
410
+ return this.lineAnchor === undefined
411
+ ? undefined
412
+ : {
413
+ start: Math.min(this.lineAnchor, this.cursor.line),
414
+ end: Math.max(this.lineAnchor, this.cursor.line),
415
+ };
416
+ }
417
+
418
+ private getSelectedSegments(): BtwBringToMainSegment[] {
419
+ if (this.lineAnchor !== undefined) {
420
+ return segmentsFromLineRange(this.lines, this.lineAnchor, this.cursor.line);
421
+ }
422
+ return this.anchor ? segmentsFromTextRange(this.lines, this.anchor, this.cursor) : [];
423
+ }
424
+
425
+ private moveHorizontal(delta: -1 | 1, extend: boolean): void {
426
+ if (this.lines.length === 0) return;
427
+ if (!extend) this.lineAnchor = undefined;
428
+ if (!extend && this.anchor) {
429
+ const range = this.getSelectionRange();
430
+ if (range) this.cursor = delta < 0 ? range.start : range.end;
431
+ this.anchor = undefined;
432
+ this.preferredColumn = this.cursor.column;
433
+ this.afterMove();
434
+ return;
435
+ }
436
+ this.beginOrClearSelection(extend);
437
+ const line = this.lines[this.cursor.line];
438
+ const length = line ? splitGraphemes(line.text).length : 0;
439
+ if (delta < 0) {
440
+ if (this.cursor.column > 0) this.cursor = { ...this.cursor, column: this.cursor.column - 1 };
441
+ else if (this.cursor.line > 0) {
442
+ const previousLine = this.lines[this.cursor.line - 1];
443
+ this.cursor = {
444
+ line: this.cursor.line - 1,
445
+ column: previousLine ? splitGraphemes(previousLine.text).length : 0,
446
+ };
447
+ }
448
+ } else if (this.cursor.column < length) {
449
+ this.cursor = { ...this.cursor, column: this.cursor.column + 1 };
450
+ } else if (this.cursor.line < this.lines.length - 1) {
451
+ this.cursor = { line: this.cursor.line + 1, column: 0 };
452
+ }
453
+ this.preferredColumn = this.cursor.column;
454
+ this.afterMove();
455
+ }
456
+
457
+ private moveVertical(delta: number, extend: boolean): void {
458
+ if (this.lines.length === 0) return;
459
+ if (extend || this.lineAnchor === undefined) this.beginOrClearSelection(extend);
460
+ const line = Math.max(0, Math.min(this.lines.length - 1, this.cursor.line + delta));
461
+ const target = this.lines[line];
462
+ this.cursor = {
463
+ line,
464
+ column: Math.min(this.preferredColumn, target ? splitGraphemes(target.text).length : 0),
465
+ };
466
+ this.afterMove();
467
+ }
468
+
469
+ private beginOrClearSelection(extend: boolean): void {
470
+ if (extend) this.lineAnchor = undefined;
471
+ if (extend && !this.anchor) this.anchor = { ...this.cursor };
472
+ if (!extend) this.anchor = undefined;
473
+ }
474
+
475
+ private afterMove(): void {
476
+ this.warning = undefined;
477
+ this.tui.requestRender();
478
+ }
479
+
480
+ private keepCursorVisible(height: number): void {
481
+ if (height <= 0) return;
482
+ if (this.cursor.line < this.scrollOffset) this.scrollOffset = this.cursor.line;
483
+ if (this.cursor.line >= this.scrollOffset + height) {
484
+ this.scrollOffset = this.cursor.line - height + 1;
485
+ }
486
+ }
487
+
488
+ private keepCursorHorizontallyVisible(width: number): void {
489
+ const characters = splitGraphemes(this.lines[this.cursor.line]?.text ?? "");
490
+ const displayWidths = characters.map((character) =>
491
+ visibleWidth(escapeTerminalControls(character)),
492
+ );
493
+ const currentWidth = displayWidths[this.cursor.column] ?? 0;
494
+ let usedWidth = 1 + Math.min(currentWidth, Math.max(0, width - 1));
495
+ let offset = this.cursor.column;
496
+ for (let index = this.cursor.column - 1; index >= 0; index -= 1) {
497
+ const nextWidth = usedWidth + (displayWidths[index] ?? 0) + (index > 0 ? 1 : 0);
498
+ if (nextWidth > width) break;
499
+ usedWidth += displayWidths[index] ?? 0;
500
+ offset = index;
501
+ }
502
+ this.horizontalOffset = offset;
503
+ }
504
+
505
+ private finish(action: BtwTextRangeSelectorAction): void {
506
+ if (this.finished) return;
507
+ this.finished = true;
508
+ this.onAction(action);
509
+ }
510
+ }
511
+
512
+ function clampTextPosition(
513
+ lines: readonly BtwSelectionLine[],
514
+ position: BtwTextPosition,
515
+ ): BtwTextPosition {
516
+ const line = Math.max(0, Math.min(lines.length - 1, position.line));
517
+ const text = lines[line]?.text ?? "";
518
+ return {
519
+ line,
520
+ column: Math.max(0, Math.min(splitGraphemes(text).length, position.column)),
521
+ };
522
+ }
523
+
524
+ function confirmKeyLabel(keybindings: KeybindingsManager): string {
525
+ return keybindingLabel(keybindings, "tui.select.confirm", ["ctrl+c"], "enter");
526
+ }
527
+
528
+ function matchesConfirm(data: string, keybindings: KeybindingsManager): boolean {
529
+ if (matchesKey(data, Key.ctrl("c"))) return false;
530
+ const hasUsableBinding = keybindings
531
+ .getKeys("tui.select.confirm")
532
+ .map(String)
533
+ .some((key) => key.toLowerCase() !== "ctrl+c");
534
+ return (
535
+ keybindings.matches(data, "tui.select.confirm") ||
536
+ (!hasUsableBinding && matchesKey(data, Key.enter))
537
+ );
538
+ }
539
+
540
+ function keybindingLabel(
541
+ keybindings: KeybindingsManager,
542
+ keybinding:
543
+ | "tui.select.confirm"
544
+ | "tui.select.cancel"
545
+ | "tui.select.up"
546
+ | "tui.select.down"
547
+ | "tui.select.pageUp"
548
+ | "tui.select.pageDown",
549
+ excluded: readonly string[] = [],
550
+ fallback?: string,
551
+ ): string {
552
+ const key = keybindings
553
+ .getKeys(keybinding)
554
+ .map(String)
555
+ .find((candidate) => !excluded.includes(candidate.toLowerCase()));
556
+ return formatKeyLabel(key ?? fallback ?? keybinding);
557
+ }
558
+
559
+ function formatKeyLabel(key: string): string {
560
+ return key
561
+ .split("+")
562
+ .map((part) => {
563
+ const lower = part.toLowerCase();
564
+ if (lower === "ctrl") return "Ctrl";
565
+ if (lower === "alt") return "Alt";
566
+ if (lower === "shift") return "Shift";
567
+ if (lower === "escape" || lower === "esc") return "Esc";
568
+ if (lower === "enter" || lower === "return") return "Enter";
569
+ if (lower === "pageup") return "PgUp";
570
+ if (lower === "pagedown") return "PgDn";
571
+ return part.length === 1
572
+ ? part.toUpperCase()
573
+ : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
574
+ })
575
+ .join("+");
576
+ }
577
+
578
+ function splitGraphemes(text: string): string[] {
579
+ return [...GRAPHEME_SEGMENTER.segment(text)].map(({ segment }) => segment);
580
+ }
581
+
582
+ function compareTextPositions(first: BtwTextPosition, second: BtwTextPosition): number {
583
+ return first.line === second.line ? first.column - second.column : first.line - second.line;
584
+ }
585
+
586
+ function positionFallsInside(
587
+ line: number,
588
+ column: number,
589
+ range: { start: BtwTextPosition; end: BtwTextPosition },
590
+ ): boolean {
591
+ const position = { line, column };
592
+ return (
593
+ compareTextPositions(position, range.start) >= 0 &&
594
+ compareTextPositions(position, range.end) < 0
595
+ );
596
+ }
597
+
598
+ function fitRows(rows: string[], availableRows: number): string[] {
599
+ if (rows.length <= availableRows) return rows;
600
+ if (availableRows <= 1) return rows.slice(0, 1);
601
+ return [rows[0] ?? "", ...rows.slice(rows.length - availableRows + 1)];
602
+ }
603
+
604
+ function escapeBringToMainText(text: string): string {
605
+ return [...text]
606
+ .map((character) => {
607
+ if (character === "\n") return character;
608
+ if (character === "\t") return " ";
609
+ const code = character.charCodeAt(0);
610
+ if (code <= 31 || (code >= 127 && code <= 159)) {
611
+ return `\\x${code.toString(16).padStart(2, "0")}`;
612
+ }
613
+ return character;
614
+ })
615
+ .join("")
616
+ .replace(/<btw_context(?=[ \t\r\n>])/g, "&lt;btw_context")
617
+ .replace(/<\/btw_context[ \t\r\n]*>/g, (terminator) =>
618
+ terminator.replaceAll("<", "&lt;").replaceAll(">", "&gt;"),
619
+ );
620
+ }
621
+
622
+ function escapeTerminalControls(text: string): string {
623
+ return [...text]
624
+ .map((character) => {
625
+ const code = character.charCodeAt(0);
626
+ if (code <= 31 || (code >= 127 && code <= 159)) {
627
+ return `\\x${code.toString(16).padStart(2, "0")}`;
628
+ }
629
+ return character;
630
+ })
631
+ .join("");
632
+ }