@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,75 @@
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ import { WidgetStore } from "../../widgets/store.js";
4
+ import {
5
+ applyGlobalMenuAction,
6
+ applyGlobalSettingsAction,
7
+ applyGlobalSettingsBackspace,
8
+ applyGlobalSettingsTextInput,
9
+ GLOBAL_MENU_ACTIONS,
10
+ GLOBAL_MENU_HINT,
11
+ globalMenuAction,
12
+ globalMenuFields,
13
+ } from "../global-menu.js";
14
+ import { isPrintable, wrap } from "../helpers.js";
15
+ import { Controller } from "./controller.js";
16
+
17
+ export class GlobalScreen extends Controller {
18
+ private selected = 0;
19
+
20
+ renderScreen(width: number): string[] {
21
+ const fields = globalMenuFields(this.ctx.state.store.settings);
22
+ return [
23
+ this.render.line(
24
+ this.render.menuTitle("Global Overrides", "Configure global settings for the pi-footer"),
25
+ width,
26
+ ),
27
+ this.render.line(this.ctx.theme.dim(GLOBAL_MENU_HINT), width),
28
+ ...fields.map((field, index) => this.render.menuLine(index === this.selected, field, width)),
29
+ ];
30
+ }
31
+
32
+ handleInput(data: string): void {
33
+ if (matchesKey(data, Key.up))
34
+ this.selected = wrap(this.selected - 1, GLOBAL_MENU_ACTIONS.length);
35
+ else if (matchesKey(data, Key.down))
36
+ this.selected = wrap(this.selected + 1, GLOBAL_MENU_ACTIONS.length);
37
+ else if (matchesKey(data, Key.left)) this.adjust(-1);
38
+ else if (matchesKey(data, Key.right) || matchesKey(data, Key.enter)) this.adjust(1);
39
+ else if (matchesKey(data, Key.backspace)) this.applyBackspace();
40
+ else if (isPrintable(data)) this.applyTextInput(data);
41
+ }
42
+
43
+ private adjust(delta: number): void {
44
+ const action = globalMenuAction(this.selected);
45
+ const settings = this.ctx.state.store.settings;
46
+
47
+ if (!applyGlobalSettingsAction(settings, action, delta)) {
48
+ this.ctx.state.store = WidgetStore.fromConfig(
49
+ applyGlobalMenuAction(this.ctx.state.store.toConfig(), action, delta),
50
+ );
51
+ }
52
+
53
+ this.ctx.emitChange();
54
+ }
55
+
56
+ private applyTextInput(data: string): void {
57
+ if (
58
+ !applyGlobalSettingsTextInput(
59
+ this.ctx.state.store.settings,
60
+ globalMenuAction(this.selected),
61
+ data,
62
+ )
63
+ )
64
+ return;
65
+ this.ctx.emitChange();
66
+ }
67
+
68
+ private applyBackspace(): void {
69
+ if (
70
+ !applyGlobalSettingsBackspace(this.ctx.state.store.settings, globalMenuAction(this.selected))
71
+ )
72
+ return;
73
+ this.ctx.emitChange();
74
+ }
75
+ }
@@ -0,0 +1,108 @@
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ import { registry } from "../../widgets/registry.js";
4
+ import { wrap } from "../helpers.js";
5
+ import { LINE_LIST_HINT, lineListCountLabel, lineListItemLabel } from "../line-list.js";
6
+ import type { ScreenView } from "../model.js";
7
+ import { pageSelection, scrollWindow } from "../navigation.js";
8
+ import type { ScreenContext } from "../screen-context.js";
9
+ import type { ScreenRender } from "../screen-render.js";
10
+ import { addLineAfter, cloneLineAfter, deleteLine, moveLine } from "../widget-actions.js";
11
+ import { Controller } from "./controller.js";
12
+
13
+ export class LineListScreen extends Controller {
14
+ constructor(
15
+ ctx: ScreenContext,
16
+ render: ScreenRender,
17
+ private readonly title: string,
18
+ private readonly nextView: ScreenView,
19
+ ) {
20
+ super(ctx, render);
21
+ }
22
+
23
+ renderScreen(width: number): string[] {
24
+ const visibleCount = this.ctx.visibleRowCount();
25
+ const { start, end } = scrollWindow(
26
+ this.ctx.state.store.lines.length,
27
+ this.ctx.state.selectedLine,
28
+ visibleCount,
29
+ );
30
+ const visible = this.ctx.state.store.lines.slice(start, end);
31
+ const lines = [
32
+ this.render.line(
33
+ this.render.menuTitle(this.title, "Choose which status line to configure"),
34
+ width,
35
+ ),
36
+ this.render.line(this.ctx.theme.dim(LINE_LIST_HINT), width),
37
+ this.render.line(
38
+ this.ctx.theme.dim(lineListCountLabel(this.ctx.state.store.lines.length, start, end)),
39
+ width,
40
+ ),
41
+ ];
42
+ visible.forEach((line, offset) => {
43
+ const index = start + offset;
44
+ const text = lineListItemLabel(index, line.length, (value) => this.ctx.theme.dim(value));
45
+ lines.push(this.render.menuLine(index === this.ctx.state.selectedLine, text, width));
46
+ });
47
+ return lines;
48
+ }
49
+
50
+ handleInput(data: string): void {
51
+ if (matchesKey(data, Key.up))
52
+ this.ctx.state.selectedLine = wrap(
53
+ this.ctx.state.selectedLine - 1,
54
+ this.ctx.state.store.lines.length,
55
+ );
56
+ else if (matchesKey(data, Key.down))
57
+ this.ctx.state.selectedLine = wrap(
58
+ this.ctx.state.selectedLine + 1,
59
+ this.ctx.state.store.lines.length,
60
+ );
61
+ else if (matchesKey(data, Key.pageUp)) this.page(-1);
62
+ else if (matchesKey(data, Key.pageDown)) this.page(1);
63
+ else if (matchesKey(data, Key.enter)) {
64
+ this.ctx.state.selectedWidget = 0;
65
+ this.ctx.show(this.nextView);
66
+ } else if (data === "a") {
67
+ this.ctx.state.selectedLine = addLineAfter(
68
+ this.ctx.state.store.lines,
69
+ this.ctx.state.selectedLine,
70
+ );
71
+ this.ctx.emitChange();
72
+ } else if (data === "c") {
73
+ this.ctx.state.selectedLine = cloneLineAfter(
74
+ this.ctx.state.store.lines,
75
+ this.ctx.state.selectedLine,
76
+ (widget) => registry.cloneWidget(widget),
77
+ );
78
+ this.ctx.emitChange();
79
+ } else if (data === "w") this.move(-1);
80
+ else if (data === "s") this.move(1);
81
+ else if (data === "d") this.delete();
82
+ }
83
+
84
+ private page(delta: -1 | 1): void {
85
+ this.ctx.state.selectedLine = pageSelection(
86
+ this.ctx.state.selectedLine,
87
+ this.ctx.state.store.lines.length,
88
+ this.ctx.visibleRowCount(),
89
+ delta,
90
+ );
91
+ }
92
+
93
+ private move(delta: number): void {
94
+ const next = moveLine(this.ctx.state.store.lines, this.ctx.state.selectedLine, delta);
95
+ if (next === this.ctx.state.selectedLine) return;
96
+ this.ctx.state.selectedLine = next;
97
+ this.ctx.emitChange();
98
+ }
99
+
100
+ private delete(): void {
101
+ const previousLength = this.ctx.state.store.lines.length;
102
+ this.ctx.state.selectedLine = deleteLine(
103
+ this.ctx.state.store.lines,
104
+ this.ctx.state.selectedLine,
105
+ );
106
+ if (this.ctx.state.store.lines.length !== previousLength) this.ctx.emitChange();
107
+ }
108
+ }
@@ -0,0 +1,99 @@
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ import { wrap } from "../helpers.js";
4
+ import type { ScreenView } from "../model.js";
5
+ import { Controller } from "./controller.js";
6
+
7
+ type MenuAction =
8
+ | { type: "view"; view: ScreenView }
9
+ | { type: "save-exit" }
10
+ | { type: "discard-exit" };
11
+
12
+ interface MenuItem {
13
+ label: string;
14
+ description: string;
15
+ action: MenuAction;
16
+ }
17
+
18
+ const HINT = "↑/↓ select • enter option • ctrl+s save • esc exit";
19
+
20
+ const ITEMS: readonly MenuItem[] = [
21
+ {
22
+ label: "Edit lines",
23
+ description: "Manage status lines and line widgets",
24
+ action: { type: "view", view: "line-list" },
25
+ },
26
+ {
27
+ label: "Edit colors",
28
+ description: "Configure per-widget foreground/background/bold",
29
+ action: { type: "view", view: "color-line-list" },
30
+ },
31
+ {
32
+ label: "Terminal Options",
33
+ description: "Terminal width and color level",
34
+ action: { type: "view", view: "terminal" },
35
+ },
36
+ {
37
+ label: "Global Overrides",
38
+ description: "Global presets, separators, icons, minimalist mode",
39
+ action: { type: "view", view: "global" },
40
+ },
41
+ {
42
+ label: "Pi extensions",
43
+ description: "Published statuses and extension status row visibility",
44
+ action: { type: "view", view: "extension-status-row" },
45
+ },
46
+ {
47
+ label: "Save & Exit",
48
+ description: "Persist changes and close the configuration UI",
49
+ action: { type: "save-exit" },
50
+ },
51
+ {
52
+ label: "Exit without saving",
53
+ description: "Discard unsaved changes and close immediately",
54
+ action: { type: "discard-exit" },
55
+ },
56
+ ];
57
+
58
+ export class MainScreen extends Controller {
59
+ private selected = 0;
60
+
61
+ renderScreen(width: number): string[] {
62
+ return [
63
+ this.render.line(
64
+ this.render.menuTitle(
65
+ "Main Menu",
66
+ "Configure any number of status lines with various widgets",
67
+ ),
68
+ width,
69
+ ),
70
+ this.render.line(this.ctx.theme.dim(HINT), width),
71
+ ...ITEMS.map((item, index) =>
72
+ this.render.menuLine(
73
+ index === this.selected,
74
+ `${item.label} ${this.ctx.theme.dim(item.description)}`,
75
+ width,
76
+ ),
77
+ ),
78
+ ];
79
+ }
80
+
81
+ handleInput(data: string): void {
82
+ if (matchesKey(data, Key.up)) this.selected = wrap(this.selected - 1, ITEMS.length);
83
+ else if (matchesKey(data, Key.down)) this.selected = wrap(this.selected + 1, ITEMS.length);
84
+ else if (matchesKey(data, Key.enter)) this.applySelectedAction();
85
+ }
86
+
87
+ private menuAction(index: number): MenuAction | undefined {
88
+ return ITEMS[index]?.action;
89
+ }
90
+
91
+ private applySelectedAction(): void {
92
+ const action = this.menuAction(this.selected);
93
+ if (!action) return;
94
+
95
+ if (action.type === "view") this.ctx.show(action.view);
96
+ else if (action.type === "save-exit") this.ctx.save(true);
97
+ else this.ctx.exitWithoutSaving();
98
+ }
99
+ }
@@ -0,0 +1,121 @@
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ import type { ColorLevel } from "../../colors.js";
4
+ import {
5
+ COLOR_LEVEL_CONFIRM_HINT,
6
+ COLOR_LEVEL_CONFIRM_WARNING,
7
+ colorLevelConfirmAction,
8
+ colorLevelConfirmValueLabel,
9
+ } from "../color-level-confirm.js";
10
+ import { hasCustomAnsiColors, resetCustomAnsiColors } from "../color-options.js";
11
+ import { wrap } from "../helpers.js";
12
+ import type { ScreenContext } from "../screen-context.js";
13
+ import type { ScreenRender } from "../screen-render.js";
14
+ import {
15
+ nextTerminalColorLevel,
16
+ nextTerminalWidthMode,
17
+ TERMINAL_MENU_ACTIONS,
18
+ TERMINAL_MENU_HINT,
19
+ terminalMenuAction,
20
+ terminalMenuFields,
21
+ } from "../terminal-menu.js";
22
+ import { Controller } from "./controller.js";
23
+
24
+ export class TerminalState {
25
+ pendingColorLevel: ColorLevel | undefined;
26
+ }
27
+
28
+ export class TerminalScreen extends Controller {
29
+ private selected = 0;
30
+
31
+ constructor(
32
+ ctx: ScreenContext,
33
+ render: ScreenRender,
34
+ private readonly terminalState: TerminalState,
35
+ ) {
36
+ super(ctx, render);
37
+ }
38
+
39
+ renderScreen(width: number): string[] {
40
+ const fields = terminalMenuFields(this.ctx.state.store.settings);
41
+ return [
42
+ this.render.line(
43
+ this.render.menuTitle(
44
+ "Terminal Options",
45
+ "Configure terminal width behavior and color level",
46
+ ),
47
+ width,
48
+ ),
49
+ this.render.line(this.ctx.theme.dim(TERMINAL_MENU_HINT), width),
50
+ ...fields.map((field, index) => this.render.menuLine(index === this.selected, field, width)),
51
+ ];
52
+ }
53
+
54
+ handleInput(data: string): void {
55
+ if (matchesKey(data, Key.up))
56
+ this.selected = wrap(this.selected - 1, TERMINAL_MENU_ACTIONS.length);
57
+ else if (matchesKey(data, Key.down))
58
+ this.selected = wrap(this.selected + 1, TERMINAL_MENU_ACTIONS.length);
59
+ else if (matchesKey(data, Key.left)) this.adjust(-1);
60
+ else if (matchesKey(data, Key.right) || matchesKey(data, Key.enter)) this.adjust(1);
61
+ }
62
+
63
+ private adjust(delta: number): void {
64
+ if (terminalMenuAction(this.selected) === "width-mode") {
65
+ this.ctx.state.store.settings.terminal.widthMode = nextTerminalWidthMode(
66
+ this.ctx.state.store.settings,
67
+ delta,
68
+ );
69
+ this.ctx.emitChange();
70
+ return;
71
+ }
72
+ const settings = this.ctx.state.store.settings;
73
+ const next = nextTerminalColorLevel(settings, delta);
74
+ if (next !== settings.terminal.colorLevel && hasCustomAnsiColors(this.ctx.state.store.lines)) {
75
+ this.terminalState.pendingColorLevel = next;
76
+ this.ctx.show("confirm-color-level");
77
+ return;
78
+ }
79
+ settings.terminal.colorLevel = next;
80
+ this.ctx.emitChange();
81
+ }
82
+ }
83
+
84
+ export class ColorLevelConfirmScreen extends Controller {
85
+ constructor(
86
+ ctx: ScreenContext,
87
+ render: ScreenRender,
88
+ private readonly terminalState: TerminalState,
89
+ ) {
90
+ super(ctx, render);
91
+ }
92
+
93
+ renderScreen(width: number): string[] {
94
+ return [
95
+ this.render.line(this.ctx.theme.warning(COLOR_LEVEL_CONFIRM_WARNING), width),
96
+ this.render.line(colorLevelConfirmValueLabel(this.terminalState.pendingColorLevel), width),
97
+ this.render.line(this.ctx.theme.dim(COLOR_LEVEL_CONFIRM_HINT), width),
98
+ ];
99
+ }
100
+
101
+ handleInput(data: string): void {
102
+ const action = colorLevelConfirmAction(
103
+ data,
104
+ matchesKey(data, Key.escape),
105
+ matchesKey(data, Key.enter),
106
+ );
107
+ if (action === "cancel") {
108
+ this.terminalState.pendingColorLevel = undefined;
109
+ this.ctx.show("terminal");
110
+ return;
111
+ }
112
+ if (action === "confirm") {
113
+ if (this.terminalState.pendingColorLevel)
114
+ this.ctx.state.store.settings.terminal.colorLevel = this.terminalState.pendingColorLevel;
115
+ resetCustomAnsiColors(this.ctx.state.store.lines);
116
+ this.terminalState.pendingColorLevel = undefined;
117
+ this.ctx.show("terminal");
118
+ this.ctx.emitChange();
119
+ }
120
+ }
121
+ }
@@ -0,0 +1,154 @@
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
+
3
+ import { registry } from "../../widgets/registry.js";
4
+ import type { Widget } from "../../widgets/types.js";
5
+ import { formatWidgetColorOptions, formatWidgetOptions } from "../fields.js";
6
+ import { wrap } from "../helpers.js";
7
+ import { pageSelection, scrollWindow } from "../navigation.js";
8
+ import type { ScreenContext } from "../screen-context.js";
9
+ import type { ScreenRender } from "../screen-render.js";
10
+ import {
11
+ cloneSelectedWidget,
12
+ deleteSelectedWidget,
13
+ moveWidget,
14
+ toggleWidgetEnabled,
15
+ toggleWidgetRaw,
16
+ } from "../widget-actions.js";
17
+ import { Controller } from "./controller.js";
18
+
19
+ const HINT =
20
+ "↑/↓ select • enter options • a add • c clone • w/s move • d delete • space toggle • r raw • esc back";
21
+ const COLOR_HINT = "↑/↓ select • pgup/pgdn jump • enter colors • esc back";
22
+
23
+ export class WidgetListScreen extends Controller {
24
+ constructor(
25
+ ctx: ScreenContext,
26
+ render: ScreenRender,
27
+ private readonly colors: boolean,
28
+ ) {
29
+ super(ctx, render);
30
+ }
31
+
32
+ renderScreen(width: number): string[] {
33
+ const line = this.ctx.currentLine();
34
+ const visibleCount = this.ctx.visibleRowCount();
35
+ const { start, end } = scrollWindow(line.length, this.ctx.state.selectedWidget, visibleCount);
36
+ const visible = line.slice(start, end);
37
+ const lines = [
38
+ this.render.line(
39
+ this.render.menuTitle(
40
+ `${this.colors ? "Edit widget colors" : "Edit widgets"} / Line ${this.ctx.state.selectedLine + 1}`,
41
+ this.colors
42
+ ? "Select a widget to edit its colors"
43
+ : "Select a widget to edit its options, or add/remove/reorder widgets",
44
+ ),
45
+ width,
46
+ ),
47
+ this.render.line(this.ctx.theme.dim(this.colors ? COLOR_HINT : HINT), width),
48
+ this.render.line(this.ctx.theme.dim(this.countLabel(line.length, start, end)), width),
49
+ ];
50
+ if (line.length === 0)
51
+ lines.push(
52
+ this.render.line(this.ctx.theme.warning("Empty line. Press a to add a widget."), width),
53
+ );
54
+ visible.forEach((widget, offset) => {
55
+ const index = start + offset;
56
+ const text = this.colors ? this.colorItemLabel(index, widget) : this.itemLabel(index, widget);
57
+ lines.push(this.render.menuLine(index === this.ctx.state.selectedWidget, text, width));
58
+ });
59
+ return lines;
60
+ }
61
+
62
+ handleInput(data: string): void {
63
+ const line = this.ctx.currentLine();
64
+ if (matchesKey(data, Key.up))
65
+ this.ctx.state.selectedWidget = wrap(
66
+ this.ctx.state.selectedWidget - 1,
67
+ Math.max(1, line.length),
68
+ );
69
+ else if (matchesKey(data, Key.down))
70
+ this.ctx.state.selectedWidget = wrap(
71
+ this.ctx.state.selectedWidget + 1,
72
+ Math.max(1, line.length),
73
+ );
74
+ else if (matchesKey(data, Key.pageUp)) this.page(line.length, -1);
75
+ else if (matchesKey(data, Key.pageDown)) this.page(line.length, 1);
76
+ else if (this.colors && matchesKey(data, Key.enter)) this.ctx.show("edit-colors");
77
+ else if (!this.colors) this.handleEditListInput(data);
78
+ }
79
+
80
+ private countLabel(total: number, start: number, end: number): string {
81
+ const range = total === 0 ? "0-0" : `${start + 1}-${end}`;
82
+ return `${total} widget(s), showing ${range}`;
83
+ }
84
+
85
+ private itemLabel(index: number, widget: Widget): string {
86
+ const enabled = widget.enabled ? this.ctx.theme.success("on ") : this.ctx.theme.dim("off");
87
+ const indexPad = index < 9 ? " " : "";
88
+ const options = this.ctx.theme.dim(formatWidgetOptions(widget));
89
+ return `${enabled} ${index + 1}.${indexPad} ${registry.spec(widget.type).label} ${options}`;
90
+ }
91
+
92
+ private colorItemLabel(index: number, widget: Widget): string {
93
+ const enabled = widget.enabled ? this.ctx.theme.success("on ") : this.ctx.theme.dim("off");
94
+ const indexPad = index < 9 ? " " : "";
95
+ const options = this.ctx.theme.dim(formatWidgetColorOptions(widget));
96
+ return `${enabled} ${index + 1}.${indexPad} ${registry.spec(widget.type).label} ${options}`;
97
+ }
98
+
99
+ private handleEditListInput(data: string): void {
100
+ if (data === "a") this.ctx.show("add-widget");
101
+ else if (matchesKey(data, Key.enter) || data === "e") this.ctx.show("edit-widget");
102
+ else if (data === "c") this.cloneCurrentWidget();
103
+ else if (data === "w") this.moveWidget(-1);
104
+ else if (data === "s") this.moveWidget(1);
105
+ else if (data === "d") this.deleteCurrentWidget();
106
+ else if (matchesKey(data, Key.space)) this.toggleCurrentWidget();
107
+ else if (data === "r") this.toggleCurrentWidgetRaw();
108
+ }
109
+
110
+ private page(length: number, delta: -1 | 1): void {
111
+ this.ctx.state.selectedWidget = pageSelection(
112
+ this.ctx.state.selectedWidget,
113
+ length,
114
+ this.ctx.visibleRowCount(),
115
+ delta,
116
+ );
117
+ }
118
+
119
+ private moveWidget(delta: number): void {
120
+ const next = moveWidget(this.ctx.currentLine(), this.ctx.state.selectedWidget, delta);
121
+ if (next === this.ctx.state.selectedWidget) return;
122
+ this.ctx.state.selectedWidget = next;
123
+ this.ctx.emitChange();
124
+ }
125
+
126
+ private cloneCurrentWidget(): void {
127
+ const next = cloneSelectedWidget(
128
+ this.ctx.currentLine(),
129
+ this.ctx.state.selectedWidget,
130
+ (widget) => registry.cloneWidget(widget),
131
+ );
132
+ if (next === this.ctx.state.selectedWidget) return;
133
+ this.ctx.state.selectedWidget = next;
134
+ this.ctx.emitChange();
135
+ }
136
+
137
+ private deleteCurrentWidget(): void {
138
+ const line = this.ctx.currentLine();
139
+ const previousLength = line.length;
140
+ this.ctx.state.selectedWidget = deleteSelectedWidget(line, this.ctx.state.selectedWidget);
141
+ if (line.length === previousLength) return;
142
+ this.ctx.emitChange();
143
+ }
144
+
145
+ private toggleCurrentWidget(): void {
146
+ if (!toggleWidgetEnabled(this.ctx.currentWidget())) return;
147
+ this.ctx.emitChange();
148
+ }
149
+
150
+ private toggleCurrentWidgetRaw(): void {
151
+ if (!toggleWidgetRaw(this.ctx.currentWidget())) return;
152
+ this.ctx.emitChange();
153
+ }
154
+ }
@@ -0,0 +1,32 @@
1
+ import { COLOR_LEVEL_VALUES, type ColorLevel } from "../colors.js";
2
+ import { TERMINAL_WIDTH_MODE_VALUES, type StatuslineSettings } from "../types.js";
3
+ import { cycle } from "./helpers.js";
4
+ import { COLOR_LEVEL_LABELS, WIDTH_MODE_LABELS } from "./model.js";
5
+
6
+ export const TERMINAL_MENU_ACTIONS = ["width-mode", "color-level"] as const;
7
+
8
+ type TerminalMenuAction = (typeof TERMINAL_MENU_ACTIONS)[number];
9
+
10
+ export const TERMINAL_MENU_HINT = "↑/↓ option • ←/→ change • esc back";
11
+
12
+ export function terminalMenuFields(config: StatuslineSettings): string[] {
13
+ return [
14
+ `Terminal Width: ${WIDTH_MODE_LABELS[config.terminal.widthMode]}`,
15
+ `Color Level: ${COLOR_LEVEL_LABELS[config.terminal.colorLevel]}`,
16
+ ];
17
+ }
18
+
19
+ export function terminalMenuAction(index: number): TerminalMenuAction {
20
+ return TERMINAL_MENU_ACTIONS[index] ?? "color-level";
21
+ }
22
+
23
+ export function nextTerminalWidthMode(
24
+ config: StatuslineSettings,
25
+ delta: number,
26
+ ): StatuslineSettings["terminal"]["widthMode"] {
27
+ return cycle(TERMINAL_WIDTH_MODE_VALUES, config.terminal.widthMode, delta);
28
+ }
29
+
30
+ export function nextTerminalColorLevel(config: StatuslineSettings, delta: number): ColorLevel {
31
+ return cycle(COLOR_LEVEL_VALUES, config.terminal.colorLevel, delta);
32
+ }
@@ -0,0 +1,48 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ConfigState } from "./config-lifecycle.js";
4
+
5
+ export type ThemeProvider = () => Theme;
6
+
7
+ export interface UiTheme {
8
+ accent(text: string): string;
9
+ dim(text: string): string;
10
+ muted(text: string): string;
11
+ success(text: string): string;
12
+ warning(text: string): string;
13
+ error(text: string): string;
14
+ bold(text: string): string;
15
+ selected(text: string): string;
16
+ border(text: string, color?: ThemeColor): string;
17
+ previewTitle(text: string): string;
18
+ configStateLabel(state: ConfigState, label: string): string;
19
+ }
20
+
21
+ export function createUiTheme(getTheme: ThemeProvider): UiTheme {
22
+ return {
23
+ accent: (text) => getTheme().fg("accent", text),
24
+ dim: (text) => getTheme().fg("dim", text),
25
+ muted: (text) => getTheme().fg("muted", text),
26
+ success: (text) => getTheme().fg("success", text),
27
+ warning: (text) => getTheme().fg("warning", text),
28
+ error: (text) => getTheme().fg("error", text),
29
+ bold: (text) => getTheme().bold(text),
30
+ selected: (text) => {
31
+ const theme = getTheme();
32
+ return theme.bg("selectedBg", theme.fg("accent", text));
33
+ },
34
+ border: (text, color = "border") => getTheme().fg(color, text),
35
+ previewTitle: (text) => {
36
+ const theme = getTheme();
37
+ return theme.fg("accent", theme.bold(text));
38
+ },
39
+ configStateLabel: (state, label) => {
40
+ const theme = getTheme();
41
+ if (state === "saved") return theme.fg("accent", label);
42
+ if (state === "dirty") return theme.bold(theme.fg("warning", label));
43
+ if (state === "saving") return theme.fg("dim", label);
44
+ if (state === "error") return theme.bold(theme.fg("error", label));
45
+ return "";
46
+ },
47
+ };
48
+ }