@oh-my-pi/pi-coding-agent 1.337.0

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 (224) hide show
  1. package/CHANGELOG.md +1228 -0
  2. package/README.md +1041 -0
  3. package/docs/compaction.md +403 -0
  4. package/docs/custom-tools.md +541 -0
  5. package/docs/extension-loading.md +1004 -0
  6. package/docs/hooks.md +867 -0
  7. package/docs/rpc.md +1040 -0
  8. package/docs/sdk.md +994 -0
  9. package/docs/session-tree-plan.md +441 -0
  10. package/docs/session.md +240 -0
  11. package/docs/skills.md +290 -0
  12. package/docs/theme.md +637 -0
  13. package/docs/tree.md +197 -0
  14. package/docs/tui.md +341 -0
  15. package/examples/README.md +21 -0
  16. package/examples/custom-tools/README.md +124 -0
  17. package/examples/custom-tools/hello/index.ts +20 -0
  18. package/examples/custom-tools/question/index.ts +84 -0
  19. package/examples/custom-tools/subagent/README.md +172 -0
  20. package/examples/custom-tools/subagent/agents/planner.md +37 -0
  21. package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
  22. package/examples/custom-tools/subagent/agents/scout.md +50 -0
  23. package/examples/custom-tools/subagent/agents/worker.md +24 -0
  24. package/examples/custom-tools/subagent/agents.ts +156 -0
  25. package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
  26. package/examples/custom-tools/subagent/commands/implement.md +10 -0
  27. package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
  28. package/examples/custom-tools/subagent/index.ts +1002 -0
  29. package/examples/custom-tools/todo/index.ts +212 -0
  30. package/examples/hooks/README.md +56 -0
  31. package/examples/hooks/auto-commit-on-exit.ts +49 -0
  32. package/examples/hooks/confirm-destructive.ts +59 -0
  33. package/examples/hooks/custom-compaction.ts +116 -0
  34. package/examples/hooks/dirty-repo-guard.ts +52 -0
  35. package/examples/hooks/file-trigger.ts +41 -0
  36. package/examples/hooks/git-checkpoint.ts +53 -0
  37. package/examples/hooks/handoff.ts +150 -0
  38. package/examples/hooks/permission-gate.ts +34 -0
  39. package/examples/hooks/protected-paths.ts +30 -0
  40. package/examples/hooks/qna.ts +119 -0
  41. package/examples/hooks/snake.ts +343 -0
  42. package/examples/hooks/status-line.ts +40 -0
  43. package/examples/sdk/01-minimal.ts +22 -0
  44. package/examples/sdk/02-custom-model.ts +49 -0
  45. package/examples/sdk/03-custom-prompt.ts +44 -0
  46. package/examples/sdk/04-skills.ts +44 -0
  47. package/examples/sdk/05-tools.ts +90 -0
  48. package/examples/sdk/06-hooks.ts +61 -0
  49. package/examples/sdk/07-context-files.ts +36 -0
  50. package/examples/sdk/08-slash-commands.ts +42 -0
  51. package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
  52. package/examples/sdk/10-settings.ts +38 -0
  53. package/examples/sdk/11-sessions.ts +48 -0
  54. package/examples/sdk/12-full-control.ts +95 -0
  55. package/examples/sdk/README.md +154 -0
  56. package/package.json +81 -0
  57. package/src/cli/args.ts +246 -0
  58. package/src/cli/file-processor.ts +72 -0
  59. package/src/cli/list-models.ts +104 -0
  60. package/src/cli/plugin-cli.ts +650 -0
  61. package/src/cli/session-picker.ts +41 -0
  62. package/src/cli.ts +10 -0
  63. package/src/commands/init.md +20 -0
  64. package/src/config.ts +159 -0
  65. package/src/core/agent-session.ts +1900 -0
  66. package/src/core/auth-storage.ts +236 -0
  67. package/src/core/bash-executor.ts +196 -0
  68. package/src/core/compaction/branch-summarization.ts +343 -0
  69. package/src/core/compaction/compaction.ts +742 -0
  70. package/src/core/compaction/index.ts +7 -0
  71. package/src/core/compaction/utils.ts +154 -0
  72. package/src/core/custom-tools/index.ts +21 -0
  73. package/src/core/custom-tools/loader.ts +248 -0
  74. package/src/core/custom-tools/types.ts +169 -0
  75. package/src/core/custom-tools/wrapper.ts +28 -0
  76. package/src/core/exec.ts +129 -0
  77. package/src/core/export-html/index.ts +211 -0
  78. package/src/core/export-html/template.css +781 -0
  79. package/src/core/export-html/template.html +54 -0
  80. package/src/core/export-html/template.js +1185 -0
  81. package/src/core/export-html/vendor/highlight.min.js +1213 -0
  82. package/src/core/export-html/vendor/marked.min.js +6 -0
  83. package/src/core/hooks/index.ts +16 -0
  84. package/src/core/hooks/loader.ts +312 -0
  85. package/src/core/hooks/runner.ts +434 -0
  86. package/src/core/hooks/tool-wrapper.ts +99 -0
  87. package/src/core/hooks/types.ts +773 -0
  88. package/src/core/index.ts +52 -0
  89. package/src/core/mcp/client.ts +158 -0
  90. package/src/core/mcp/config.ts +154 -0
  91. package/src/core/mcp/index.ts +45 -0
  92. package/src/core/mcp/loader.ts +68 -0
  93. package/src/core/mcp/manager.ts +181 -0
  94. package/src/core/mcp/tool-bridge.ts +148 -0
  95. package/src/core/mcp/transports/http.ts +316 -0
  96. package/src/core/mcp/transports/index.ts +6 -0
  97. package/src/core/mcp/transports/stdio.ts +252 -0
  98. package/src/core/mcp/types.ts +220 -0
  99. package/src/core/messages.ts +189 -0
  100. package/src/core/model-registry.ts +317 -0
  101. package/src/core/model-resolver.ts +393 -0
  102. package/src/core/plugins/doctor.ts +59 -0
  103. package/src/core/plugins/index.ts +38 -0
  104. package/src/core/plugins/installer.ts +189 -0
  105. package/src/core/plugins/loader.ts +338 -0
  106. package/src/core/plugins/manager.ts +672 -0
  107. package/src/core/plugins/parser.ts +105 -0
  108. package/src/core/plugins/paths.ts +32 -0
  109. package/src/core/plugins/types.ts +190 -0
  110. package/src/core/sdk.ts +760 -0
  111. package/src/core/session-manager.ts +1128 -0
  112. package/src/core/settings-manager.ts +443 -0
  113. package/src/core/skills.ts +437 -0
  114. package/src/core/slash-commands.ts +248 -0
  115. package/src/core/system-prompt.ts +439 -0
  116. package/src/core/timings.ts +25 -0
  117. package/src/core/tools/ask.ts +211 -0
  118. package/src/core/tools/bash-interceptor.ts +120 -0
  119. package/src/core/tools/bash.ts +250 -0
  120. package/src/core/tools/context.ts +32 -0
  121. package/src/core/tools/edit-diff.ts +475 -0
  122. package/src/core/tools/edit.ts +208 -0
  123. package/src/core/tools/exa/company.ts +59 -0
  124. package/src/core/tools/exa/index.ts +64 -0
  125. package/src/core/tools/exa/linkedin.ts +59 -0
  126. package/src/core/tools/exa/logger.ts +56 -0
  127. package/src/core/tools/exa/mcp-client.ts +368 -0
  128. package/src/core/tools/exa/render.ts +196 -0
  129. package/src/core/tools/exa/researcher.ts +90 -0
  130. package/src/core/tools/exa/search.ts +337 -0
  131. package/src/core/tools/exa/types.ts +168 -0
  132. package/src/core/tools/exa/websets.ts +248 -0
  133. package/src/core/tools/find.ts +261 -0
  134. package/src/core/tools/grep.ts +555 -0
  135. package/src/core/tools/index.ts +202 -0
  136. package/src/core/tools/ls.ts +140 -0
  137. package/src/core/tools/lsp/client.ts +605 -0
  138. package/src/core/tools/lsp/config.ts +147 -0
  139. package/src/core/tools/lsp/edits.ts +101 -0
  140. package/src/core/tools/lsp/index.ts +804 -0
  141. package/src/core/tools/lsp/render.ts +447 -0
  142. package/src/core/tools/lsp/rust-analyzer.ts +145 -0
  143. package/src/core/tools/lsp/types.ts +463 -0
  144. package/src/core/tools/lsp/utils.ts +486 -0
  145. package/src/core/tools/notebook.ts +229 -0
  146. package/src/core/tools/path-utils.ts +61 -0
  147. package/src/core/tools/read.ts +240 -0
  148. package/src/core/tools/renderers.ts +540 -0
  149. package/src/core/tools/task/agents.ts +153 -0
  150. package/src/core/tools/task/artifacts.ts +114 -0
  151. package/src/core/tools/task/bundled-agents/browser.md +71 -0
  152. package/src/core/tools/task/bundled-agents/explore.md +82 -0
  153. package/src/core/tools/task/bundled-agents/plan.md +54 -0
  154. package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
  155. package/src/core/tools/task/bundled-agents/task.md +53 -0
  156. package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
  157. package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
  158. package/src/core/tools/task/bundled-commands/implement.md +11 -0
  159. package/src/core/tools/task/commands.ts +213 -0
  160. package/src/core/tools/task/discovery.ts +208 -0
  161. package/src/core/tools/task/executor.ts +367 -0
  162. package/src/core/tools/task/index.ts +388 -0
  163. package/src/core/tools/task/model-resolver.ts +115 -0
  164. package/src/core/tools/task/parallel.ts +38 -0
  165. package/src/core/tools/task/render.ts +232 -0
  166. package/src/core/tools/task/types.ts +99 -0
  167. package/src/core/tools/truncate.ts +265 -0
  168. package/src/core/tools/web-fetch.ts +2370 -0
  169. package/src/core/tools/web-search/auth.ts +193 -0
  170. package/src/core/tools/web-search/index.ts +537 -0
  171. package/src/core/tools/web-search/providers/anthropic.ts +198 -0
  172. package/src/core/tools/web-search/providers/exa.ts +302 -0
  173. package/src/core/tools/web-search/providers/perplexity.ts +195 -0
  174. package/src/core/tools/web-search/render.ts +182 -0
  175. package/src/core/tools/web-search/types.ts +180 -0
  176. package/src/core/tools/write.ts +99 -0
  177. package/src/index.ts +176 -0
  178. package/src/main.ts +464 -0
  179. package/src/migrations.ts +135 -0
  180. package/src/modes/index.ts +43 -0
  181. package/src/modes/interactive/components/armin.ts +382 -0
  182. package/src/modes/interactive/components/assistant-message.ts +86 -0
  183. package/src/modes/interactive/components/bash-execution.ts +196 -0
  184. package/src/modes/interactive/components/bordered-loader.ts +41 -0
  185. package/src/modes/interactive/components/branch-summary-message.ts +42 -0
  186. package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
  187. package/src/modes/interactive/components/custom-editor.ts +122 -0
  188. package/src/modes/interactive/components/diff.ts +147 -0
  189. package/src/modes/interactive/components/dynamic-border.ts +25 -0
  190. package/src/modes/interactive/components/footer.ts +381 -0
  191. package/src/modes/interactive/components/hook-editor.ts +117 -0
  192. package/src/modes/interactive/components/hook-input.ts +64 -0
  193. package/src/modes/interactive/components/hook-message.ts +96 -0
  194. package/src/modes/interactive/components/hook-selector.ts +91 -0
  195. package/src/modes/interactive/components/model-selector.ts +247 -0
  196. package/src/modes/interactive/components/oauth-selector.ts +120 -0
  197. package/src/modes/interactive/components/plugin-settings.ts +479 -0
  198. package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
  199. package/src/modes/interactive/components/session-selector.ts +204 -0
  200. package/src/modes/interactive/components/settings-selector.ts +453 -0
  201. package/src/modes/interactive/components/show-images-selector.ts +45 -0
  202. package/src/modes/interactive/components/theme-selector.ts +62 -0
  203. package/src/modes/interactive/components/thinking-selector.ts +64 -0
  204. package/src/modes/interactive/components/tool-execution.ts +675 -0
  205. package/src/modes/interactive/components/tree-selector.ts +866 -0
  206. package/src/modes/interactive/components/user-message-selector.ts +159 -0
  207. package/src/modes/interactive/components/user-message.ts +18 -0
  208. package/src/modes/interactive/components/visual-truncate.ts +50 -0
  209. package/src/modes/interactive/components/welcome.ts +183 -0
  210. package/src/modes/interactive/interactive-mode.ts +2516 -0
  211. package/src/modes/interactive/theme/dark.json +101 -0
  212. package/src/modes/interactive/theme/light.json +98 -0
  213. package/src/modes/interactive/theme/theme-schema.json +308 -0
  214. package/src/modes/interactive/theme/theme.ts +998 -0
  215. package/src/modes/print-mode.ts +128 -0
  216. package/src/modes/rpc/rpc-client.ts +527 -0
  217. package/src/modes/rpc/rpc-mode.ts +483 -0
  218. package/src/modes/rpc/rpc-types.ts +203 -0
  219. package/src/utils/changelog.ts +99 -0
  220. package/src/utils/clipboard.ts +265 -0
  221. package/src/utils/fuzzy.ts +108 -0
  222. package/src/utils/mime.ts +30 -0
  223. package/src/utils/shell.ts +276 -0
  224. package/src/utils/tools-manager.ts +274 -0
