pi-agent-extensions 0.4.1 → 0.4.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.
|
@@ -80,7 +80,7 @@ Implementing the `/handoff` command as specified in `docs/spec_handoff.md`. Foll
|
|
|
80
80
|
| 3 | LLM extraction | Done | - | extraction.ts with retry logic |
|
|
81
81
|
| 3 | Loader UI | Done | - | progress.ts with ProgressLoader + BorderedLoader fallback |
|
|
82
82
|
| 4 | Editor review | Done | - | Using ctx.ui.editor() |
|
|
83
|
-
| 4 | Session creation | Done | - | Using ctx.newSession() with parent tracking |
|
|
83
|
+
| 4 | Session creation | Done | - | Using ctx.newSession() with parent tracking and withSession |
|
|
84
84
|
| 5 | Non-UI mode | Done | - | Print to stdout |
|
|
85
85
|
| 5 | Documentation | Done | - | docs/handoff.md |
|
|
86
86
|
| 5 | Edge cases | Done | - | Error handling, model resolution |
|
package/docs/dev/handoff/spec.md
CHANGED
|
@@ -124,8 +124,8 @@ When goal is too short or vague (e.g., “continue”, “fix”):
|
|
|
124
124
|
- Optionally prepend `/skill:<name>` if a skill was used last.
|
|
125
125
|
- Optionally prepend a short handoff preamble to set expectations in the new thread.
|
|
126
126
|
5. **Create new session**:
|
|
127
|
-
- `ctx.newSession({ parentSession: currentSessionFile })`
|
|
128
|
-
- `
|
|
127
|
+
- `ctx.newSession({ parentSession: currentSessionFile, withSession })`
|
|
128
|
+
- inside `withSession(newCtx)`, call `newCtx.ui.setEditorText(compiledPrompt)`
|
|
129
129
|
|
|
130
130
|
## Output Schema (LLM → Extension)
|
|
131
131
|
```json
|
|
@@ -441,8 +441,8 @@ The following decisions were made during implementation planning:
|
|
|
441
441
|
- **Why**: Provides valuable context about the codebase state at handoff time
|
|
442
442
|
|
|
443
443
|
#### Session Creation Flow
|
|
444
|
-
- **Decision**: Use `ctx.newSession({ parentSession })`
|
|
445
|
-
- **Why**: Creates proper session lineage tracking; setEditorText prefills the prompt for user
|
|
444
|
+
- **Decision**: Use `ctx.newSession({ parentSession, withSession })` and perform editor setup inside `withSession(newCtx)`
|
|
445
|
+
- **Why**: Creates proper session lineage tracking and avoids using a stale command context after session replacement; `newCtx.ui.setEditorText()` prefills the prompt for user review and submission
|
|
446
446
|
|
|
447
447
|
#### Documentation
|
|
448
448
|
- **Decision**: Create `docs/handoff.md` with usage and examples
|
package/extensions/btw/index.ts
CHANGED
|
@@ -29,10 +29,8 @@ import {
|
|
|
29
29
|
} from "@mariozechner/pi-coding-agent";
|
|
30
30
|
import {
|
|
31
31
|
type Component,
|
|
32
|
-
Container,
|
|
33
32
|
Key,
|
|
34
33
|
matchesKey,
|
|
35
|
-
Text,
|
|
36
34
|
wrapTextWithAnsi,
|
|
37
35
|
visibleWidth,
|
|
38
36
|
type TUI,
|
|
@@ -48,8 +46,8 @@ import {
|
|
|
48
46
|
|
|
49
47
|
|
|
50
48
|
/**
|
|
51
|
-
* Overlay component that displays the BTW answer.
|
|
52
|
-
*
|
|
49
|
+
* Overlay component that displays the BTW question and answer.
|
|
50
|
+
* The full page scrolls together so large input and output remain usable.
|
|
53
51
|
*/
|
|
54
52
|
class BtwOverlay implements Component {
|
|
55
53
|
private tui: TUI;
|
|
@@ -60,6 +58,7 @@ class BtwOverlay implements Component {
|
|
|
60
58
|
private scrollOffset = 0;
|
|
61
59
|
private cachedWidth?: number;
|
|
62
60
|
private cachedLines?: string[];
|
|
61
|
+
private maxScrollOffset = 0;
|
|
63
62
|
|
|
64
63
|
constructor(
|
|
65
64
|
tui: TUI,
|
|
@@ -76,7 +75,6 @@ class BtwOverlay implements Component {
|
|
|
76
75
|
}
|
|
77
76
|
|
|
78
77
|
handleInput(data: string): void {
|
|
79
|
-
// Dismiss overlay
|
|
80
78
|
if (
|
|
81
79
|
matchesKey(data, Key.escape) ||
|
|
82
80
|
matchesKey(data, Key.ctrl("c")) ||
|
|
@@ -87,7 +85,8 @@ class BtwOverlay implements Component {
|
|
|
87
85
|
return;
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
|
|
88
|
+
const pageStep = Math.max(4, (this.tui.height ?? 24) - 8);
|
|
89
|
+
|
|
91
90
|
if (matchesKey(data, Key.up) || data === "k") {
|
|
92
91
|
if (this.scrollOffset > 0) {
|
|
93
92
|
this.scrollOffset--;
|
|
@@ -97,21 +96,21 @@ class BtwOverlay implements Component {
|
|
|
97
96
|
return;
|
|
98
97
|
}
|
|
99
98
|
if (matchesKey(data, Key.down) || data === "j") {
|
|
100
|
-
this.scrollOffset
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
if (this.scrollOffset < this.maxScrollOffset) {
|
|
100
|
+
this.scrollOffset++;
|
|
101
|
+
this.invalidate();
|
|
102
|
+
this.tui.requestRender();
|
|
103
|
+
}
|
|
103
104
|
return;
|
|
104
105
|
}
|
|
105
|
-
|
|
106
|
-
// Page up/down
|
|
107
106
|
if (matchesKey(data, Key.pageUp)) {
|
|
108
|
-
this.scrollOffset = Math.max(0, this.scrollOffset -
|
|
107
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - pageStep);
|
|
109
108
|
this.invalidate();
|
|
110
109
|
this.tui.requestRender();
|
|
111
110
|
return;
|
|
112
111
|
}
|
|
113
112
|
if (matchesKey(data, Key.pageDown)) {
|
|
114
|
-
this.scrollOffset
|
|
113
|
+
this.scrollOffset = Math.min(this.maxScrollOffset, this.scrollOffset + pageStep);
|
|
115
114
|
this.invalidate();
|
|
116
115
|
this.tui.requestRender();
|
|
117
116
|
return;
|
|
@@ -129,13 +128,24 @@ class BtwOverlay implements Component {
|
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
const theme = this.theme;
|
|
132
|
-
const boxWidth = Math.min(width - 2,
|
|
133
|
-
const contentWidth = boxWidth -
|
|
131
|
+
const boxWidth = Math.min(width - 2, 144);
|
|
132
|
+
const contentWidth = Math.max(24, boxWidth - 8);
|
|
134
133
|
|
|
135
134
|
const horizontalLine = (count: number) => "─".repeat(count);
|
|
135
|
+
const meter = (value: number, total: number, size: number) => {
|
|
136
|
+
if (total <= 0) return "░".repeat(size);
|
|
137
|
+
const filled = Math.max(1, Math.min(size, Math.round((value / total) * size)));
|
|
138
|
+
return "█".repeat(filled) + "░".repeat(Math.max(0, size - filled));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const fitInline = (text: string, targetWidth: number): string => {
|
|
142
|
+
if (targetWidth <= 0) return "";
|
|
143
|
+
const wrapped = wrapTextWithAnsi(text, targetWidth);
|
|
144
|
+
return wrapped[0] ?? "";
|
|
145
|
+
};
|
|
136
146
|
|
|
137
147
|
const boxLine = (content: string, leftPad: number = 2): string => {
|
|
138
|
-
const paddedContent = " ".repeat(leftPad) + content;
|
|
148
|
+
const paddedContent = " ".repeat(leftPad) + fitInline(content, Math.max(0, boxWidth - leftPad - 3));
|
|
139
149
|
const contentLen = visibleWidth(paddedContent);
|
|
140
150
|
const rightPad = Math.max(0, boxWidth - contentLen - 2);
|
|
141
151
|
return theme.fg("border", "│") + paddedContent + " ".repeat(rightPad) + theme.fg("border", "│");
|
|
@@ -150,73 +160,69 @@ class BtwOverlay implements Component {
|
|
|
150
160
|
return line + " ".repeat(Math.max(0, width - len));
|
|
151
161
|
};
|
|
152
162
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
// Title
|
|
159
|
-
const title = theme.fg("accent", theme.bold("btw"));
|
|
160
|
-
lines.push(padToWidth(boxLine(title)));
|
|
161
|
-
|
|
162
|
-
// Separator
|
|
163
|
-
lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
|
|
164
|
-
|
|
165
|
-
// Question
|
|
166
|
-
const questionLabel = theme.fg("muted", "Q: ") + theme.fg("text", this.question);
|
|
167
|
-
const wrappedQuestion = wrapTextWithAnsi(questionLabel, contentWidth);
|
|
168
|
-
for (const line of wrappedQuestion) {
|
|
169
|
-
lines.push(padToWidth(boxLine(line)));
|
|
170
|
-
}
|
|
163
|
+
const sectionTitle = (label: string, meta: string) => {
|
|
164
|
+
const text = `${theme.fg("accent", theme.bold(label))}${theme.fg("muted", ` · ${meta}`)}`;
|
|
165
|
+
return boxLine(text, 2);
|
|
166
|
+
};
|
|
171
167
|
|
|
172
|
-
|
|
168
|
+
const pushWrappedSection = (
|
|
169
|
+
bodyLines: string[],
|
|
170
|
+
label: string,
|
|
171
|
+
meta: string,
|
|
172
|
+
text: string,
|
|
173
|
+
prefix: string,
|
|
174
|
+
) => {
|
|
175
|
+
bodyLines.push(sectionTitle(label, meta));
|
|
176
|
+
bodyLines.push(emptyBoxLine());
|
|
177
|
+
for (const paragraph of text.split("\n")) {
|
|
178
|
+
if (paragraph.trim() === "") {
|
|
179
|
+
bodyLines.push(boxLine("", 2));
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const wrapped = wrapTextWithAnsi(paragraph, Math.max(12, contentWidth - visibleWidth(prefix)));
|
|
183
|
+
for (const line of wrapped) {
|
|
184
|
+
bodyLines.push(boxLine(`${prefix}${line}`, 2));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
173
188
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
lines.push(padToWidth(emptyBoxLine()));
|
|
189
|
+
const questionWords = this.question.trim().split(/\s+/).filter(Boolean).length;
|
|
190
|
+
const answerParagraphs = this.answer.split("\n").filter((line) => line.trim() !== "").length;
|
|
177
191
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const wrapped = wrapTextWithAnsi(paragraph, contentWidth);
|
|
185
|
-
answerLines.push(...wrapped);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
192
|
+
const bodyLines: string[] = [];
|
|
193
|
+
pushWrappedSection(bodyLines, "Question", `${questionWords} words`, this.question, theme.fg("muted", "› "));
|
|
194
|
+
bodyLines.push(emptyBoxLine());
|
|
195
|
+
bodyLines.push(boxLine(theme.fg("border", horizontalLine(Math.max(10, contentWidth - 6))), 3));
|
|
196
|
+
bodyLines.push(emptyBoxLine());
|
|
197
|
+
pushWrappedSection(bodyLines, "Answer", `${answerParagraphs} paragraphs`, this.answer, "");
|
|
188
198
|
|
|
189
|
-
// Clamp scroll offset
|
|
190
199
|
const termHeight = this.tui.height ?? 24;
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.scrollOffset = Math.max(0, answerLines.length - maxVisibleAnswerLines);
|
|
200
|
+
const fixedLines = 7;
|
|
201
|
+
const maxVisibleBodyLines = Math.max(4, termHeight - fixedLines);
|
|
202
|
+
this.maxScrollOffset = Math.max(0, bodyLines.length - maxVisibleBodyLines);
|
|
203
|
+
if (this.scrollOffset > this.maxScrollOffset) {
|
|
204
|
+
this.scrollOffset = this.maxScrollOffset;
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
const
|
|
207
|
+
const visibleBodyLines = bodyLines.slice(
|
|
200
208
|
this.scrollOffset,
|
|
201
|
-
this.scrollOffset +
|
|
209
|
+
this.scrollOffset + maxVisibleBodyLines,
|
|
202
210
|
);
|
|
203
211
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (answerLines.length > maxVisibleAnswerLines) {
|
|
210
|
-
const scrollInfo = theme.fg("dim", `[${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisibleAnswerLines, answerLines.length)}/${answerLines.length}]`);
|
|
211
|
-
lines.push(padToWidth(boxLine(scrollInfo)));
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
lines.push(padToWidth(emptyBoxLine()));
|
|
212
|
+
const scrollCurrent = Math.min(bodyLines.length, this.scrollOffset + maxVisibleBodyLines);
|
|
213
|
+
const scrollInfo = this.maxScrollOffset > 0
|
|
214
|
+
? `${this.scrollOffset + 1}-${scrollCurrent}/${bodyLines.length}`
|
|
215
|
+
: "full";
|
|
216
|
+
const progress = meter(scrollCurrent, Math.max(bodyLines.length, 1), 10);
|
|
215
217
|
|
|
216
|
-
|
|
218
|
+
const lines: string[] = [];
|
|
219
|
+
lines.push(padToWidth(theme.fg("accent", "╭" + horizontalLine(boxWidth - 2) + "╮")));
|
|
220
|
+
lines.push(padToWidth(boxLine(`${theme.fg("accent", theme.bold("BTW"))}${theme.fg("muted", " · side question")}`, 2)));
|
|
221
|
+
lines.push(padToWidth(boxLine(theme.fg("dim", "An editorial-style reading pane for long prompts and answers."), 2)));
|
|
217
222
|
lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
|
|
218
|
-
|
|
219
|
-
lines.push(padToWidth(
|
|
223
|
+
lines.push(...visibleBodyLines.map(padToWidth));
|
|
224
|
+
lines.push(padToWidth(theme.fg("accent", "├" + horizontalLine(boxWidth - 2) + "┤")));
|
|
225
|
+
lines.push(padToWidth(boxLine(`${theme.fg("accent", progress)} ${theme.fg("muted", scrollInfo)}${theme.fg("dim", " · Esc dismiss · ↑↓ / j k · PgUp PgDn")}`, 2)));
|
|
220
226
|
lines.push(padToWidth(theme.fg("accent", "╰" + horizontalLine(boxWidth - 2) + "╯")));
|
|
221
227
|
|
|
222
228
|
this.cachedWidth = width;
|
|
@@ -348,6 +354,14 @@ async function runBtwCommand(
|
|
|
348
354
|
// Step 2: Show the answer in an overlay
|
|
349
355
|
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
350
356
|
return new BtwOverlay(tui, theme, question, answerResult, done);
|
|
357
|
+
}, {
|
|
358
|
+
overlay: true,
|
|
359
|
+
overlayOptions: {
|
|
360
|
+
anchor: "center",
|
|
361
|
+
width: "92%",
|
|
362
|
+
maxHeight: "92%",
|
|
363
|
+
margin: { top: 1, bottom: 1, left: 1, right: 1 },
|
|
364
|
+
},
|
|
351
365
|
});
|
|
352
366
|
|
|
353
367
|
// Nothing persisted — fully ephemeral
|
|
@@ -196,19 +196,21 @@ async function runHandoffCommand(
|
|
|
196
196
|
return;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
// Create new session with parent tracking
|
|
199
|
+
// Create new session with parent tracking.
|
|
200
|
+
// Any post-session-replacement work must happen inside withSession,
|
|
201
|
+
// using the fresh ctx passed by Pi.
|
|
200
202
|
const newSessionResult = await ctx.newSession({
|
|
201
203
|
parentSession: currentSessionFile,
|
|
204
|
+
withSession: async (newCtx) => {
|
|
205
|
+
newCtx.ui.setEditorText(editedPrompt);
|
|
206
|
+
newCtx.ui.notify("Handoff ready. Press Enter to send.", "info");
|
|
207
|
+
},
|
|
202
208
|
});
|
|
203
209
|
|
|
204
210
|
if (newSessionResult.cancelled) {
|
|
205
211
|
ctx.ui.notify("New session cancelled", "info");
|
|
206
212
|
return;
|
|
207
213
|
}
|
|
208
|
-
|
|
209
|
-
// Set the edited prompt in the editor for submission
|
|
210
|
-
ctx.ui.setEditorText(editedPrompt);
|
|
211
|
-
ctx.ui.notify("Handoff ready. Press Enter to send.", "info");
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext,
|
|
2
|
-
import { type Component, type Theme, type TUI, visibleWidth } from "@mariozechner/pi-tui";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { type Component, type Theme, type TUI, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
3
3
|
import * as child_process from "child_process";
|
|
4
4
|
|
|
5
5
|
class PowerlineFooter implements Component {
|
|
@@ -9,6 +9,14 @@ class PowerlineFooter implements Component {
|
|
|
9
9
|
private ctx: ExtensionContext;
|
|
10
10
|
private interval?: ReturnType<typeof setInterval>;
|
|
11
11
|
private sessionStartTime: number;
|
|
12
|
+
private disposed = false;
|
|
13
|
+
private cwd: string;
|
|
14
|
+
private lastRenderedLine = "";
|
|
15
|
+
private lastShortDir: string;
|
|
16
|
+
private lastModelShort = "unknown";
|
|
17
|
+
private lastContextInfo = "";
|
|
18
|
+
private lastCostInfo = "";
|
|
19
|
+
private lastSessionName = "";
|
|
12
20
|
|
|
13
21
|
// Cached git state (updated async every 10s)
|
|
14
22
|
private gitStatusExtras: string = "";
|
|
@@ -19,33 +27,40 @@ class PowerlineFooter implements Component {
|
|
|
19
27
|
this.footerData = footerData;
|
|
20
28
|
this.ctx = ctx;
|
|
21
29
|
this.sessionStartTime = Date.now();
|
|
30
|
+
this.cwd = ctx.cwd;
|
|
31
|
+
this.lastShortDir = this.formatShortDir(this.cwd);
|
|
22
32
|
|
|
23
33
|
this.fetchAsyncData();
|
|
24
34
|
|
|
25
35
|
this.interval = setInterval(() => {
|
|
36
|
+
if (this.disposed) return;
|
|
26
37
|
this.fetchAsyncData();
|
|
27
38
|
this.tui.requestRender();
|
|
28
39
|
}, 10000);
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
dispose() {
|
|
43
|
+
this.disposed = true;
|
|
32
44
|
if (this.interval) {
|
|
33
45
|
clearInterval(this.interval);
|
|
46
|
+
this.interval = undefined;
|
|
34
47
|
}
|
|
35
48
|
}
|
|
36
49
|
|
|
37
50
|
private fetchAsyncData() {
|
|
38
|
-
|
|
51
|
+
if (this.disposed) return;
|
|
52
|
+
const cwd = this.cwd;
|
|
39
53
|
const branch = this.footerData.getGitBranch();
|
|
40
54
|
|
|
41
55
|
if (branch) {
|
|
42
56
|
child_process.exec("git --no-optional-locks status --porcelain", { encoding: "utf8", cwd }, (err, stdout) => {
|
|
43
|
-
if (err) return;
|
|
57
|
+
if (this.disposed || err) return;
|
|
44
58
|
const staged = (stdout.match(/^[AMDRC]/gm) || []).length;
|
|
45
59
|
const unstaged = (stdout.match(/^.[MD]/gm) || []).length;
|
|
46
60
|
const untracked = (stdout.match(/^\?\?/gm) || []).length;
|
|
47
61
|
|
|
48
62
|
child_process.exec("git --no-optional-locks rev-list --count --left-right @{u}...HEAD", { encoding: "utf8", cwd }, (err2, revListOut) => {
|
|
63
|
+
if (this.disposed) return;
|
|
49
64
|
let ahead = 0;
|
|
50
65
|
let behind = 0;
|
|
51
66
|
if (!err2 && revListOut && revListOut.includes("\t")) {
|
|
@@ -72,6 +87,93 @@ class PowerlineFooter implements Component {
|
|
|
72
87
|
invalidate() {}
|
|
73
88
|
handleInput(_data: string) {}
|
|
74
89
|
|
|
90
|
+
private formatShortDir(cwd: string): string {
|
|
91
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
92
|
+
let shortDir = cwd;
|
|
93
|
+
if (homeDir && shortDir.startsWith(homeDir)) {
|
|
94
|
+
shortDir = "~" + shortDir.slice(homeDir.length);
|
|
95
|
+
}
|
|
96
|
+
const parts = shortDir.split("/");
|
|
97
|
+
if (parts.length > 4) {
|
|
98
|
+
shortDir = parts.slice(parts.length - 4).join("/");
|
|
99
|
+
}
|
|
100
|
+
return shortDir;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private getShortDir(): string {
|
|
104
|
+
try {
|
|
105
|
+
this.lastShortDir = this.formatShortDir(this.ctx.cwd);
|
|
106
|
+
} catch {}
|
|
107
|
+
return this.lastShortDir;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private getModelShort(): string {
|
|
111
|
+
try {
|
|
112
|
+
const model = this.ctx.model;
|
|
113
|
+
this.lastModelShort = model?.name || model?.id || "unknown";
|
|
114
|
+
} catch {}
|
|
115
|
+
return this.lastModelShort;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private getContextDetails(reset: string, green: string, yellow: string, red: string, peach: string, dim: string) {
|
|
119
|
+
try {
|
|
120
|
+
const model = this.ctx.model;
|
|
121
|
+
const contextUsage = this.ctx.getContextUsage();
|
|
122
|
+
let contextInfo = "";
|
|
123
|
+
let costInfo = "";
|
|
124
|
+
|
|
125
|
+
if (contextUsage) {
|
|
126
|
+
const used = contextUsage.tokens;
|
|
127
|
+
const total = contextUsage.contextWindow;
|
|
128
|
+
const pct = contextUsage.percent;
|
|
129
|
+
if (used !== null && pct !== null) {
|
|
130
|
+
const remaining = 100 - pct;
|
|
131
|
+
|
|
132
|
+
let ctxColor = green;
|
|
133
|
+
if (remaining < 20) {
|
|
134
|
+
ctxColor = red;
|
|
135
|
+
} else if (remaining < 50) {
|
|
136
|
+
ctxColor = yellow;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pctDisplay = pct % 1 === 0 ? pct.toFixed(0) : pct.toFixed(1);
|
|
140
|
+
contextInfo = `${ctxColor}${this.formatTokens(used)}/${this.formatTokens(total)} (${pctDisplay}%)${reset}`;
|
|
141
|
+
} else {
|
|
142
|
+
contextInfo = `${green}?/${this.formatTokens(total)}${reset}`;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let totalCost = 0;
|
|
147
|
+
for (const entry of this.ctx.sessionManager.getEntries()) {
|
|
148
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
149
|
+
totalCost += entry.message.usage?.cost?.total ?? 0;
|
|
150
|
+
}
|
|
151
|
+
const usingSubscription = model ? this.ctx.modelRegistry.isUsingOAuth(model) : false;
|
|
152
|
+
if (totalCost > 0 || usingSubscription) {
|
|
153
|
+
const formattedCost = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`;
|
|
154
|
+
costInfo = ` ${dim}|${reset} ${peach}${formattedCost}${reset}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.lastContextInfo = contextInfo;
|
|
158
|
+
this.lastCostInfo = costInfo;
|
|
159
|
+
} catch {}
|
|
160
|
+
|
|
161
|
+
return { contextInfo: this.lastContextInfo, costInfo: this.lastCostInfo };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private getSessionName(): string {
|
|
165
|
+
try {
|
|
166
|
+
this.lastSessionName = this.ctx.sessionManager.getSessionName() ?? "";
|
|
167
|
+
} catch {}
|
|
168
|
+
return this.lastSessionName;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fitToWidth(line: string, width: number): string {
|
|
172
|
+
if (width <= 0) return "";
|
|
173
|
+
if (visibleWidth(line) <= width) return line;
|
|
174
|
+
return truncateToWidth(line, width, "...");
|
|
175
|
+
}
|
|
176
|
+
|
|
75
177
|
private formatTokens(num: number): string {
|
|
76
178
|
if (num >= 1_000_000) {
|
|
77
179
|
return (num / 1_000_000).toFixed(1) + "M";
|
|
@@ -95,19 +197,11 @@ class PowerlineFooter implements Component {
|
|
|
95
197
|
const PEACH = "\x1b[38;5;216m";
|
|
96
198
|
const OVERLAY2 = "\x1b[38;5;103m";
|
|
97
199
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
101
|
-
let shortDir = cwd;
|
|
102
|
-
if (homeDir && shortDir.startsWith(homeDir)) {
|
|
103
|
-
shortDir = "~" + shortDir.slice(homeDir.length);
|
|
104
|
-
}
|
|
105
|
-
const parts = shortDir.split("/");
|
|
106
|
-
if (parts.length > 4) {
|
|
107
|
-
shortDir = parts.slice(parts.length - 4).join("/");
|
|
200
|
+
if (this.disposed) {
|
|
201
|
+
return [this.fitToWidth(this.lastRenderedLine || "", width)];
|
|
108
202
|
}
|
|
109
203
|
|
|
110
|
-
|
|
204
|
+
const shortDir = this.getShortDir();
|
|
111
205
|
let gitInfo = "";
|
|
112
206
|
const branch = this.footerData.getGitBranch();
|
|
113
207
|
if (branch) {
|
|
@@ -117,49 +211,9 @@ class PowerlineFooter implements Component {
|
|
|
117
211
|
}
|
|
118
212
|
}
|
|
119
213
|
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
const modelShort = model?.name || model?.id || "unknown";
|
|
123
|
-
|
|
124
|
-
// --- Context usage ---
|
|
125
|
-
const contextUsage = this.ctx.getContextUsage();
|
|
126
|
-
let contextInfo = "";
|
|
127
|
-
let costInfo = "";
|
|
128
|
-
|
|
129
|
-
if (contextUsage) {
|
|
130
|
-
const used = contextUsage.tokens;
|
|
131
|
-
const total = contextUsage.contextWindow;
|
|
132
|
-
const pct = contextUsage.percent;
|
|
133
|
-
const remaining = 100 - pct;
|
|
134
|
-
|
|
135
|
-
let ctxColor = GREEN;
|
|
136
|
-
if (remaining < 20) {
|
|
137
|
-
ctxColor = RED;
|
|
138
|
-
} else if (remaining < 50) {
|
|
139
|
-
ctxColor = YELLOW;
|
|
140
|
-
}
|
|
214
|
+
const modelShort = this.getModelShort();
|
|
215
|
+
const { contextInfo, costInfo } = this.getContextDetails(RESET, GREEN, YELLOW, RED, PEACH, DIM);
|
|
141
216
|
|
|
142
|
-
const pctDisplay = pct % 1 === 0 ? pct.toFixed(0) : pct.toFixed(1);
|
|
143
|
-
contextInfo = `${ctxColor}${this.formatTokens(used)}/${this.formatTokens(total)} (${pctDisplay}%)${RESET}`;
|
|
144
|
-
|
|
145
|
-
// Cost estimation from model pricing
|
|
146
|
-
if (model?.cost && contextUsage.usageTokens > 0) {
|
|
147
|
-
// Rough estimate: treat usageTokens as input, trailingTokens as recent output
|
|
148
|
-
const inputTokens = contextUsage.usageTokens;
|
|
149
|
-
const outputTokens = contextUsage.trailingTokens;
|
|
150
|
-
const cost =
|
|
151
|
-
(inputTokens * model.cost.input) / 1_000_000 +
|
|
152
|
-
(outputTokens * model.cost.output) / 1_000_000;
|
|
153
|
-
|
|
154
|
-
if (cost >= 0.005) {
|
|
155
|
-
costInfo = ` ${DIM}|${RESET} ${PEACH}$${cost.toFixed(2)}${RESET}`;
|
|
156
|
-
} else if (cost > 0) {
|
|
157
|
-
costInfo = ` ${DIM}|${RESET} ${PEACH}<$0.01${RESET}`;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// --- Session duration ---
|
|
163
217
|
let durationInfo = "";
|
|
164
218
|
const durationMs = Date.now() - this.sessionStartTime;
|
|
165
219
|
if (durationMs > 0) {
|
|
@@ -177,7 +231,6 @@ class PowerlineFooter implements Component {
|
|
|
177
231
|
durationInfo = ` ${DIM}|${RESET} ${OVERLAY2}${durFmt}${RESET}`;
|
|
178
232
|
}
|
|
179
233
|
|
|
180
|
-
// --- Python env ---
|
|
181
234
|
let envInfo = "";
|
|
182
235
|
if (process.env.CONDA_DEFAULT_ENV) {
|
|
183
236
|
envInfo = ` ${SKY}(${process.env.CONDA_DEFAULT_ENV})${RESET}`;
|
|
@@ -186,16 +239,31 @@ class PowerlineFooter implements Component {
|
|
|
186
239
|
envInfo = ` ${SKY}(${venvName})${RESET}`;
|
|
187
240
|
}
|
|
188
241
|
|
|
189
|
-
// --- Time ---
|
|
190
242
|
const date = new Date();
|
|
191
243
|
const currentTime = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
192
|
-
|
|
193
|
-
// --- Session name ---
|
|
194
|
-
const sessionName = this.ctx.sessionManager.getSessionName();
|
|
244
|
+
const sessionName = this.getSessionName();
|
|
195
245
|
const sessionInfo = sessionName ? `${MAUVE}[${sessionName}]${RESET} ` : "";
|
|
196
246
|
|
|
197
|
-
|
|
198
|
-
|
|
247
|
+
const left = `${sessionInfo}${BOLD}${BLUE} ${shortDir}${RESET}${gitInfo}`;
|
|
248
|
+
const right = `${OVERLAY2}${modelShort}${RESET} ${contextInfo}${costInfo}${durationInfo}${envInfo} ${DIM}${currentTime}${RESET}`;
|
|
249
|
+
const leftWidth = visibleWidth(left);
|
|
250
|
+
const rightWidth = visibleWidth(right);
|
|
251
|
+
const minPadding = 1;
|
|
252
|
+
let line = "";
|
|
253
|
+
|
|
254
|
+
if (leftWidth + minPadding + rightWidth <= width) {
|
|
255
|
+
line = left + " ".repeat(width - leftWidth - rightWidth) + right;
|
|
256
|
+
} else {
|
|
257
|
+
const availableForLeft = Math.max(0, width - rightWidth - minPadding);
|
|
258
|
+
if (availableForLeft > 0) {
|
|
259
|
+
const truncatedLeft = this.fitToWidth(left, availableForLeft);
|
|
260
|
+
const truncatedLeftWidth = visibleWidth(truncatedLeft);
|
|
261
|
+
line = truncatedLeft + " ".repeat(Math.max(minPadding, width - truncatedLeftWidth - rightWidth)) + right;
|
|
262
|
+
} else {
|
|
263
|
+
line = this.fitToWidth(right, width);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
199
267
|
const statuses = this.footerData.getExtensionStatuses();
|
|
200
268
|
if (statuses.size > 0) {
|
|
201
269
|
const parts: string[] = [];
|
|
@@ -203,13 +271,13 @@ class PowerlineFooter implements Component {
|
|
|
203
271
|
if (text) parts.push(text);
|
|
204
272
|
}
|
|
205
273
|
if (parts.length > 0) {
|
|
206
|
-
|
|
274
|
+
line = this.fitToWidth(`${line} ${DIM}|${RESET} ${parts.join(" ")}`, width);
|
|
207
275
|
}
|
|
208
276
|
}
|
|
209
277
|
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
return [
|
|
278
|
+
const fittedLine = this.fitToWidth(line, width);
|
|
279
|
+
this.lastRenderedLine = fittedLine;
|
|
280
|
+
return [fittedLine];
|
|
213
281
|
}
|
|
214
282
|
}
|
|
215
283
|
|
|
@@ -491,7 +491,7 @@ export default function whimsicalExtension(pi: ExtensionAPI) {
|
|
|
491
491
|
|
|
492
492
|
pi.on("turn_start", async (_event, ctx) => {
|
|
493
493
|
await ensureStateLoaded();
|
|
494
|
-
if (!state.enabled) return;
|
|
494
|
+
if (!state.enabled || !ctx.hasUI) return;
|
|
495
495
|
|
|
496
496
|
stopActiveTicker();
|
|
497
497
|
|
|
@@ -517,6 +517,11 @@ export default function whimsicalExtension(pi: ExtensionAPI) {
|
|
|
517
517
|
}, SPINNER_FRAME_INTERVAL_MS);
|
|
518
518
|
});
|
|
519
519
|
|
|
520
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
521
|
+
stopActiveTicker();
|
|
522
|
+
if (ctx.hasUI) ctx.ui.setWorkingMessage();
|
|
523
|
+
});
|
|
524
|
+
|
|
520
525
|
// Keep running until the next turn_start replaces cadence.
|
|
521
526
|
pi.on("turn_end", async () => {
|
|
522
527
|
// no-op by design
|