@narumitw/pi-statusline 0.50.3 → 0.51.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-statusline",
3
- "version": "0.50.3",
3
+ "version": "0.51.0",
4
4
  "description": "Pi extension that replaces the footer with an information-rich statusline.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,6 +17,7 @@
17
17
  "dist",
18
18
  "docs",
19
19
  "README.md",
20
+ "CHANGELOG.md",
20
21
  "LICENSE"
21
22
  ],
22
23
  "pi": {
@@ -36,9 +37,9 @@
36
37
  "@earendil-works/pi-tui": "*"
37
38
  },
38
39
  "devDependencies": {
39
- "@earendil-works/pi-coding-agent": "1.4.0",
40
- "@earendil-works/pi-tui": "1.4.0",
41
- "@types/node": "26.6.5",
40
+ "@earendil-works/pi-coding-agent": "1.5.0",
41
+ "@earendil-works/pi-tui": "1.5.0",
42
+ "@types/node": "26.6.6",
42
43
  "esbuild": "0.28.2",
43
44
  "typescript": "7.0.2"
44
45
  },
@@ -48,6 +49,6 @@
48
49
  "directory": "packages/pi-statusline"
49
50
  },
50
51
  "dependencies": {
51
- "@narumitw/pi-tui-kit": "^0.68.1"
52
+ "@narumitw/pi-tui-kit": "^0.69.0"
52
53
  }
53
54
  }
package/src/commands.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  SEGMENT_NAMES,
31
31
  type SegmentName,
32
32
  } from "./types.js";
33
+ import { summarizeFooterUsage } from "./usage.js";
33
34
 
34
35
  const EDIT_SETTINGS_LABEL = "Edit settings JSON";
35
36
  const SEGMENT_VIEWPORT_SIZE = 6;
