@oh-my-pi/pi-coding-agent 3.15.0 → 3.20.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 +61 -1
- package/docs/extensions.md +1055 -0
- package/docs/rpc.md +69 -13
- package/docs/session-tree-plan.md +1 -1
- package/examples/extensions/README.md +141 -0
- package/examples/extensions/api-demo.ts +87 -0
- package/examples/extensions/chalk-logger.ts +26 -0
- package/examples/extensions/hello.ts +33 -0
- package/examples/extensions/pirate.ts +44 -0
- package/examples/extensions/plan-mode.ts +551 -0
- package/examples/extensions/subagent/agents/reviewer.md +35 -0
- package/examples/extensions/todo.ts +299 -0
- package/examples/extensions/tools.ts +145 -0
- package/examples/extensions/with-deps/index.ts +36 -0
- package/examples/extensions/with-deps/package-lock.json +31 -0
- package/examples/extensions/with-deps/package.json +16 -0
- package/examples/sdk/02-custom-model.ts +3 -3
- package/examples/sdk/05-tools.ts +7 -3
- package/examples/sdk/06-extensions.ts +81 -0
- package/examples/sdk/06-hooks.ts +14 -13
- package/examples/sdk/08-prompt-templates.ts +42 -0
- package/examples/sdk/08-slash-commands.ts +17 -12
- package/examples/sdk/09-api-keys-and-oauth.ts +2 -2
- package/examples/sdk/12-full-control.ts +6 -6
- package/package.json +11 -7
- package/src/capability/extension-module.ts +34 -0
- package/src/cli/args.ts +22 -7
- package/src/cli/file-processor.ts +38 -67
- package/src/cli/list-models.ts +1 -1
- package/src/config.ts +25 -14
- package/src/core/agent-session.ts +505 -242
- package/src/core/auth-storage.ts +33 -21
- package/src/core/compaction/branch-summarization.ts +4 -4
- package/src/core/compaction/compaction.ts +3 -3
- package/src/core/custom-commands/bundled/wt/index.ts +430 -0
- package/src/core/custom-commands/loader.ts +9 -0
- package/src/core/custom-tools/wrapper.ts +5 -0
- package/src/core/event-bus.ts +59 -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/extensions/index.ts +100 -0
- package/src/core/extensions/loader.ts +501 -0
- package/src/core/extensions/runner.ts +477 -0
- package/src/core/extensions/types.ts +712 -0
- package/src/core/extensions/wrapper.ts +147 -0
- package/src/core/hooks/types.ts +2 -2
- package/src/core/index.ts +10 -21
- package/src/core/keybindings.ts +199 -0
- package/src/core/messages.ts +26 -7
- package/src/core/model-registry.ts +123 -46
- package/src/core/model-resolver.ts +7 -5
- package/src/core/prompt-templates.ts +242 -0
- package/src/core/sdk.ts +378 -295
- package/src/core/session-manager.ts +72 -58
- package/src/core/settings-manager.ts +118 -22
- package/src/core/system-prompt.ts +24 -1
- package/src/core/terminal-notify.ts +37 -0
- package/src/core/tools/context.ts +4 -4
- package/src/core/tools/exa/mcp-client.ts +5 -4
- package/src/core/tools/exa/render.ts +176 -131
- package/src/core/tools/gemini-image.ts +361 -0
- package/src/core/tools/git.ts +216 -0
- package/src/core/tools/index.ts +28 -15
- package/src/core/tools/lsp/config.ts +5 -4
- package/src/core/tools/lsp/index.ts +17 -12
- package/src/core/tools/lsp/render.ts +39 -47
- package/src/core/tools/read.ts +66 -29
- package/src/core/tools/render-utils.ts +268 -0
- package/src/core/tools/renderers.ts +243 -225
- package/src/core/tools/task/discovery.ts +2 -2
- package/src/core/tools/task/executor.ts +66 -58
- package/src/core/tools/task/index.ts +29 -10
- package/src/core/tools/task/model-resolver.ts +8 -13
- package/src/core/tools/task/omp-command.ts +24 -0
- package/src/core/tools/task/render.ts +35 -60
- package/src/core/tools/task/types.ts +3 -0
- package/src/core/tools/web-fetch.ts +29 -28
- package/src/core/tools/web-search/index.ts +6 -5
- package/src/core/tools/web-search/providers/exa.ts +6 -5
- package/src/core/tools/web-search/render.ts +66 -111
- package/src/core/voice-controller.ts +135 -0
- package/src/core/voice-supervisor.ts +1003 -0
- package/src/core/voice.ts +308 -0
- package/src/discovery/builtin.ts +75 -1
- package/src/discovery/claude.ts +47 -1
- package/src/discovery/codex.ts +54 -2
- package/src/discovery/gemini.ts +55 -2
- package/src/discovery/helpers.ts +100 -1
- package/src/discovery/index.ts +2 -0
- package/src/index.ts +14 -9
- package/src/lib/worktree/collapse.ts +179 -0
- package/src/lib/worktree/constants.ts +14 -0
- package/src/lib/worktree/errors.ts +23 -0
- package/src/lib/worktree/git.ts +110 -0
- package/src/lib/worktree/index.ts +23 -0
- package/src/lib/worktree/operations.ts +216 -0
- package/src/lib/worktree/session.ts +114 -0
- package/src/lib/worktree/stats.ts +67 -0
- package/src/main.ts +61 -37
- package/src/migrations.ts +37 -7
- package/src/modes/interactive/components/bash-execution.ts +6 -4
- package/src/modes/interactive/components/custom-editor.ts +55 -0
- package/src/modes/interactive/components/custom-message.ts +95 -0
- package/src/modes/interactive/components/extensions/extension-list.ts +5 -0
- package/src/modes/interactive/components/extensions/inspector-panel.ts +18 -12
- package/src/modes/interactive/components/extensions/state-manager.ts +12 -0
- package/src/modes/interactive/components/extensions/types.ts +1 -0
- package/src/modes/interactive/components/footer.ts +324 -0
- package/src/modes/interactive/components/hook-editor.ts +1 -0
- package/src/modes/interactive/components/hook-selector.ts +3 -3
- package/src/modes/interactive/components/model-selector.ts +7 -6
- package/src/modes/interactive/components/oauth-selector.ts +3 -3
- package/src/modes/interactive/components/settings-defs.ts +55 -6
- package/src/modes/interactive/components/status-line/separators.ts +4 -4
- package/src/modes/interactive/components/status-line.ts +45 -35
- package/src/modes/interactive/components/tool-execution.ts +95 -23
- package/src/modes/interactive/interactive-mode.ts +644 -113
- package/src/modes/interactive/theme/defaults/alabaster.json +99 -0
- package/src/modes/interactive/theme/defaults/amethyst.json +103 -0
- package/src/modes/interactive/theme/defaults/anthracite.json +100 -0
- package/src/modes/interactive/theme/defaults/basalt.json +90 -0
- package/src/modes/interactive/theme/defaults/birch.json +101 -0
- package/src/modes/interactive/theme/defaults/dark-abyss.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-aurora.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-cavern.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-copper.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-cosmos.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-eclipse.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-ember.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-equinox.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-lavender.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-lunar.json +95 -0
- package/src/modes/interactive/theme/defaults/dark-midnight.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-nebula.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-rainforest.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-reef.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-sakura.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-slate.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-solstice.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-starfall.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-swamp.json +96 -0
- package/src/modes/interactive/theme/defaults/dark-taiga.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-terminal.json +94 -0
- package/src/modes/interactive/theme/defaults/dark-tundra.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-twilight.json +97 -0
- package/src/modes/interactive/theme/defaults/dark-volcanic.json +97 -0
- package/src/modes/interactive/theme/defaults/graphite.json +99 -0
- package/src/modes/interactive/theme/defaults/index.ts +128 -0
- package/src/modes/interactive/theme/defaults/light-aurora-day.json +97 -0
- package/src/modes/interactive/theme/defaults/light-canyon.json +97 -0
- package/src/modes/interactive/theme/defaults/light-cirrus.json +96 -0
- package/src/modes/interactive/theme/defaults/light-coral.json +94 -0
- package/src/modes/interactive/theme/defaults/light-dawn.json +96 -0
- package/src/modes/interactive/theme/defaults/light-dunes.json +97 -0
- package/src/modes/interactive/theme/defaults/light-eucalyptus.json +94 -0
- package/src/modes/interactive/theme/defaults/light-frost.json +94 -0
- package/src/modes/interactive/theme/defaults/light-glacier.json +97 -0
- package/src/modes/interactive/theme/defaults/light-haze.json +96 -0
- package/src/modes/interactive/theme/defaults/light-honeycomb.json +94 -0
- package/src/modes/interactive/theme/defaults/light-lagoon.json +97 -0
- package/src/modes/interactive/theme/defaults/light-lavender.json +94 -0
- package/src/modes/interactive/theme/defaults/light-meadow.json +97 -0
- package/src/modes/interactive/theme/defaults/light-mint.json +94 -0
- package/src/modes/interactive/theme/defaults/light-opal.json +97 -0
- package/src/modes/interactive/theme/defaults/light-orchard.json +97 -0
- package/src/modes/interactive/theme/defaults/light-paper.json +94 -0
- package/src/modes/interactive/theme/defaults/light-prism.json +96 -0
- package/src/modes/interactive/theme/defaults/light-sand.json +94 -0
- package/src/modes/interactive/theme/defaults/light-savanna.json +97 -0
- package/src/modes/interactive/theme/defaults/light-soleil.json +96 -0
- package/src/modes/interactive/theme/defaults/light-wetland.json +97 -0
- package/src/modes/interactive/theme/defaults/light-zenith.json +95 -0
- package/src/modes/interactive/theme/defaults/limestone.json +100 -0
- package/src/modes/interactive/theme/defaults/mahogany.json +104 -0
- package/src/modes/interactive/theme/defaults/marble.json +99 -0
- package/src/modes/interactive/theme/defaults/obsidian.json +90 -0
- package/src/modes/interactive/theme/defaults/onyx.json +90 -0
- package/src/modes/interactive/theme/defaults/pearl.json +99 -0
- package/src/modes/interactive/theme/defaults/porcelain.json +90 -0
- package/src/modes/interactive/theme/defaults/quartz.json +102 -0
- package/src/modes/interactive/theme/defaults/sandstone.json +101 -0
- package/src/modes/interactive/theme/defaults/titanium.json +89 -0
- package/src/modes/print-mode.ts +14 -72
- package/src/modes/rpc/rpc-client.ts +23 -9
- package/src/modes/rpc/rpc-mode.ts +137 -125
- package/src/modes/rpc/rpc-types.ts +46 -24
- package/src/prompts/task.md +1 -0
- package/src/prompts/tools/gemini-image.md +4 -0
- package/src/prompts/tools/git.md +9 -0
- package/src/prompts/voice-summary.md +12 -0
- package/src/utils/image-convert.ts +26 -0
- package/src/utils/image-resize.ts +215 -0
- package/src/utils/shell-snapshot.ts +22 -20
|
@@ -29,12 +29,14 @@ export class BashExecutionComponent extends Container {
|
|
|
29
29
|
private contentContainer: Container;
|
|
30
30
|
private ui: TUI;
|
|
31
31
|
|
|
32
|
-
constructor(command: string, ui: TUI) {
|
|
32
|
+
constructor(command: string, ui: TUI, excludeFromContext = false) {
|
|
33
33
|
super();
|
|
34
34
|
this.command = command;
|
|
35
35
|
this.ui = ui;
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Use dim border for excluded-from-context commands (!! prefix)
|
|
38
|
+
const colorKey = excludeFromContext ? "dim" : "bashMode";
|
|
39
|
+
const borderColor = (str: string) => theme.fg(colorKey, str);
|
|
38
40
|
|
|
39
41
|
// Add spacer
|
|
40
42
|
this.addChild(new Spacer(1));
|
|
@@ -47,13 +49,13 @@ export class BashExecutionComponent extends Container {
|
|
|
47
49
|
this.addChild(this.contentContainer);
|
|
48
50
|
|
|
49
51
|
// Command header
|
|
50
|
-
const header = new Text(theme.fg(
|
|
52
|
+
const header = new Text(theme.fg(colorKey, theme.bold(`$ ${command}`)), 1, 0);
|
|
51
53
|
this.contentContainer.addChild(header);
|
|
52
54
|
|
|
53
55
|
// Loader
|
|
54
56
|
this.loader = new Loader(
|
|
55
57
|
ui,
|
|
56
|
-
(spinner) => theme.fg(
|
|
58
|
+
(spinner) => theme.fg(colorKey, spinner),
|
|
57
59
|
(text) => theme.fg("muted", text),
|
|
58
60
|
`Running${theme.format.ellipsis} (esc to cancel)`,
|
|
59
61
|
getSymbolTheme().spinnerFrames,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Editor,
|
|
3
|
+
isCapsLock,
|
|
3
4
|
isCtrlC,
|
|
4
5
|
isCtrlD,
|
|
5
6
|
isCtrlG,
|
|
@@ -8,10 +9,13 @@ import {
|
|
|
8
9
|
isCtrlP,
|
|
9
10
|
isCtrlT,
|
|
10
11
|
isCtrlV,
|
|
12
|
+
isCtrlY,
|
|
11
13
|
isCtrlZ,
|
|
12
14
|
isEscape,
|
|
13
15
|
isShiftCtrlP,
|
|
14
16
|
isShiftTab,
|
|
17
|
+
type KeyId,
|
|
18
|
+
matchesKey,
|
|
15
19
|
} from "@oh-my-pi/pi-tui";
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -30,10 +34,41 @@ export class CustomEditor extends Editor {
|
|
|
30
34
|
public onCtrlG?: () => void;
|
|
31
35
|
public onCtrlZ?: () => void;
|
|
32
36
|
public onQuestionMark?: () => void;
|
|
37
|
+
public onCapsLock?: () => void;
|
|
38
|
+
public onCtrlY?: () => void;
|
|
33
39
|
/** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
|
|
34
40
|
public onCtrlV?: () => Promise<boolean>;
|
|
35
41
|
|
|
42
|
+
/** Custom key handlers from extensions */
|
|
43
|
+
private customKeyHandlers = new Map<KeyId, () => void>();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Register a custom key handler. Extensions use this for shortcuts.
|
|
47
|
+
*/
|
|
48
|
+
setCustomKeyHandler(key: KeyId, handler: () => void): void {
|
|
49
|
+
this.customKeyHandlers.set(key, handler);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Remove a custom key handler.
|
|
54
|
+
*/
|
|
55
|
+
removeCustomKeyHandler(key: KeyId): void {
|
|
56
|
+
this.customKeyHandlers.delete(key);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear all custom key handlers.
|
|
61
|
+
*/
|
|
62
|
+
clearCustomKeyHandlers(): void {
|
|
63
|
+
this.customKeyHandlers.clear();
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
handleInput(data: string): void {
|
|
67
|
+
if (isCapsLock(data) && this.onCapsLock) {
|
|
68
|
+
this.onCapsLock();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
// Intercept Ctrl+V for image paste (async - fires and handles result)
|
|
38
73
|
if (isCtrlV(data) && this.onCtrlV) {
|
|
39
74
|
void this.onCtrlV();
|
|
@@ -46,6 +81,12 @@ export class CustomEditor extends Editor {
|
|
|
46
81
|
return;
|
|
47
82
|
}
|
|
48
83
|
|
|
84
|
+
// Intercept Ctrl+Y for voice input
|
|
85
|
+
if (isCtrlY(data) && this.onCtrlY) {
|
|
86
|
+
this.onCtrlY();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
// Intercept Ctrl+Z for suspend
|
|
50
91
|
if (isCtrlZ(data) && this.onCtrlZ) {
|
|
51
92
|
this.onCtrlZ();
|
|
@@ -58,6 +99,12 @@ export class CustomEditor extends Editor {
|
|
|
58
99
|
return;
|
|
59
100
|
}
|
|
60
101
|
|
|
102
|
+
// Intercept Ctrl+Y for role-based model cycling
|
|
103
|
+
if (isCtrlY(data) && this.onCtrlY) {
|
|
104
|
+
this.onCtrlY();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
// Intercept Ctrl+L for model selector
|
|
62
109
|
if (isCtrlL(data) && this.onCtrlL) {
|
|
63
110
|
this.onCtrlL();
|
|
@@ -116,6 +163,14 @@ export class CustomEditor extends Editor {
|
|
|
116
163
|
return;
|
|
117
164
|
}
|
|
118
165
|
|
|
166
|
+
// Check custom key handlers (extensions)
|
|
167
|
+
for (const [keyId, handler] of this.customKeyHandlers) {
|
|
168
|
+
if (matchesKey(data, keyId)) {
|
|
169
|
+
handler();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
119
174
|
// Pass to parent for normal handling
|
|
120
175
|
super.handleInput(data);
|
|
121
176
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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 { MessageRenderer } from "../../../core/extensions/types";
|
|
5
|
+
import type { CustomMessage } from "../../../core/messages";
|
|
6
|
+
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Component that renders a custom message entry from extensions.
|
|
10
|
+
* Uses distinct styling to differentiate from user messages.
|
|
11
|
+
*/
|
|
12
|
+
export class CustomMessageComponent extends Container {
|
|
13
|
+
private message: CustomMessage<unknown>;
|
|
14
|
+
private customRenderer?: MessageRenderer;
|
|
15
|
+
private box: Box;
|
|
16
|
+
private customComponent?: Component;
|
|
17
|
+
private _expanded = false;
|
|
18
|
+
|
|
19
|
+
constructor(message: CustomMessage<unknown>, customRenderer?: MessageRenderer) {
|
|
20
|
+
super();
|
|
21
|
+
this.message = message;
|
|
22
|
+
this.customRenderer = customRenderer;
|
|
23
|
+
|
|
24
|
+
this.addChild(new Spacer(1));
|
|
25
|
+
|
|
26
|
+
// Create box with custom 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
|
+
this.customComponent = component;
|
|
53
|
+
this.addChild(component);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Fall through to default rendering
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Default rendering uses our box
|
|
62
|
+
this.addChild(this.box);
|
|
63
|
+
this.box.clear();
|
|
64
|
+
|
|
65
|
+
// Default rendering: label + content
|
|
66
|
+
const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
|
|
67
|
+
this.box.addChild(new Text(label, 0, 0));
|
|
68
|
+
this.box.addChild(new Spacer(1));
|
|
69
|
+
|
|
70
|
+
// Extract text content
|
|
71
|
+
let text: string;
|
|
72
|
+
if (typeof this.message.content === "string") {
|
|
73
|
+
text = this.message.content;
|
|
74
|
+
} else {
|
|
75
|
+
text = this.message.content
|
|
76
|
+
.filter((c): c is TextContent => c.type === "text")
|
|
77
|
+
.map((c) => c.text)
|
|
78
|
+
.join("\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Limit lines when collapsed
|
|
82
|
+
if (!this._expanded) {
|
|
83
|
+
const lines = text.split("\n");
|
|
84
|
+
if (lines.length > 5) {
|
|
85
|
+
text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
this.box.addChild(
|
|
90
|
+
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
|
91
|
+
color: (value: string) => theme.fg("customMessageText", value),
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -230,6 +230,8 @@ export class ExtensionList implements Component {
|
|
|
230
230
|
|
|
231
231
|
private getKindIcon(kind: ExtensionKind): string {
|
|
232
232
|
switch (kind) {
|
|
233
|
+
case "extension-module":
|
|
234
|
+
return theme.icon.extensionTool;
|
|
233
235
|
case "skill":
|
|
234
236
|
return theme.icon.extensionSkill;
|
|
235
237
|
case "tool":
|
|
@@ -316,6 +318,7 @@ export class ExtensionList implements Component {
|
|
|
316
318
|
}
|
|
317
319
|
|
|
318
320
|
const kindOrder: ExtensionKind[] = [
|
|
321
|
+
"extension-module",
|
|
319
322
|
"skill",
|
|
320
323
|
"tool",
|
|
321
324
|
"slash-command",
|
|
@@ -347,6 +350,8 @@ export class ExtensionList implements Component {
|
|
|
347
350
|
|
|
348
351
|
private getKindLabel(kind: ExtensionKind): string {
|
|
349
352
|
switch (kind) {
|
|
353
|
+
case "extension-module":
|
|
354
|
+
return "Extension Modules";
|
|
350
355
|
case "skill":
|
|
351
356
|
return "Skills";
|
|
352
357
|
case "tool":
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* Shows name, description, origin, status, and kind-specific preview.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { readFileSync } from "node:fs";
|
|
8
7
|
import { homedir } from "node:os";
|
|
9
8
|
import { type Component, truncateToWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
|
|
10
9
|
import { theme } from "../../theme/theme";
|
|
@@ -98,16 +97,22 @@ export class InspectorPanel implements Component {
|
|
|
98
97
|
lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
|
|
99
98
|
|
|
100
99
|
try {
|
|
101
|
-
const content =
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
100
|
+
const content = Bun.file(path).text();
|
|
101
|
+
// Note: async call to sync context - will show empty on first render
|
|
102
|
+
// This is acceptable for preview which can populate on next render
|
|
103
|
+
if (typeof content === "object" && "then" in content) {
|
|
104
|
+
content.then((text: string) => {
|
|
105
|
+
const fileLines = text.split("\n").slice(0, 20);
|
|
106
|
+
|
|
107
|
+
for (const line of fileLines) {
|
|
108
|
+
const highlighted = this.highlightMarkdown(line);
|
|
109
|
+
lines.push(truncateToWidth(highlighted, width - 2));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (text.split("\n").length > 20) {
|
|
113
|
+
lines.push(theme.fg("dim", "(truncated at line 20)"));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
111
116
|
}
|
|
112
117
|
} catch (err) {
|
|
113
118
|
lines.push(theme.fg("error", `Failed to read file: ${err instanceof Error ? err.message : String(err)}`));
|
|
@@ -261,6 +266,7 @@ export class InspectorPanel implements Component {
|
|
|
261
266
|
|
|
262
267
|
private getKindBadge(kind: string): string {
|
|
263
268
|
const kindColors: Record<string, string> = {
|
|
269
|
+
"extension-module": "accent",
|
|
264
270
|
skill: "accent",
|
|
265
271
|
rule: "success",
|
|
266
272
|
tool: "warning",
|
|
@@ -296,7 +302,7 @@ export class InspectorPanel implements Component {
|
|
|
296
302
|
|
|
297
303
|
private shortenPath(path: string): string {
|
|
298
304
|
const home = homedir();
|
|
299
|
-
if (path.startsWith(home)) {
|
|
305
|
+
if (home && path.startsWith(home)) {
|
|
300
306
|
return `~${path.slice(home.length)}`;
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ContextFile } from "../../../../capability/context-file";
|
|
7
|
+
import type { ExtensionModule } from "../../../../capability/extension-module";
|
|
7
8
|
import type { Hook } from "../../../../capability/hook";
|
|
8
9
|
import type { MCPServer } from "../../../../capability/mcp";
|
|
9
10
|
import type { Prompt } from "../../../../capability/prompt";
|
|
@@ -128,6 +129,15 @@ export function loadAllExtensions(cwd?: string, disabledIds?: string[]): Extensi
|
|
|
128
129
|
// Capability may not be registered
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// Load extension modules
|
|
133
|
+
try {
|
|
134
|
+
const modules = loadSync<ExtensionModule>("extension-modules", loadOpts);
|
|
135
|
+
const nativeModules = modules.all.filter((module) => module._source.provider === "native");
|
|
136
|
+
addItems(nativeModules, "extension-module");
|
|
137
|
+
} catch {
|
|
138
|
+
// Capability may not be registered
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
// Load MCP servers
|
|
132
142
|
try {
|
|
133
143
|
const mcps = loadSync<MCPServer>("mcps", loadOpts);
|
|
@@ -394,6 +404,8 @@ export function applyFilter(extensions: Extension[], query: string): Extension[]
|
|
|
394
404
|
*/
|
|
395
405
|
function getKindDisplayName(kind: ExtensionKind): string {
|
|
396
406
|
switch (kind) {
|
|
407
|
+
case "extension-module":
|
|
408
|
+
return "Extension Modules";
|
|
397
409
|
case "skill":
|
|
398
410
|
return "Skills";
|
|
399
411
|
case "rule":
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { existsSync, type FSWatcher, readFileSync, watch } from "node:fs";
|
|
2
|
+
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
|
|
3
|
+
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
import type { AgentSession } from "../../../core/agent-session";
|
|
6
|
+
import { theme } from "../theme/theme";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sanitize text for display in a single-line status.
|
|
10
|
+
* Removes newlines, tabs, carriage returns, and other control characters.
|
|
11
|
+
*/
|
|
12
|
+
function sanitizeStatusText(text: string): string {
|
|
13
|
+
// Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
|
|
14
|
+
return text
|
|
15
|
+
.replace(/[\r\n\t]/g, " ")
|
|
16
|
+
.replace(/ +/g, " ")
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find the git root directory by walking up from cwd.
|
|
22
|
+
* Returns the path to .git/HEAD if found, null otherwise.
|
|
23
|
+
*/
|
|
24
|
+
function findGitHeadPath(): string | null {
|
|
25
|
+
let dir = process.cwd();
|
|
26
|
+
while (true) {
|
|
27
|
+
const gitHeadPath = join(dir, ".git", "HEAD");
|
|
28
|
+
if (existsSync(gitHeadPath)) {
|
|
29
|
+
return gitHeadPath;
|
|
30
|
+
}
|
|
31
|
+
const parent = dirname(dir);
|
|
32
|
+
if (parent === dir) {
|
|
33
|
+
// Reached filesystem root
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Footer component that shows pwd, token stats, and context usage
|
|
42
|
+
*/
|
|
43
|
+
export class FooterComponent implements Component {
|
|
44
|
+
private session: AgentSession;
|
|
45
|
+
private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
|
|
46
|
+
private gitWatcher: FSWatcher | null = null;
|
|
47
|
+
private onBranchChange: (() => void) | null = null;
|
|
48
|
+
private autoCompactEnabled: boolean = true;
|
|
49
|
+
private extensionStatuses: Map<string, string> = new Map();
|
|
50
|
+
|
|
51
|
+
constructor(session: AgentSession) {
|
|
52
|
+
this.session = session;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setAutoCompactEnabled(enabled: boolean): void {
|
|
56
|
+
this.autoCompactEnabled = enabled;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Set extension status text to display in the footer.
|
|
61
|
+
* Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
|
|
62
|
+
* ANSI escape codes for styling are preserved.
|
|
63
|
+
* @param key - Unique key to identify this status
|
|
64
|
+
* @param text - Status text, or undefined to clear
|
|
65
|
+
*/
|
|
66
|
+
setExtensionStatus(key: string, text: string | undefined): void {
|
|
67
|
+
if (text === undefined) {
|
|
68
|
+
this.extensionStatuses.delete(key);
|
|
69
|
+
} else {
|
|
70
|
+
this.extensionStatuses.set(key, text);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set up a file watcher on .git/HEAD to detect branch changes.
|
|
76
|
+
* Call the provided callback when branch changes.
|
|
77
|
+
*/
|
|
78
|
+
watchBranch(onBranchChange: () => void): void {
|
|
79
|
+
this.onBranchChange = onBranchChange;
|
|
80
|
+
this.setupGitWatcher();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private setupGitWatcher(): void {
|
|
84
|
+
// Clean up existing watcher
|
|
85
|
+
if (this.gitWatcher) {
|
|
86
|
+
this.gitWatcher.close();
|
|
87
|
+
this.gitWatcher = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const gitHeadPath = findGitHeadPath();
|
|
91
|
+
if (!gitHeadPath) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
this.gitWatcher = watch(gitHeadPath, () => {
|
|
97
|
+
this.cachedBranch = undefined; // Invalidate cache
|
|
98
|
+
if (this.onBranchChange) {
|
|
99
|
+
this.onBranchChange();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
// Silently fail if we can't watch
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clean up the file watcher
|
|
109
|
+
*/
|
|
110
|
+
dispose(): void {
|
|
111
|
+
if (this.gitWatcher) {
|
|
112
|
+
this.gitWatcher.close();
|
|
113
|
+
this.gitWatcher = null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate(): void {
|
|
118
|
+
// Invalidate cached branch so it gets re-read on next render
|
|
119
|
+
this.cachedBranch = undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get current git branch by reading .git/HEAD directly.
|
|
124
|
+
* Returns null if not in a git repo, branch name otherwise.
|
|
125
|
+
*/
|
|
126
|
+
private getCurrentBranch(): string | null {
|
|
127
|
+
// Return cached value if available
|
|
128
|
+
if (this.cachedBranch !== undefined) {
|
|
129
|
+
return this.cachedBranch;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const gitHeadPath = findGitHeadPath();
|
|
134
|
+
if (!gitHeadPath) {
|
|
135
|
+
this.cachedBranch = null;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const content = readFileSync(gitHeadPath, "utf8").trim();
|
|
139
|
+
|
|
140
|
+
if (content.startsWith("ref: refs/heads/")) {
|
|
141
|
+
// Normal branch: extract branch name
|
|
142
|
+
this.cachedBranch = content.slice(16);
|
|
143
|
+
} else {
|
|
144
|
+
// Detached HEAD state
|
|
145
|
+
this.cachedBranch = "detached";
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Not in a git repo or error reading file
|
|
149
|
+
this.cachedBranch = null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return this.cachedBranch;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
render(width: number): string[] {
|
|
156
|
+
const state = this.session.state;
|
|
157
|
+
|
|
158
|
+
// Calculate cumulative usage from ALL session entries (not just post-compaction messages)
|
|
159
|
+
let totalInput = 0;
|
|
160
|
+
let totalOutput = 0;
|
|
161
|
+
let totalCacheRead = 0;
|
|
162
|
+
let totalCacheWrite = 0;
|
|
163
|
+
let totalCost = 0;
|
|
164
|
+
|
|
165
|
+
for (const entry of this.session.sessionManager.getEntries()) {
|
|
166
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
167
|
+
totalInput += entry.message.usage.input;
|
|
168
|
+
totalOutput += entry.message.usage.output;
|
|
169
|
+
totalCacheRead += entry.message.usage.cacheRead;
|
|
170
|
+
totalCacheWrite += entry.message.usage.cacheWrite;
|
|
171
|
+
totalCost += entry.message.usage.cost.total;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Get last assistant message for context percentage calculation (skip aborted messages)
|
|
176
|
+
const lastAssistantMessage = state.messages
|
|
177
|
+
.slice()
|
|
178
|
+
.reverse()
|
|
179
|
+
.find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined;
|
|
180
|
+
|
|
181
|
+
// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)
|
|
182
|
+
const contextTokens = lastAssistantMessage
|
|
183
|
+
? lastAssistantMessage.usage.input +
|
|
184
|
+
lastAssistantMessage.usage.output +
|
|
185
|
+
lastAssistantMessage.usage.cacheRead +
|
|
186
|
+
lastAssistantMessage.usage.cacheWrite
|
|
187
|
+
: 0;
|
|
188
|
+
const contextWindow = state.model?.contextWindow || 0;
|
|
189
|
+
const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
|
|
190
|
+
const contextPercent = contextPercentValue.toFixed(1);
|
|
191
|
+
|
|
192
|
+
// Format token counts (similar to web-ui)
|
|
193
|
+
const formatTokens = (count: number): string => {
|
|
194
|
+
if (count < 1000) return count.toString();
|
|
195
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
196
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
197
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
198
|
+
return `${Math.round(count / 1000000)}M`;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// Replace home directory with ~
|
|
202
|
+
let pwd = process.cwd();
|
|
203
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
204
|
+
if (home && pwd.startsWith(home)) {
|
|
205
|
+
pwd = `~${pwd.slice(home.length)}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Add git branch if available
|
|
209
|
+
const branch = this.getCurrentBranch();
|
|
210
|
+
if (branch) {
|
|
211
|
+
pwd = `${pwd} (${branch})`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Truncate path if too long to fit width
|
|
215
|
+
if (pwd.length > width) {
|
|
216
|
+
const half = Math.floor(width / 2) - 2;
|
|
217
|
+
if (half > 0) {
|
|
218
|
+
const start = pwd.slice(0, half);
|
|
219
|
+
const end = pwd.slice(-(half - 1));
|
|
220
|
+
pwd = `${start}...${end}`;
|
|
221
|
+
} else {
|
|
222
|
+
pwd = pwd.slice(0, Math.max(1, width));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Build stats line
|
|
227
|
+
const statsParts = [];
|
|
228
|
+
if (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);
|
|
229
|
+
if (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);
|
|
230
|
+
if (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);
|
|
231
|
+
if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);
|
|
232
|
+
|
|
233
|
+
// Show cost with "(sub)" indicator if using OAuth subscription
|
|
234
|
+
const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
|
|
235
|
+
if (totalCost || usingSubscription) {
|
|
236
|
+
const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
|
237
|
+
statsParts.push(costStr);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Colorize context percentage based on usage
|
|
241
|
+
let contextPercentStr: string;
|
|
242
|
+
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
|
|
243
|
+
const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
|
|
244
|
+
if (contextPercentValue > 90) {
|
|
245
|
+
contextPercentStr = theme.fg("error", contextPercentDisplay);
|
|
246
|
+
} else if (contextPercentValue > 70) {
|
|
247
|
+
contextPercentStr = theme.fg("warning", contextPercentDisplay);
|
|
248
|
+
} else {
|
|
249
|
+
contextPercentStr = contextPercentDisplay;
|
|
250
|
+
}
|
|
251
|
+
statsParts.push(contextPercentStr);
|
|
252
|
+
|
|
253
|
+
let statsLeft = statsParts.join(" ");
|
|
254
|
+
|
|
255
|
+
// Add model name on the right side, plus thinking level if model supports it
|
|
256
|
+
const modelName = state.model?.id || "no-model";
|
|
257
|
+
|
|
258
|
+
// Add thinking level hint if model supports reasoning and thinking is enabled
|
|
259
|
+
let rightSide = modelName;
|
|
260
|
+
if (state.model?.reasoning) {
|
|
261
|
+
const thinkingLevel = state.thinkingLevel || "off";
|
|
262
|
+
if (thinkingLevel !== "off") {
|
|
263
|
+
rightSide = `${modelName} • ${thinkingLevel}`;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
268
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
269
|
+
|
|
270
|
+
// If statsLeft is too wide, truncate it
|
|
271
|
+
if (statsLeftWidth > width) {
|
|
272
|
+
// Truncate statsLeft to fit width (no room for right side)
|
|
273
|
+
const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
|
|
274
|
+
statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`;
|
|
275
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Calculate available space for padding (minimum 2 spaces between stats and model)
|
|
279
|
+
const minPadding = 2;
|
|
280
|
+
const totalNeeded = statsLeftWidth + minPadding + rightSideWidth;
|
|
281
|
+
|
|
282
|
+
let statsLine: string;
|
|
283
|
+
if (totalNeeded <= width) {
|
|
284
|
+
// Both fit - add padding to right-align model
|
|
285
|
+
const padding = " ".repeat(width - statsLeftWidth - rightSideWidth);
|
|
286
|
+
statsLine = statsLeft + padding + rightSide;
|
|
287
|
+
} else {
|
|
288
|
+
// Need to truncate right side
|
|
289
|
+
const availableForRight = width - statsLeftWidth - minPadding;
|
|
290
|
+
if (availableForRight > 3) {
|
|
291
|
+
// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
|
|
292
|
+
const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
|
|
293
|
+
const truncatedPlain = plainRightSide.substring(0, availableForRight);
|
|
294
|
+
// For simplicity, just use plain truncated version (loses color, but fits)
|
|
295
|
+
const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
|
|
296
|
+
statsLine = statsLeft + padding + truncatedPlain;
|
|
297
|
+
} else {
|
|
298
|
+
// Not enough space for right side at all
|
|
299
|
+
statsLine = statsLeft;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Apply dim to each part separately. statsLeft may contain color codes (for context %)
|
|
304
|
+
// that end with a reset, which would clear an outer dim wrapper. So we dim the parts
|
|
305
|
+
// before and after the colored section independently.
|
|
306
|
+
const dimStatsLeft = theme.fg("dim", statsLeft);
|
|
307
|
+
const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
|
|
308
|
+
const dimRemainder = theme.fg("dim", remainder);
|
|
309
|
+
|
|
310
|
+
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
|
|
311
|
+
|
|
312
|
+
// Add extension statuses on a single line, sorted by key alphabetically
|
|
313
|
+
if (this.extensionStatuses.size > 0) {
|
|
314
|
+
const sortedStatuses = Array.from(this.extensionStatuses.entries())
|
|
315
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
316
|
+
.map(([, text]) => sanitizeStatusText(text));
|
|
317
|
+
const statusLine = sortedStatuses.join(" ");
|
|
318
|
+
// Truncate to terminal width with dim ellipsis for consistency with footer style
|
|
319
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return lines;
|
|
323
|
+
}
|
|
324
|
+
}
|