@groeponline/pi-wishcraft 0.17.3
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/AGENTS.md +68 -0
- package/CHANGELOG.md +724 -0
- package/CONTRIBUTING.md +37 -0
- package/README.md +648 -0
- package/RELEASE.md +117 -0
- package/ROADMAP.md +52 -0
- package/bash-mode/completion-providers.ts +269 -0
- package/bash-mode/completion.ts +416 -0
- package/bash-mode/editor-ghost.ts +40 -0
- package/bash-mode/editor-input.ts +80 -0
- package/bash-mode/editor.ts +437 -0
- package/bash-mode/history.ts +263 -0
- package/bash-mode/shell-session.ts +286 -0
- package/bash-mode/transcript.ts +108 -0
- package/bash-mode/types.ts +80 -0
- package/index.ts +6 -0
- package/package.json +55 -0
- package/queue/store.ts +443 -0
- package/queue/types.ts +54 -0
- package/src/config/custom-items.ts +182 -0
- package/src/config/extension-statuses.ts +51 -0
- package/src/config/layout.ts +60 -0
- package/src/config/parse.ts +127 -0
- package/src/config/powerline-config.ts +18 -0
- package/src/config/presets.ts +245 -0
- package/src/config/primitives.ts +117 -0
- package/src/config/segment-ids.ts +114 -0
- package/src/config/segment-options.ts +128 -0
- package/src/config/settings-patch.ts +26 -0
- package/src/config/types.ts +277 -0
- package/src/core/frontmatter.ts +40 -0
- package/src/editor/autocomplete-chain.ts +41 -0
- package/src/extension/activate.ts +28 -0
- package/src/extension/bash-mode-actions.ts +104 -0
- package/src/extension/commands.ts +268 -0
- package/src/extension/constants.ts +46 -0
- package/src/extension/custom-editor.ts +406 -0
- package/src/extension/git-invalidation.ts +40 -0
- package/src/extension/layout.ts +160 -0
- package/src/extension/menu-views.ts +393 -0
- package/src/extension/powerline-widgets.ts +95 -0
- package/src/extension/prompt-history.ts +219 -0
- package/src/extension/queue-commands.ts +245 -0
- package/src/extension/queue-context.ts +12 -0
- package/src/extension/queue-integration.ts +434 -0
- package/src/extension/segment-context.ts +212 -0
- package/src/extension/session-lifecycle.ts +373 -0
- package/src/extension/settings-io.ts +202 -0
- package/src/extension/shortcuts-config.ts +357 -0
- package/src/extension/shortcuts-router.ts +383 -0
- package/src/extension/skills/inline-invocation.ts +174 -0
- package/src/extension/skills/ook.md +6 -0
- package/src/extension/skills/test.md +6 -0
- package/src/extension/stale-context.ts +10 -0
- package/src/extension/stash-history.ts +103 -0
- package/src/extension/state.ts +159 -0
- package/src/extension/status-line-renderers.ts +222 -0
- package/src/extension/types.ts +97 -0
- package/src/extension/vibe-command.ts +160 -0
- package/src/extension/welcome-control.ts +27 -0
- package/src/extension/welcome-integration.ts +153 -0
- package/src/git/status.ts +332 -0
- package/src/paths/agent-dirs.ts +67 -0
- package/src/render/timer.ts +46 -0
- package/src/segments/core.ts +256 -0
- package/src/segments/custom.ts +114 -0
- package/src/segments/index.ts +3 -0
- package/src/segments/registry.ts +87 -0
- package/src/segments/shared.ts +36 -0
- package/src/segments/system.ts +235 -0
- package/src/segments/usage.ts +178 -0
- package/src/shell/cd-command.ts +190 -0
- package/src/shortcuts/matching.ts +61 -0
- package/src/theme/colors.ts +60 -0
- package/src/theme/icons.ts +175 -0
- package/src/theme/separators.ts +41 -0
- package/src/theme/theme.ts +211 -0
- package/src/tools/graph.ts +75 -0
- package/src/tools/patch.ts +179 -0
- package/src/tools/ripgrep.ts +104 -0
- package/src/usage/context.ts +97 -0
- package/src/usage/ledger.ts +293 -0
- package/src/usage/rates.ts +155 -0
- package/src/welcome/auto-dismiss.ts +43 -0
- package/src/welcome/banner.ts +68 -0
- package/src/welcome/discover.ts +234 -0
- package/src/welcome/format.ts +18 -0
- package/src/welcome/index.ts +5 -0
- package/src/welcome/layout.ts +36 -0
- package/src/welcome/overlay.ts +80 -0
- package/src/welcome/renderer.ts +157 -0
- package/src/welcome/sessions.ts +107 -0
- package/src/welcome/types.ts +41 -0
- package/src/welcome/widgets/graph-widget.ts +25 -0
- package/src/welcome/widgets/index.ts +20 -0
- package/src/welcome/widgets/queue-widget.ts +26 -0
- package/src/welcome/widgets/sessions-widget.ts +23 -0
- package/src/welcome/widgets/shortcuts-widget.ts +17 -0
- package/src/welcome/widgets/system-widget.ts +29 -0
- package/src/working-vibes/generate.ts +144 -0
- package/src/working-vibes/index.ts +24 -0
- package/src/working-vibes/manager.ts +198 -0
- package/src/working-vibes/provider.ts +163 -0
- package/src/working-vibes/storage.ts +357 -0
- package/theme.example.json +24 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { StatusLineSegment } from "../config/types.ts";
|
|
4
|
+
import { normalizeCompactExtensionStatus } from "../config/powerline-config.ts";
|
|
5
|
+
import { getIcons, SEP_DOT } from "../theme/icons.ts";
|
|
6
|
+
import { color, withIcon } from "./shared.ts";
|
|
7
|
+
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
// Segment Implementations
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
11
|
+
|
|
12
|
+
export const thinkingSegment: StatusLineSegment = {
|
|
13
|
+
id: "thinking",
|
|
14
|
+
render(ctx) {
|
|
15
|
+
const level = ctx.thinkingLevel || "off";
|
|
16
|
+
|
|
17
|
+
const levelText: Record<string, string> = {
|
|
18
|
+
off: "off",
|
|
19
|
+
minimal: "min",
|
|
20
|
+
low: "low",
|
|
21
|
+
medium: "med",
|
|
22
|
+
high: "high",
|
|
23
|
+
xhigh: "xhigh",
|
|
24
|
+
};
|
|
25
|
+
const label = levelText[level] || level;
|
|
26
|
+
const content = `think:${label}`;
|
|
27
|
+
|
|
28
|
+
if (level === "high" || level === "xhigh" || level === "max") {
|
|
29
|
+
return { content: color(ctx, "thinking", content), visible: true };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (level === "minimal") {
|
|
33
|
+
return { content: color(ctx, "thinkingMinimal", content), visible: true };
|
|
34
|
+
}
|
|
35
|
+
if (level === "low") {
|
|
36
|
+
return { content: color(ctx, "thinkingLow", content), visible: true };
|
|
37
|
+
}
|
|
38
|
+
if (level === "medium") {
|
|
39
|
+
return { content: color(ctx, "thinkingMedium", content), visible: true };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { content: color(ctx, "thinking", content), visible: true };
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const subagentsSegment: StatusLineSegment = {
|
|
47
|
+
id: "subagents",
|
|
48
|
+
render() {
|
|
49
|
+
// Note: pi-mono doesn't have subagent tracking built-in
|
|
50
|
+
// This would require extension state management
|
|
51
|
+
// For now, return not visible
|
|
52
|
+
return { content: "", visible: false };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const queueSegment: StatusLineSegment = {
|
|
57
|
+
id: "queue",
|
|
58
|
+
render(ctx) {
|
|
59
|
+
const summary = ctx.queueSummary;
|
|
60
|
+
const parts: string[] = [];
|
|
61
|
+
|
|
62
|
+
if (summary.compacting && summary.queueCount > 0) {
|
|
63
|
+
parts.push(`compact q ${summary.queueCount}`);
|
|
64
|
+
} else if (summary.queueCount > 0) {
|
|
65
|
+
parts.push(`q ${summary.queueCount}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (summary.ideaCount > 0) {
|
|
69
|
+
parts.push(`ideas ${summary.ideaCount}`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (summary.blockedCount > 0) {
|
|
73
|
+
parts.push(`blocked ${summary.blockedCount}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (parts.length === 0) return { content: "", visible: false };
|
|
77
|
+
return { content: color(ctx, "queue", parts.join(SEP_DOT)), visible: true };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const extensionStatusesSegment: StatusLineSegment = {
|
|
82
|
+
id: "extension_statuses",
|
|
83
|
+
render(ctx) {
|
|
84
|
+
const statuses = ctx.extensionStatuses;
|
|
85
|
+
if (!statuses || statuses.size === 0)
|
|
86
|
+
return { content: "", visible: false };
|
|
87
|
+
|
|
88
|
+
// Join compact statuses with a separator
|
|
89
|
+
// Skip: empty strings, notification-style ("[...") shown above editor,
|
|
90
|
+
// and strings that are only ANSI codes with no visible text.
|
|
91
|
+
// Also skip statuses explicitly elevated into dedicated custom segments.
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
for (const [statusKey, value] of statuses.entries()) {
|
|
94
|
+
if (ctx.hiddenExtensionStatusKeys.has(statusKey)) continue;
|
|
95
|
+
const normalized = value ? normalizeCompactExtensionStatus(value) : null;
|
|
96
|
+
if (normalized) {
|
|
97
|
+
parts.push(normalized);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parts.length === 0) return { content: "", visible: false };
|
|
102
|
+
|
|
103
|
+
// Statuses already have their own styling applied by the extensions
|
|
104
|
+
const content = parts.join(` ${SEP_DOT} `);
|
|
105
|
+
return { content, visible: true };
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export function countListeningPorts(includeUdp = false): number {
|
|
110
|
+
// ponytail: count UNIQUE TCP listening ports (dedupes IPv4/IPv6 dual-stack and
|
|
111
|
+
// repeated multicast binds). UDP is noisy (mDNS/DHCP/ephemeral) so it's opt-in.
|
|
112
|
+
const run = (cmd: string): string | null => {
|
|
113
|
+
try {
|
|
114
|
+
return execSync(cmd, { encoding: "utf8" });
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const proto = includeUdp ? "-tulnH" : "-tlnH";
|
|
120
|
+
let out = run(`ss ${proto} 2>/dev/null`);
|
|
121
|
+
if (out === null) out = run(`ss ${proto.replace("H", "")} 2>/dev/null`);
|
|
122
|
+
if (out === null)
|
|
123
|
+
out = run(
|
|
124
|
+
includeUdp ? "netstat -tuln 2>/dev/null" : "netstat -tln 2>/dev/null",
|
|
125
|
+
);
|
|
126
|
+
if (out === null) return readProcListeningPorts(includeUdp);
|
|
127
|
+
|
|
128
|
+
const lines = out
|
|
129
|
+
.split("\n")
|
|
130
|
+
.map((l) => l.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
const start = /^(Proto|Netid|State|Local)/.test(lines[0] ?? "") ? 1 : 0;
|
|
133
|
+
const ports = new Set<number>();
|
|
134
|
+
for (const line of lines.slice(start)) {
|
|
135
|
+
// ss/netstat put the local address at different columns; take the first addr:port token
|
|
136
|
+
for (const col of line.split(/\s+/)) {
|
|
137
|
+
const m = /:(\d+)$/.exec(col);
|
|
138
|
+
if (m) {
|
|
139
|
+
ports.add(Number(m[1]));
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return ports.size;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function readProcListeningPorts(includeUdp: boolean): number {
|
|
148
|
+
// ponytail: last-resort /proc parse when ss/netstat are unavailable; dedupe by port
|
|
149
|
+
const files = includeUdp ? ["tcp", "tcp6", "udp", "udp6"] : ["tcp", "tcp6"];
|
|
150
|
+
const ports = new Set<number>();
|
|
151
|
+
for (const f of files) {
|
|
152
|
+
try {
|
|
153
|
+
const data = readFileSync(`/proc/net/${f}`, "utf8");
|
|
154
|
+
for (const line of data.split("\n")) {
|
|
155
|
+
const cols = line.trim().split(/\s+/);
|
|
156
|
+
if (cols.length < 4) continue;
|
|
157
|
+
if (f.startsWith("tcp") && cols[3] !== "0A") continue; // LISTEN state
|
|
158
|
+
const m = /:([0-9A-Fa-f]{1,4})$/.exec(cols[1]);
|
|
159
|
+
if (m) ports.add(parseInt(m[1], 16));
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// file may not exist; skip
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return ports.size;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Rolling 1-second sliding window of (timestamp, cumulative output) samples.
|
|
169
|
+
// Renders fire every ~33ms during streaming, so a per-render delta spikes (tiny dt);
|
|
170
|
+
// a fixed ~1s lookback gives a stable, honest tokens/sec over the last second.
|
|
171
|
+
const tpsSamples: { at: number; output: number }[] = [];
|
|
172
|
+
|
|
173
|
+
export const tpsSegment: StatusLineSegment = {
|
|
174
|
+
id: "tps",
|
|
175
|
+
render(ctx) {
|
|
176
|
+
const override = process.env.POWERLINE_TPS?.trim();
|
|
177
|
+
if (override) {
|
|
178
|
+
return {
|
|
179
|
+
content: withIcon(getIcons().tps, color(ctx, "tokens", override)),
|
|
180
|
+
visible: true,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const out = ctx.usageStats?.output ?? 0;
|
|
184
|
+
const now = Date.now();
|
|
185
|
+
tpsSamples.push({ at: now, output: out });
|
|
186
|
+
// keep the last 5s of samples; drop everything older (idle gaps get forgotten)
|
|
187
|
+
while (tpsSamples.length > 0 && now - tpsSamples[0].at > 5000)
|
|
188
|
+
tpsSamples.shift();
|
|
189
|
+
if (tpsSamples.length > 240) tpsSamples.splice(0, tpsSamples.length - 240);
|
|
190
|
+
|
|
191
|
+
// pick the sample closest to 1s old (window [0.5s, 2s]) for a stable rate
|
|
192
|
+
let ref: { at: number; output: number } | null = null;
|
|
193
|
+
let bestDelta = Infinity;
|
|
194
|
+
for (const s of tpsSamples) {
|
|
195
|
+
const age = now - s.at;
|
|
196
|
+
if (age < 500) continue;
|
|
197
|
+
const d = Math.abs(age - 1000);
|
|
198
|
+
if (d < bestDelta) {
|
|
199
|
+
bestDelta = d;
|
|
200
|
+
ref = s;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
let tps = 0;
|
|
204
|
+
if (ref) {
|
|
205
|
+
const dt = (now - ref.at) / 1000;
|
|
206
|
+
const dOut = out - ref.output;
|
|
207
|
+
if (dt > 0 && dOut >= 0) tps = dOut / dt;
|
|
208
|
+
}
|
|
209
|
+
const valueText = tps >= 100 ? Math.round(tps).toString() : tps.toFixed(1);
|
|
210
|
+
const label = ctx.segmentLabels?.get("tps");
|
|
211
|
+
const text = label ? `${label} ${valueText}` : valueText;
|
|
212
|
+
// levendig: light up in the tokens color while generating, dim while idle
|
|
213
|
+
return {
|
|
214
|
+
content: withIcon(
|
|
215
|
+
getIcons().tps,
|
|
216
|
+
color(ctx, tps > 0 ? "tokens" : "queue", text),
|
|
217
|
+
),
|
|
218
|
+
visible: true,
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const openPortsSegment: StatusLineSegment = {
|
|
224
|
+
id: "open_ports",
|
|
225
|
+
render(ctx) {
|
|
226
|
+
const includeUdp = ctx.options?.openPorts?.includeUdp === true;
|
|
227
|
+
const count = countListeningPorts(includeUdp);
|
|
228
|
+
const label = ctx.segmentLabels?.get("open_ports");
|
|
229
|
+
const text = label ? `${label} ${count}` : String(count);
|
|
230
|
+
return {
|
|
231
|
+
content: withIcon(getIcons().ports, color(ctx, "queue", text)),
|
|
232
|
+
visible: true,
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { StatusLineSegment } from "../config/types.ts";
|
|
2
|
+
import { getIcons } from "../theme/icons.ts";
|
|
3
|
+
import { formatUsdCost } from "../usage/rates.ts";
|
|
4
|
+
import { color, withIcon, formatTokens } from "./shared.ts";
|
|
5
|
+
|
|
6
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
// Segment Implementations
|
|
8
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
9
|
+
|
|
10
|
+
export const tokenInSegment: StatusLineSegment = {
|
|
11
|
+
id: "token_in",
|
|
12
|
+
render(ctx) {
|
|
13
|
+
const icons = getIcons();
|
|
14
|
+
const { input } = ctx.usageStats;
|
|
15
|
+
if (!input) return { content: "", visible: false };
|
|
16
|
+
|
|
17
|
+
const content = withIcon(icons.input, formatTokens(input));
|
|
18
|
+
return { content: color(ctx, "tokens", content), visible: true };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const tokenOutSegment: StatusLineSegment = {
|
|
23
|
+
id: "token_out",
|
|
24
|
+
render(ctx) {
|
|
25
|
+
const icons = getIcons();
|
|
26
|
+
const { output } = ctx.usageStats;
|
|
27
|
+
if (!output) return { content: "", visible: false };
|
|
28
|
+
|
|
29
|
+
const content = withIcon(icons.output, formatTokens(output));
|
|
30
|
+
return { content: color(ctx, "tokens", content), visible: true };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const tokenTotalSegment: StatusLineSegment = {
|
|
35
|
+
id: "token_total",
|
|
36
|
+
render(ctx) {
|
|
37
|
+
const icons = getIcons();
|
|
38
|
+
const { input, output, cacheRead, cacheWrite } = ctx.usageStats;
|
|
39
|
+
const total = input + output + cacheRead + cacheWrite;
|
|
40
|
+
if (!total) return { content: "", visible: false };
|
|
41
|
+
|
|
42
|
+
const content = withIcon(icons.tokens, formatTokens(total));
|
|
43
|
+
return { content: color(ctx, "tokens", content), visible: true };
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const costSegment: StatusLineSegment = {
|
|
48
|
+
id: "cost",
|
|
49
|
+
render(ctx) {
|
|
50
|
+
const cost = ctx.usageStats.cost + (ctx.usageStats.subagentCost ?? 0);
|
|
51
|
+
const usingSubscription = ctx.usingSubscription;
|
|
52
|
+
|
|
53
|
+
if (!cost && !usingSubscription) {
|
|
54
|
+
return { content: "", visible: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const reportedCost =
|
|
58
|
+
cost > 0 ? formatUsdCost(cost, ctx.options.cost?.currency) : null;
|
|
59
|
+
if (!usingSubscription) {
|
|
60
|
+
return reportedCost
|
|
61
|
+
? { content: color(ctx, "cost", reportedCost), visible: true }
|
|
62
|
+
: { content: "", visible: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const subscriptionDisplay =
|
|
66
|
+
ctx.options.cost?.subscriptionDisplay ?? "subscription";
|
|
67
|
+
if (subscriptionDisplay === "reported-cost" && reportedCost) {
|
|
68
|
+
return { content: color(ctx, "cost", reportedCost), visible: true };
|
|
69
|
+
}
|
|
70
|
+
if (subscriptionDisplay === "both" && reportedCost) {
|
|
71
|
+
return {
|
|
72
|
+
content: color(ctx, "cost", `${reportedCost} (sub)`),
|
|
73
|
+
visible: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { content: color(ctx, "cost", "(sub)"), visible: true };
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const contextPctSegment: StatusLineSegment = {
|
|
82
|
+
id: "context_pct",
|
|
83
|
+
render(ctx) {
|
|
84
|
+
if (ctx.customCompactionEnabled) return { content: "", visible: false };
|
|
85
|
+
|
|
86
|
+
const icons = getIcons();
|
|
87
|
+
const { contextTokens, contextPercent, contextWindow } = ctx;
|
|
88
|
+
if (!contextWindow || !Number.isFinite(contextPercent)) {
|
|
89
|
+
return { content: "", visible: false };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const autoIcon =
|
|
93
|
+
ctx.autoCompactEnabled && icons.auto ? ` ${icons.auto}` : "";
|
|
94
|
+
const percentOnly = ctx.options.context?.format === "percent";
|
|
95
|
+
// "full" (default): tokens/window + one-decimal percentage + auto-compact icon.
|
|
96
|
+
// "percent": bare rounded percentage, threshold-colored, no icons.
|
|
97
|
+
const text = percentOnly
|
|
98
|
+
? `${Math.round(contextPercent)}%`
|
|
99
|
+
: `${formatTokens(contextTokens)}/${formatTokens(contextWindow)} (${contextPercent.toFixed(1)}%)${autoIcon}`;
|
|
100
|
+
|
|
101
|
+
// Icon outside color, text inside - use semantic colors for thresholds
|
|
102
|
+
let content: string;
|
|
103
|
+
const colored = (semantic: "context" | "contextWarn" | "contextError") =>
|
|
104
|
+
percentOnly
|
|
105
|
+
? color(ctx, semantic, text)
|
|
106
|
+
: withIcon(icons.context, color(ctx, semantic, text));
|
|
107
|
+
if (contextPercent > 90) {
|
|
108
|
+
content = colored("contextError");
|
|
109
|
+
} else if (contextPercent > 70) {
|
|
110
|
+
content = colored("contextWarn");
|
|
111
|
+
} else {
|
|
112
|
+
content = colored("context");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { content, visible: true };
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const contextTotalSegment: StatusLineSegment = {
|
|
120
|
+
id: "context_total",
|
|
121
|
+
render(ctx) {
|
|
122
|
+
if (ctx.customCompactionEnabled) return { content: "", visible: false };
|
|
123
|
+
|
|
124
|
+
const icons = getIcons();
|
|
125
|
+
const window = ctx.contextWindow;
|
|
126
|
+
if (!window) return { content: "", visible: false };
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
content: color(
|
|
130
|
+
ctx,
|
|
131
|
+
"context",
|
|
132
|
+
withIcon(icons.context, formatTokens(window)),
|
|
133
|
+
),
|
|
134
|
+
visible: true,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
export const cacheReadSegment: StatusLineSegment = {
|
|
140
|
+
id: "cache_read",
|
|
141
|
+
render(ctx) {
|
|
142
|
+
const icons = getIcons();
|
|
143
|
+
const { cacheRead, input } = ctx.usageStats;
|
|
144
|
+
if (!cacheRead) return { content: "", visible: false };
|
|
145
|
+
|
|
146
|
+
const format = ctx.options.cache_read?.format ?? "tokens";
|
|
147
|
+
const hitRate =
|
|
148
|
+
input + cacheRead > 0
|
|
149
|
+
? ((cacheRead / (input + cacheRead)) * 100).toFixed(0)
|
|
150
|
+
: "0";
|
|
151
|
+
|
|
152
|
+
let content: string;
|
|
153
|
+
if (format === "percent") {
|
|
154
|
+
content = [icons.cache, `${hitRate}%`].filter(Boolean).join(" ");
|
|
155
|
+
} else {
|
|
156
|
+
const tokens = [icons.cache, icons.input, formatTokens(cacheRead)]
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(" ");
|
|
159
|
+
content = format === "both" ? `${tokens} (${hitRate}%)` : tokens;
|
|
160
|
+
}
|
|
161
|
+
return { content: color(ctx, "tokens", content), visible: true };
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const cacheWriteSegment: StatusLineSegment = {
|
|
166
|
+
id: "cache_write",
|
|
167
|
+
render(ctx) {
|
|
168
|
+
const icons = getIcons();
|
|
169
|
+
const { cacheWrite } = ctx.usageStats;
|
|
170
|
+
if (!cacheWrite) return { content: "", visible: false };
|
|
171
|
+
|
|
172
|
+
const parts = [icons.cache, icons.output, formatTokens(cacheWrite)].filter(
|
|
173
|
+
Boolean,
|
|
174
|
+
);
|
|
175
|
+
const content = parts.join(" ");
|
|
176
|
+
return { content: color(ctx, "tokens", content), visible: true };
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { SessionManager, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
function stripMatchingQuotes(value: string): string {
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (trimmed.length >= 2) {
|
|
10
|
+
const first = trimmed[0];
|
|
11
|
+
const last = trimmed[trimmed.length - 1];
|
|
12
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
13
|
+
return trimmed.slice(1, -1);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return trimmed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function unescapePathArg(value: string): string {
|
|
20
|
+
return stripMatchingQuotes(value).replace(/\\(.)/g, "$1");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function expandHomePath(value: string, homeDir = homedir()): string {
|
|
24
|
+
if (value === "~") return homeDir;
|
|
25
|
+
if (value.startsWith("~/")) return join(homeDir, value.slice(2));
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeCompletionPath(value: string): string {
|
|
30
|
+
return value.replace(/([\\\s"'`$&|;<>()[\]{}?!*])/g, "\\$1");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function displayPath(value: string): string {
|
|
34
|
+
return value.replace(/\\/g, "/");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveCdTarget(args: string, cwd: string, homeDir = homedir()): { path: string } | { error: string } | null {
|
|
38
|
+
const input = unescapePathArg(args);
|
|
39
|
+
if (!input) return null;
|
|
40
|
+
|
|
41
|
+
const expanded = expandHomePath(input, homeDir);
|
|
42
|
+
const target = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
43
|
+
|
|
44
|
+
if (!existsSync(target)) {
|
|
45
|
+
return { error: `Directory does not exist: ${target}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
if (!statSync(target).isDirectory()) {
|
|
50
|
+
return { error: `Not a directory: ${target}` };
|
|
51
|
+
}
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
return { error: `Cannot access directory: ${message}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { path: target };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function pathBase(token: string, cwd: string, homeDir: string): { dir: string; prefix: string; displayPrefix: string } {
|
|
61
|
+
const unescaped = unescapePathArg(token);
|
|
62
|
+
const expanded = expandHomePath(unescaped, homeDir);
|
|
63
|
+
const hasSlash = expanded.includes("/");
|
|
64
|
+
|
|
65
|
+
if (!hasSlash) {
|
|
66
|
+
return { dir: cwd, prefix: expanded, displayPrefix: "" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const baseDir = expanded.endsWith("/") ? expanded.slice(0, -1) : dirname(expanded);
|
|
70
|
+
const dir = isAbsolute(baseDir) ? baseDir : resolve(cwd, baseDir);
|
|
71
|
+
const prefix = expanded.endsWith("/") ? "" : basename(expanded);
|
|
72
|
+
const displayPrefix = unescaped.endsWith("/") ? unescaped : unescaped.slice(0, Math.max(0, unescaped.length - prefix.length));
|
|
73
|
+
return { dir, prefix, displayPrefix };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isDirectoryEntry(dir: string, name: string): boolean {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(join(dir, name)).isDirectory();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getCdArgumentCompletions(argumentPrefix: string, cwd: string, homeDir = homedir()): AutocompleteItem[] {
|
|
85
|
+
const prefix = argumentPrefix.trimStart();
|
|
86
|
+
const suggestions: AutocompleteItem[] = [];
|
|
87
|
+
|
|
88
|
+
for (const quickPick of ["../", "~/"]) {
|
|
89
|
+
if (!prefix || quickPick.startsWith(prefix)) {
|
|
90
|
+
suggestions.push({ value: quickPick, label: quickPick, description: quickPick === "../" ? "Parent directory" : "Home directory" });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { dir, prefix: entryPrefix, displayPrefix } = pathBase(prefix, cwd, homeDir);
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
98
|
+
.filter((entry) => entry.name.startsWith(entryPrefix) && (entry.isDirectory() || entry.isSymbolicLink() && isDirectoryEntry(dir, entry.name)))
|
|
99
|
+
.slice(0, 50)
|
|
100
|
+
.map((entry) => {
|
|
101
|
+
const path = displayPath(`${displayPrefix}${entry.name}/`);
|
|
102
|
+
return {
|
|
103
|
+
value: escapeCompletionPath(path),
|
|
104
|
+
label: path,
|
|
105
|
+
} satisfies AutocompleteItem;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
suggestions.push(...entries);
|
|
109
|
+
} catch {
|
|
110
|
+
return suggestions;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const seen = new Set<string>();
|
|
114
|
+
return suggestions.filter((item) => {
|
|
115
|
+
if (seen.has(item.value)) return false;
|
|
116
|
+
seen.add(item.value);
|
|
117
|
+
return true;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function removeSessionFile(filePath: string | undefined): void {
|
|
122
|
+
if (!filePath) return;
|
|
123
|
+
try {
|
|
124
|
+
unlinkSync(filePath);
|
|
125
|
+
} catch {
|
|
126
|
+
// Best-effort cleanup only; the command still reports the cancellation/error.
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runCdCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
131
|
+
const resolved = resolveCdTarget(args, ctx.cwd);
|
|
132
|
+
if (resolved === null) {
|
|
133
|
+
ctx.ui.notify(ctx.cwd, "info");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if ("error" in resolved) {
|
|
137
|
+
ctx.ui.notify(resolved.error, "error");
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (resolved.path === ctx.cwd) {
|
|
141
|
+
ctx.ui.notify(`Already in ${ctx.cwd}`, "info");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
146
|
+
if (!sessionFile) {
|
|
147
|
+
ctx.ui.notify("/cd requires a persisted Pi session", "error");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await ctx.waitForIdle();
|
|
152
|
+
const nextSession = SessionManager.forkFrom(sessionFile, resolved.path);
|
|
153
|
+
const nextSessionFile = nextSession.getSessionFile();
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const result = await ctx.switchSession(nextSessionFile!, {
|
|
157
|
+
withSession: async (nextCtx) => {
|
|
158
|
+
try {
|
|
159
|
+
process.chdir(nextCtx.cwd);
|
|
160
|
+
nextCtx.ui.notify(`Changed directory to ${nextCtx.cwd}`, "info");
|
|
161
|
+
nextCtx.ui.setTitle(`pi - ${basename(nextCtx.cwd) || nextCtx.cwd}`);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.debug("[powerline-footer] Failed to update process cwd after /cd:", error);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
if (result.cancelled) {
|
|
168
|
+
removeSessionFile(nextSessionFile);
|
|
169
|
+
ctx.ui.notify("Directory change cancelled", "warning");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
try {
|
|
175
|
+
ctx.ui.notify(`Failed to change directory: ${message}`, "error");
|
|
176
|
+
} catch {
|
|
177
|
+
console.debug("[powerline-footer] Failed to report /cd error:", error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function registerCdCommand(pi: ExtensionAPI, getCwd: () => string): void {
|
|
183
|
+
pi.registerCommand("cd", {
|
|
184
|
+
description: "Switch the Pi session working directory",
|
|
185
|
+
getArgumentCompletions(argumentPrefix) {
|
|
186
|
+
return getCdArgumentCompletions(argumentPrefix, getCwd());
|
|
187
|
+
},
|
|
188
|
+
handler: runCdCommand,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { isKeyRelease, matchesKey, type KeyId } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
const SUPER_SHORTCUT_PATTERNS = new Map<string, RegExp>([
|
|
4
|
+
["super+up", /^\x1b\[(?:1;9(?::[12])?[AH]|574(?:19|23);9(?::[12])?u|7;9(?::[12])?~|27;9;65~)$/],
|
|
5
|
+
["super+down", /^\x1b\[(?:1;9(?::[12])?[BF]|574(?:20|24);9(?::[12])?u|8;9(?::[12])?~|27;9;66~)$/],
|
|
6
|
+
["super+home", /^\x1b\[(?:1;9(?::[12])?H|57423;9(?::[12])?u|7;9(?::[12])?~)$/],
|
|
7
|
+
["super+end", /^\x1b\[(?:1;9(?::[12])?F|57424;9(?::[12])?u|8;9(?::[12])?~)$/],
|
|
8
|
+
["super+pageup", /^\x1b\[(?:5;9(?::[12])?~|57421;9(?::[12])?u)$/],
|
|
9
|
+
["super+pagedown", /^\x1b\[(?:6;9(?::[12])?~|57422;9(?::[12])?u)$/],
|
|
10
|
+
["super+shift+up", /^\x1b\[(?:1;10(?::[12])?[AH]|574(?:19|23);10(?::[12])?u|7;10(?::[12])?~|27;10;65~)$/],
|
|
11
|
+
["super+shift+down", /^\x1b\[(?:1;10(?::[12])?[BF]|574(?:20|24);10(?::[12])?u|8;10(?::[12])?~|27;10;66~)$/],
|
|
12
|
+
["super+shift+home", /^\x1b\[(?:1;10(?::[12])?H|57423;10(?::[12])?u|7;10(?::[12])?~)$/],
|
|
13
|
+
["super+shift+end", /^\x1b\[(?:1;10(?::[12])?F|57424;10(?::[12])?u|8;10(?::[12])?~)$/],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export function shortcutUsesSuper(shortcut: string): boolean {
|
|
17
|
+
const parts = shortcut.toLowerCase().split("+");
|
|
18
|
+
return parts.slice(0, -1).includes("super");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isSupportedSuperShortcut(shortcut: string): boolean {
|
|
22
|
+
return SUPER_SHORTCUT_PATTERNS.has(shortcut.toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function shortcutConflictKey(shortcut: string): string {
|
|
26
|
+
switch (shortcut.toLowerCase()) {
|
|
27
|
+
case "super+home":
|
|
28
|
+
return "super+up";
|
|
29
|
+
case "super+end":
|
|
30
|
+
return "super+down";
|
|
31
|
+
case "super+shift+home":
|
|
32
|
+
return "super+shift+up";
|
|
33
|
+
case "super+shift+end":
|
|
34
|
+
return "super+shift+down";
|
|
35
|
+
default:
|
|
36
|
+
return shortcut;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function matchesConfiguredShortcut(data: string, shortcut: string | null | undefined): boolean {
|
|
41
|
+
if (!shortcut) return false;
|
|
42
|
+
|
|
43
|
+
const normalizedShortcut = shortcut.toLowerCase();
|
|
44
|
+
if (shortcutUsesSuper(normalizedShortcut)) {
|
|
45
|
+
return SUPER_SHORTCUT_PATTERNS.get(normalizedShortcut)?.test(data) ?? false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return matchesKey(data, shortcut as KeyId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function matchesStashShortcutInput(data: string, options: { includePrintableSharpS?: boolean } = {}): boolean {
|
|
52
|
+
if (isKeyRelease(data)) return false;
|
|
53
|
+
|
|
54
|
+
return (options.includePrintableSharpS === true && data === "ß")
|
|
55
|
+
|| data === "\x1bs"
|
|
56
|
+
|| data === "\x1bS"
|
|
57
|
+
|| /^\x1b\[(?:83|115)(?::\d*)?(?::\d*)?;3(?::\d+)?u$/.test(data)
|
|
58
|
+
|| data === "\x1b[27;3;115~"
|
|
59
|
+
|| data === "\x1b[27;3;83~"
|
|
60
|
+
|| matchesKey(data, "alt+s");
|
|
61
|
+
}
|