@herbertgao/pi-extensions 2026.8.3 → 2026.8.4

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.
Files changed (122) hide show
  1. package/README.md +41 -0
  2. package/examples/pi-footer.json +275 -0
  3. package/node_modules/pi-footer/CHANGELOG.md +145 -0
  4. package/node_modules/pi-footer/LICENSE +21 -0
  5. package/node_modules/pi-footer/README.md +354 -0
  6. package/node_modules/pi-footer/package.json +68 -0
  7. package/node_modules/pi-footer/src/cache.ts +106 -0
  8. package/node_modules/pi-footer/src/colors.ts +237 -0
  9. package/node_modules/pi-footer/src/config.ts +202 -0
  10. package/node_modules/pi-footer/src/event-widgets.ts +41 -0
  11. package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
  12. package/node_modules/pi-footer/src/git.ts +135 -0
  13. package/node_modules/pi-footer/src/index.ts +268 -0
  14. package/node_modules/pi-footer/src/metrics.ts +141 -0
  15. package/node_modules/pi-footer/src/presets.ts +383 -0
  16. package/node_modules/pi-footer/src/render.ts +114 -0
  17. package/node_modules/pi-footer/src/separators.ts +52 -0
  18. package/node_modules/pi-footer/src/types.ts +122 -0
  19. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
  20. package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
  21. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
  22. package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
  23. package/node_modules/pi-footer/src/ui/events.ts +35 -0
  24. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
  25. package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
  26. package/node_modules/pi-footer/src/ui/fields.ts +252 -0
  27. package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
  28. package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
  29. package/node_modules/pi-footer/src/ui/layout.ts +17 -0
  30. package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
  31. package/node_modules/pi-footer/src/ui/model.ts +64 -0
  32. package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
  33. package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
  34. package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
  35. package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
  36. package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
  37. package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
  38. package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
  39. package/node_modules/pi-footer/src/ui/screen.ts +243 -0
  40. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
  41. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
  42. package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
  43. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
  44. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
  45. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
  46. package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
  47. package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
  48. package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
  49. package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
  50. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
  51. package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
  52. package/node_modules/pi-footer/src/ui/theme.ts +48 -0
  53. package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
  54. package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
  55. package/node_modules/pi-footer/src/ui.ts +66 -0
  56. package/node_modules/pi-footer/src/widgets/context.ts +28 -0
  57. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
  58. package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
  59. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
  60. package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
  61. package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
  62. package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
  63. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
  64. package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
  65. package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
  66. package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
  67. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
  68. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
  69. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
  70. package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
  71. package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
  72. package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
  73. package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
  74. package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
  75. package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
  76. package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
  77. package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
  78. package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
  79. package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
  80. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
  81. package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
  82. package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
  83. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
  84. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
  85. package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
  86. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
  87. package/node_modules/pi-footer/src/widgets/options.ts +122 -0
  88. package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
  89. package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
  90. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
  91. package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
  92. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
  93. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
  94. package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
  95. package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
  96. package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
  97. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
  98. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
  99. package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
  100. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
  101. package/node_modules/pi-footer/src/widgets/store.ts +26 -0
  102. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
  103. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
  104. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
  105. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
  106. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
  107. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
  108. package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
  109. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
  110. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
  111. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
  112. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
  113. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
  114. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
  115. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
  116. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
  117. package/node_modules/pi-footer/src/widgets/types.ts +181 -0
  118. package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
  119. package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
  120. package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
  121. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
  122. package/package.json +7 -2
