@narumitw/pi-statusline 0.21.0 → 0.23.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/src/statusline.ts CHANGED
@@ -1,21 +1,24 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ type ExtensionAPI,
3
+ type ExtensionContext,
4
+ getAgentDir,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { registerStatuslineCommand } from "./commands.js";
2
7
  import {
3
8
  buildExtensionStatusIconAliases,
4
9
  type ExtensionStatusIconAliasMap,
5
10
  findDuplicateExtensions,
6
11
  readInstalledExtensionPackages,
7
12
  } from "./extension-status.js";
13
+ import { type GitStatusSummary, gitStatusSummaryEqual, readGitStatus } from "./git-status.js";
14
+ import { type RuntimeState, renderExtensionStatusline, renderStatusline } from "./render.js";
8
15
  import {
9
- gitStatusSummaryEqual,
10
- readGitStatus,
11
- type GitStatusSummary,
12
- } from "./git-status.js";
13
- import {
14
- renderExtensionStatusline,
15
- renderStatusline,
16
- type RuntimeState,
17
- } from "./render.js";
18
- import { consumeStatuslineSettingsNotice, createDefaultConfig } from "./settings.js";
16
+ consumeStatuslineSettingsNotice,
17
+ type LoadedStatuslineSettings,
18
+ loadOrCreateStatuslineSettings,
19
+ loadStatuslineSettings,
20
+ settingsFilePath,
21
+ } from "./settings.js";
19
22
 
20
23
  const STATUSLINE_KEY = "statusline";
21
24
  const GIT_STATUS_REFRESH_INTERVAL_MS = 30_000;
@@ -23,7 +26,7 @@ const GIT_STATUS_EVENT_DEBOUNCE_MS = 250;
23
26
  const EMPTY_EXTENSION_STATUS_ICON_ALIASES: ExtensionStatusIconAliasMap = new Map();
24
27
 
