pi-agent-extensions 0.4.0 → 0.4.2

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.
@@ -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
- * Supports scrolling for long answers.
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
- // Scroll
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
- this.invalidate();
102
- this.tui.requestRender();
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 - 10);
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 += 10;
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, 120);
133
- const contentWidth = boxWidth - 6; // padding on each side
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 lines: string[] = [];
154
-
155
- // Top border
156
- lines.push(padToWidth(theme.fg("accent", "╭" + horizontalLine(boxWidth - 2) + "╮")));
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
- lines.push(padToWidth(emptyBoxLine()));
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
- // Separator between question and answer
175
- lines.push(padToWidth(theme.fg("border", "├" + horizontalLine(boxWidth - 2) + "")));
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
- // Answer wrap and apply scroll
179
- const answerLines: string[] = [];
180
- for (const paragraph of this.answer.split("\n")) {
181
- if (paragraph.trim() === "") {
182
- answerLines.push("");
183
- } else {
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 headerLines = lines.length;
192
- const footerLines = 3; // separator + hint + bottom border
193
- const maxVisibleAnswerLines = Math.max(1, termHeight - headerLines - footerLines - 2);
194
-
195
- if (this.scrollOffset > Math.max(0, answerLines.length - maxVisibleAnswerLines)) {
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 visibleAnswerLines = answerLines.slice(
207
+ const visibleBodyLines = bodyLines.slice(
200
208
  this.scrollOffset,
201
- this.scrollOffset + maxVisibleAnswerLines,
209
+ this.scrollOffset + maxVisibleBodyLines,
202
210
  );
203
211
 
204
- for (const line of visibleAnswerLines) {
205
- lines.push(padToWidth(boxLine(line)));
206
- }
207
-
208
- // Scroll indicator
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
- // Footer
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
- const hint = theme.fg("dim", "Esc/Space/q to dismiss · ↑↓/j/k scroll · PgUp/PgDn");
219
- lines.push(padToWidth(boxLine(hint)));
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
@@ -1,5 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext, ContextUsage, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
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
- const cwd = this.ctx.cwd;
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
- // --- Directory ---
99
- const cwd = this.ctx.cwd;
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
- // --- Git ---
204
+ const shortDir = this.getShortDir();
111
205
  let gitInfo = "";
112
206
  const branch = this.footerData.getGitBranch();
113
207
  if (branch) {
@@ -117,46 +211,9 @@ class PowerlineFooter implements Component {
117
211
  }
118
212
  }
119
213
 
120
- // --- Model ---
121
- const model = this.ctx.model;
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
- }
141
-
142
- contextInfo = `${ctxColor}${this.formatTokens(used)}/${this.formatTokens(total)} (${pct}%)${RESET}`;
214
+ const modelShort = this.getModelShort();
215
+ const { contextInfo, costInfo } = this.getContextDetails(RESET, GREEN, YELLOW, RED, PEACH, DIM);
143
216
 
144
- // Cost estimation from model pricing
145
- if (model?.cost && contextUsage.usageTokens > 0) {
146
- // Rough estimate: treat usageTokens as input, trailingTokens as recent output
147
- const inputTokens = contextUsage.usageTokens;
148
- const outputTokens = contextUsage.trailingTokens;
149
- const cost =
150
- (inputTokens * model.cost.input) / 1_000_000 +
151
- (outputTokens * model.cost.output) / 1_000_000;
152
-
153
- if (cost > 0.001) {
154
- costInfo = ` ${DIM}|${RESET} ${PEACH}$${cost.toFixed(2)}${RESET}`;
155
- }
156
- }
157
- }
158
-
159
- // --- Session duration ---
160
217
  let durationInfo = "";
161
218
  const durationMs = Date.now() - this.sessionStartTime;
162
219
  if (durationMs > 0) {
@@ -174,7 +231,6 @@ class PowerlineFooter implements Component {
174
231
  durationInfo = ` ${DIM}|${RESET} ${OVERLAY2}${durFmt}${RESET}`;
175
232
  }
176
233
 
177
- // --- Python env ---
178
234
  let envInfo = "";
179
235
  if (process.env.CONDA_DEFAULT_ENV) {
180
236
  envInfo = ` ${SKY}(${process.env.CONDA_DEFAULT_ENV})${RESET}`;
@@ -183,16 +239,31 @@ class PowerlineFooter implements Component {
183
239
  envInfo = ` ${SKY}(${venvName})${RESET}`;
184
240
  }
185
241
 
186
- // --- Time ---
187
242
  const date = new Date();
188
243
  const currentTime = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
189
-
190
- // --- Session name ---
191
- const sessionName = this.ctx.sessionManager.getSessionName();
244
+ const sessionName = this.getSessionName();
192
245
  const sessionInfo = sessionName ? `${MAUVE}[${sessionName}]${RESET} ` : "";
193
246
 
194
- // --- Extension statuses ---
195
- let statusInfo = "";
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
+
196
267
  const statuses = this.footerData.getExtensionStatuses();
197
268
  if (statuses.size > 0) {
198
269
  const parts: string[] = [];
@@ -200,13 +271,13 @@ class PowerlineFooter implements Component {
200
271
  if (text) parts.push(text);
201
272
  }
202
273
  if (parts.length > 0) {
203
- statusInfo = ` ${DIM}|${RESET} ${parts.join(" ")}`;
274
+ line = this.fitToWidth(`${line} ${DIM}|${RESET} ${parts.join(" ")}`, width);
204
275
  }
205
276
  }
206
277
 
207
- const line = `${sessionInfo}${BOLD}${BLUE} ${shortDir}${RESET}${gitInfo} ${DIM}|${RESET} ${OVERLAY2}${modelShort}${RESET} ${contextInfo}${costInfo}${durationInfo}${envInfo}${statusInfo} ${DIM}${currentTime}${RESET}`;
208
-
209
- return [line];
278
+ const fittedLine = this.fitToWidth(line, width);
279
+ this.lastRenderedLine = fittedLine;
280
+ return [fittedLine];
210
281
  }
211
282
  }
212
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-extensions",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Collection of extensions for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {