@hadooppei/hwcode 0.1.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/.env.example +11 -0
- package/.pi/APPEND_SYSTEM.md +5 -0
- package/.pi/extensions/command-filter.ts +46 -0
- package/.pi/extensions/cwd.ts +188 -0
- package/.pi/extensions/footer-tps.ts +292 -0
- package/.pi/extensions/hwcode.ts +10 -0
- package/.pi/extensions/model-providers.ts +450 -0
- package/.pi/extensions/welcome.ts +168 -0
- package/.pi/extensions/workflows.ts +292 -0
- package/.pi/lib/command-filter.ts +49 -0
- package/.pi/lib/pixel-font.ts +264 -0
- package/.pi/lib/welcome-input.ts +18 -0
- package/.pi/lib/workflow-guard.ts +99 -0
- package/.pi/lib/working-directory.ts +252 -0
- package/.pi/model-providers.json +62 -0
- package/.pi/skills/hwcode-sdd/SKILL.md +113 -0
- package/.pi/skills/hwcode-sdd/agents/openai.yaml +4 -0
- package/.pi/skills/hwcode-sdd/references/spec-artifacts.md +161 -0
- package/.pi/skills/hwcode-vibe/SKILL.md +52 -0
- package/.pi/skills/hwcode-vibe/agents/openai.yaml +4 -0
- package/.pi/welcome.json +15 -0
- package/README.md +145 -0
- package/bin/hwcode.js +85 -0
- package/package.json +51 -0
package/.env.example
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Keep real credentials outside version control.
|
|
2
|
+
# Local provider credentials. Model addresses and capabilities live in
|
|
3
|
+
# .pi/model-providers.json.
|
|
4
|
+
PI_LOCAL_MODEL_API_KEY=local
|
|
5
|
+
|
|
6
|
+
# Optional credential used as the default when logging in to the configurable
|
|
7
|
+
# "hw" provider. Leave unset for a keyless local endpoint.
|
|
8
|
+
# HW_API_KEY=
|
|
9
|
+
|
|
10
|
+
# OPENAI_API_KEY=
|
|
11
|
+
# ANTHROPIC_API_KEY=
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# HWCode Behavior
|
|
2
|
+
|
|
3
|
+
Treat this repository as the active project boundary. Follow `AGENTS.md`, keep edits scoped to the user's request, and verify material changes before reporting completion.
|
|
4
|
+
|
|
5
|
+
Do not expose secrets or perform destructive, irreversible, or externally visible actions without explicit user authorization.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_HIDDEN_COMMANDS,
|
|
8
|
+
filterCommandSuggestions,
|
|
9
|
+
parseHiddenCommands,
|
|
10
|
+
} from "../lib/command-filter.ts";
|
|
11
|
+
|
|
12
|
+
function loadHiddenCommands(cwd: string, notify: (message: string) => void): Set<string> {
|
|
13
|
+
const settingsPath = join(cwd, ".pi", "settings.json");
|
|
14
|
+
if (!existsSync(settingsPath)) return new Set(DEFAULT_HIDDEN_COMMANDS);
|
|
15
|
+
try {
|
|
16
|
+
return parseHiddenCommands(readFileSync(settingsPath, "utf8"));
|
|
17
|
+
} catch (error) {
|
|
18
|
+
notify(
|
|
19
|
+
`Invalid hwcode.hiddenCommands configuration; using defaults. ${error instanceof Error ? error.message : String(error)}`,
|
|
20
|
+
);
|
|
21
|
+
return new Set(DEFAULT_HIDDEN_COMMANDS);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function commandFilterExtension(pi: ExtensionAPI) {
|
|
26
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
27
|
+
if (ctx.mode !== "tui") return;
|
|
28
|
+
const hiddenCommands = loadHiddenCommands(ctx.cwd, (message) => {
|
|
29
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
ctx.ui.addAutocompleteProvider((current) => ({
|
|
33
|
+
triggerCharacters: current.triggerCharacters,
|
|
34
|
+
getSuggestions: async (...args) => {
|
|
35
|
+
const suggestions = await current.getSuggestions(...args);
|
|
36
|
+
if (!suggestions) return null;
|
|
37
|
+
const items = filterCommandSuggestions(suggestions.items, suggestions.prefix, hiddenCommands);
|
|
38
|
+
return items.length > 0 ? { ...suggestions, items } : null;
|
|
39
|
+
},
|
|
40
|
+
applyCompletion: (...args) => current.applyCompletion(...args),
|
|
41
|
+
...(current.shouldTriggerFileCompletion && {
|
|
42
|
+
shouldTriggerFileCompletion: (...args) => current.shouldTriggerFileCompletion!(...args),
|
|
43
|
+
}),
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BashOperations,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
canonicalizeDirectory,
|
|
10
|
+
findChainedDirectoryChange,
|
|
11
|
+
getActiveWorkflowRoot,
|
|
12
|
+
getWorkingDirectory,
|
|
13
|
+
getWorkingDirectoryState,
|
|
14
|
+
parseLeadingDirectoryChange,
|
|
15
|
+
resetWorkingDirectoryState,
|
|
16
|
+
resolveDirectoryArgument,
|
|
17
|
+
resolveWorkingPath,
|
|
18
|
+
setWorkingDirectoryState,
|
|
19
|
+
shellQuote,
|
|
20
|
+
type WorkingDirectoryState,
|
|
21
|
+
WORKING_DIRECTORY_STATE_TYPE,
|
|
22
|
+
} from "../lib/working-directory.ts";
|
|
23
|
+
|
|
24
|
+
const FILE_PATH_TOOLS = new Set(["read", "write", "edit", "grep", "find", "ls"]);
|
|
25
|
+
const OPTIONAL_PATH_TOOLS = new Set(["grep", "find", "ls"]);
|
|
26
|
+
|
|
27
|
+
interface ChangeResult {
|
|
28
|
+
state?: WorkingDirectoryState;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function notify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
33
|
+
if (ctx.hasUI) ctx.ui.notify(message, level);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function commandArgument(args: string): string {
|
|
37
|
+
const trimmed = args.trim();
|
|
38
|
+
if (!trimmed) return "";
|
|
39
|
+
const parsed = parseLeadingDirectoryChange(`cd ${trimmed}`);
|
|
40
|
+
return parsed && !parsed.remainder ? parsed.argument : trimmed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function workingDirectoryExtension(pi: ExtensionAPI) {
|
|
44
|
+
const localBash = createLocalBashOperations();
|
|
45
|
+
|
|
46
|
+
function changeDirectory(argument: string, ctx: ExtensionContext): ChangeResult {
|
|
47
|
+
const current = getWorkingDirectoryState(ctx.sessionManager);
|
|
48
|
+
let target: string;
|
|
49
|
+
try {
|
|
50
|
+
target = resolveDirectoryArgument(current.cwd, argument, current.previousCwd);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const workflowRoot = getActiveWorkflowRoot(ctx.sessionManager.getEntries());
|
|
56
|
+
if (workflowRoot) {
|
|
57
|
+
let canonicalWorkflowRoot = workflowRoot;
|
|
58
|
+
try {
|
|
59
|
+
canonicalWorkflowRoot = canonicalizeDirectory(workflowRoot);
|
|
60
|
+
} catch {
|
|
61
|
+
// The workflow guard will handle a missing locked root separately.
|
|
62
|
+
}
|
|
63
|
+
if (target !== canonicalWorkflowRoot) {
|
|
64
|
+
return {
|
|
65
|
+
error: `The active HWCode workflow locks this session to ${workflowRoot}. Start a new session before switching to ${target}.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const state: WorkingDirectoryState = {
|
|
71
|
+
version: 1,
|
|
72
|
+
cwd: target,
|
|
73
|
+
previousCwd: target === current.cwd ? current.previousCwd : current.cwd,
|
|
74
|
+
};
|
|
75
|
+
setWorkingDirectoryState(ctx.sessionManager, state);
|
|
76
|
+
pi.appendEntry<WorkingDirectoryState>(WORKING_DIRECTORY_STATE_TYPE, state);
|
|
77
|
+
pi.events.emit("hwcode:working-directory-changed", state);
|
|
78
|
+
return { state };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pi.registerCommand("cd", {
|
|
82
|
+
description: "Change the persistent working directory for this session",
|
|
83
|
+
handler: async (args, ctx) => {
|
|
84
|
+
if (!ctx.isIdle()) {
|
|
85
|
+
notify(ctx, "Wait for the current response to finish before changing directory.", "warning");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const result = changeDirectory(commandArgument(args), ctx);
|
|
89
|
+
if (result.error) {
|
|
90
|
+
notify(ctx, result.error, "error");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
notify(ctx, `Working directory: ${result.state!.cwd}`);
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
pi.on("session_start", (_event, ctx) => {
|
|
98
|
+
try {
|
|
99
|
+
resetWorkingDirectoryState(ctx.sessionManager);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const fallback: WorkingDirectoryState = {
|
|
102
|
+
version: 1,
|
|
103
|
+
cwd: canonicalizeDirectory(ctx.sessionManager.getCwd()),
|
|
104
|
+
};
|
|
105
|
+
setWorkingDirectoryState(ctx.sessionManager, fallback);
|
|
106
|
+
pi.appendEntry<WorkingDirectoryState>(WORKING_DIRECTORY_STATE_TYPE, fallback);
|
|
107
|
+
notify(
|
|
108
|
+
ctx,
|
|
109
|
+
`Stored working directory is unavailable; restored ${fallback.cwd}. ${error instanceof Error ? error.message : String(error)}`,
|
|
110
|
+
"warning",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
116
|
+
const cwd = getWorkingDirectory(ctx.sessionManager);
|
|
117
|
+
const prompt = event.systemPrompt.replace(
|
|
118
|
+
/\nCurrent working directory: [^\n]*(?=\n|$)/gu,
|
|
119
|
+
`\nCurrent working directory: ${cwd}`,
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
systemPrompt: `${prompt}\nA leading shell \`cd\` changes the persistent working directory for this session. All relative shell and built-in file-tool paths resolve from the current working directory.`,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pi.on("tool_call", (event, ctx) => {
|
|
127
|
+
const input = event.input as Record<string, unknown>;
|
|
128
|
+
if (event.toolName === "bash" && typeof input.command === "string") {
|
|
129
|
+
const executionCwd = getWorkingDirectory(ctx.sessionManager);
|
|
130
|
+
const change = findChainedDirectoryChange(input.command);
|
|
131
|
+
if (change) {
|
|
132
|
+
const result = changeDirectory(change.argument, ctx);
|
|
133
|
+
if (result.error) return { block: true, reason: result.error };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
input.command = `cd -- ${shellQuote(executionCwd)} || exit $?\n${input.command}`;
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!FILE_PATH_TOOLS.has(event.toolName)) return undefined;
|
|
141
|
+
const cwd = getWorkingDirectory(ctx.sessionManager);
|
|
142
|
+
if (typeof input.path === "string") {
|
|
143
|
+
input.path = resolveWorkingPath(cwd, input.path);
|
|
144
|
+
} else if (OPTIONAL_PATH_TOOLS.has(event.toolName)) {
|
|
145
|
+
input.path = cwd;
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.on("user_bash", (event, ctx) => {
|
|
151
|
+
const executionCwd = getWorkingDirectory(ctx.sessionManager);
|
|
152
|
+
const change = findChainedDirectoryChange(event.command);
|
|
153
|
+
if (change) {
|
|
154
|
+
const result = changeDirectory(change.argument, ctx);
|
|
155
|
+
if (result.error) {
|
|
156
|
+
return {
|
|
157
|
+
result: {
|
|
158
|
+
output: `${result.error}\n`,
|
|
159
|
+
exitCode: 1,
|
|
160
|
+
cancelled: false,
|
|
161
|
+
truncated: false,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (change.standalone) {
|
|
166
|
+
return {
|
|
167
|
+
result: {
|
|
168
|
+
output: `${result.state!.cwd}\n`,
|
|
169
|
+
exitCode: 0,
|
|
170
|
+
cancelled: false,
|
|
171
|
+
truncated: false,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const operations: BashOperations = {
|
|
176
|
+
exec: (command, _cwd, options) => localBash.exec(command, executionCwd, options),
|
|
177
|
+
};
|
|
178
|
+
return { operations };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cwd = getWorkingDirectory(ctx.sessionManager);
|
|
182
|
+
if (cwd === event.cwd) return undefined;
|
|
183
|
+
const operations: BashOperations = {
|
|
184
|
+
exec: (command, _cwd, options) => localBash.exec(command, cwd, options),
|
|
185
|
+
};
|
|
186
|
+
return { operations };
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
|
|
3
|
+
import {
|
|
4
|
+
type ExtensionAPI,
|
|
5
|
+
type ExtensionContext,
|
|
6
|
+
type ReadonlyFooterDataProvider,
|
|
7
|
+
type Theme,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
canonicalizeDirectory,
|
|
13
|
+
getWorkingDirectory,
|
|
14
|
+
} from "../lib/working-directory.ts";
|
|
15
|
+
|
|
16
|
+
interface UsageTotals {
|
|
17
|
+
input: number;
|
|
18
|
+
output: number;
|
|
19
|
+
cacheRead: number;
|
|
20
|
+
cacheWrite: number;
|
|
21
|
+
cost: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TpsState {
|
|
25
|
+
streaming: boolean;
|
|
26
|
+
firstDeltaAt?: number;
|
|
27
|
+
streamedChunks: number;
|
|
28
|
+
reportedTokens: number;
|
|
29
|
+
value?: number;
|
|
30
|
+
approximate: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tps: TpsState = {
|
|
34
|
+
streaming: false,
|
|
35
|
+
streamedChunks: 0,
|
|
36
|
+
reportedTokens: 0,
|
|
37
|
+
approximate: false,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let requestRender: (() => void) | undefined;
|
|
41
|
+
|
|
42
|
+
function addUsage(totals: UsageTotals, usage: Usage): void {
|
|
43
|
+
totals.input += usage.input;
|
|
44
|
+
totals.output += usage.output;
|
|
45
|
+
totals.cacheRead += usage.cacheRead;
|
|
46
|
+
totals.cacheWrite += usage.cacheWrite;
|
|
47
|
+
totals.cost += usage.cost.total;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatTokens(count: number): string {
|
|
51
|
+
if (count < 1000) return count.toString();
|
|
52
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
53
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
54
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
55
|
+
return `${Math.round(count / 1000000)}M`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function formatCwd(cwd: string, home: string | undefined): string {
|
|
59
|
+
if (!home) return cwd;
|
|
60
|
+
|
|
61
|
+
const resolvedCwd = resolve(cwd);
|
|
62
|
+
const resolvedHome = resolve(home);
|
|
63
|
+
const relativeToHome = relative(resolvedHome, resolvedCwd);
|
|
64
|
+
const isInsideHome =
|
|
65
|
+
relativeToHome === "" ||
|
|
66
|
+
(relativeToHome !== ".." &&
|
|
67
|
+
!relativeToHome.startsWith(`..${sep}`) &&
|
|
68
|
+
!isAbsolute(relativeToHome));
|
|
69
|
+
|
|
70
|
+
if (!isInsideHome) return cwd;
|
|
71
|
+
return relativeToHome === "" ? "~" : `~${sep}${relativeToHome}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sanitizeStatusText(text: string): string {
|
|
75
|
+
return text.replace(/[\r\n\t]/gu, " ").replace(/ +/gu, " ").trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateTps(now: number, exactTokens?: number): void {
|
|
79
|
+
if (tps.firstDeltaAt === undefined) return;
|
|
80
|
+
|
|
81
|
+
const elapsedSeconds = (now - tps.firstDeltaAt) / 1000;
|
|
82
|
+
if (elapsedSeconds <= 0 || (elapsedSeconds < 0.1 && exactTokens === undefined)) return;
|
|
83
|
+
|
|
84
|
+
const tokenCount = exactTokens && exactTokens > 0 ? exactTokens : tps.streamedChunks;
|
|
85
|
+
if (tokenCount <= 0) return;
|
|
86
|
+
|
|
87
|
+
tps.value = tokenCount / elapsedSeconds;
|
|
88
|
+
tps.approximate = !(exactTokens && exactTokens > 0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function tpsLabel(): string {
|
|
92
|
+
if (tps.value === undefined) return tps.streaming ? "TPS …" : "TPS --";
|
|
93
|
+
return `TPS ${tps.approximate ? "~" : ""}${tps.value.toFixed(1)}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resetTps(streaming: boolean): void {
|
|
97
|
+
tps.streaming = streaming;
|
|
98
|
+
tps.firstDeltaAt = undefined;
|
|
99
|
+
tps.streamedChunks = 0;
|
|
100
|
+
tps.reportedTokens = 0;
|
|
101
|
+
tps.value = undefined;
|
|
102
|
+
tps.approximate = false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isGeneratedDelta(type: string): boolean {
|
|
106
|
+
return type === "text_delta" || type === "thinking_delta" || type === "toolcall_delta";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function collectUsage(ctx: ExtensionContext): {
|
|
110
|
+
totals: UsageTotals;
|
|
111
|
+
latestCacheHitRate?: number;
|
|
112
|
+
} {
|
|
113
|
+
const totals: UsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
114
|
+
let latestCacheHitRate: number | undefined;
|
|
115
|
+
|
|
116
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
117
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
118
|
+
const usage = (entry.message as AssistantMessage).usage;
|
|
119
|
+
addUsage(totals, usage);
|
|
120
|
+
const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
121
|
+
latestCacheHitRate = promptTokens > 0 ? (usage.cacheRead / promptTokens) * 100 : undefined;
|
|
122
|
+
} else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
123
|
+
addUsage(totals, entry.message.usage);
|
|
124
|
+
} else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
125
|
+
addUsage(totals, entry.usage);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { totals, latestCacheHitRate };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderFooter(
|
|
133
|
+
ctx: ExtensionContext,
|
|
134
|
+
theme: Theme,
|
|
135
|
+
footerData: ReadonlyFooterDataProvider,
|
|
136
|
+
width: number,
|
|
137
|
+
): string[] {
|
|
138
|
+
const { totals, latestCacheHitRate } = collectUsage(ctx);
|
|
139
|
+
const contextUsage = ctx.getContextUsage();
|
|
140
|
+
const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
141
|
+
const contextPercentValue = contextUsage?.percent ?? 0;
|
|
142
|
+
const contextPercent = contextUsage?.percent === null ? "?" : contextPercentValue.toFixed(1);
|
|
143
|
+
|
|
144
|
+
const workingDirectory = getWorkingDirectory(ctx.sessionManager);
|
|
145
|
+
let pwd = formatCwd(workingDirectory, process.env.HOME || process.env.USERPROFILE);
|
|
146
|
+
let sessionDirectory = ctx.sessionManager.getCwd();
|
|
147
|
+
try {
|
|
148
|
+
sessionDirectory = canonicalizeDirectory(sessionDirectory);
|
|
149
|
+
} catch {
|
|
150
|
+
// If the launch directory disappeared, do not show its stale Git branch.
|
|
151
|
+
}
|
|
152
|
+
const branch = workingDirectory === sessionDirectory ? footerData.getGitBranch() : undefined;
|
|
153
|
+
if (branch) pwd = `${pwd} (${branch})`;
|
|
154
|
+
const sessionName = ctx.sessionManager.getSessionName();
|
|
155
|
+
if (sessionName) pwd = `${pwd} • ${sessionName}`;
|
|
156
|
+
|
|
157
|
+
const statsParts: string[] = [];
|
|
158
|
+
if (totals.input) statsParts.push(`↑${formatTokens(totals.input)}`);
|
|
159
|
+
if (totals.output) statsParts.push(`↓${formatTokens(totals.output)}`);
|
|
160
|
+
if (totals.cacheRead) statsParts.push(`R${formatTokens(totals.cacheRead)}`);
|
|
161
|
+
if (totals.cacheWrite) statsParts.push(`W${formatTokens(totals.cacheWrite)}`);
|
|
162
|
+
if ((totals.cacheRead > 0 || totals.cacheWrite > 0) && latestCacheHitRate !== undefined) {
|
|
163
|
+
statsParts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
|
|
164
|
+
}
|
|
165
|
+
if (totals.cost) statsParts.push(`$${totals.cost.toFixed(3)}`);
|
|
166
|
+
|
|
167
|
+
const contextDisplay =
|
|
168
|
+
contextPercent === "?"
|
|
169
|
+
? `?/${formatTokens(contextWindow)} (auto)`
|
|
170
|
+
: `${contextPercent}%/${formatTokens(contextWindow)} (auto)`;
|
|
171
|
+
if (contextPercentValue > 90) {
|
|
172
|
+
statsParts.push(theme.fg("error", contextDisplay));
|
|
173
|
+
} else if (contextPercentValue > 70) {
|
|
174
|
+
statsParts.push(theme.fg("warning", contextDisplay));
|
|
175
|
+
} else {
|
|
176
|
+
statsParts.push(contextDisplay);
|
|
177
|
+
}
|
|
178
|
+
statsParts.push(tpsLabel());
|
|
179
|
+
|
|
180
|
+
let statsLeft = statsParts.join(" ");
|
|
181
|
+
let statsLeftWidth = visibleWidth(statsLeft);
|
|
182
|
+
if (statsLeftWidth > width) {
|
|
183
|
+
statsLeft = truncateToWidth(statsLeft, width, "...");
|
|
184
|
+
statsLeftWidth = visibleWidth(statsLeft);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const modelName = ctx.model?.id ?? "no-model";
|
|
188
|
+
let rightWithoutProvider = modelName;
|
|
189
|
+
if (ctx.model?.reasoning) {
|
|
190
|
+
const thinkingLevel = ctx.thinkingLevel ?? "off";
|
|
191
|
+
rightWithoutProvider =
|
|
192
|
+
thinkingLevel === "off" ? `${modelName} • thinking off` : `${modelName} • ${thinkingLevel}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let rightSide = rightWithoutProvider;
|
|
196
|
+
const minimumPadding = 2;
|
|
197
|
+
if (footerData.getAvailableProviderCount() > 1 && ctx.model) {
|
|
198
|
+
rightSide = `(${ctx.model.provider}) ${rightWithoutProvider}`;
|
|
199
|
+
if (statsLeftWidth + minimumPadding + visibleWidth(rightSide) > width) {
|
|
200
|
+
rightSide = rightWithoutProvider;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const rightSideWidth = visibleWidth(rightSide);
|
|
205
|
+
let statsLine: string;
|
|
206
|
+
if (statsLeftWidth + minimumPadding + rightSideWidth <= width) {
|
|
207
|
+
statsLine = statsLeft + " ".repeat(width - statsLeftWidth - rightSideWidth) + rightSide;
|
|
208
|
+
} else {
|
|
209
|
+
const availableForRight = width - statsLeftWidth - minimumPadding;
|
|
210
|
+
if (availableForRight > 0) {
|
|
211
|
+
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
|
|
212
|
+
statsLine =
|
|
213
|
+
statsLeft +
|
|
214
|
+
" ".repeat(Math.max(0, width - statsLeftWidth - visibleWidth(truncatedRight))) +
|
|
215
|
+
truncatedRight;
|
|
216
|
+
} else {
|
|
217
|
+
statsLine = statsLeft;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const remainder = statsLine.slice(statsLeft.length);
|
|
222
|
+
const lines = [
|
|
223
|
+
truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "...")),
|
|
224
|
+
theme.fg("dim", statsLeft) + theme.fg("dim", remainder),
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const statuses = footerData.getExtensionStatuses();
|
|
228
|
+
if (statuses.size > 0) {
|
|
229
|
+
const statusLine = Array.from(statuses.entries())
|
|
230
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
231
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
232
|
+
.join(" ");
|
|
233
|
+
lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export default function footerTpsExtension(pi: ExtensionAPI) {
|
|
240
|
+
pi.on("session_start", (_event, ctx) => {
|
|
241
|
+
if (ctx.mode !== "tui") return;
|
|
242
|
+
resetTps(false);
|
|
243
|
+
|
|
244
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
245
|
+
requestRender = () => tui.requestRender();
|
|
246
|
+
const unsubscribeBranch = footerData.onBranchChange(requestRender);
|
|
247
|
+
const unsubscribeCwd = pi.events.on("hwcode:working-directory-changed", requestRender);
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
render: (width) => renderFooter(ctx, theme, footerData, width),
|
|
251
|
+
invalidate() {},
|
|
252
|
+
dispose() {
|
|
253
|
+
unsubscribeBranch();
|
|
254
|
+
unsubscribeCwd();
|
|
255
|
+
requestRender = undefined;
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
pi.on("message_start", (event) => {
|
|
262
|
+
if (event.message.role !== "assistant") return;
|
|
263
|
+
resetTps(true);
|
|
264
|
+
requestRender?.();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
pi.on("message_update", (event) => {
|
|
268
|
+
if (event.message.role !== "assistant") return;
|
|
269
|
+
|
|
270
|
+
const now = performance.now();
|
|
271
|
+
if (isGeneratedDelta(event.assistantMessageEvent.type)) {
|
|
272
|
+
tps.firstDeltaAt ??= now;
|
|
273
|
+
tps.streamedChunks += 1;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const reportedTokens = event.message.usage.output;
|
|
277
|
+
if (reportedTokens > 0) tps.reportedTokens = reportedTokens;
|
|
278
|
+
updateTps(now, tps.reportedTokens || undefined);
|
|
279
|
+
requestRender?.();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
pi.on("message_end", (event) => {
|
|
283
|
+
if (event.message.role !== "assistant") return;
|
|
284
|
+
|
|
285
|
+
const now = performance.now();
|
|
286
|
+
const reportedTokens = event.message.usage.output;
|
|
287
|
+
if (reportedTokens > 0) tps.reportedTokens = reportedTokens;
|
|
288
|
+
updateTps(now, tps.reportedTokens || undefined);
|
|
289
|
+
tps.streaming = false;
|
|
290
|
+
requestRender?.();
|
|
291
|
+
});
|
|
292
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export default function hwcodeExtension(pi: ExtensionAPI) {
|
|
4
|
+
pi.registerCommand("hwcode", {
|
|
5
|
+
description: "Show whether the HWCode project profile is active",
|
|
6
|
+
handler: async (_args, ctx) => {
|
|
7
|
+
ctx.ui.notify("HWCode project profile is active.", "info");
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
}
|