25
28
  export default function statusline(pi: ExtensionAPI) {
26
- const config = createDefaultConfig();
29
+ let loaded: LoadedStatuslineSettings | undefined;
27
30
  const runtime: RuntimeState = {
28
31
  turnCount: 0,
29
32
  activeTools: new Map(),
@@ -38,9 +41,7 @@ export default function statusline(pi: ExtensionAPI) {
38
41
  let activeGitStatusTarget: { cwd: string; generation: number } | undefined;
39
42
  let gitStatusRefreshInFlight = false;
40
43
  let gitStatusDebounceTimer: ReturnType<typeof setTimeout> | undefined;
41
- let pendingGitStatusRefresh:
42
- | { cwd: string; generation: number; requestId: number }
43
- | undefined;
44
+ let pendingGitStatusRefresh: { cwd: string; generation: number; requestId: number } | undefined;
44
45
 
45
46
  const refresh = () => runtime.requestRender?.();
46
47
 
@@ -116,7 +117,7 @@ export default function statusline(pi: ExtensionAPI) {
116
117
  runtime.duplicateExtensions = [];
117
118
  runtime.extensionStatusIconAliases = EMPTY_EXTENSION_STATUS_ICON_ALIASES;
118
119
  ctx.ui.setStatus(STATUSLINE_KEY, undefined);
119
- if (!activeGitStatusTarget) return;
120
+ if (!activeGitStatusTarget || !loaded) return;
120
121
  const installedPackages = readInstalledExtensionPackages(cwd);
121
122
  runtime.duplicateExtensions = findDuplicateExtensions(installedPackages);
122
123
  runtime.extensionStatusIconAliases = buildExtensionStatusIconAliases(installedPackages);
@@ -152,8 +153,13 @@ export default function statusline(pi: ExtensionAPI) {
152
153
  },
153
154
  invalidate() {},
154
155
  render(width: number): string[] {
155
- const lines = [renderStatusline(width, ctx, footerData, theme, config, runtime)];
156
- lines.push(...renderExtensionStatusline(width, footerData, theme, config, runtime));
156
+ if (!loaded) return [];
157
+ const config = loaded.config;
158
+ const mainLine = renderStatusline(width, ctx, footerData, theme, config, runtime);
159
+ const lines = mainLine ? mainLine.split("\n") : [];
160
+ lines.push(
161
+ ...renderExtensionStatusline(width, footerData, theme, config, runtime, mainLine),
162
+ );
157
163
  return lines;
158
164
  },
159
165
  };
@@ -161,9 +167,24 @@ export default function statusline(pi: ExtensionAPI) {
161
167
  refreshGitStatus(cwd, generation);
162
168
  };
163
169
 
170
+ const agentDir = getAgentDir();
171
+ const configPath = settingsFilePath(agentDir);
172
+ registerStatuslineCommand(pi, {
173
+ settingsPath: configPath,
174
+ getLoaded: () => loaded ?? loadStatuslineSettings(configPath),
175
+ apply(next) {
176
+ loaded = next;
177
+ refresh();
178
+ },
179
+ });
180
+
164
181
  pi.on("session_start", (_event, ctx) => {
182
+ loaded = loadOrCreateStatuslineSettings(agentDir);
165
183
  const settingsNotice = consumeStatuslineSettingsNotice();
166
184
  if (settingsNotice) ctx.ui.notify(settingsNotice, "warning");
185
+ if (loaded.diagnostics.length > 0) {
186
+ ctx.ui.notify(formatSettingsDiagnostics(loaded), "warning");
187
+ }
167
188
  runtime.thinkingLevel = pi.getThinkingLevel();
168
189
  installFooter(ctx);
169
190
  });
@@ -233,6 +254,15 @@ export default function statusline(pi: ExtensionAPI) {
233
254
  });
234
255
  }
235
256
 
257
+ function formatSettingsDiagnostics(loaded: LoadedStatuslineSettings): string {
258
+ const details = loaded.diagnostics.slice(0, 5).map((item) => item.message);
259
+ const remaining = loaded.diagnostics.length - details.length;
260
+ return [
261
+ `pi-statusline settings: ${details.join("; ")}`,
262
+ ...(remaining > 0 ? [`+${remaining} more`] : []),
263
+ ].join(" ");
264
+ }
265
+
236
266
  export {
237
267
  buildExtensionStatusIconAliases,
238
268
  type ExtensionStatusIconAliasMap,
@@ -246,14 +276,16 @@ export {
246
276
  } from "./extension-status.js";
247
277
  export {
248
278
  formatGitBranchText,
279
+ formatGitBranchValue,
249
280
  formatGitStatusSummary,
250
- parseGitStatusPorcelain,
251
281
  type GitStatusSummary,
282
+ parseGitStatusPorcelain,
252
283
  } from "./git-status.js";
253
284
  export {
254
285
  contextColor,
255
286
  formatCount,
256
287
  formatToolActivity,
288
+ prContextFromStatuses,
257
289
  prLinkFromStatuses,
258
290
  shortenModel,
259
291
  } from "./render.js";
@@ -0,0 +1,180 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { ansiFg, ansiStyle } from "./ansi.js";
4
+ import {
5
+ LINE_BREAK_SEGMENT_NAME,
6
+ type PaletteName,
7
+ type RenderItem,
8
+ type RenderSegment,
9
+ type SeparatorName,
10
+ type StatuslineConfig,
11
+ type TokyoNightBlockName,
12
+ } from "./types.js";
13
+
14
+ interface TokyoNightBlock {
15
+ name: TokyoNightBlockName;
16
+ segments: RenderSegment[];
17
+ }
18
+
19
+ interface BlockColors {
20
+ fg: string;
21
+ bg: string;
22
+ }
23
+
24
+ interface PowerlinePalette {
25
+ lead: string;
26
+ blocks: Record<TokyoNightBlockName, BlockColors>;
27
+ extensionSeparator: string;
28
+ }
29
+
30
+ const BLOCK_NAMES: TokyoNightBlockName[] = ["header", "directory", "git", "runtime", "meter"];
31
+
32
+ const TOKYO_NIGHT_PALETTE: PowerlinePalette = {
33
+ lead: "#a3aed2",
34
+ blocks: {
35
+ header: { fg: "#090c0c", bg: "#a3aed2" },
36
+ directory: { fg: "#e3e5e5", bg: "#769ff0" },
37
+ git: { fg: "#769ff0", bg: "#394260" },
38
+ runtime: { fg: "#769ff0", bg: "#212736" },
39
+ meter: { fg: "#a0a9cb", bg: "#1d2230" },
40
+ },
41
+ extensionSeparator: "#394260",
42
+ };
43
+
44
+ const SEMANTIC_COLORS = {
45
+ accent: "#7aa2f7",
46
+ muted: "#565f89",
47
+ success: "#9ece6a",
48
+ warning: "#e0af68",
49
+ error: "#f7768e",
50
+ dim: "#414868",
51
+ } as const;
52
+
53
+ type SemanticColor = keyof typeof SEMANTIC_COLORS;
54
+
55
+ const PALETTE_SEQUENCES: Record<Exclude<PaletteName, "tokyo-night">, SemanticColor[]> = {
56
+ ocean: ["accent", "muted", "success", "warning"],
57
+ sunset: ["warning", "accent", "success", "muted"],
58
+ forest: ["success", "accent", "muted", "warning"],
59
+ candy: ["accent", "warning", "success", "muted"],
60
+ neon: ["accent", "success", "warning", "error"],
61
+ mono: ["muted", "dim"],
62
+ };
63
+
64
+ export function renderTokyoNightStatusline(
65
+ width: number,
66
+ items: RenderItem[],
67
+ config: Pick<StatuslineConfig, "palette" | "density" | "separator">,
68
+ ): string {
69
+ if (items.length === 0 || width <= 0) return "";
70
+ return splitLines(items)
71
+ .map((segments) =>
72
+ segments.length === 0
73
+ ? ""
74
+ : truncateToWidth(joinTokyoNightSegments(segments, config), width, ""),
75
+ )
76
+ .join("\n");
77
+ }
78
+
79
+ function splitLines(items: RenderItem[]): RenderSegment[][] {
80
+ const lines: RenderSegment[][] = [[]];
81
+ for (const item of items) {
82
+ if (item.name === LINE_BREAK_SEGMENT_NAME) lines.push([]);
83
+ else lines.at(-1)?.push(item);
84
+ }
85
+ return lines;
86
+ }
87
+
88
+ export function tokyoNightExtensionSeparator(
89
+ _theme: Theme,
90
+ paletteName: PaletteName = "tokyo-night",
91
+ ): string {
92
+ return ansiFg(resolvePalette(paletteName).extensionSeparator, " • ");
93
+ }
94
+
95
+ function joinTokyoNightSegments(
96
+ segments: RenderSegment[],
97
+ config: Pick<StatuslineConfig, "palette" | "density" | "separator">,
98
+ ): string {
99
+ const palette = resolvePalette(config.palette);
100
+ const blocks = contiguousBlocks(segments);
101
+ let line = ansiFg(palette.lead, "░▒▓");
102
+
103
+ for (const [index, block] of blocks.entries()) {
104
+ const colors = palette.blocks[block.name];
105
+ const previous = index === 0 ? undefined : palette.blocks[blocks[index - 1]?.name ?? "header"];
106
+ if (previous) line += ansiStyle("", { fg: previous.bg, bg: colors.bg });
107
+ line += ansiStyle(formatBlockText(block, config), colors);
108
+ }
109
+
110
+ const lastBlock = blocks.at(-1);
111
+ if (lastBlock) line += ansiFg(palette.blocks[lastBlock.name].bg, "");
112
+ return line;
113
+ }
114
+
115
+ function contiguousBlocks(segments: RenderSegment[]): TokyoNightBlock[] {
116
+ const blocks: TokyoNightBlock[] = [];
117
+ for (const segment of segments) {
118
+ const previous = blocks.at(-1);
119
+ if (previous?.name === segment.block) previous.segments.push(segment);
120
+ else blocks.push({ name: segment.block, segments: [segment] });
121
+ }
122
+ return blocks;
123
+ }
124
+
125
+ function formatBlockText(
126
+ block: TokyoNightBlock,
127
+ config: Pick<StatuslineConfig, "density" | "separator">,
128
+ ): string {
129
+ const texts = block.segments.map(formatSegmentText);
130
+ const separator = separatorText(config.separator, config.density === "cozy");
131
+ const leading = config.density === "cozy" ? " " : " ";
132
+ const trailing = config.density === "cozy" ? " " : "";
133
+ return `${leading}${texts.join(separator)}${trailing}`;
134
+ }
135
+
136
+ function formatSegmentText(segment: RenderSegment): string {
137
+ return segment.emphasis ? `\u001b[1m${segment.text}\u001b[22m` : segment.text;
138
+ }
139
+
140
+ function separatorText(separator: SeparatorName, cozy: boolean): string {
141
+ const padding = cozy ? " " : " ";
142
+ switch (separator) {
143
+ case "dot":
144
+ return `${padding}•${padding}`;
145
+ case "bar":
146
+ return `${padding}│${padding}`;
147
+ case "powerline":
148
+ return `${padding}${padding}`;
149
+ case "round":
150
+ return `${padding}❯${padding}`;
151
+ case "none":
152
+ return padding;
153
+ }
154
+ }
155
+
156
+ function resolvePalette(name: PaletteName): PowerlinePalette {
157
+ if (name === "tokyo-night") return TOKYO_NIGHT_PALETTE;
158
+ const sequence = PALETTE_SEQUENCES[name];
159
+ const backgrounds = BLOCK_NAMES.map(
160
+ (_block, index) => SEMANTIC_COLORS[sequence[index % sequence.length] ?? "muted"],
161
+ );
162
+ return {
163
+ lead: backgrounds[0] ?? SEMANTIC_COLORS.accent,
164
+ blocks: Object.fromEntries(
165
+ BLOCK_NAMES.map((block, index) => {
166
+ const background = backgrounds[index] ?? SEMANTIC_COLORS.muted;
167
+ return [block, { fg: contrastColor(background), bg: background }];
168
+ }),
169
+ ) as Record<TokyoNightBlockName, BlockColors>,
170
+ extensionSeparator: backgrounds[2] ?? SEMANTIC_COLORS.muted,
171
+ };
172
+ }
173
+
174
+ function contrastColor(hex: string): string {
175
+ const normalized = hex.slice(1);
176
+ const red = Number.parseInt(normalized.slice(0, 2), 16);
177
+ const green = Number.parseInt(normalized.slice(2, 4), 16);
178
+ const blue = Number.parseInt(normalized.slice(4, 6), 16);
179
+ return red * 0.299 + green * 0.587 + blue * 0.114 > 150 ? "#090c0c" : "#f0f0f0";
180
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const SEGMENT_NAMES = [
4
+ "brand",
5
+ "provider",
6
+ "model",
7
+ "thinking",
8
+ "cwd",
9
+ "branch",
10
+ "tools",
11
+ "context",
12
+ "tokens",
13
+ "cost",
14
+ "time",
15
+ "turn",
16
+ ] as const;
17
+ export type SegmentName = (typeof SEGMENT_NAMES)[number];
18
+
19
+ export const LINE_BREAK_SEGMENT_NAME = "line_break" as const;
20
+ export type ConfigSegmentName = SegmentName | typeof LINE_BREAK_SEGMENT_NAME;
21
+
22
+ export const PALETTE_NAMES = [
23
+ "tokyo-night",
24
+ "ocean",
25
+ "sunset",
26
+ "forest",
27
+ "candy",
28
+ "neon",
29
+ "mono",
30
+ ] as const;
31
+ export type PaletteName = (typeof PALETTE_NAMES)[number];
32
+
33
+ export const DENSITIES = ["compact", "cozy"] as const;
34
+ export type Density = (typeof DENSITIES)[number];
35
+
36
+ export const SEPARATOR_NAMES = ["none", "dot", "bar", "powerline", "round"] as const;
37
+ export type SeparatorName = (typeof SEPARATOR_NAMES)[number];
38
+
39
+ export type TokyoNightBlockName = "header" | "directory" | "git" | "runtime" | "meter";
40
+
41
+ export interface SegmentTextConfig {
42
+ prefix: string;
43
+ suffix: string;
44
+ }
45
+
46
+ export interface StatuslineConfig {
47
+ palette: PaletteName;
48
+ density: Density;
49
+ separator: SeparatorName;
50
+ segments: ConfigSegmentName[];
51
+ segmentText: Record<SegmentName, SegmentTextConfig>;
52
+ extensionStatusIcons: Record<string, string>;
53
+ }
54
+
55
+ export interface RenderSegment {
56
+ name: SegmentName;
57
+ text: string;
58
+ color: ThemeColor;
59
+ block: TokyoNightBlockName;
60
+ emphasis?: boolean;
61
+ }
62
+
63
+ export interface RenderLineBreak {
64
+ name: typeof LINE_BREAK_SEGMENT_NAME;
65
+ }
66
+
67
+ export type RenderItem = RenderSegment | RenderLineBreak;
@@ -1,55 +0,0 @@
1
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import type { RenderSegment, SeparatorName, StatuslineConfig } from "./types.js";
4
-
5
- export function renderClassicStatusline(
6
- width: number,
7
- segments: RenderSegment[],
8
- theme: Theme,
9
- config: StatuslineConfig,
10
- ): string {
11
- return truncateToWidth(joinSegments(segments, theme, config), width, "");
12
- }
13
-
14
- export function classicExtensionSeparator(theme: Theme): string {
15
- return theme.fg("dim", " • ");
16
- }
17
-
18
- function joinSegments(segments: RenderSegment[], theme: Theme, config: StatuslineConfig): string {
19
- const separator = separatorText(config.separator);
20
- return segments
21
- .map((segment, index) => styleSegment(segment, index, theme, config))
22
- .join(theme.fg("dim", separator));
23
- }
24
-
25
- function styleSegment(
26
- segment: RenderSegment,
27
- index: number,
28
- theme: Theme,
29
- config: StatuslineConfig,
30
- ): string {
31
- const padding = config.density === "cozy" ? " " : "";
32
- const text = `${padding}${segment.text}${padding}`;
33
- const styledText = segment.emphasis ? theme.bold(text) : text;
34
-
35
- if (config.palette === "mono") {
36
- return index === 0 ? theme.fg("muted", styledText) : theme.fg("dim", styledText);
37
- }
38
-
39
- return theme.fg(segment.color as ThemeColor, styledText);
40
- }
41
-
42
- function separatorText(separator: SeparatorName): string {
43
- switch (separator) {
44
- case "powerline":
45
- return "  ";
46
- case "bar":
47
- return " │ ";
48
- case "round":
49
- return " ❯ ";
50
- case "none":
51
- return " ";
52
- case "dot":
53
- return " • ";
54
- }
55
- }
@@ -1,84 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth } from "@earendil-works/pi-tui";
3
- import { ansiFg, ansiStyle } from "./ansi.js";
4
- import type { RenderSegment, TokyoNightBlockName } from "./types.js";
5
-
6
- interface TokyoNightBlock {
7
- name: TokyoNightBlockName;
8
- segments: RenderSegment[];
9
- }
10
-
11
- interface BlockColors {
12
- fg: string;
13
- bg: string;
14
- }
15
-
16
- const TOKYO_NIGHT_COLORS = {
17
- lead: "#a3aed2",
18
- header: { fg: "#090c0c", bg: "#a3aed2" },
19
- directory: { fg: "#e3e5e5", bg: "#769ff0" },
20
- git: { fg: "#769ff0", bg: "#394260" },
21
- runtime: { fg: "#769ff0", bg: "#212736" },
22
- meter: { fg: "#a0a9cb", bg: "#1d2230" },
23
- extensionSeparator: "#394260",
24
- } as const satisfies Record<string, string | BlockColors>;
25
-
26
- const TOKYO_NIGHT_BLOCK_ORDER: TokyoNightBlockName[] = [
27
- "header",
28
- "directory",
29
- "git",
30
- "runtime",
31
- "meter",
32
- ];
33
-
34
- export function renderTokyoNightStatusline(width: number, segments: RenderSegment[]): string {
35
- return truncateToWidth(joinTokyoNightSegments(segments), width, "");
36
- }
37
-
38
- export function tokyoNightExtensionSeparator(_theme: Theme): string {
39
- return ansiFg(TOKYO_NIGHT_COLORS.extensionSeparator, " • ");
40
- }
41
-
42
- function joinTokyoNightSegments(segments: RenderSegment[]): string {
43
- const blocks = groupTokyoNightBlocks(segments);
44
- let line = ansiFg(TOKYO_NIGHT_COLORS.lead, "░▒▓");
45
-
46
- for (const [index, block] of blocks.entries()) {
47
- const colors = getTokyoNightBlockColors(block.name);
48
- const previous =
49
- index === 0 ? undefined : getTokyoNightBlockColors(blocks[index - 1]?.name ?? "header");
50
- if (previous) line += ansiStyle("", { fg: previous.bg, bg: colors.bg });
51
- line += ansiStyle(formatTokyoNightBlockText(block), colors);
52
- }
53
-
54
- const lastBlock = blocks.at(-1);
55
- if (lastBlock) line += ansiFg(getTokyoNightBlockColors(lastBlock.name).bg, "");
56
-
57
- return line;
58
- }
59
-
60
- function groupTokyoNightBlocks(segments: RenderSegment[]): TokyoNightBlock[] {
61
- const blocksByName = new Map<TokyoNightBlockName, RenderSegment[]>();
62
- for (const segment of segments) {
63
- const blockSegments = blocksByName.get(segment.block) ?? [];
64
- blockSegments.push(segment);
65
- blocksByName.set(segment.block, blockSegments);
66
- }
67
-
68
- return TOKYO_NIGHT_BLOCK_ORDER.flatMap((name) => {
69
- const blockSegments = blocksByName.get(name);
70
- return blockSegments ? [{ name, segments: blockSegments }] : [];
71
- });
72
- }
73
-
74
- function formatTokyoNightBlockText(block: TokyoNightBlock): string {
75
- return ` ${block.segments.map(formatTokyoNightSegmentText).join(" ")}`;
76
- }
77
-
78
- function formatTokyoNightSegmentText(segment: RenderSegment): string {
79
- return segment.emphasis ? `\u001b[1m${segment.text}\u001b[22m` : segment.text;
80
- }
81
-
82
- function getTokyoNightBlockColors(block: TokyoNightBlockName): BlockColors {
83
- return TOKYO_NIGHT_COLORS[block];
84
- }
package/presets/types.ts DELETED
@@ -1,39 +0,0 @@
1
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
-
3
- export type SegmentName =
4
- | "brand"
5
- | "provider"
6
- | "model"
7
- | "thinking"
8
- | "cwd"
9
- | "branch"
10
- | "tools"
11
- | "context"
12
- | "tokens"
13
- | "cost"
14
- | "time"
15
- | "turn";
16
-
17
- export type StatuslinePresetName = "classic" | "tokyo-night";
18
- export type TokyoNightBlockName = "header" | "directory" | "git" | "runtime" | "meter";
19
- export type PaletteName = "ocean" | "sunset" | "forest" | "candy" | "neon" | "mono";
20
- export type Density = "compact" | "cozy";
21
- export type SeparatorName = "dot" | "bar" | "powerline" | "round" | "none";
22
-
23
- export interface StatuslineConfig {
24
- preset: StatuslinePresetName;
25
- palette: PaletteName;
26
- density: Density;
27
- separator: SeparatorName;
28
- showLabels: boolean;
29
- segments: SegmentName[];
30
- extensionStatusIcons: Record<string, string>;
31
- }
32
-
33
- export interface RenderSegment {
34
- name: SegmentName;
35
- text: string;
36
- color: ThemeColor;
37
- block: TokyoNightBlockName;
38
- emphasis?: boolean;
39
- }
File without changes