@@ -0,0 +1,100 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ import packageJson from "../../package.json" with { type: "json" };
4
+
5
+ const CONFIG_TITLE_TEXT = " pi-footer configuration ";
6
+ const VERSION = packageJson.version;
7
+
8
+ interface TitleBarParts {
9
+ title: string;
10
+ rightPad: number;
11
+ }
12
+
13
+ export function previewTitleParts(innerWidth: number): TitleBarParts {
14
+ const title = truncateToWidth(" Preview ", innerWidth, "…");
15
+ return {
16
+ title,
17
+ rightPad: Math.max(0, innerWidth - visibleWidth(title)),
18
+ };
19
+ }
20
+
21
+ export function configTitleBarParts(
22
+ innerWidth: number,
23
+ dim: (text: string) => string,
24
+ now = Date.now(),
25
+ status = "",
26
+ border: (text: string) => string = (text) => text,
27
+ ): TitleBarParts {
28
+ const titleWidth = Math.max(1, innerWidth - 1);
29
+ const decoratedStatus = status ? ` ${status} ` : "";
30
+ const statusWidth = visibleWidth(decoratedStatus);
31
+ const contentWidth = statusWidth > 0 ? Math.max(1, titleWidth - 1) : titleWidth;
32
+ const reserveForStatus = statusWidth > 0 ? statusWidth + 1 : 0;
33
+ const leftWidth = Math.max(1, contentWidth - reserveForStatus);
34
+ const suffixWidth = Math.max(0, leftWidth - visibleWidth(CONFIG_TITLE_TEXT));
35
+ const suffix = suffixWidth > 0 ? dim(truncateToWidth(`| v${VERSION} `, suffixWidth, "…")) : "";
36
+ const left = truncateToWidth(retroText(CONFIG_TITLE_TEXT, now) + suffix, leftWidth, "…");
37
+ if (statusWidth === 0) {
38
+ return {
39
+ title: left,
40
+ rightPad: Math.max(0, titleWidth - visibleWidth(left)),
41
+ };
42
+ }
43
+ const separator = border(
44
+ "─".repeat(Math.max(1, contentWidth - visibleWidth(left) - statusWidth)),
45
+ );
46
+ return {
47
+ title: `${left}${separator}${decoratedStatus}`,
48
+ rightPad: 1,
49
+ };
50
+ }
51
+
52
+ type Rgb = readonly [number, number, number];
53
+
54
+ const RETRO_GRADIENT: readonly Rgb[] = [
55
+ [63, 81, 177],
56
+ [90, 85, 174],
57
+ [123, 95, 172],
58
+ [143, 106, 174],
59
+ [168, 106, 164],
60
+ [204, 107, 142],
61
+ [241, 130, 113],
62
+ [243, 164, 105],
63
+ [247, 201, 120],
64
+ ];
65
+
66
+ export const RETRO_FRAME_INTERVAL_MS = 140;
67
+ const RETRO_CYCLE_MS = 5000;
68
+
69
+ export function retroText(text: string, now = Date.now()): string {
70
+ return gradientText(text, RETRO_GRADIENT, (now / RETRO_CYCLE_MS) % 1);
71
+ }
72
+
73
+ function gradientText(text: string, stops: readonly Rgb[], phase: number): string {
74
+ const chars = Array.from(text);
75
+ if (chars.length === 0) return "";
76
+ return chars
77
+ .map((char, index) => {
78
+ const position = chars.length === 1 ? phase : index / (chars.length - 1) + phase;
79
+ const [r, g, b] = sampleLoopingGradient(stops, position);
80
+ return `\x1b[1;38;2;${r};${g};${b}m${char}\x1b[0m`;
81
+ })
82
+ .join("");
83
+ }
84
+
85
+ function sampleLoopingGradient(stops: readonly Rgb[], t: number): Rgb {
86
+ if (stops.length === 0) return [255, 255, 255];
87
+ if (stops.length === 1) return stops[0] ?? [255, 255, 255];
88
+
89
+ const wrapped = ((t % 1) + 1) % 1;
90
+ const scaled = wrapped * stops.length;
91
+ const index = Math.floor(scaled) % stops.length;
92
+ const mix = scaled - Math.floor(scaled);
93
+ const left = stops[index] ?? stops[0] ?? [255, 255, 255];
94
+ const right = stops[(index + 1) % stops.length] ?? left;
95
+ return [
96
+ Math.round(left[0] + (right[0] - left[0]) * mix),
97
+ Math.round(left[1] + (right[1] - left[1]) * mix),
98
+ Math.round(left[2] + (right[2] - left[2]) * mix),
99
+ ];
100
+ }
@@ -0,0 +1,76 @@
1
+ import { registry, type WidgetType } from "../widgets/registry.js";
2
+ import type { Widget } from "../widgets/types.js";
3
+
4
+ export function addLineAfter(lines: Widget[][], selectedLine: number): number {
5
+ lines.splice(selectedLine + 1, 0, []);
6
+ return selectedLine + 1;
7
+ }
8
+
9
+ export function cloneLineAfter(
10
+ lines: Widget[][],
11
+ selectedLine: number,
12
+ cloneWidget: (widget: Widget) => Widget = (widget) => registry.cloneWidget(widget),
13
+ ): number {
14
+ const line = lines[selectedLine] ?? [];
15
+ lines.splice(selectedLine + 1, 0, line.map(cloneWidget));
16
+ return selectedLine + 1;
17
+ }
18
+
19
+ export function deleteLine(lines: Widget[][], selectedLine: number): number {
20
+ if (lines.length <= 1) return selectedLine;
21
+ lines.splice(selectedLine, 1);
22
+ return Math.min(selectedLine, lines.length - 1);
23
+ }
24
+
25
+ export function moveLine(lines: Widget[][], selectedLine: number, delta: number): number {
26
+ const next = selectedLine + delta;
27
+ if (next < 0 || next >= lines.length) return selectedLine;
28
+ const [line] = lines.splice(selectedLine, 1);
29
+ if (!line) return selectedLine;
30
+ lines.splice(next, 0, line);
31
+ return next;
32
+ }
33
+
34
+ export function moveWidget(line: Widget[], selectedWidget: number, delta: number): number {
35
+ const next = selectedWidget + delta;
36
+ if (next < 0 || next >= line.length) return selectedWidget;
37
+ const [widget] = line.splice(selectedWidget, 1);
38
+ if (!widget) return selectedWidget;
39
+ line.splice(next, 0, widget);
40
+ return next;
41
+ }
42
+
43
+ export function cloneSelectedWidget(
44
+ line: Widget[],
45
+ selectedWidget: number,
46
+ cloneWidget: (widget: Widget) => Widget = (widget) => registry.cloneWidget(widget),
47
+ ): number {
48
+ const widget = line[selectedWidget];
49
+ if (!widget) return selectedWidget;
50
+ line.splice(selectedWidget + 1, 0, cloneWidget(widget));
51
+ return selectedWidget + 1;
52
+ }
53
+
54
+ export function deleteSelectedWidget(line: Widget[], selectedWidget: number): number {
55
+ if (line.length === 0) return selectedWidget;
56
+ line.splice(selectedWidget, 1);
57
+ return Math.max(0, Math.min(selectedWidget, line.length - 1));
58
+ }
59
+
60
+ export function toggleWidgetEnabled(widget: Widget | undefined): boolean {
61
+ if (!widget) return false;
62
+ widget.toggle();
63
+ return true;
64
+ }
65
+
66
+ export function toggleWidgetRaw(widget: Widget | undefined): boolean {
67
+ if (!widget || isLayoutWidgetType(widget.type)) return false;
68
+ widget.update({ raw: !(widget.options.raw ?? false) });
69
+ return true;
70
+ }
71
+
72
+ function isLayoutWidgetType(type: WidgetType): boolean {
73
+ return (
74
+ type === "custom-text" || type === "separator" || type === "spacer" || type === "flex-separator"
75
+ );
76
+ }
@@ -0,0 +1,66 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { cloneConfig } from "./config.js";
4
+ import type { GetExtensionStatuses } from "./extension-statuses.js";
5
+ import type { StatuslineConfig, StatuslineData } from "./types.js";
6
+ import { type ConfigChange, type ConfigSave, type ConfigUiResult } from "./ui/config-lifecycle.js";
7
+ import { CONFIG_UI_HEIGHT_RATIO } from "./ui/model.js";
8
+ import { StatuslineConfigScreen } from "./ui/screen.js";
9
+
10
+ export { StatuslineConfigScreen } from "./ui/screen.js";
11
+
12
+ export async function openStatuslineConfigUi(
13
+ ctx: ExtensionCommandContext,
14
+ initialConfig: StatuslineConfig,
15
+ previewData: StatuslineData,
16
+ onChange: ConfigChange,
17
+ onSave: ConfigSave,
18
+ getExtensionStatuses: GetExtensionStatuses,
19
+ ): Promise<ConfigUiResult> {
20
+ let finalConfig = cloneConfig(initialConfig);
21
+
22
+ return ctx.ui.custom<ConfigUiResult>(
23
+ (tui, theme, _keybindings, done) => {
24
+ const screen = new StatuslineConfigScreen(
25
+ finalConfig,
26
+ previewData,
27
+ getExtensionStatuses,
28
+ () => tui.requestRender(),
29
+ () => tui.terminal.rows,
30
+ {
31
+ onChange(config) {
32
+ finalConfig = cloneConfig(config);
33
+ onChange(finalConfig);
34
+ tui.requestRender();
35
+ },
36
+ async onSave(config) {
37
+ finalConfig = cloneConfig(config);
38
+ try {
39
+ await onSave(finalConfig);
40
+ } catch (error) {
41
+ ctx.ui.notify(
42
+ `Could not save pi-footer config: ${error instanceof Error ? error.message : String(error)}`,
43
+ "error",
44
+ );
45
+ throw error;
46
+ }
47
+ },
48
+ onClose(result) {
49
+ done(result);
50
+ },
51
+ getTheme: () => theme,
52
+ },
53
+ );
54
+ return screen;
55
+ },
56
+ {
57
+ overlay: true,
58
+ overlayOptions: {
59
+ anchor: "top-center",
60
+ width: "100%",
61
+ maxHeight: `${CONFIG_UI_HEIGHT_RATIO * 100}%`,
62
+ margin: 0,
63
+ },
64
+ },
65
+ );
66
+ }
@@ -0,0 +1,28 @@
1
+ import type { RenderStatuslineOptions } from "../render.js";
2
+ import type { StatuslineData } from "../types.js";
3
+ import type {
4
+ BaseWidgetContext,
5
+ WidgetContext,
6
+ WidgetDependency,
7
+ WidgetDependencyValues,
8
+ } from "./types.js";
9
+
10
+ export function contextForDependencies<const TDeps extends readonly WidgetDependency[]>(
11
+ baseCtx: BaseWidgetContext,
12
+ dependencies: TDeps,
13
+ data: StatuslineData,
14
+ options: RenderStatuslineOptions,
15
+ ): WidgetContext<TDeps> {
16
+ // getExtensionStatuses is read live from options, never snapshotted into data.
17
+ const source: WidgetDependencyValues = {
18
+ ...data,
19
+ getExtensionStatuses: options.getExtensionStatuses,
20
+ };
21
+ const output: BaseWidgetContext & Partial<WidgetDependencyValues> = { ...baseCtx };
22
+ const writableOutput = output as BaseWidgetContext & Partial<Record<WidgetDependency, unknown>>;
23
+ for (const dependency of dependencies) {
24
+ writableOutput[dependency] = source[dependency];
25
+ }
26
+ // TODO(widget-spec): remove this cast if TypeScript gains better support for dynamic object construction.
27
+ return output as WidgetContext<TDeps>;
28
+ }
@@ -0,0 +1,17 @@
1
+ import { defineWidget } from "../types.js";
2
+
3
+ export const ActiveToolsWidget = defineWidget({
4
+ type: "active-tools",
5
+ label: "Active Tools",
6
+ category: "Core",
7
+ description: "Active tool count",
8
+ dependencies: ["activeToolCount"],
9
+ baseOptions: ["raw", "hideWhenZero", "icon", "text"],
10
+ baseOptionDefaults: { hideWhenZero: true },
11
+ properties: [],
12
+ icons: { emoji: "🛠️", nerd: "󰒓", text: "tools" },
13
+ defaultStyle: { fg: "yellow", bg: "default", bold: false },
14
+ render({ ctx, renderWidget }) {
15
+ return renderWidget(String(ctx.activeToolCount));
16
+ },
17
+ });
@@ -0,0 +1,24 @@
1
+ import { defineWidget } from "../types.js";
2
+ import { contextColorProperties, contextColors } from "../utils/context.js";
3
+ import { formatTokenCount, tokenFormatStyleProperty } from "../utils/token-format.js";
4
+
5
+ export const ContextWindowWidget = defineWidget({
6
+ type: "context-window",
7
+ label: "Context Window",
8
+ category: "Core",
9
+ description: "Model context window size",
10
+ dependencies: ["contextTokens", "contextMaxTokens"],
11
+ baseOptions: ["raw", "hideWhenZero", "icon"],
12
+ baseOptionDefaults: {},
13
+ properties: [tokenFormatStyleProperty(), ...contextColorProperties()],
14
+ icons: { emoji: "🪟", nerd: "󰍛", text: "window" },
15
+ defaultStyle: { fg: "brightBlack", bg: "default", bold: false },
16
+ render({ ctx, options, renderWidget }) {
17
+ return renderWidget(
18
+ ctx.contextMaxTokens ? formatTokenCount(ctx.contextMaxTokens, options.tokenFormatStyle) : "?",
19
+ {
20
+ ...contextColors(options, ctx.contextTokens, ctx.contextMaxTokens),
21
+ },
22
+ );
23
+ },
24
+ });
@@ -0,0 +1,19 @@
1
+ import { basename } from "node:path";
2
+
3
+ import { defineWidget } from "../types.js";
4
+
5
+ export const CwdBasenameWidget = defineWidget({
6
+ type: "cwd-basename",
7
+ label: "Working Dir Name",
8
+ category: "Core",
9
+ description: "Current directory name",
10
+ dependencies: ["cwd"],
11
+ baseOptions: ["raw", "hideWhenEmpty", "icon", "text"],
12
+ baseOptionDefaults: { text: "" },
13
+ properties: [],
14
+ icons: { emoji: "📂", nerd: "", text: "dir" },
15
+ defaultStyle: { fg: "blue", bg: "default", bold: false },
16
+ render({ ctx, renderWidget }) {
17
+ return renderWidget(basename(ctx.cwd));
18
+ },
19
+ });
@@ -0,0 +1,108 @@
1
+ import { basename, sep } from "node:path";
2
+
3
+ import { defineWidget } from "../types.js";
4
+
5
+ const CWD_DISPLAY_STYLES = {
6
+ default: {
7
+ label: "Default (last path segments)",
8
+ list: "default",
9
+ },
10
+ "full-home": {
11
+ label: "Full path (~ home)",
12
+ list: "full-path",
13
+ },
14
+ fish: {
15
+ label: "Fish-style abbreviations",
16
+ list: "fish-style",
17
+ },
18
+ } as const;
19
+
20
+ type CwdDisplayStyle = keyof typeof CWD_DISPLAY_STYLES;
21
+
22
+ const CWD_DISPLAY_STYLE_VALUES = Object.keys(CWD_DISPLAY_STYLES) as CwdDisplayStyle[];
23
+
24
+ export const CwdWidget = defineWidget({
25
+ type: "cwd",
26
+ label: "Working Dir",
27
+ category: "Core",
28
+ description: "Current working directory",
29
+ dependencies: ["cwd"],
30
+ baseOptions: ["raw", "icon"],
31
+ baseOptionDefaults: {},
32
+ properties: [
33
+ {
34
+ id: "cwdDisplayStyle",
35
+ label: "Display style",
36
+ kind: "choice",
37
+ description: "Working directory display style",
38
+ default: "default",
39
+ options: {
40
+ choices: CWD_DISPLAY_STYLE_VALUES.map((style) => ({
41
+ id: style,
42
+ label: CWD_DISPLAY_STYLES[style].label,
43
+ list: CWD_DISPLAY_STYLES[style].list,
44
+ })),
45
+ showInWidgets: true,
46
+ showInColors: false,
47
+ listProperty: "display",
48
+ },
49
+ },
50
+ {
51
+ id: "segments",
52
+ label: "Segments",
53
+ kind: "number",
54
+ description: "Number of trailing path segments or fish abbreviation width",
55
+ default: 2,
56
+ options: { min: 1, max: 8, showInWidgets: true, showInColors: false },
57
+ },
58
+ ],
59
+ icons: { emoji: "📁", nerd: "", text: "cwd" },
60
+ defaultStyle: { fg: "blue", bg: "default", bold: false },
61
+ render({ ctx, options, renderWidget }) {
62
+ return renderWidget(
63
+ formatCwdPath(ctx.cwd, options.cwdDisplayStyle, options.segments) || basename(ctx.cwd),
64
+ );
65
+ },
66
+ });
67
+
68
+ function formatCwdPath(path: string, style: CwdDisplayStyle, segments: number) {
69
+ if (style === "full-home") return fullHomePath(path);
70
+ if (style === "fish") return fishStylePath(path, segments);
71
+ return shortenPath(path, segments);
72
+ }
73
+
74
+ function shortenPath(path: string, maxSegments: number) {
75
+ const normalized = fullHomePath(path);
76
+ const parts = normalized.split(/[\\/]+/).filter(Boolean);
77
+ if (parts.length <= maxSegments) return normalized || sep;
78
+ const prefix = normalized.startsWith("~") ? "~/…" : "…";
79
+ return `${prefix}/${parts.slice(-maxSegments).join("/")}`;
80
+ }
81
+
82
+ function fullHomePath(path: string) {
83
+ const home = process.env.HOME;
84
+ if (!home || !isPathInsideHome(path, home)) return path;
85
+ return `~${path.slice(home.length)}`;
86
+ }
87
+
88
+ function fishStylePath(path: string, segmentLength: number) {
89
+ const normalized = fullHomePath(path);
90
+ const hasHomePrefix = normalized.startsWith("~");
91
+ const parts = normalized.split(/[\\/]+/).filter(Boolean);
92
+ const pathParts = hasHomePrefix ? parts.slice(1) : parts;
93
+ if (pathParts.length <= 1) return normalized || sep;
94
+
95
+ const prefix = hasHomePrefix ? "~/" : normalized.startsWith(sep) ? sep : "";
96
+ const abbreviated = pathParts.map((part, index) =>
97
+ index === pathParts.length - 1
98
+ ? part
99
+ : Array.from(part).slice(0, segmentLength).join("") || part,
100
+ );
101
+ return `${prefix}${abbreviated.join("/")}`;
102
+ }
103
+
104
+ function isPathInsideHome(path: string, home: string) {
105
+ if (path === home) return true;
106
+ const next = path[home.length];
107
+ return path.startsWith(home) && (next === "/" || next === "\\");
108
+ }
@@ -0,0 +1,34 @@
1
+ import { createEventWidgetId } from "../../event-widgets.js";
2
+ import { defineWidget } from "../types.js";
3
+
4
+ export const EventValueWidget = defineWidget({
5
+ type: "event",
6
+ label: "Event Value",
7
+ category: "Core",
8
+ description: "Value updated by other extensions through pi.events",
9
+ dependencies: ["eventWidgets"],
10
+ baseOptions: ["raw", "hideWhenEmpty", "icon", "text"],
11
+ baseOptionDefaults: { hideWhenEmpty: true },
12
+ properties: [
13
+ {
14
+ id: "widgetId",
15
+ label: "Widget ID",
16
+ kind: "text",
17
+ description: "ID used by pi.events publishers to update this widget",
18
+ default: "",
19
+ options: {
20
+ showInWidgets: true,
21
+ showInColors: false,
22
+ listProperty: "id",
23
+ },
24
+ },
25
+ ],
26
+ icons: { emoji: "", nerd: "", text: "" },
27
+ defaultStyle: { fg: "default", bg: "default", bold: false },
28
+ createOptionDefaults() {
29
+ return { widgetId: createEventWidgetId() };
30
+ },
31
+ render({ ctx, options, renderWidget }) {
32
+ return renderWidget(ctx.eventWidgets?.get(options.widgetId ?? ""));
33
+ },
34
+ });
@@ -0,0 +1,102 @@
1
+ import type { WidgetOptions } from "../../types.js";
2
+ import { defineWidget } from "../types.js";
3
+
4
+ export const ExtensionStatusWidget = defineWidget({
5
+ type: "external-status",
6
+ label: "Extension Status",
7
+ category: "Core",
8
+ description: "Status value published by another pi extension through ctx.ui.setStatus",
9
+ dependencies: ["getExtensionStatuses"],
10
+ baseOptions: ["raw", "hideWhenEmpty", "icon", "text"],
11
+ baseOptionDefaults: { hideWhenEmpty: true },
12
+ properties: [
13
+ {
14
+ id: "externalStatusKey",
15
+ label: "Status key",
16
+ kind: "text",
17
+ description: "Extension status key to render",
18
+ default: "",
19
+ options: {
20
+ showInWidgets: true,
21
+ showInColors: true,
22
+ listProperty: "status",
23
+ editAction: "external-status-key",
24
+ },
25
+ },
26
+ {
27
+ id: "trimValue",
28
+ label: "Trim value",
29
+ kind: "number",
30
+ description: "Leading visible characters to trim from the status value",
31
+ default: 0,
32
+ options: {
33
+ min: 0,
34
+ max: 10,
35
+ showInWidgets: true,
36
+ showInColors: false,
37
+ listProperty: "trim",
38
+ },
39
+ },
40
+ {
41
+ id: "preserveTrimStyles",
42
+ label: "Preserve trim styles",
43
+ kind: "boolean",
44
+ description: "Replay ANSI styles from the trimmed prefix after labels or custom icons",
45
+ default: true,
46
+ options: {
47
+ showInWidgets: false,
48
+ showInColors: false,
49
+ },
50
+ },
51
+ ],
52
+ icons: { emoji: "", nerd: "", text: "" },
53
+ defaultStyle: { fg: "default", bg: "default", bold: false },
54
+ render({ ctx, options, renderWidget }) {
55
+ const key = options.externalStatusKey.trim();
56
+ const value = key ? ctx.getExtensionStatuses?.()?.get(key) : undefined;
57
+ const rendered = value
58
+ ? trimLeadingVisibleChars(value, options.trimValue, options.preserveTrimStyles)
59
+ : { value: "" };
60
+ return renderWidget(rendered.value, {
61
+ ...(rendered.preservedTrimStyles
62
+ ? { preservedTrimStyles: rendered.preservedTrimStyles }
63
+ : {}),
64
+ ...(shouldStripIncomingStyles(options) ? { stripIncomingStyles: true } : {}),
65
+ });
66
+ },
67
+ });
68
+
69
+ function shouldStripIncomingStyles(options: WidgetOptions) {
70
+ return (
71
+ (options.fg !== undefined && options.fg !== "default") ||
72
+ (options.bg !== undefined && options.bg !== "default") ||
73
+ options.bold === true
74
+ );
75
+ }
76
+
77
+ function trimLeadingVisibleChars(text: string, count: number, preserveStyles: boolean) {
78
+ if (count <= 0) return { value: text };
79
+
80
+ let trimmed = 0;
81
+ let index = 0;
82
+ let preservedStyles = "";
83
+ while (index < text.length && trimmed < count) {
84
+ // ANSI sequences have zero visible width. Keep walking without counting them as trimmed chars.
85
+ // oxlint-disable-next-line no-control-regex
86
+ const ansiMatch = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(text.slice(index));
87
+ if (ansiMatch) {
88
+ if (preserveStyles) preservedStyles += ansiMatch[0];
89
+ index += ansiMatch[0].length;
90
+ continue;
91
+ }
92
+
93
+ // Advance by Unicode code point so surrogate-pair symbols count as one visible character.
94
+ const codePoint = text.codePointAt(index);
95
+ if (codePoint === undefined) break;
96
+ index += codePoint > 0xffff ? 2 : 1;
97
+ trimmed += 1;
98
+ }
99
+
100
+ const value = text.slice(index);
101
+ return preservedStyles ? { value, preservedTrimStyles: preservedStyles } : { value };
102
+ }
@@ -0,0 +1,20 @@
1
+ import { defineWidget } from "../types.js";
2
+
3
+ export const ModelProviderWidget = defineWidget({
4
+ type: "model-provider",
5
+ label: "Provider/Model",
6
+ category: "Core",
7
+ description: "Provider and model together",
8
+ dependencies: ["model", "provider"],
9
+ baseOptions: ["raw", "icon"],
10
+ baseOptionDefaults: {},
11
+ properties: [],
12
+ icons: { emoji: "🤖", nerd: "󰚩", text: "model" },
13
+ defaultStyle: { fg: "cyan", bg: "default", bold: false },
14
+ render({ ctx, renderWidget }) {
15
+ const value = ctx.provider
16
+ ? `${ctx.provider}/${ctx.model ?? "no-model"}`
17
+ : (ctx.model ?? "no-model");
18
+ return renderWidget(value);
19
+ },
20
+ });
@@ -0,0 +1,35 @@
1
+ import { defineWidget } from "../types.js";
2
+
3
+ export const ModelWidget = defineWidget({
4
+ type: "model",
5
+ label: "Model",
6
+ category: "Core",
7
+ description: "Active model id",
8
+ dependencies: ["model", "provider"],
9
+ baseOptions: ["raw", "icon"],
10
+ baseOptionDefaults: {},
11
+ properties: [
12
+ {
13
+ id: "showProvider",
14
+ label: "Show provider",
15
+ kind: "boolean",
16
+ description: "Show provider name before the model id",
17
+ default: false,
18
+ options: {
19
+ label: "with-provider",
20
+ showInWidgets: true,
21
+ showInColors: true,
22
+ listProperty: "",
23
+ },
24
+ },
25
+ ],
26
+ icons: { emoji: "🤖", nerd: "󰚩", text: "model" },
27
+ defaultStyle: { fg: "cyan", bg: "default", bold: false },
28
+ render({ ctx, options, renderWidget }) {
29
+ const value =
30
+ options.showProvider && ctx.provider
31
+ ? `${ctx.provider}/${ctx.model ?? "no-model"}`
32
+ : (ctx.model ?? "no-model");
33
+ return renderWidget(value);
34
+ },
35
+ });
@@ -0,0 +1,17 @@
1
+ import { defineWidget } from "../types.js";
2
+
3
+ export const ProviderWidget = defineWidget({
4
+ type: "provider",
5
+ label: "Provider",
6
+ category: "Core",
7
+ description: "Active model provider",
8
+ dependencies: ["provider"],
9
+ baseOptions: ["raw", "hideWhenEmpty", "icon", "text"],
10
+ baseOptionDefaults: {},
11
+ properties: [],
12
+ icons: { emoji: "☁️", nerd: "󰒋", text: "provider" },
13
+ defaultStyle: { fg: "cyan", bg: "default", bold: false },
14
+ render({ ctx, renderWidget }) {
15
+ return renderWidget(ctx.provider);
16
+ },
17
+ });
@@ -0,0 +1,17 @@
1
+ import { defineWidget } from "../types.js";
2
+
3
+ export const SessionNameWidget = defineWidget({
4
+ type: "session-name",
5
+ label: "Session Name",
6
+ category: "Core",
7
+ description: "Pi session name",
8
+ dependencies: ["sessionName"],
9
+ baseOptions: ["raw", "hideWhenEmpty", "icon", "text"],
10
+ baseOptionDefaults: { hideWhenEmpty: true },
11
+ properties: [],
12
+ icons: { emoji: "🏷️", nerd: "󰍹", text: "session" },
13
+ defaultStyle: { fg: "cyan", bg: "default", bold: false },
14
+ render({ ctx, renderWidget }) {
15
+ return renderWidget(ctx.sessionName);
16
+ },
17
+ });