@@ -477,6 +478,7 @@ async function chooseSegments(ctx: ExtensionCommandContext, options: StatuslineC
477
478
  updateThemedText();
478
479
 
479
480
  return {
481
+ capturesViewportInput: true,
480
482
  render: (width: number) => container.render(width),
481
483
  invalidate() {
482
484
  container.invalidate();
@@ -816,6 +818,8 @@ function editableSettings(
816
818
  function showStatus(ctx: ExtensionCommandContext, options: StatuslineCommandOptions) {
817
819
  if (!canNotify(ctx)) return;
818
820
  const loaded = options.getLoaded();
821
+ const usage = summarizeFooterUsage(ctx.sessionManager.getEntries());
822
+ const costSource = usage.costTotalFromProvider ? "provider-reported" : "unconfirmed";
819
823
  const diagnostics = loaded.diagnostics
820
824
  .slice(0, 5)
821
825
  .map((item) => `${item.path || "root"}: ${item.message}`)
@@ -830,6 +834,7 @@ function showStatus(ctx: ExtensionCommandContext, options: StatuslineCommandOpti
830
834
  `separator: ${loaded.config.separator}`,
831
835
  `information: ${inferInformationProfile(loaded.config.segments)}`,
832
836
  `segments: ${loaded.config.segments.join(", ") || "none"}`,
837
+ `session cost: $${usage.cost.toFixed(usage.cost >= 1 ? 2 : 3)} (${costSource})`,
833
838
  diagnostics ? `warnings: ${diagnostics}` : "warnings: none",
834
839
  ].join("\n"),
835
840
  loaded.diagnostics.length > 0 ? "warning" : "info",
@@ -842,7 +847,7 @@ function showHelp(ctx: ExtensionCommandContext, settingsPath: string) {
842
847
  [
843
848
  "/statusline — open Appearance, Information, Advanced, Status, and Help",
844
849
  "/statusline settings — edit and apply JSON",
845
- "/statusline status — show source, path, information level, and warnings",
850
+ "/statusline status — show settings, session cost source, and warnings",
846
851
  "/statusline help — show this help",
847
852
  "Menu actions: Appearance, Information, Advanced, Status, Help.",
848
853
  "Information levels: minimal, balanced, detailed; any other segment array is custom.",
@@ -0,0 +1,208 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const UPDATE_CHANNEL = "extension-status:v1:update";
4
+ const REQUEST_CHANNEL = "extension-status:v1:request";
5
+ const ACTIVITIES = new Set([
6
+ "idle",
7
+ "checking",
8
+ "working",
9
+ "pushing",
10
+ "pulling",
11
+ "resolving",
12
+ "retry",
13
+ "blocked",
14
+ "local-change",
15
+ "remote-change",
16
+ "review",
17
+ ] as const);
18
+
19
+ export type ExtensionStatusActivity =
20
+ | "idle"
21
+ | "checking"
22
+ | "working"
23
+ | "pushing"
24
+ | "pulling"
25
+ | "resolving"
26
+ | "retry"
27
+ | "blocked"
28
+ | "local-change"
29
+ | "remote-change"
30
+ | "review";
31
+
32
+ export interface ExtensionStatusPresentation {
33
+ readonly value: string;
34
+ readonly activity: ExtensionStatusActivity;
35
+ readonly reason?: string;
36
+ }
37
+
38
+ export interface ExtensionStatusMetadata {
39
+ get(key: string, value: string): ExtensionStatusPresentation | undefined;
40
+ reconcile(statuses: ReadonlyMap<string, string>): void;
41
+ dispose(): void;
42
+ }
43
+
44
+ function parseMetadata(data: Record<string, unknown>): {
45
+ presentation?: ExtensionStatusPresentation;
46
+ withdrawal: boolean;
47
+ } {
48
+ if (Array.isArray(data)) return { withdrawal: false };
49
+ // Capture fields once: accessors and proxy traps can synchronously replace the current state.
50
+ const { version, value, activity, reason } = data;
51
+ const hasValue = Object.hasOwn(data, "value");
52
+ const hasActivity = "activity" in data;
53
+ const hasReason = "reason" in data;
54
+ const withdrawal = version === 1 && hasValue && value === undefined && !hasActivity && !hasReason;
55
+ if (
56
+ version !== 1 ||
57
+ !hasValue ||
58
+ typeof value !== "string" ||
59
+ value.length === 0 ||
60
+ !ACTIVITIES.has(activity as ExtensionStatusActivity)
61
+ )
62
+ return { withdrawal };
63
+ if (activity === "retry" || activity === "blocked") {
64
+ // JavaScript's $ also matches before a final newline; require the complete safe label.
65
+ if (typeof reason !== "string" || /^[a-z][a-z0-9-]{0,31}$/.exec(reason)?.[0] !== reason) return { withdrawal };
66
+ return { presentation: Object.freeze({ value, activity, reason }), withdrawal };
67
+ }
68
+ if (hasReason) return { withdrawal };
69
+ return { presentation: Object.freeze({ value, activity: activity as ExtensionStatusActivity }), withdrawal };
70
+ }
71
+
72
+ /** Optional display metadata; the raw Pi status map remains authoritative. */
73
+ export function subscribeExtensionStatusMetadata(
74
+ events: ExtensionAPI["events"],
75
+ sessionManager: object,
76
+ getStatuses: () => ReadonlyMap<string, string>,
77
+ requestRender: () => void,
78
+ ): ExtensionStatusMetadata {
79
+ const records = new Map<string, ExtensionStatusPresentation>();
80
+ const liveKeys = new Set<string>();
81
+ const requestId = {};
82
+ let observedStatuses = new Map<string, string>();
83
+ try {
84
+ observedStatuses = new Map(getStatuses());
85
+ } catch {
86
+ // A raw-map read failure must leave the ordinary footer available.
87
+ }
88
+ let disposed = false;
89
+ let revision = 0;
90
+ const keyRevisions = new Map<string, number>();
91
+ const isCurrent = (key: string, owner: number) => !disposed && keyRevisions.get(key) === owner;
92
+ let unsubscribe: (() => void) | undefined;
93
+
94
+ const reconcile = (statuses: ReadonlyMap<string, string>, ownerKey?: string) => {
95
+ if (disposed) return;
96
+ const before = revision;
97
+ const snapshot = new Map(statuses);
98
+ if (disposed || revision !== before) return;
99
+ for (const [key, value] of observedStatuses) {
100
+ if (snapshot.get(key) === value) continue;
101
+ records.delete(key);
102
+ // An observed raw replacement must also fence off a delayed same-value snapshot.
103
+ liveKeys.add(key);
104
+ if (key !== ownerKey) keyRevisions.set(key, ++revision);
105
+ }
106
+ for (const key of snapshot.keys()) {
107
+ if (observedStatuses.has(key)) continue;
108
+ liveKeys.add(key);
109
+ if (key !== ownerKey) keyRevisions.set(key, ++revision);
110
+ }
111
+ observedStatuses = snapshot;
112
+ };
113
+
114
+ const metadata: ExtensionStatusMetadata = {
115
+ get(key, value) {
116
+ const presentation = records.get(key);
117
+ return presentation?.value === value ? presentation : undefined;
118
+ },
119
+ reconcile(statuses) {
120
+ reconcile(statuses);
121
+ },
122
+ dispose() {
123
+ if (disposed) return;
124
+ disposed = true;
125
+ revision++;
126
+ keyRevisions.clear();
127
+ records.clear();
128
+ liveKeys.clear();
129
+ observedStatuses.clear();
130
+ try {
131
+ unsubscribe?.();
132
+ } catch {
133
+ // Optional metadata cleanup must not prevent footer disposal.
134
+ }
135
+ unsubscribe = undefined;
136
+ },
137
+ };
138
+
139
+ const receive = (payload: unknown) => {
140
+ if (disposed || !payload || typeof payload !== "object") return;
141
+ const entered = revision;
142
+ let key: string;
143
+ let snapshot: boolean;
144
+ const data = payload as Record<string, unknown>;
145
+ try {
146
+ const rawKey = data.key;
147
+ const manager = data.sessionManager;
148
+ snapshot = "requestId" in data;
149
+ const replyId = snapshot ? data.requestId : undefined;
150
+ if (disposed || manager !== sessionManager || typeof rawKey !== "string" || !rawKey) return;
151
+ key = rawKey;
152
+ if ((keyRevisions.get(key) ?? 0) > entered) return;
153
+ if (snapshot && (replyId !== requestId || liveKeys.has(key))) return;
154
+ } catch {
155
+ return;
156
+ }
157
+
158
+ // Only an accepted envelope for this key can supersede its in-flight update.
159
+ const owner = ++revision;
160
+ keyRevisions.set(key, owner);
161
+ if (!snapshot) liveKeys.add(key);
162
+ const previous = records.get(key);
163
+ try {
164
+ const { presentation, withdrawal } = parseMetadata(data);
165
+ if (!isCurrent(key, owner)) return;
166
+ // Read after payload accessors, and copy before touching the cache: either operation can reenter.
167
+ const reading = revision;
168
+ const statuses = new Map(getStatuses());
169
+ if (!isCurrent(key, owner)) return;
170
+ if (revision !== reading) {
171
+ // An unstable read must not retain old hidden state or erase a newer sibling record.
172
+ records.delete(key);
173
+ } else {
174
+ reconcile(statuses, key);
175
+ if (!isCurrent(key, owner) || (snapshot && liveKeys.has(key))) return;
176
+ if (presentation && statuses.get(key) === presentation.value) {
177
+ records.set(key, presentation);
178
+ } else if (!snapshot || withdrawal) {
179
+ // Invalid live data and explicit withdrawals restore the existing raw-text path.
180
+ records.delete(key);
181
+ }
182
+ }
183
+ } catch {
184
+ if (!isCurrent(key, owner)) return;
185
+ if (!snapshot) records.delete(key);
186
+ }
187
+ if (isCurrent(key, owner) && previous !== records.get(key)) {
188
+ try {
189
+ requestRender();
190
+ } catch {
191
+ // A closed UI must not turn optional metadata delivery into an event-bus error.
192
+ }
193
+ }
194
+ };
195
+
196
+ try {
197
+ unsubscribe = events.on(UPDATE_CHANNEL, receive);
198
+ } catch {
199
+ // Metadata support must not prevent the footer from loading.
200
+ return metadata;
201
+ }
202
+ try {
203
+ events.emit(REQUEST_CHANNEL, { version: 1, sessionManager, requestId });
204
+ } catch {
205
+ // Keep the live subscription usable when a snapshot request fails.
206
+ }
207
+ return metadata;
208
+ }
@@ -1,7 +1,9 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { getAgentDir, type Theme, type ThemeColor } from "@earendil-works/pi-coding-agent";
4
- import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
+ import { sanitizeTerminalText } from "@narumitw/pi-tui-kit/terminal-text";
6
+ import type { ExtensionStatusMetadata, ExtensionStatusPresentation } from "./extension-status-metadata.js";
5
7
  import { powerlineExtensionSeparator } from "./powerline.js";
6
8
  import { DEFAULT_EXTENSION_STATUS_ICONS } from "./settings.js";
7
9
  import type { StatuslineConfig } from "./types.js";
@@ -10,6 +12,7 @@ export type ExtensionStatusIconAliasMap = ReadonlyMap<string, readonly string[]>
10
12
  export interface ExtensionStatusRuntime {
11
13
  duplicateExtensions: string[];
12
14
  extensionStatusIconAliases: ExtensionStatusIconAliasMap;
15
+ extensionStatusMetadata?: ExtensionStatusMetadata;
13
16
  }
14
17
 
15
18
  const STATUSLINE_KEY = "statusline";
@@ -32,12 +35,19 @@ export function formatExtensionStatuses(
32
35
  hiddenKeys: ReadonlySet<string> = new Set(),
33
36
  trueColor = true,
34
37
  ): string {
38
+ runtime.extensionStatusMetadata?.reconcile(statuses);
35
39
  const separator = extensionStatusSeparator(config, theme, trueColor);
36
40
  const visibleStatuses = [
37
41
  ...formatDuplicateExtensionStatus(runtime, theme),
38
42
  ...[...statuses.entries()]
39
43
  .filter(([key, value]) => key !== STATUSLINE_KEY && !hiddenKeys.has(key) && value.trim().length > 0)
40
- .map(([key, value]) => formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases)),
44
+ .map(([key, value]) => {
45
+ const presentation = runtime.extensionStatusMetadata?.get(key, value);
46
+ return presentation
47
+ ? formatCompactExtensionStatus(key, value, presentation, theme, config, runtime.extensionStatusIconAliases)
48
+ : formatExtensionStatus(key, value, theme, config, runtime.extensionStatusIconAliases);
49
+ })
50
+ .filter((status) => status.length > 0),
41
51
  ].slice(0, 5);
42
52
 
43
53
  return visibleStatuses.join(separator);
@@ -59,6 +69,38 @@ export function formatExtensionStatus(
59
69
  return icon ? `${theme.fg(color, icon)} ${renderedText}` : renderedText;
60
70
  }
61
71
 
72
+ function formatCompactExtensionStatus(
73
+ key: string,
74
+ value: string,
75
+ presentation: ExtensionStatusPresentation,
76
+ theme: Theme,
77
+ config: Pick<StatuslineConfig, "extensionStatusIcons">,
78
+ aliases: ExtensionStatusIconAliasMap,
79
+ ): string {
80
+ if (presentation.activity === "idle") return "";
81
+ // Only the compact path strips terminal controls; raw statuses retain their existing PR links.
82
+ const status = splitExtensionStatusIcon(
83
+ stripExtensionStatusPrefix(sanitizeTerminalText(key), sanitizeTerminalText(value)),
84
+ );
85
+ const icon = sanitizeTerminalText(extensionStatusIcon(key, status.icon, config.extensionStatusIcons, aliases));
86
+ const bodies = {
87
+ checking: "…",
88
+ working: "↻",
89
+ pushing: "↑",
90
+ pulling: "↓",
91
+ resolving: "AI",
92
+ retry: `retry: ${presentation.reason}`,
93
+ blocked: `! ${presentation.reason}`,
94
+ "local-change": "⇡",
95
+ "remote-change": "⇣",
96
+ review: "⇕",
97
+ };
98
+ const warning = presentation.activity === "blocked" || presentation.activity === "retry";
99
+ const color = warning ? "warning" : extensionColor(key, value);
100
+ const body = theme.fg(warning ? "warning" : "muted", bodies[presentation.activity]);
101
+ return icon ? `${theme.fg(color, icon)} ${body}` : body;
102
+ }
103
+
62
104
  function extensionStatusIcon(
63
105
  key: string,
64
106
  leadingIcon: string | undefined,
@@ -68,14 +110,16 @@ function extensionStatusIcon(
68
110
  if (Object.hasOwn(configuredIcons, key)) return configuredIcons[key];
69
111
  const namespaceIcon = configuredNamespaceIcon(key, configuredIcons);
70
112
  if (namespaceIcon !== undefined) return namespaceIcon;
71
- const compatibleKey = COMPATIBLE_STATUS_ICON_KEYS[key];
113
+ const compatibleKey = Object.hasOwn(COMPATIBLE_STATUS_ICON_KEYS, key) ? COMPATIBLE_STATUS_ICON_KEYS[key] : undefined;
72
114
  if (compatibleKey && Object.hasOwn(configuredIcons, compatibleKey)) {
73
115
  return configuredIcons[compatibleKey];
74
116
  }
75
117
  for (const alias of extensionStatusAliasesForKey(key, extensionStatusIconAliases)) {
76
118
  if (Object.hasOwn(configuredIcons, alias)) return configuredIcons[alias];
77
119
  }
78
- return leadingIcon ?? DEFAULT_EXTENSION_STATUS_ICONS[key] ?? "🔌";
120
+ return (
121
+ leadingIcon ?? (Object.hasOwn(DEFAULT_EXTENSION_STATUS_ICONS, key) ? DEFAULT_EXTENSION_STATUS_ICONS[key] : "🔌")
122
+ );
79
123
  }
80
124
 
81
125
  function configuredNamespaceIcon(key: string, configuredIcons: Readonly<Record<string, string>>): string | undefined {
@@ -105,7 +149,9 @@ function statusKeyMatchesStatusBase(key: string, statusBase: string): boolean {
105
149
 
106
150
  export function wrapExtensionStatusline(status: string, width: number): string[] {
107
151
  if (!status || width <= 0) return [];
108
- return wrapTextWithAnsi(status, width);
152
+ return wrapTextWithAnsi(status, width).map((line) =>
153
+ visibleWidth(line) > width ? truncateToWidth(line, width, "") : line,
154
+ );
109
155
  }
110
156
 
111
157
  function formatDuplicateExtensionStatus(runtime: ExtensionStatusRuntime, theme: Theme): string[] {
package/src/git-status.ts CHANGED
@@ -2,6 +2,9 @@ import { isAbsolute } from "node:path";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
 
4
4
  const GIT_STATUS_TIMEOUT_MS = 3_000;
5
+ // Bound combined stdout/stderr while allowing large worktree listings and one absolute root path.
6
+ const GIT_STATUS_MAX_BUFFER = 1024 * 1024;
7
+ const GIT_ROOT_MAX_BUFFER = 16 * 1024;
5
8
 
6
9
  export interface GitStatusSummary {
7
10
  root?: string;
@@ -22,19 +25,21 @@ export async function readGitStatus(
22
25
  pi.exec("git", ["--no-optional-locks", "status", "--porcelain=v1", "--branch", "--untracked-files=normal"], {
23
26
  cwd,
24
27
  timeout: GIT_STATUS_TIMEOUT_MS,
28
+ maxBuffer: GIT_STATUS_MAX_BUFFER,
25
29
  signal,
26
30
  }),
27
31
  pi
28
32
  .exec("git", ["rev-parse", "--path-format=absolute", "--show-toplevel"], {
29
33
  cwd,
30
34
  timeout: GIT_STATUS_TIMEOUT_MS,
35
+ maxBuffer: GIT_ROOT_MAX_BUFFER,
31
36
  signal,
32
37
  })
33
38
  .catch(() => undefined),
34
39
  ]);
35
- if (statusResult.code !== 0 || statusResult.killed) return undefined;
40
+ if (statusResult.truncated || statusResult.code !== 0 || statusResult.killed) return undefined;
36
41
  const summary = parseGitStatusPorcelain(statusResult.stdout);
37
- if (rootResult && rootResult.code === 0 && !rootResult.killed) {
42
+ if (rootResult && !rootResult.truncated && rootResult.code === 0 && !rootResult.killed) {
38
43
  const root = parseGitRoot(rootResult.stdout);
39
44
  if (root) summary.root = root;
40
45
  }
package/src/render.ts CHANGED
@@ -47,6 +47,7 @@ export function renderStatusline(
47
47
  ): string {
48
48
  if (width <= 0) return "";
49
49
 
50
+ const inlineStatuses = legacyInlineStatuses(footerData.getExtensionStatuses(), runtime);
50
51
  const usageSummary = summarizeFooterUsage(ctx.sessionManager.getEntries());
51
52
  const rows: Array<{ configuredSegments: number; segments: RenderSegment[] }> = [
52
53
  { configuredSegments: 0, segments: [] },
@@ -60,7 +61,7 @@ export function renderStatusline(
60
61
  const row = rows.at(-1);
61
62
  if (!row) continue;
62
63
  row.configuredSegments += 1;
63
- const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary);
64
+ const rendered = buildSegment(name, ctx, footerData, config, runtime, usageSummary, inlineStatuses);
64
65
  if (rendered && rendered.text.length > 0) row.segments.push(rendered);
65
66
  }
66
67
 
@@ -84,7 +85,7 @@ export function renderExtensionStatusline(
84
85
  trueColor = true,
85
86
  ): string[] {
86
87
  const statuses = footerData.getExtensionStatuses();
87
- const prContext = prContextFromStatuses(statuses);
88
+ const prContext = prContextFromStatuses(legacyInlineStatuses(statuses, runtime));
88
89
  const rendersPrInline = prContext !== undefined && mainLine.includes(prContext);
89
90
  const status = formatExtensionStatuses(
90
91
  statuses,
@@ -97,6 +98,16 @@ export function renderExtensionStatusline(
97
98
  return wrapExtensionStatusline(status, width);
98
99
  }
99
100
 
101
+ function legacyInlineStatuses(
102
+ statuses: ReadonlyMap<string, string>,
103
+ runtime: ExtensionStatusRuntime,
104
+ ): ReadonlyMap<string, string> {
105
+ const metadata = runtime.extensionStatusMetadata;
106
+ if (!metadata) return statuses;
107
+ metadata.reconcile(statuses);
108
+ return new Map([...statuses].filter(([key, value]) => !metadata.get(key, value)));
109
+ }
110
+
100
111
  function buildSegment(
101
112
  name: SegmentName,
102
113
  ctx: ExtensionContext,
@@ -104,6 +115,7 @@ function buildSegment(
104
115
  config: StatuslineConfig,
105
116
  runtime: RuntimeState,
106
117
  usageSummary: FooterUsageSummary,
118
+ inlineStatuses: ReadonlyMap<string, string>,
107
119
  ): RenderSegment | undefined {
108
120
  switch (name) {
109
121
  case "brand":
@@ -124,7 +136,7 @@ function buildSegment(
124
136
  return segment(name, runtime.thinkingLevel, config, thinkingColor(runtime.thinkingLevel), "header");
125
137
  case "branch": {
126
138
  const branch = footerData.getGitBranch();
127
- const pr = branch ? prContextFromStatuses(footerData.getExtensionStatuses()) : undefined;
139
+ const pr = branch ? prContextFromStatuses(inlineStatuses) : undefined;
128
140
  return segment(name, formatGitBranchValue(branch, runtime.gitStatus, pr), config, "accent", "git");
129
141
  }
130
142
  case "cwd":