@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.
- package/CHANGELOG.md +1228 -0
- package/README.md +1041 -0
- package/docs/compaction.md +403 -0
- package/docs/custom-tools.md +541 -0
- package/docs/extension-loading.md +1004 -0
- package/docs/hooks.md +867 -0
- package/docs/rpc.md +1040 -0
- package/docs/sdk.md +994 -0
- package/docs/session-tree-plan.md +441 -0
- package/docs/session.md +240 -0
- package/docs/skills.md +290 -0
- package/docs/theme.md +637 -0
- package/docs/tree.md +197 -0
- package/docs/tui.md +341 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +124 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/question/index.ts +84 -0
- package/examples/custom-tools/subagent/README.md +172 -0
- package/examples/custom-tools/subagent/agents/planner.md +37 -0
- package/examples/custom-tools/subagent/agents/reviewer.md +35 -0
- package/examples/custom-tools/subagent/agents/scout.md +50 -0
- package/examples/custom-tools/subagent/agents/worker.md +24 -0
- package/examples/custom-tools/subagent/agents.ts +156 -0
- package/examples/custom-tools/subagent/commands/implement-and-review.md +10 -0
- package/examples/custom-tools/subagent/commands/implement.md +10 -0
- package/examples/custom-tools/subagent/commands/scout-and-plan.md +9 -0
- package/examples/custom-tools/subagent/index.ts +1002 -0
- package/examples/custom-tools/todo/index.ts +212 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +49 -0
- package/examples/hooks/confirm-destructive.ts +59 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +52 -0
- package/examples/hooks/file-trigger.ts +41 -0
- package/examples/hooks/git-checkpoint.ts +53 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +34 -0
- package/examples/hooks/protected-paths.ts +30 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/snake.ts +343 -0
- package/examples/hooks/status-line.ts +40 -0
- package/examples/sdk/01-minimal.ts +22 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +44 -0
- package/examples/sdk/04-skills.ts +44 -0
- package/examples/sdk/05-tools.ts +90 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +42 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +55 -0
- package/examples/sdk/10-settings.ts +38 -0
- package/examples/sdk/11-sessions.ts +48 -0
- package/examples/sdk/12-full-control.ts +95 -0
- package/examples/sdk/README.md +154 -0
- package/package.json +81 -0
- package/src/cli/args.ts +246 -0
- package/src/cli/file-processor.ts +72 -0
- package/src/cli/list-models.ts +104 -0
- package/src/cli/plugin-cli.ts +650 -0
- package/src/cli/session-picker.ts +41 -0
- package/src/cli.ts +10 -0
- package/src/commands/init.md +20 -0
- package/src/config.ts +159 -0
- package/src/core/agent-session.ts +1900 -0
- package/src/core/auth-storage.ts +236 -0
- package/src/core/bash-executor.ts +196 -0
- package/src/core/compaction/branch-summarization.ts +343 -0
- package/src/core/compaction/compaction.ts +742 -0
- package/src/core/compaction/index.ts +7 -0
- package/src/core/compaction/utils.ts +154 -0
- package/src/core/custom-tools/index.ts +21 -0
- package/src/core/custom-tools/loader.ts +248 -0
- package/src/core/custom-tools/types.ts +169 -0
- package/src/core/custom-tools/wrapper.ts +28 -0
- package/src/core/exec.ts +129 -0
- package/src/core/export-html/index.ts +211 -0
- package/src/core/export-html/template.css +781 -0
- package/src/core/export-html/template.html +54 -0
- package/src/core/export-html/template.js +1185 -0
- package/src/core/export-html/vendor/highlight.min.js +1213 -0
- package/src/core/export-html/vendor/marked.min.js +6 -0
- package/src/core/hooks/index.ts +16 -0
- package/src/core/hooks/loader.ts +312 -0
- package/src/core/hooks/runner.ts +434 -0
- package/src/core/hooks/tool-wrapper.ts +99 -0
- package/src/core/hooks/types.ts +773 -0
- package/src/core/index.ts +52 -0
- package/src/core/mcp/client.ts +158 -0
- package/src/core/mcp/config.ts +154 -0
- package/src/core/mcp/index.ts +45 -0
- package/src/core/mcp/loader.ts +68 -0
- package/src/core/mcp/manager.ts +181 -0
- package/src/core/mcp/tool-bridge.ts +148 -0
- package/src/core/mcp/transports/http.ts +316 -0
- package/src/core/mcp/transports/index.ts +6 -0
- package/src/core/mcp/transports/stdio.ts +252 -0
- package/src/core/mcp/types.ts +220 -0
- package/src/core/messages.ts +189 -0
- package/src/core/model-registry.ts +317 -0
- package/src/core/model-resolver.ts +393 -0
- package/src/core/plugins/doctor.ts +59 -0
- package/src/core/plugins/index.ts +38 -0
- package/src/core/plugins/installer.ts +189 -0
- package/src/core/plugins/loader.ts +338 -0
- package/src/core/plugins/manager.ts +672 -0
- package/src/core/plugins/parser.ts +105 -0
- package/src/core/plugins/paths.ts +32 -0
- package/src/core/plugins/types.ts +190 -0
- package/src/core/sdk.ts +760 -0
- package/src/core/session-manager.ts +1128 -0
- package/src/core/settings-manager.ts +443 -0
- package/src/core/skills.ts +437 -0
- package/src/core/slash-commands.ts +248 -0
- package/src/core/system-prompt.ts +439 -0
- package/src/core/timings.ts +25 -0
- package/src/core/tools/ask.ts +211 -0
- package/src/core/tools/bash-interceptor.ts +120 -0
- package/src/core/tools/bash.ts +250 -0
- package/src/core/tools/context.ts +32 -0
- package/src/core/tools/edit-diff.ts +475 -0
- package/src/core/tools/edit.ts +208 -0
- package/src/core/tools/exa/company.ts +59 -0
- package/src/core/tools/exa/index.ts +64 -0
- package/src/core/tools/exa/linkedin.ts +59 -0
- package/src/core/tools/exa/logger.ts +56 -0
- package/src/core/tools/exa/mcp-client.ts +368 -0
- package/src/core/tools/exa/render.ts +196 -0
- package/src/core/tools/exa/researcher.ts +90 -0
- package/src/core/tools/exa/search.ts +337 -0
- package/src/core/tools/exa/types.ts +168 -0
- package/src/core/tools/exa/websets.ts +248 -0
- package/src/core/tools/find.ts +261 -0
- package/src/core/tools/grep.ts +555 -0
- package/src/core/tools/index.ts +202 -0
- package/src/core/tools/ls.ts +140 -0
- package/src/core/tools/lsp/client.ts +605 -0
- package/src/core/tools/lsp/config.ts +147 -0
- package/src/core/tools/lsp/edits.ts +101 -0
- package/src/core/tools/lsp/index.ts +804 -0
- package/src/core/tools/lsp/render.ts +447 -0
- package/src/core/tools/lsp/rust-analyzer.ts +145 -0
- package/src/core/tools/lsp/types.ts +463 -0
- package/src/core/tools/lsp/utils.ts +486 -0
- package/src/core/tools/notebook.ts +229 -0
- package/src/core/tools/path-utils.ts +61 -0
- package/src/core/tools/read.ts +240 -0
- package/src/core/tools/renderers.ts +540 -0
- package/src/core/tools/task/agents.ts +153 -0
- package/src/core/tools/task/artifacts.ts +114 -0
- package/src/core/tools/task/bundled-agents/browser.md +71 -0
- package/src/core/tools/task/bundled-agents/explore.md +82 -0
- package/src/core/tools/task/bundled-agents/plan.md +54 -0
- package/src/core/tools/task/bundled-agents/reviewer.md +59 -0
- package/src/core/tools/task/bundled-agents/task.md +53 -0
- package/src/core/tools/task/bundled-commands/architect-plan.md +10 -0
- package/src/core/tools/task/bundled-commands/implement-with-critic.md +11 -0
- package/src/core/tools/task/bundled-commands/implement.md +11 -0
- package/src/core/tools/task/commands.ts +213 -0
- package/src/core/tools/task/discovery.ts +208 -0
- package/src/core/tools/task/executor.ts +367 -0
- package/src/core/tools/task/index.ts +388 -0
- package/src/core/tools/task/model-resolver.ts +115 -0
- package/src/core/tools/task/parallel.ts +38 -0
- package/src/core/tools/task/render.ts +232 -0
- package/src/core/tools/task/types.ts +99 -0
- package/src/core/tools/truncate.ts +265 -0
- package/src/core/tools/web-fetch.ts +2370 -0
- package/src/core/tools/web-search/auth.ts +193 -0
- package/src/core/tools/web-search/index.ts +537 -0
- package/src/core/tools/web-search/providers/anthropic.ts +198 -0
- package/src/core/tools/web-search/providers/exa.ts +302 -0
- package/src/core/tools/web-search/providers/perplexity.ts +195 -0
- package/src/core/tools/web-search/render.ts +182 -0
- package/src/core/tools/web-search/types.ts +180 -0
- package/src/core/tools/write.ts +99 -0
- package/src/index.ts +176 -0
- package/src/main.ts +464 -0
- package/src/migrations.ts +135 -0
- package/src/modes/index.ts +43 -0
- package/src/modes/interactive/components/armin.ts +382 -0
- package/src/modes/interactive/components/assistant-message.ts +86 -0
- package/src/modes/interactive/components/bash-execution.ts +196 -0
- package/src/modes/interactive/components/bordered-loader.ts +41 -0
- package/src/modes/interactive/components/branch-summary-message.ts +42 -0
- package/src/modes/interactive/components/compaction-summary-message.ts +45 -0
- package/src/modes/interactive/components/custom-editor.ts +122 -0
- package/src/modes/interactive/components/diff.ts +147 -0
- package/src/modes/interactive/components/dynamic-border.ts +25 -0
- package/src/modes/interactive/components/footer.ts +381 -0
- package/src/modes/interactive/components/hook-editor.ts +117 -0
- package/src/modes/interactive/components/hook-input.ts +64 -0
- package/src/modes/interactive/components/hook-message.ts +96 -0
- package/src/modes/interactive/components/hook-selector.ts +91 -0
- package/src/modes/interactive/components/model-selector.ts +247 -0
- package/src/modes/interactive/components/oauth-selector.ts +120 -0
- package/src/modes/interactive/components/plugin-settings.ts +479 -0
- package/src/modes/interactive/components/queue-mode-selector.ts +56 -0
- package/src/modes/interactive/components/session-selector.ts +204 -0
- package/src/modes/interactive/components/settings-selector.ts +453 -0
- package/src/modes/interactive/components/show-images-selector.ts +45 -0
- package/src/modes/interactive/components/theme-selector.ts +62 -0
- package/src/modes/interactive/components/thinking-selector.ts +64 -0
- package/src/modes/interactive/components/tool-execution.ts +675 -0
- package/src/modes/interactive/components/tree-selector.ts +866 -0
- package/src/modes/interactive/components/user-message-selector.ts +159 -0
- package/src/modes/interactive/components/user-message.ts +18 -0
- package/src/modes/interactive/components/visual-truncate.ts +50 -0
- package/src/modes/interactive/components/welcome.ts +183 -0
- package/src/modes/interactive/interactive-mode.ts +2516 -0
- package/src/modes/interactive/theme/dark.json +101 -0
- package/src/modes/interactive/theme/light.json +98 -0
- package/src/modes/interactive/theme/theme-schema.json +308 -0
- package/src/modes/interactive/theme/theme.ts +998 -0
- package/src/modes/print-mode.ts +128 -0
- package/src/modes/rpc/rpc-client.ts +527 -0
- package/src/modes/rpc/rpc-mode.ts +483 -0
- package/src/modes/rpc/rpc-types.ts +203 -0
- package/src/utils/changelog.ts +99 -0
- package/src/utils/clipboard.ts +265 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/shell.ts +276 -0
- 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
|
+
}
|