@@ -0,0 +1,381 @@
1
+ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
+ import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
3
+ import { execSync } from "child_process";
4
+ import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
5
+ import { dirname, join } from "path";
6
+ import type { AgentSession } from "../../../core/agent-session.js";
7
+ import { theme } from "../theme/theme.js";
8
+
9
+ // Nerd Font icons (matching Claude/statusline-nerd.sh)
10
+ const ICONS = {
11
+ model: "\uf4bc", // robot/model
12
+ folder: "\uf115", // folder
13
+ branch: "\uf126", // git branch
14
+ sep: "\ue0b1", // powerline thin chevron
15
+ tokens: "\uf0ce", // table/tokens
16
+ } as const;
17
+
18
+ /**
19
+ * Sanitize text for display in a single-line status.
20
+ * Removes newlines, tabs, carriage returns, and other control characters.
21
+ */
22
+ function sanitizeStatusText(text: string): string {
23
+ // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
24
+ return text
25
+ .replace(/[\r\n\t]/g, " ")
26
+ .replace(/ +/g, " ")
27
+ .trim();
28
+ }
29
+
30
+ /**
31
+ * Find the git root directory by walking up from cwd.
32
+ * Returns the path to .git/HEAD if found, null otherwise.
33
+ */
34
+ function findGitHeadPath(): string | null {
35
+ let dir = process.cwd();
36
+ while (true) {
37
+ const gitHeadPath = join(dir, ".git", "HEAD");
38
+ if (existsSync(gitHeadPath)) {
39
+ return gitHeadPath;
40
+ }
41
+ const parent = dirname(dir);
42
+ if (parent === dir) {
43
+ // Reached filesystem root
44
+ return null;
45
+ }
46
+ dir = parent;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Footer component that shows pwd, token stats, and context usage
52
+ */
53
+ export class FooterComponent implements Component {
54
+ private session: AgentSession;
55
+ private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
56
+ private gitWatcher: FSWatcher | null = null;
57
+ private onBranchChange: (() => void) | null = null;
58
+ private autoCompactEnabled: boolean = true;
59
+ private hookStatuses: Map<string, string> = new Map();
60
+
61
+ constructor(session: AgentSession) {
62
+ this.session = session;
63
+ }
64
+
65
+ setAutoCompactEnabled(enabled: boolean): void {
66
+ this.autoCompactEnabled = enabled;
67
+ }
68
+
69
+ /**
70
+ * Set hook status text to display in the footer.
71
+ * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
72
+ * ANSI escape codes for styling are preserved.
73
+ * @param key - Unique key to identify this status
74
+ * @param text - Status text, or undefined to clear
75
+ */
76
+ setHookStatus(key: string, text: string | undefined): void {
77
+ if (text === undefined) {
78
+ this.hookStatuses.delete(key);
79
+ } else {
80
+ this.hookStatuses.set(key, text);
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Set up a file watcher on .git/HEAD to detect branch changes.
86
+ * Call the provided callback when branch changes.
87
+ */
88
+ watchBranch(onBranchChange: () => void): void {
89
+ this.onBranchChange = onBranchChange;
90
+ this.setupGitWatcher();
91
+ }
92
+
93
+ private setupGitWatcher(): void {
94
+ // Clean up existing watcher
95
+ if (this.gitWatcher) {
96
+ this.gitWatcher.close();
97
+ this.gitWatcher = null;
98
+ }
99
+
100
+ const gitHeadPath = findGitHeadPath();
101
+ if (!gitHeadPath) {
102
+ return;
103
+ }
104
+
105
+ try {
106
+ this.gitWatcher = watch(gitHeadPath, () => {
107
+ this.cachedBranch = undefined; // Invalidate cache
108
+ if (this.onBranchChange) {
109
+ this.onBranchChange();
110
+ }
111
+ });
112
+ } catch {
113
+ // Silently fail if we can't watch
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Clean up the file watcher
119
+ */
120
+ dispose(): void {
121
+ if (this.gitWatcher) {
122
+ this.gitWatcher.close();
123
+ this.gitWatcher = null;
124
+ }
125
+ }
126
+
127
+ invalidate(): void {
128
+ // Invalidate cached branch so it gets re-read on next render
129
+ this.cachedBranch = undefined;
130
+ }
131
+
132
+ /**
133
+ * Get current git branch by reading .git/HEAD directly.
134
+ * Returns null if not in a git repo, branch name otherwise.
135
+ */
136
+ private getCurrentBranch(): string | null {
137
+ // Return cached value if available
138
+ if (this.cachedBranch !== undefined) {
139
+ return this.cachedBranch;
140
+ }
141
+
142
+ try {
143
+ const gitHeadPath = findGitHeadPath();
144
+ if (!gitHeadPath) {
145
+ this.cachedBranch = null;
146
+ return null;
147
+ }
148
+ const content = readFileSync(gitHeadPath, "utf8").trim();
149
+
150
+ if (content.startsWith("ref: refs/heads/")) {
151
+ // Normal branch: extract branch name
152
+ this.cachedBranch = content.slice(16);
153
+ } else {
154
+ // Detached HEAD state
155
+ this.cachedBranch = "detached";
156
+ }
157
+ } catch {
158
+ // Not in a git repo or error reading file
159
+ this.cachedBranch = null;
160
+ }
161
+
162
+ return this.cachedBranch;
163
+ }
164
+
165
+ /**
166
+ * Get git status indicators (staged, unstaged, untracked counts).
167
+ * Returns null if not in a git repo.
168
+ */
169
+ private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
170
+ try {
171
+ const output = execSync("git status --porcelain 2>/dev/null", {
172
+ encoding: "utf8",
173
+ timeout: 1000,
174
+ stdio: ["pipe", "pipe", "pipe"],
175
+ });
176
+
177
+ let staged = 0;
178
+ let unstaged = 0;
179
+ let untracked = 0;
180
+
181
+ for (const line of output.split("\n")) {
182
+ if (!line) continue;
183
+ const x = line[0]; // Index (staged) status
184
+ const y = line[1]; // Working tree status
185
+
186
+ // Untracked files
187
+ if (x === "?" && y === "?") {
188
+ untracked++;
189
+ continue;
190
+ }
191
+
192
+ // Staged changes (first column is not space or ?)
193
+ if (x && x !== " " && x !== "?") {
194
+ staged++;
195
+ }
196
+
197
+ // Unstaged changes (second column is not space)
198
+ if (y && y !== " ") {
199
+ unstaged++;
200
+ }
201
+ }
202
+
203
+ return { staged, unstaged, untracked };
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ render(width: number): string[] {
210
+ const state = this.session.state;
211
+
212
+ // Calculate cumulative usage from ALL session entries
213
+ let totalInput = 0;
214
+ let totalOutput = 0;
215
+ let totalCacheRead = 0;
216
+ let totalCacheWrite = 0;
217
+ let totalCost = 0;
218
+
219
+ for (const entry of this.session.sessionManager.getEntries()) {
220
+ if (entry.type === "message" && entry.message.role === "assistant") {
221
+ totalInput += entry.message.usage.input;
222
+ totalOutput += entry.message.usage.output;
223
+ totalCacheRead += entry.message.usage.cacheRead;
224
+ totalCacheWrite += entry.message.usage.cacheWrite;
225
+ totalCost += entry.message.usage.cost.total;
226
+ }
227
+ }
228
+
229
+ // Get context percentage from last assistant message
230
+ const lastAssistantMessage = state.messages
231
+ .slice()
232
+ .reverse()
233
+ .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
234
+
235
+ const contextTokens = lastAssistantMessage
236
+ ? lastAssistantMessage.usage.input +
237
+ lastAssistantMessage.usage.output +
238
+ lastAssistantMessage.usage.cacheRead +
239
+ lastAssistantMessage.usage.cacheWrite
240
+ : 0;
241
+ const contextWindow = state.model?.contextWindow || 0;
242
+ const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
243
+
244
+ // Format helpers
245
+ const formatTokens = (n: number): string => {
246
+ if (n < 1000) return n.toString();
247
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
248
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
249
+ if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
250
+ return `${Math.round(n / 1000000)}M`;
251
+ };
252
+
253
+ // Powerline separator (very dim)
254
+ const sep = theme.fg("footerSep", ` ${ICONS.sep} `);
255
+
256
+ // ═══════════════════════════════════════════════════════════════════════
257
+ // SEGMENT 1: Model (Gold/White)
258
+ // ═══════════════════════════════════════════════════════════════════════
259
+ const modelName = state.model?.id || "no-model";
260
+ let modelSegment = theme.fg("footerModel", `${ICONS.model} ${modelName}`);
261
+ if (state.model?.reasoning) {
262
+ const level = state.thinkingLevel || "off";
263
+ if (level !== "off") {
264
+ modelSegment += theme.fg("footerSep", " · ") + theme.fg("footerModel", level);
265
+ }
266
+ }
267
+
268
+ // ═══════════════════════════════════════════════════════════════════════
269
+ // SEGMENT 2: Path (Cyan with dim separators)
270
+ // Replace home with ~, strip /work/, color separators
271
+ // ═══════════════════════════════════════════════════════════════════════
272
+ let pwd = process.cwd();
273
+ const home = process.env.HOME || process.env.USERPROFILE;
274
+ if (home && pwd.startsWith(home)) {
275
+ pwd = `~${pwd.slice(home.length)}`;
276
+ }
277
+ // Strip /work/ prefix
278
+ if (pwd.startsWith("/work/")) {
279
+ pwd = pwd.slice(6);
280
+ }
281
+ // Color path with dim separators: ~/foo/bar -> ~/foo/bar (separators dim)
282
+ const pathColored = pwd
283
+ .split("/")
284
+ .map((part) => theme.fg("footerPath", part))
285
+ .join(theme.fg("footerSep", "/"));
286
+ const pathSegment = theme.fg("footerIcon", `${ICONS.folder} `) + pathColored;
287
+
288
+ // ═══════════════════════════════════════════════════════════════════════
289
+ // SEGMENT 3: Git Branch + Status (Green/Yellow)
290
+ // ═══════════════════════════════════════════════════════════════════════
291
+ const branch = this.getCurrentBranch();
292
+ let gitSegment = "";
293
+ if (branch) {
294
+ const gitStatus = this.getGitStatus();
295
+ const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
296
+
297
+ // Branch name - green if clean, yellow if dirty
298
+ const branchColor = isDirty ? "footerDirty" : "footerBranch";
299
+ gitSegment = theme.fg("footerIcon", `${ICONS.branch} `) + theme.fg(branchColor, branch);
300
+
301
+ // Add status indicators
302
+ if (gitStatus) {
303
+ const indicators: string[] = [];
304
+ if (gitStatus.unstaged > 0) {
305
+ indicators.push(theme.fg("footerDirty", `*${gitStatus.unstaged}`));
306
+ }
307
+ if (gitStatus.staged > 0) {
308
+ indicators.push(theme.fg("footerStaged", `+${gitStatus.staged}`));
309
+ }
310
+ if (gitStatus.untracked > 0) {
311
+ indicators.push(theme.fg("footerUntracked", `!${gitStatus.untracked}`));
312
+ }
313
+ if (indicators.length > 0) {
314
+ gitSegment += ` ${indicators.join(" ")}`;
315
+ }
316
+ }
317
+ }
318
+
319
+ // ═══════════════════════════════════════════════════════════════════════
320
+ // SEGMENT 4: Stats (Pink/Magenta tones)
321
+ // Concise: total tokens, cost, context%
322
+ // ═══════════════════════════════════════════════════════════════════════
323
+ const statParts: string[] = [];
324
+
325
+ // Total tokens (input + output + cache)
326
+ const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
327
+ if (totalTokens) {
328
+ statParts.push(theme.fg("footerOutput", `${ICONS.tokens} ${formatTokens(totalTokens)}`));
329
+ }
330
+
331
+ // Cost (pink)
332
+ const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
333
+ if (totalCost || usingSubscription) {
334
+ const costDisplay = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
335
+ statParts.push(theme.fg("footerCost", costDisplay));
336
+ }
337
+
338
+ // Context percentage with severity coloring
339
+ const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
340
+ const contextDisplay = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
341
+ let contextColored: string;
342
+ if (contextPercentValue > 90) {
343
+ contextColored = theme.fg("error", contextDisplay);
344
+ } else if (contextPercentValue > 70) {
345
+ contextColored = theme.fg("warning", contextDisplay);
346
+ } else {
347
+ contextColored = theme.fg("footerSep", contextDisplay);
348
+ }
349
+ statParts.push(contextColored);
350
+
351
+ const statsSegment = statParts.join(" ");
352
+
353
+ // ═══════════════════════════════════════════════════════════════════════
354
+ // Assemble single powerline-style line
355
+ // [Model] > [Path] > [Git] > [Stats]
356
+ // ═══════════════════════════════════════════════════════════════════════
357
+ const segments = [modelSegment, pathSegment];
358
+ if (gitSegment) segments.push(gitSegment);
359
+ segments.push(statsSegment);
360
+
361
+ let statusLine = segments.join(sep);
362
+
363
+ // Truncate if needed
364
+ if (visibleWidth(statusLine) > width) {
365
+ statusLine = truncateToWidth(statusLine, width, theme.fg("footerSep", "…"));
366
+ }
367
+
368
+ const lines = [statusLine];
369
+
370
+ // Hook statuses (optional second line)
371
+ if (this.hookStatuses.size > 0) {
372
+ const sortedStatuses = Array.from(this.hookStatuses.entries())
373
+ .sort(([a], [b]) => a.localeCompare(b))
374
+ .map(([, text]) => sanitizeStatusText(text));
375
+ const hookLine = sortedStatuses.join(" ");
376
+ lines.push(truncateToWidth(hookLine, width, theme.fg("footerSep", "…")));
377
+ }
378
+
379
+ return lines;
380
+ }
381
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Multi-line editor component for hooks.
3
+ * Supports Ctrl+G for external editor.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
10
+ import { getEditorTheme, theme } from "../theme/theme.js";
11
+ import { DynamicBorder } from "./dynamic-border.js";
12
+
13
+ export class HookEditorComponent extends Container {
14
+ private editor: Editor;
15
+ private onSubmitCallback: (value: string) => void;
16
+ private onCancelCallback: () => void;
17
+ private tui: TUI;
18
+
19
+ constructor(
20
+ tui: TUI,
21
+ title: string,
22
+ prefill: string | undefined,
23
+ onSubmit: (value: string) => void,
24
+ onCancel: () => void,
25
+ ) {
26
+ super();
27
+
28
+ this.tui = tui;
29
+ this.onSubmitCallback = onSubmit;
30
+ this.onCancelCallback = onCancel;
31
+
32
+ // Add top border
33
+ this.addChild(new DynamicBorder());
34
+ this.addChild(new Spacer(1));
35
+
36
+ // Add title
37
+ this.addChild(new Text(theme.fg("accent", title), 1, 0));
38
+ this.addChild(new Spacer(1));
39
+
40
+ // Create editor
41
+ this.editor = new Editor(getEditorTheme());
42
+ if (prefill) {
43
+ this.editor.setText(prefill);
44
+ }
45
+ this.addChild(this.editor);
46
+
47
+ this.addChild(new Spacer(1));
48
+
49
+ // Add hint
50
+ const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR);
51
+ const hint = hasExternalEditor
52
+ ? "ctrl+enter submit esc cancel ctrl+g external editor"
53
+ : "ctrl+enter submit esc cancel";
54
+ this.addChild(new Text(theme.fg("dim", hint), 1, 0));
55
+
56
+ this.addChild(new Spacer(1));
57
+
58
+ // Add bottom border
59
+ this.addChild(new DynamicBorder());
60
+ }
61
+
62
+ handleInput(keyData: string): void {
63
+ // Ctrl+Enter to submit
64
+ if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") {
65
+ this.onSubmitCallback(this.editor.getText());
66
+ return;
67
+ }
68
+
69
+ // Escape to cancel
70
+ if (isEscape(keyData)) {
71
+ this.onCancelCallback();
72
+ return;
73
+ }
74
+
75
+ // Ctrl+G for external editor
76
+ if (isCtrlG(keyData)) {
77
+ this.openExternalEditor();
78
+ return;
79
+ }
80
+
81
+ // Forward to editor
82
+ this.editor.handleInput(keyData);
83
+ }
84
+
85
+ private openExternalEditor(): void {
86
+ const editorCmd = process.env.VISUAL || process.env.EDITOR;
87
+ if (!editorCmd) {
88
+ return;
89
+ }
90
+
91
+ const currentText = this.editor.getText();
92
+ const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`);
93
+
94
+ try {
95
+ fs.writeFileSync(tmpFile, currentText, "utf-8");
96
+ this.tui.stop();
97
+
98
+ const [editor, ...editorArgs] = editorCmd.split(" ");
99
+ const result = Bun.spawnSync([editor, ...editorArgs, tmpFile], {
100
+ stdio: ["inherit", "inherit", "inherit"],
101
+ });
102
+
103
+ if (result.exitCode === 0) {
104
+ const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, "");
105
+ this.editor.setText(newContent);
106
+ }
107
+ } finally {
108
+ try {
109
+ fs.unlinkSync(tmpFile);
110
+ } catch {
111
+ // Ignore cleanup errors
112
+ }
113
+ this.tui.start();
114
+ this.tui.requestRender();
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Simple text input component for hooks.
3
+ */
4
+
5
+ import { Container, Input, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
6
+ import { theme } from "../theme/theme.js";
7
+ import { DynamicBorder } from "./dynamic-border.js";
8
+
9
+ export class HookInputComponent extends Container {
10
+ private input: Input;
11
+ private onSubmitCallback: (value: string) => void;
12
+ private onCancelCallback: () => void;
13
+
14
+ constructor(
15
+ title: string,
16
+ _placeholder: string | undefined,
17
+ onSubmit: (value: string) => void,
18
+ onCancel: () => void,
19
+ ) {
20
+ super();
21
+
22
+ this.onSubmitCallback = onSubmit;
23
+ this.onCancelCallback = onCancel;
24
+
25
+ // Add top border
26
+ this.addChild(new DynamicBorder());
27
+ this.addChild(new Spacer(1));
28
+
29
+ // Add title
30
+ this.addChild(new Text(theme.fg("accent", title), 1, 0));
31
+ this.addChild(new Spacer(1));
32
+
33
+ // Create input
34
+ this.input = new Input();
35
+ this.addChild(this.input);
36
+
37
+ this.addChild(new Spacer(1));
38
+
39
+ // Add hint
40
+ this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
41
+
42
+ this.addChild(new Spacer(1));
43
+
44
+ // Add bottom border
45
+ this.addChild(new DynamicBorder());
46
+ }
47
+
48
+ handleInput(keyData: string): void {
49
+ // Enter
50
+ if (isEnter(keyData) || keyData === "\n") {
51
+ this.onSubmitCallback(this.input.getValue());
52
+ return;
53
+ }
54
+
55
+ // Escape to cancel
56
+ if (isEscape(keyData)) {
57
+ this.onCancelCallback();
58
+ return;
59
+ }
60
+
61
+ // Forward to input
62
+ this.input.handleInput(keyData);
63
+ }
64
+ }
@@ -0,0 +1,96 @@
1
+ import type { TextContent } from "@oh-my-pi/pi-ai";
2
+ import type { Component } from "@oh-my-pi/pi-tui";
3
+ import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
4
+ import type { HookMessageRenderer } from "../../../core/hooks/types.js";
5
+ import type { HookMessage } from "../../../core/messages.js";
6
+ import { getMarkdownTheme, theme } from "../theme/theme.js";
7
+
8
+ /**
9
+ * Component that renders a custom message entry from hooks.
10
+ * Uses distinct styling to differentiate from user messages.
11
+ */
12
+ export class HookMessageComponent extends Container {
13
+ private message: HookMessage<unknown>;
14
+ private customRenderer?: HookMessageRenderer;
15
+ private box: Box;
16
+ private customComponent?: Component;
17
+ private _expanded = false;
18
+
19
+ constructor(message: HookMessage<unknown>, customRenderer?: HookMessageRenderer) {
20
+ super();
21
+ this.message = message;
22
+ this.customRenderer = customRenderer;
23
+
24
+ this.addChild(new Spacer(1));
25
+
26
+ // Create box with purple background (used for default rendering)
27
+ this.box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
28
+
29
+ this.rebuild();
30
+ }
31
+
32
+ setExpanded(expanded: boolean): void {
33
+ if (this._expanded !== expanded) {
34
+ this._expanded = expanded;
35
+ this.rebuild();
36
+ }
37
+ }
38
+
39
+ private rebuild(): void {
40
+ // Remove previous content component
41
+ if (this.customComponent) {
42
+ this.removeChild(this.customComponent);
43
+ this.customComponent = undefined;
44
+ }
45
+ this.removeChild(this.box);
46
+
47
+ // Try custom renderer first - it handles its own styling
48
+ if (this.customRenderer) {
49
+ try {
50
+ const component = this.customRenderer(this.message, { expanded: this._expanded }, theme);
51
+ if (component) {
52
+ // Custom renderer provides its own styled component
53
+ this.customComponent = component;
54
+ this.addChild(component);
55
+ return;
56
+ }
57
+ } catch {
58
+ // Fall through to default rendering
59
+ }
60
+ }
61
+
62
+ // Default rendering uses our box
63
+ this.addChild(this.box);
64
+ this.box.clear();
65
+
66
+ // Default rendering: label + content
67
+ const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
68
+ this.box.addChild(new Text(label, 0, 0));
69
+ this.box.addChild(new Spacer(1));
70
+
71
+ // Extract text content
72
+ let text: string;
73
+ if (typeof this.message.content === "string") {
74
+ text = this.message.content;
75
+ } else {
76
+ text = this.message.content
77
+ .filter((c): c is TextContent => c.type === "text")
78
+ .map((c) => c.text)
79
+ .join("\n");
80
+ }
81
+
82
+ // Limit lines when collapsed
83
+ if (!this._expanded) {
84
+ const lines = text.split("\n");
85
+ if (lines.length > 5) {
86
+ text = `${lines.slice(0, 5).join("\n")}\n...`;
87
+ }
88
+ }
89
+
90
+ this.box.addChild(
91
+ new Markdown(text, 0, 0, getMarkdownTheme(), {
92
+ color: (text: string) => theme.fg("customMessageText", text),
93
+ }),
94
+ );
95
+ }
96
+ }