@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.
- package/README.md +41 -0
- package/examples/pi-footer.json +275 -0
- package/node_modules/pi-footer/CHANGELOG.md +145 -0
- package/node_modules/pi-footer/LICENSE +21 -0
- package/node_modules/pi-footer/README.md +354 -0
- package/node_modules/pi-footer/package.json +68 -0
- package/node_modules/pi-footer/src/cache.ts +106 -0
- package/node_modules/pi-footer/src/colors.ts +237 -0
- package/node_modules/pi-footer/src/config.ts +202 -0
- package/node_modules/pi-footer/src/event-widgets.ts +41 -0
- package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
- package/node_modules/pi-footer/src/git.ts +135 -0
- package/node_modules/pi-footer/src/index.ts +268 -0
- package/node_modules/pi-footer/src/metrics.ts +141 -0
- package/node_modules/pi-footer/src/presets.ts +383 -0
- package/node_modules/pi-footer/src/render.ts +114 -0
- package/node_modules/pi-footer/src/separators.ts +52 -0
- package/node_modules/pi-footer/src/types.ts +122 -0
- package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
- package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
- package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
- package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
- package/node_modules/pi-footer/src/ui/events.ts +35 -0
- package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
- package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
- package/node_modules/pi-footer/src/ui/fields.ts +252 -0
- package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
- package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
- package/node_modules/pi-footer/src/ui/layout.ts +17 -0
- package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
- package/node_modules/pi-footer/src/ui/model.ts +64 -0
- package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
- package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
- package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
- package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
- package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
- package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
- package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
- package/node_modules/pi-footer/src/ui/screen.ts +243 -0
- package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
- package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
- package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
- package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
- package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
- package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
- package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
- package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
- package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
- package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
- package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
- package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
- package/node_modules/pi-footer/src/ui/theme.ts +48 -0
- package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
- package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
- package/node_modules/pi-footer/src/ui.ts +66 -0
- package/node_modules/pi-footer/src/widgets/context.ts +28 -0
- package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
- package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
- package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
- package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
- package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
- package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
- package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
- package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
- package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
- package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
- package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
- package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
- package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
- package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
- package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
- package/node_modules/pi-footer/src/widgets/options.ts +122 -0
- package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
- package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
- package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/store.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/types.ts +181 -0
- package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
- package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
- package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
- package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
- package/package.json +7 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ColorName } from "../colors.js";
|
|
2
|
+
import {
|
|
3
|
+
ansi256Digits,
|
|
4
|
+
appendAnsi256Digit,
|
|
5
|
+
colorDisplayName,
|
|
6
|
+
deleteAnsi256Digit,
|
|
7
|
+
STANDARD_COLORS,
|
|
8
|
+
} from "../colors.js";
|
|
9
|
+
import { cloneConfig, configWithPreset, DEFAULT_CONFIG } from "../config.js";
|
|
10
|
+
import { PRESET_DEFINITIONS, type Preset } from "../presets.js";
|
|
11
|
+
import { SEPARATOR_VALUES } from "../separators.js";
|
|
12
|
+
import type { StatuslineConfig, StatuslineSettings } from "../types.js";
|
|
13
|
+
import { ICON_MODE_VALUES } from "../types.js";
|
|
14
|
+
import { adjustAnsi, cycle } from "./helpers.js";
|
|
15
|
+
import { ICON_MODE_LABELS } from "./model.js";
|
|
16
|
+
|
|
17
|
+
export const GLOBAL_MENU_ACTIONS = [
|
|
18
|
+
"toggle-enabled",
|
|
19
|
+
"preset",
|
|
20
|
+
"separator",
|
|
21
|
+
"separator-fg",
|
|
22
|
+
"separator-bg",
|
|
23
|
+
"separator-fg-ansi",
|
|
24
|
+
"separator-bg-ansi",
|
|
25
|
+
"icon-mode",
|
|
26
|
+
"minimalist",
|
|
27
|
+
"reset",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
type GlobalMenuAction = (typeof GLOBAL_MENU_ACTIONS)[number];
|
|
31
|
+
|
|
32
|
+
export const GLOBAL_MENU_HINT =
|
|
33
|
+
"↑/↓ option • ←/→ or enter change • type digits for ANSI256 • backspace delete • esc back";
|
|
34
|
+
|
|
35
|
+
export function globalMenuFields(config: StatuslineSettings): string[] {
|
|
36
|
+
return [
|
|
37
|
+
`Enabled: ${config.enabled ? "on" : "off"}`,
|
|
38
|
+
`Preset: ${config.preset}`,
|
|
39
|
+
`Separator: ${config.separator}`,
|
|
40
|
+
`Separator foreground: ${colorDisplayName(config.separatorFg)}`,
|
|
41
|
+
`Separator background: ${colorDisplayName(config.separatorBg)}`,
|
|
42
|
+
`Custom ANSI256 separator foreground: ${ansi256Digits(config.separatorFg)}`,
|
|
43
|
+
`Custom ANSI256 separator background: ${ansi256Digits(config.separatorBg)}`,
|
|
44
|
+
`Icons: ${ICON_MODE_LABELS[config.iconMode]}`,
|
|
45
|
+
`Minimalist mode: ${config.minimalist ? "on" : "off"}`,
|
|
46
|
+
"Reset to defaults",
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function globalMenuAction(index: number): GlobalMenuAction {
|
|
51
|
+
return GLOBAL_MENU_ACTIONS[index] ?? "reset";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function applyGlobalMenuAction(
|
|
55
|
+
config: StatuslineConfig,
|
|
56
|
+
action: GlobalMenuAction,
|
|
57
|
+
delta: number,
|
|
58
|
+
): StatuslineConfig {
|
|
59
|
+
if (action === "preset")
|
|
60
|
+
return configWithPreset(
|
|
61
|
+
config,
|
|
62
|
+
cycle(Object.keys(PRESET_DEFINITIONS) as Preset[], config.preset, delta),
|
|
63
|
+
);
|
|
64
|
+
if (action === "reset") return cloneConfig(DEFAULT_CONFIG);
|
|
65
|
+
|
|
66
|
+
const next = { ...config };
|
|
67
|
+
applyGlobalSettingsAction(next, action, delta);
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applyGlobalSettingsAction(
|
|
72
|
+
settings: StatuslineSettings,
|
|
73
|
+
action: GlobalMenuAction,
|
|
74
|
+
delta: number,
|
|
75
|
+
): boolean {
|
|
76
|
+
if (action === "toggle-enabled") settings.enabled = !settings.enabled;
|
|
77
|
+
else if (action === "separator")
|
|
78
|
+
settings.separator = cycle(SEPARATOR_VALUES, settings.separator, delta);
|
|
79
|
+
else if (action === "separator-fg")
|
|
80
|
+
settings.separatorFg = cycleStandardColor(settings.separatorFg, delta);
|
|
81
|
+
else if (action === "separator-bg")
|
|
82
|
+
settings.separatorBg = cycleStandardColor(settings.separatorBg, delta);
|
|
83
|
+
else if (action === "separator-fg-ansi")
|
|
84
|
+
settings.separatorFg = adjustAnsi(settings.separatorFg, delta);
|
|
85
|
+
else if (action === "separator-bg-ansi")
|
|
86
|
+
settings.separatorBg = adjustAnsi(settings.separatorBg, delta);
|
|
87
|
+
else if (action === "icon-mode")
|
|
88
|
+
settings.iconMode = cycle(ICON_MODE_VALUES, settings.iconMode, delta);
|
|
89
|
+
else if (action === "minimalist") settings.minimalist = !settings.minimalist;
|
|
90
|
+
else return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function applyGlobalSettingsTextInput(
|
|
95
|
+
settings: StatuslineSettings,
|
|
96
|
+
action: GlobalMenuAction,
|
|
97
|
+
data: string,
|
|
98
|
+
): boolean {
|
|
99
|
+
if (!/^\d$/.test(data)) return false;
|
|
100
|
+
if (action === "separator-fg-ansi")
|
|
101
|
+
settings.separatorFg = appendAnsi256Digit(settings.separatorFg, data);
|
|
102
|
+
else if (action === "separator-bg-ansi")
|
|
103
|
+
settings.separatorBg = appendAnsi256Digit(settings.separatorBg, data);
|
|
104
|
+
else return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function applyGlobalSettingsBackspace(
|
|
109
|
+
settings: StatuslineSettings,
|
|
110
|
+
action: GlobalMenuAction,
|
|
111
|
+
): boolean {
|
|
112
|
+
if (action === "separator-fg-ansi")
|
|
113
|
+
settings.separatorFg = deleteAnsi256Digit(settings.separatorFg);
|
|
114
|
+
else if (action === "separator-bg-ansi")
|
|
115
|
+
settings.separatorBg = deleteAnsi256Digit(settings.separatorBg);
|
|
116
|
+
else return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function cycleStandardColor(current: ColorName, delta: number): ColorName {
|
|
121
|
+
return cycle(
|
|
122
|
+
STANDARD_COLORS.map((color) => color.value),
|
|
123
|
+
current,
|
|
124
|
+
delta,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ColorName } from "../colors.js";
|
|
2
|
+
import type { ScreenView } from "./model.js";
|
|
3
|
+
|
|
4
|
+
export function escapeTarget(view: ScreenView): ScreenView | "close" {
|
|
5
|
+
if (view === "main") return "close";
|
|
6
|
+
if (
|
|
7
|
+
view === "line-list" ||
|
|
8
|
+
view === "color-line-list" ||
|
|
9
|
+
view === "terminal" ||
|
|
10
|
+
view === "global" ||
|
|
11
|
+
view === "extension-status-row"
|
|
12
|
+
)
|
|
13
|
+
return "main";
|
|
14
|
+
if (view === "widget-list") return "line-list";
|
|
15
|
+
if (view === "color-widget-list") return "color-line-list";
|
|
16
|
+
if (view === "edit-colors") return "color-widget-list";
|
|
17
|
+
if (view === "add-widget" || view === "edit-widget") return "widget-list";
|
|
18
|
+
if (view === "confirm-color-level") return "terminal";
|
|
19
|
+
if (view === "confirm-exit") return "main";
|
|
20
|
+
return "main";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function cycle<T>(values: readonly T[], current: T, delta: number): T {
|
|
24
|
+
const index = values.indexOf(current);
|
|
25
|
+
return values[(index + delta + values.length) % values.length] ?? current;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function adjustAnsi(current: ColorName | undefined, delta: number): ColorName {
|
|
29
|
+
const currentCode = current?.startsWith("ansi256:") ? Number(current.slice(8)) : 0;
|
|
30
|
+
return `ansi256:${wrap(currentCode + delta, 256)}` as ColorName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function wrap(value: number, length: number): number {
|
|
34
|
+
if (length <= 0) return 0;
|
|
35
|
+
return (value + length) % length;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
39
|
+
return Math.min(max, Math.max(min, value));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isPrintable(data: string): boolean {
|
|
43
|
+
return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) !== 127;
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const CONFIG_UI_RESERVED_ROWS = 8;
|
|
2
|
+
export const MIN_VISIBLE_ROW_COUNT = 4;
|
|
3
|
+
|
|
4
|
+
export function activeLineCount(lines: readonly (readonly unknown[])[]): number {
|
|
5
|
+
return lines.filter((line) => line.length > 0).length;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function visibleRowCount(
|
|
9
|
+
terminalRows: number,
|
|
10
|
+
heightRatio: number,
|
|
11
|
+
activeLines: number,
|
|
12
|
+
): number {
|
|
13
|
+
return Math.max(
|
|
14
|
+
MIN_VISIBLE_ROW_COUNT,
|
|
15
|
+
Math.floor(terminalRows * heightRatio) - CONFIG_UI_RESERVED_ROWS - activeLines,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const LINE_LIST_HINT =
|
|
2
|
+
"↑/↓ select • pgup/pgdn jump • enter edit • a add • c clone • w/s move • d delete • esc back";
|
|
3
|
+
|
|
4
|
+
export function lineListCountLabel(total: number, start: number, end: number): string {
|
|
5
|
+
return `${total} line(s), showing ${rangeLabel(start, end, total)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function lineListItemLabel(
|
|
9
|
+
index: number,
|
|
10
|
+
widgetCount: number,
|
|
11
|
+
dim: (text: string) => string,
|
|
12
|
+
): string {
|
|
13
|
+
return `☰ Line ${index + 1} ${dim(`(${widgetCount} widget${widgetCount === 1 ? "" : "s"})`)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function rangeLabel(start: number, end: number, total: number): string {
|
|
17
|
+
if (total === 0) return "0-0";
|
|
18
|
+
return `${start + 1}-${end}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ColorLevel } from "../colors.js";
|
|
2
|
+
import type { IconMode, TerminalWidthMode } from "../types.js";
|
|
3
|
+
import type { WidgetProperty } from "../widgets/types.js";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_UI_HEIGHT_RATIO = 1;
|
|
6
|
+
|
|
7
|
+
export const WIDTH_MODE_LABELS: Record<TerminalWidthMode, string> = {
|
|
8
|
+
full: "Full width always",
|
|
9
|
+
"full-minus-40": "Full width minus 40",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const COLOR_LEVEL_LABELS: Record<ColorLevel, string> = {
|
|
13
|
+
truecolor: "Truecolor",
|
|
14
|
+
ansi256: "256 Color",
|
|
15
|
+
ansi16: "Basic (Standard 16-color)",
|
|
16
|
+
none: "No Color",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const ICON_MODE_LABELS: Record<IconMode, string> = {
|
|
20
|
+
emoji: "Emoji",
|
|
21
|
+
nerd: "Nerd Font icons",
|
|
22
|
+
text: "Text labels",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ScreenView =
|
|
26
|
+
| "main"
|
|
27
|
+
| "line-list"
|
|
28
|
+
| "widget-list"
|
|
29
|
+
| "add-widget"
|
|
30
|
+
| "edit-widget"
|
|
31
|
+
| "color-line-list"
|
|
32
|
+
| "color-widget-list"
|
|
33
|
+
| "edit-colors"
|
|
34
|
+
| "terminal"
|
|
35
|
+
| "confirm-color-level"
|
|
36
|
+
| "confirm-exit"
|
|
37
|
+
| "global"
|
|
38
|
+
| "extension-status-row";
|
|
39
|
+
|
|
40
|
+
type ColorField =
|
|
41
|
+
| "fg"
|
|
42
|
+
| "bg"
|
|
43
|
+
| "bold"
|
|
44
|
+
| "fgAnsi"
|
|
45
|
+
| "bgAnsi"
|
|
46
|
+
| "warningFg"
|
|
47
|
+
| "warningBg"
|
|
48
|
+
| "warningFgAnsi"
|
|
49
|
+
| "warningBgAnsi"
|
|
50
|
+
| "dangerFg"
|
|
51
|
+
| "dangerBg"
|
|
52
|
+
| "dangerFgAnsi"
|
|
53
|
+
| "dangerBgAnsi";
|
|
54
|
+
|
|
55
|
+
// Flattened, UI-facing projection of WidgetProperty: id/label/kind plus the editable bits
|
|
56
|
+
// hoisted out of WidgetProperty.options. Derived structurally so it can't drift from the source.
|
|
57
|
+
export type OptionField = Pick<WidgetProperty, "id" | "label" | "kind"> &
|
|
58
|
+
Pick<NonNullable<WidgetProperty["options"]>, "min" | "max" | "choices" | "editAction">;
|
|
59
|
+
|
|
60
|
+
export interface ColorOptionField {
|
|
61
|
+
id: ColorField;
|
|
62
|
+
label: string;
|
|
63
|
+
kind: "color" | "boolean" | "ansi";
|
|
64
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { clamp } from "./helpers.js";
|
|
2
|
+
|
|
3
|
+
export function pageSelection(
|
|
4
|
+
selected: number,
|
|
5
|
+
total: number,
|
|
6
|
+
visibleCount: number,
|
|
7
|
+
direction: -1 | 1,
|
|
8
|
+
): number {
|
|
9
|
+
if (total <= 0) return 0;
|
|
10
|
+
return clamp(selected + direction * visibleCount, 0, total - 1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function scrollWindow(
|
|
14
|
+
total: number,
|
|
15
|
+
selected: number,
|
|
16
|
+
visibleCount: number,
|
|
17
|
+
): { start: number; end: number } {
|
|
18
|
+
const start = clamp(
|
|
19
|
+
selected - Math.floor(visibleCount / 2),
|
|
20
|
+
0,
|
|
21
|
+
Math.max(0, total - visibleCount),
|
|
22
|
+
);
|
|
23
|
+
return { start, end: Math.min(total, start + visibleCount) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rangeLabel(start: number, end: number, total: number): string {
|
|
27
|
+
if (total === 0) return "0-0";
|
|
28
|
+
return `${start + 1}-${end}`;
|
|
29
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { registry } from "../widgets/registry.js";
|
|
2
|
+
import type { Widget, WidgetEditAction, WidgetProperty } from "../widgets/types.js";
|
|
3
|
+
import { getBooleanField, getNumberField, getTextField } from "./fields.js";
|
|
4
|
+
import { cycle } from "./helpers.js";
|
|
5
|
+
import type { OptionField } from "./model.js";
|
|
6
|
+
|
|
7
|
+
type OptionFieldApplyResult = "changed" | "unchanged" | WidgetEditAction;
|
|
8
|
+
|
|
9
|
+
type OptionFieldEdit = { kind: "appendText"; text: string } | { kind: "deleteText" };
|
|
10
|
+
|
|
11
|
+
export function applyOptionFieldEdit(
|
|
12
|
+
widget: Widget,
|
|
13
|
+
field: OptionField,
|
|
14
|
+
edit: OptionFieldEdit,
|
|
15
|
+
): OptionFieldApplyResult {
|
|
16
|
+
if (field.kind !== "text") return "unchanged";
|
|
17
|
+
if (edit.kind === "appendText") {
|
|
18
|
+
widget.update({ [field.id]: getTextField(widget, field.id) + edit.text });
|
|
19
|
+
return "changed";
|
|
20
|
+
}
|
|
21
|
+
widget.update({ [field.id]: getTextField(widget, field.id).slice(0, -1) });
|
|
22
|
+
return "changed";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function applyOptionField(
|
|
26
|
+
widget: Widget,
|
|
27
|
+
field: OptionField,
|
|
28
|
+
delta: number,
|
|
29
|
+
): OptionFieldApplyResult {
|
|
30
|
+
if (field.id === "enabled") {
|
|
31
|
+
widget.toggle();
|
|
32
|
+
return "changed";
|
|
33
|
+
}
|
|
34
|
+
if (field.editAction) return field.editAction;
|
|
35
|
+
|
|
36
|
+
const properties: readonly WidgetProperty[] = registry.spec(widget.type).properties;
|
|
37
|
+
const property = properties.find((item) => item.id === field.id);
|
|
38
|
+
if (property?.options?.editAction) return property.options.editAction;
|
|
39
|
+
|
|
40
|
+
if (field.kind === "boolean" && isMetadataBooleanField(widget, field.id)) {
|
|
41
|
+
metadataSetBooleanField(widget, field.id, !getBooleanField(widget, field.id));
|
|
42
|
+
return "changed";
|
|
43
|
+
}
|
|
44
|
+
if (field.kind === "number") {
|
|
45
|
+
if (property?.kind !== "number") return "unchanged";
|
|
46
|
+
const min = field.min ?? property.options?.min ?? 1;
|
|
47
|
+
const max = field.max ?? property.options?.max ?? 99;
|
|
48
|
+
const current = getNumberField(widget, field.id) ?? min;
|
|
49
|
+
metadataSetNumberField(widget, field.id, Math.min(max, Math.max(min, current + delta)));
|
|
50
|
+
return "changed";
|
|
51
|
+
}
|
|
52
|
+
if (field.kind === "choice") {
|
|
53
|
+
if (property?.kind !== "choice") return "unchanged";
|
|
54
|
+
const choices = field.choices ?? property.options?.choices;
|
|
55
|
+
const choiceIds = choices?.map((choice) => choice.id) ?? [];
|
|
56
|
+
if (choiceIds.length === 0) return "unchanged";
|
|
57
|
+
const current = getTextField(widget, field.id) || (choiceIds[0] ?? "");
|
|
58
|
+
widget.update({ [field.id]: cycle(choiceIds, current, delta) });
|
|
59
|
+
return "changed";
|
|
60
|
+
}
|
|
61
|
+
return "unchanged";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function metadataSetBooleanField(widget: Widget, id: string, value: boolean): void {
|
|
65
|
+
const spec = registry.spec(widget.type);
|
|
66
|
+
const properties: readonly WidgetProperty[] = spec.properties;
|
|
67
|
+
const property = properties.find((item) => item.id === id);
|
|
68
|
+
if (property?.kind === "boolean") {
|
|
69
|
+
widget.update({ [id]: value });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (!spec.baseOptions.some((option) => option === id)) return;
|
|
73
|
+
if (id === "raw") widget.update({ raw: value });
|
|
74
|
+
if (id === "hideWhenEmpty") widget.update({ hideWhenEmpty: value });
|
|
75
|
+
if (id === "hideWhenZero") widget.update({ hideWhenZero: value });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function metadataSetNumberField(widget: Widget, id: string, value: number): void {
|
|
79
|
+
const properties: readonly WidgetProperty[] = registry.spec(widget.type).properties;
|
|
80
|
+
const property = properties.find((item) => item.id === id);
|
|
81
|
+
if (property?.kind === "number") widget.update({ [id]: value });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isMetadataBooleanField(widget: Widget, id: string): boolean {
|
|
85
|
+
const spec = registry.spec(widget.type);
|
|
86
|
+
const properties: readonly WidgetProperty[] = spec.properties;
|
|
87
|
+
const property = properties.find((item) => item.id === id);
|
|
88
|
+
if (property?.kind === "boolean") return true;
|
|
89
|
+
return (
|
|
90
|
+
spec.baseOptions.some((option) => option === id) &&
|
|
91
|
+
(id === "raw" || id === "hideWhenEmpty" || id === "hideWhenZero")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { GetExtensionStatuses } from "../extension-statuses.js";
|
|
4
|
+
import { renderStatuslines } from "../render.js";
|
|
5
|
+
import type { StatuslineData } from "../types.js";
|
|
6
|
+
import type { WidgetStore } from "../widgets/store.js";
|
|
7
|
+
import type { ScreenRender } from "./screen-render.js";
|
|
8
|
+
import type { UiTheme } from "./theme.js";
|
|
9
|
+
import { configTitleBarParts, previewTitleParts } from "./title-bar.js";
|
|
10
|
+
|
|
11
|
+
interface OverlayRenderOptions {
|
|
12
|
+
width: number;
|
|
13
|
+
terminalRows: number;
|
|
14
|
+
activeLineCount: number;
|
|
15
|
+
visibleRowCount: number;
|
|
16
|
+
store: WidgetStore;
|
|
17
|
+
previewData: StatuslineData;
|
|
18
|
+
getExtensionStatuses: GetExtensionStatuses;
|
|
19
|
+
theme: Theme;
|
|
20
|
+
requestRender?: () => void;
|
|
21
|
+
configStateText: string;
|
|
22
|
+
body: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface OverlayLayout {
|
|
26
|
+
innerWidth: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class OverlayRender {
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly theme: UiTheme,
|
|
32
|
+
private readonly screenRender: ScreenRender,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
render(options: OverlayRenderOptions): string[] {
|
|
36
|
+
const layout = this.layout(options);
|
|
37
|
+
const lines = [
|
|
38
|
+
this.previewTitleLine(layout),
|
|
39
|
+
...this.previewLines(options),
|
|
40
|
+
this.previewBottomLine(layout),
|
|
41
|
+
this.blankSeparator(options.width),
|
|
42
|
+
this.configTitleLine(options, layout),
|
|
43
|
+
...options.body,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
this.fillToTerminal(lines, options);
|
|
47
|
+
lines.push(this.bottomBorder(layout));
|
|
48
|
+
return lines;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private layout(options: OverlayRenderOptions): OverlayLayout {
|
|
52
|
+
return { innerWidth: Math.max(1, options.width - 2) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private previewTitleLine(layout: OverlayLayout): string {
|
|
56
|
+
const previewTitle = previewTitleParts(layout.innerWidth);
|
|
57
|
+
return (
|
|
58
|
+
this.theme.border("─") +
|
|
59
|
+
this.theme.previewTitle(previewTitle.title) +
|
|
60
|
+
this.theme.border(`${"─".repeat(previewTitle.rightPad)}─`)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private previewLines(options: OverlayRenderOptions): string[] {
|
|
65
|
+
return renderStatuslines(options.store, options.previewData, Math.max(20, options.width), {
|
|
66
|
+
getExtensionStatuses: options.getExtensionStatuses,
|
|
67
|
+
theme: options.theme,
|
|
68
|
+
...(options.requestRender ? { requestRender: options.requestRender } : {}),
|
|
69
|
+
}).map((line) => this.screenRender.lineW(line, options.width));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private previewBottomLine(layout: OverlayLayout): string {
|
|
73
|
+
return this.theme.border(`─${"─".repeat(layout.innerWidth)}─`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private blankSeparator(width: number): string {
|
|
77
|
+
return " " + this.screenRender.padLine(width, "", "") + " ";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private configTitleLine(options: OverlayRenderOptions, layout: OverlayLayout): string {
|
|
81
|
+
const title = configTitleBarParts(
|
|
82
|
+
layout.innerWidth,
|
|
83
|
+
(text) => this.theme.dim(text),
|
|
84
|
+
Date.now(),
|
|
85
|
+
options.configStateText,
|
|
86
|
+
(text) => this.theme.border(text),
|
|
87
|
+
);
|
|
88
|
+
return (
|
|
89
|
+
this.theme.border("╭─") + title.title + this.theme.border(`${"─".repeat(title.rightPad)}╮`)
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private fillToTerminal(lines: string[], options: OverlayRenderOptions): void {
|
|
94
|
+
const neededToFillScreen = options.terminalRows - lines.length - 1;
|
|
95
|
+
if (neededToFillScreen > 0) {
|
|
96
|
+
lines.push(...Array(neededToFillScreen).fill(this.screenRender.line("", options.width)));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private bottomBorder(layout: OverlayLayout): string {
|
|
101
|
+
return this.theme.border(`╰${"─".repeat(layout.innerWidth)}╯`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GetExtensionStatuses } from "../extension-statuses.js";
|
|
2
|
+
import type { Widget } from "../widgets/types.js";
|
|
3
|
+
import type { ScreenView } from "./model.js";
|
|
4
|
+
import type { ScreenState } from "./screen-state.js";
|
|
5
|
+
import type { UiTheme } from "./theme.js";
|
|
6
|
+
|
|
7
|
+
export interface ScreenContext {
|
|
8
|
+
state: ScreenState;
|
|
9
|
+
theme: UiTheme;
|
|
10
|
+
getExtensionStatuses: GetExtensionStatuses;
|
|
11
|
+
currentLine(): Widget[];
|
|
12
|
+
currentWidget(): Widget | undefined;
|
|
13
|
+
visibleRowCount(): number;
|
|
14
|
+
show(view: ScreenView): void;
|
|
15
|
+
emitChange(): void;
|
|
16
|
+
save(exitAfterSave: boolean): void;
|
|
17
|
+
exitWithoutSaving(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ScreenContext } from "./screen-context.js";
|
|
2
|
+
import type { ScreenView } from "./model.js";
|
|
3
|
+
import type { Controller } from "./screens/controller.js";
|
|
4
|
+
|
|
5
|
+
export class ScreenController {
|
|
6
|
+
private readonly screens = new Map<ScreenView, Controller>();
|
|
7
|
+
|
|
8
|
+
constructor(private readonly ctx: ScreenContext) {}
|
|
9
|
+
|
|
10
|
+
register(view: ScreenView, screen: Controller): void {
|
|
11
|
+
this.screens.set(view, screen);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
renderScreen(width: number): string[] {
|
|
15
|
+
return this.currentScreen().renderScreen(width);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
handleInput(data: string): void {
|
|
19
|
+
this.currentScreen().handleInput(data);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private currentScreen(): Controller {
|
|
23
|
+
const screen = this.screens.get(this.ctx.state.view);
|
|
24
|
+
if (!screen) throw new Error(`No screen registered for view: ${this.ctx.state.view}`);
|
|
25
|
+
return screen;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import type { UiTheme } from "./theme.js";
|
|
4
|
+
|
|
5
|
+
export class ScreenRender {
|
|
6
|
+
constructor(private readonly theme: UiTheme) {}
|
|
7
|
+
|
|
8
|
+
menuTitle(title: string, description = ""): string {
|
|
9
|
+
if (description.length === 0) return this.theme.accent(title);
|
|
10
|
+
return this.theme.success(`•${title}`) + " " + this.theme.dim(description);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
padLine(width: number, content: string, ellipsis = "…"): string {
|
|
14
|
+
return truncateToWidth(content, Math.max(1, width - 2), ellipsis, true);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
lineW(content: string, width: number): string {
|
|
18
|
+
// NOTE: uncoment below for demo-look preview.
|
|
19
|
+
// const clipped = truncateToWidth(content, Math.max(0, width - 2), "…");
|
|
20
|
+
const clipped = truncateToWidth(content, Math.max(0, width), "…");
|
|
21
|
+
return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
line(content: string, width: number): string {
|
|
25
|
+
return this.theme.border("│") + this.padLine(width, content) + this.theme.border("│");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
menuLine(selected: boolean, content: string, width: number): string {
|
|
29
|
+
const text = `${selected ? "›" : " "} ${content}`;
|
|
30
|
+
return this.line(selected ? this.theme.selected(text) : text, width);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WidgetStore } from "../widgets/store.js";
|
|
2
|
+
import type { ScreenView } from "./model.js";
|
|
3
|
+
|
|
4
|
+
export interface ScreenState {
|
|
5
|
+
store: WidgetStore;
|
|
6
|
+
view: ScreenView;
|
|
7
|
+
viewBeforeConfirmExit: ScreenView;
|
|
8
|
+
selectedLine: number;
|
|
9
|
+
selectedWidget: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createScreenState(store: WidgetStore): ScreenState {
|
|
13
|
+
return {
|
|
14
|
+
store,
|
|
15
|
+
view: "main",
|
|
16
|
+
viewBeforeConfirmExit: "main",
|
|
17
|
+
selectedLine: 0,
|
|
18
|
+
selectedWidget: 0,
|
|
19
|
+
};
|
|
20
|
+
}
|