@gotgenes/pi-permission-system 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1117 @@
1
+ // Vendored from ../zellij-modal/index.ts to keep pi-permission-system standalone.
2
+ // Keep this module in sync when upstream zellij-modal primitives change.
3
+ import {
4
+ type ExtensionAPI,
5
+ getSettingsListTheme,
6
+ type Theme,
7
+ } from "@mariozechner/pi-coding-agent";
8
+ import {
9
+ Box,
10
+ Container,
11
+ type SettingItem,
12
+ SettingsList,
13
+ Spacer,
14
+ Text,
15
+ truncateToWidth,
16
+ visibleWidth,
17
+ } from "@mariozechner/pi-tui";
18
+
19
+ const ANSI_RESET = "\x1b[0m";
20
+
21
+ /**
22
+ * Border character set used to render a modal frame.
23
+ */
24
+ export interface BorderCharacters {
25
+ /** Top-left corner. */
26
+ topLeft: string;
27
+ /** Top-right corner. */
28
+ topRight: string;
29
+ /** Bottom-left corner. */
30
+ bottomLeft: string;
31
+ /** Bottom-right corner. */
32
+ bottomRight: string;
33
+ /** Horizontal line character. */
34
+ horizontal: string;
35
+ /** Vertical line character. */
36
+ vertical: string;
37
+ /** Optional left tee junction. */
38
+ verticalLeft?: string;
39
+ /** Optional right tee junction. */
40
+ verticalRight?: string;
41
+ }
42
+
43
+ /**
44
+ * Predefined border character sets aligned with Zellij styles.
45
+ */
46
+ export const BORDER_STYLES = {
47
+ rounded: {
48
+ topLeft: "╭",
49
+ topRight: "╮",
50
+ bottomLeft: "╰",
51
+ bottomRight: "╯",
52
+ horizontal: "─",
53
+ vertical: "│",
54
+ verticalLeft: "├",
55
+ verticalRight: "┤",
56
+ },
57
+ square: {
58
+ topLeft: "┌",
59
+ topRight: "┐",
60
+ bottomLeft: "└",
61
+ bottomRight: "┘",
62
+ horizontal: "─",
63
+ vertical: "│",
64
+ verticalLeft: "├",
65
+ verticalRight: "┤",
66
+ },
67
+ double: {
68
+ topLeft: "╔",
69
+ topRight: "╗",
70
+ bottomLeft: "╚",
71
+ bottomRight: "╝",
72
+ horizontal: "═",
73
+ vertical: "║",
74
+ },
75
+ none: {
76
+ topLeft: " ",
77
+ topRight: " ",
78
+ bottomLeft: " ",
79
+ bottomRight: " ",
80
+ horizontal: " ",
81
+ vertical: " ",
82
+ },
83
+ } as const satisfies Record<string, BorderCharacters>;
84
+
85
+ /**
86
+ * Name of a supported border style.
87
+ */
88
+ export type BorderStyle = keyof typeof BORDER_STYLES;
89
+
90
+ /**
91
+ * Supported palette color formats.
92
+ */
93
+ export type PaletteColor =
94
+ | { type: "rgb"; r: number; g: number; b: number }
95
+ | { type: "8bit"; code: number }
96
+ | { type: "named"; name: string };
97
+
98
+ /**
99
+ * Semantic color slots for a Zellij-style modal.
100
+ */
101
+ export interface ZellijColorPalette {
102
+ /** Primary foreground text. */
103
+ fg: PaletteColor;
104
+ /** Modal background. */
105
+ bg: PaletteColor;
106
+ /** Accent / selection color. */
107
+ accent: PaletteColor;
108
+ /** Secondary text color. */
109
+ muted: PaletteColor;
110
+ /** Tertiary text color. */
111
+ dim: PaletteColor;
112
+ /** Success state color. */
113
+ success: PaletteColor;
114
+ /** Error state color. */
115
+ error: PaletteColor;
116
+ /** Warning state color. */
117
+ warning: PaletteColor;
118
+ /** Default border color. */
119
+ border: PaletteColor;
120
+ /** Border color when focused. */
121
+ borderFocused: PaletteColor;
122
+ /** Border color when unfocused. */
123
+ borderUnfocused: PaletteColor;
124
+ }
125
+
126
+ /**
127
+ * Default Zellij-inspired palette.
128
+ */
129
+ export const DEFAULT_ZELLIJ_PALETTE: ZellijColorPalette = {
130
+ fg: { type: "named", name: "white" },
131
+ bg: { type: "named", name: "black" },
132
+ accent: { type: "8bit", code: 36 },
133
+ muted: { type: "8bit", code: 245 },
134
+ dim: { type: "8bit", code: 238 },
135
+ success: { type: "8bit", code: 154 },
136
+ error: { type: "8bit", code: 124 },
137
+ warning: { type: "8bit", code: 166 },
138
+ border: { type: "8bit", code: 238 },
139
+ borderFocused: { type: "8bit", code: 154 },
140
+ borderUnfocused: { type: "8bit", code: 238 },
141
+ };
142
+
143
+ /**
144
+ * A title segment in the top border.
145
+ */
146
+ export interface TitleSegment {
147
+ /** Segment text. */
148
+ text: string;
149
+ /** Segment foreground color slot or explicit color. */
150
+ color: keyof ZellijColorPalette | PaletteColor;
151
+ /** Optional segment background color. */
152
+ bgColor?: PaletteColor;
153
+ /** Enables bold style. */
154
+ bold?: boolean;
155
+ /** Truncation strategy when segment text is too long. */
156
+ truncate?: "start" | "middle" | "end" | "none";
157
+ /** Maximum visible width for text content (0 means unlimited). */
158
+ maxWidth?: number;
159
+ }
160
+
161
+ /**
162
+ * Three-part title bar configuration.
163
+ */
164
+ export interface TitleBarConfig {
165
+ /** Left segment (usually title). */
166
+ left?: TitleSegment | string;
167
+ /** Center segment (usually status). */
168
+ center?: TitleSegment | string;
169
+ /** Right segment (usually counters/actions). */
170
+ right?: TitleSegment | string;
171
+ /** Optional textual separator (reserved for custom renderers). */
172
+ separator?: string;
173
+ }
174
+
175
+ /**
176
+ * Help text line rendered in the bottom border.
177
+ */
178
+ export interface HelpUndertitleConfig {
179
+ /** Static help text. */
180
+ text?: string;
181
+ /** Dynamic help text generator. */
182
+ textGenerator?: (width: number) => string;
183
+ /** Progressive truncation variants from longest to shortest. */
184
+ variants?: string[];
185
+ /** Structured key hints for help text generation. */
186
+ keyHints?: Array<{
187
+ key: string;
188
+ description: string;
189
+ }>;
190
+ /** Separator between key hints. */
191
+ keyHintSeparator?: string;
192
+ /** Palette slot for help text color. */
193
+ color?: keyof ZellijColorPalette;
194
+ }
195
+
196
+ /**
197
+ * Full modal configuration.
198
+ */
199
+ export interface ZellijModalConfig {
200
+ /** Border style preset. */
201
+ borderStyle: BorderStyle;
202
+ /** Active color palette. */
203
+ palette: ZellijColorPalette;
204
+ /** Focus state for frame highlighting. */
205
+ focused: boolean;
206
+ /** Internal content padding. */
207
+ padding: number;
208
+ /** Top title bar config. */
209
+ titleBar: TitleBarConfig;
210
+ /** Optional bottom help line config. */
211
+ helpUndertitle?: HelpUndertitleConfig;
212
+ /** Minimum preferred modal width. */
213
+ minWidth: number;
214
+ /** Maximum modal width (0 means no explicit max). */
215
+ maxWidth: number;
216
+ /** Overlay options for `ctx.ui.custom()`. */
217
+ overlay: {
218
+ anchor: "center" | "top" | "bottom";
219
+ width: number | string;
220
+ maxHeight: number | string;
221
+ margin: number;
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Partial modal configuration used by consumers.
227
+ */
228
+ export type ZellijModalConfigPartial = Partial<ZellijModalConfig> & {
229
+ /** Shorthand for `titleBar.left`. */
230
+ title?: string;
231
+ /** Shorthand for help text. */
232
+ helpText?: string | HelpUndertitleConfig;
233
+ };
234
+
235
+ /**
236
+ * Modal rendering metadata.
237
+ */
238
+ export interface ZellijModalRenderOutput {
239
+ /** Fully rendered lines. */
240
+ lines: string[];
241
+ /** Visible frame width. */
242
+ visibleWidth: number;
243
+ /** Width of content area inside borders and padding. */
244
+ contentWidth: number;
245
+ /** Inclusive index of first content line. */
246
+ contentStartLine: number;
247
+ /** Inclusive index of last content line. */
248
+ contentEndLine: number;
249
+ }
250
+
251
+ /**
252
+ * Minimal content renderer contract for modal content.
253
+ */
254
+ export interface ZellijModalContentRenderer {
255
+ /** Render content into lines for the given width. */
256
+ render(width: number): string[];
257
+ /** Invalidate internal caches. */
258
+ invalidate(): void;
259
+ /** Optional input handler. */
260
+ handleInput?(data: string): void;
261
+ }
262
+
263
+ /**
264
+ * Full modal component contract.
265
+ */
266
+ export interface ZellijModalComponent extends ZellijModalContentRenderer {
267
+ /** Effective modal configuration. */
268
+ config: ZellijModalConfig;
269
+ /** Wrapped content renderer. */
270
+ content: ZellijModalContentRenderer;
271
+ /** Render complete modal output. */
272
+ renderModal(width: number): ZellijModalRenderOutput;
273
+ /** Release resources. */
274
+ dispose(): void;
275
+ }
276
+
277
+ /**
278
+ * Theme helper for modal-specific color resolution and ANSI formatting.
279
+ */
280
+ export interface ZellijModalTheme {
281
+ /** Active palette used by this theme helper. */
282
+ palette: ZellijColorPalette;
283
+ /** Resolve color slot or explicit color into ANSI foreground/background codes. */
284
+ resolveColor: (color: PaletteColor | keyof ZellijColorPalette) => {
285
+ fg: string;
286
+ bg: string;
287
+ };
288
+ /** Apply foreground color to text. */
289
+ colorizeForeground: (
290
+ color: PaletteColor | keyof ZellijColorPalette,
291
+ text: string,
292
+ ) => string;
293
+ /** Apply background color to text. */
294
+ colorizeBackground: (
295
+ color: PaletteColor | keyof ZellijColorPalette,
296
+ text: string,
297
+ ) => string;
298
+ }
299
+
300
+ /**
301
+ * Resolve a `PaletteColor` into ANSI foreground/background escape codes.
302
+ */
303
+ export function resolveColor(color: PaletteColor): { fg: string; bg: string } {
304
+ if (color.type === "rgb") {
305
+ const r = clampInt(color.r, 0, 255);
306
+ const g = clampInt(color.g, 0, 255);
307
+ const b = clampInt(color.b, 0, 255);
308
+ return {
309
+ fg: `\x1b[38;2;${r};${g};${b}m`,
310
+ bg: `\x1b[48;2;${r};${g};${b}m`,
311
+ };
312
+ }
313
+
314
+ if (color.type === "8bit") {
315
+ const code = clampInt(color.code, 0, 255);
316
+ return {
317
+ fg: `\x1b[38;5;${code}m`,
318
+ bg: `\x1b[48;5;${code}m`,
319
+ };
320
+ }
321
+
322
+ const namedMap: Record<string, number> = {
323
+ black: 16,
324
+ white: 255,
325
+ red: 196,
326
+ green: 46,
327
+ blue: 45,
328
+ yellow: 226,
329
+ cyan: 51,
330
+ magenta: 201,
331
+ gray: 245,
332
+ grey: 245,
333
+ orange: 166,
334
+ };
335
+ const code = namedMap[color.name.toLowerCase()] ?? 255;
336
+ return {
337
+ fg: `\x1b[38;5;${code}m`,
338
+ bg: `\x1b[48;5;${code}m`,
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Build a `ZellijModalTheme` helper from a palette.
344
+ */
345
+ export function createZellijModalTheme(
346
+ palette: ZellijColorPalette,
347
+ ): ZellijModalTheme {
348
+ return {
349
+ palette,
350
+ resolveColor: (color) => resolveColor(resolvePaletteColor(color, palette)),
351
+ colorizeForeground: (color, text) =>
352
+ `${resolveColor(resolvePaletteColor(color, palette)).fg}${text}${ANSI_RESET}`,
353
+ colorizeBackground: (color, text) =>
354
+ `${resolveColor(resolvePaletteColor(color, palette)).bg}${text}${ANSI_RESET}`,
355
+ };
356
+ }
357
+
358
+ /**
359
+ * Convert Pi `Theme` values to a Zellij modal palette.
360
+ */
361
+ export function themeToZellijPalette(theme: Theme): ZellijColorPalette {
362
+ const extract = (colorName: string, fallback: PaletteColor): PaletteColor => {
363
+ const provider = theme as unknown as {
364
+ getFgAnsi?: (name: string) => string;
365
+ };
366
+ if (!provider.getFgAnsi) {
367
+ return fallback;
368
+ }
369
+
370
+ try {
371
+ const ansi = provider.getFgAnsi(colorName);
372
+ const parsed = parseAnsiForegroundColor(ansi);
373
+ return parsed ?? fallback;
374
+ } catch {
375
+ return fallback;
376
+ }
377
+ };
378
+
379
+ return {
380
+ fg: extract("fg", DEFAULT_ZELLIJ_PALETTE.fg),
381
+ bg: extract("bg", DEFAULT_ZELLIJ_PALETTE.bg),
382
+ accent: extract("accent", DEFAULT_ZELLIJ_PALETTE.accent),
383
+ muted: extract("muted", DEFAULT_ZELLIJ_PALETTE.muted),
384
+ dim: extract("dim", DEFAULT_ZELLIJ_PALETTE.dim),
385
+ success: extract("success", DEFAULT_ZELLIJ_PALETTE.success),
386
+ error: extract("error", DEFAULT_ZELLIJ_PALETTE.error),
387
+ warning: extract("warning", DEFAULT_ZELLIJ_PALETTE.warning),
388
+ border: extract("borderMuted", DEFAULT_ZELLIJ_PALETTE.border),
389
+ borderFocused: extract("accent", DEFAULT_ZELLIJ_PALETTE.borderFocused),
390
+ borderUnfocused: extract(
391
+ "borderMuted",
392
+ DEFAULT_ZELLIJ_PALETTE.borderUnfocused,
393
+ ),
394
+ };
395
+ }
396
+
397
+ interface PositionedTitleSegment {
398
+ start: number;
399
+ end: number;
400
+ text: string;
401
+ color: keyof ZellijColorPalette | PaletteColor;
402
+ bold: boolean;
403
+ }
404
+
405
+ /**
406
+ * Core frame renderer for Zellij-style borders, title bar, and undertitle.
407
+ */
408
+ export class ZellijModalFrame {
409
+ private config: ZellijModalConfig;
410
+ private borders: BorderCharacters;
411
+ private theme: ZellijModalTheme;
412
+
413
+ constructor(config: ZellijModalConfig, modalTheme?: ZellijModalTheme) {
414
+ this.config = config;
415
+ this.borders = BORDER_STYLES[config.borderStyle] ?? BORDER_STYLES.rounded;
416
+ this.theme = modalTheme ?? createZellijModalTheme(config.palette);
417
+ }
418
+
419
+ /**
420
+ * Update frame configuration (used when modal config changes).
421
+ */
422
+ setConfig(config: ZellijModalConfig): void {
423
+ this.config = config;
424
+ this.borders = BORDER_STYLES[config.borderStyle] ?? BORDER_STYLES.rounded;
425
+ this.theme = createZellijModalTheme(config.palette);
426
+ }
427
+
428
+ /**
429
+ * Render one content line with left/right borders.
430
+ */
431
+ renderContentLine(
432
+ content: string,
433
+ width: number,
434
+ palette: ZellijColorPalette,
435
+ ): string {
436
+ const frameWidth = Math.max(2, width);
437
+ const innerWidth = Math.max(0, frameWidth - 2);
438
+ const borderColor = this.config.focused
439
+ ? palette.borderFocused
440
+ : palette.borderUnfocused;
441
+ const vertical = this.theme.colorizeForeground(
442
+ borderColor,
443
+ this.borders.vertical,
444
+ );
445
+ const paddedContent = truncateToWidth(content, innerWidth, "", true);
446
+ return `${vertical}${paddedContent}${vertical}`;
447
+ }
448
+
449
+ /**
450
+ * Render complete frame around provided content lines.
451
+ */
452
+ renderFrame(
453
+ contentLines: string[],
454
+ width: number,
455
+ palette: ZellijColorPalette,
456
+ ): ZellijModalRenderOutput {
457
+ const frameWidth = Math.max(4, width);
458
+ const safeContent = contentLines.length > 0 ? contentLines : [""];
459
+ const lines: string[] = [];
460
+
461
+ lines.push(this.renderTitleBar(frameWidth, palette));
462
+
463
+ const contentStartLine = lines.length;
464
+ for (const line of safeContent) {
465
+ lines.push(this.renderContentLine(line, frameWidth, palette));
466
+ }
467
+ const contentEndLine = lines.length - 1;
468
+
469
+ lines.push(this.renderBottomLine(frameWidth, palette));
470
+
471
+ return {
472
+ lines,
473
+ visibleWidth: frameWidth,
474
+ contentWidth: Math.max(1, frameWidth - 2 - this.config.padding * 2),
475
+ contentStartLine,
476
+ contentEndLine,
477
+ };
478
+ }
479
+
480
+ private renderTitleBar(width: number, palette: ZellijColorPalette): string {
481
+ const innerWidth = Math.max(0, width - 2);
482
+ const borderColor = this.config.focused
483
+ ? palette.borderFocused
484
+ : palette.borderUnfocused;
485
+ const borderPaint = (text: string) =>
486
+ this.theme.colorizeForeground(borderColor, text);
487
+
488
+ if (innerWidth === 0) {
489
+ return `${borderPaint(this.borders.topLeft)}${borderPaint(this.borders.topRight)}`;
490
+ }
491
+
492
+ const segments = this.positionTitleSegments(innerWidth);
493
+ let inner = "";
494
+ let cursor = 0;
495
+
496
+ for (const segment of segments) {
497
+ if (segment.start > cursor) {
498
+ inner += borderPaint(
499
+ this.borders.horizontal.repeat(segment.start - cursor),
500
+ );
501
+ }
502
+ const text = segment.bold
503
+ ? `\x1b[1m${segment.text}${ANSI_RESET}`
504
+ : segment.text;
505
+ inner += this.theme.colorizeForeground(segment.color, text);
506
+ cursor = segment.end;
507
+ }
508
+
509
+ if (cursor < innerWidth) {
510
+ inner += borderPaint(this.borders.horizontal.repeat(innerWidth - cursor));
511
+ }
512
+
513
+ return `${borderPaint(this.borders.topLeft)}${inner}${borderPaint(this.borders.topRight)}`;
514
+ }
515
+
516
+ private renderBottomLine(width: number, palette: ZellijColorPalette): string {
517
+ const innerWidth = Math.max(0, width - 2);
518
+ const borderColor = this.config.focused
519
+ ? palette.borderFocused
520
+ : palette.borderUnfocused;
521
+ const borderPaint = (text: string) =>
522
+ this.theme.colorizeForeground(borderColor, text);
523
+
524
+ if (innerWidth === 0) {
525
+ return `${borderPaint(this.borders.bottomLeft)}${borderPaint(this.borders.bottomRight)}`;
526
+ }
527
+
528
+ const helpText = this.resolveHelpText(Math.max(0, innerWidth - 3));
529
+ if (!helpText) {
530
+ return `${borderPaint(this.borders.bottomLeft)}${borderPaint(this.borders.horizontal.repeat(innerWidth))}${borderPaint(this.borders.bottomRight)}`;
531
+ }
532
+
533
+ const helpSlot = this.config.helpUndertitle?.color ?? "dim";
534
+ const safeHelp = truncateToWidth(
535
+ helpText,
536
+ Math.max(0, innerWidth - 3),
537
+ "…",
538
+ );
539
+ const helpWidth = visibleWidth(safeHelp);
540
+ const rightFill = Math.max(0, innerWidth - helpWidth - 3);
541
+
542
+ return `${borderPaint(this.borders.bottomLeft)}${borderPaint(this.borders.horizontal)} ${this.theme.colorizeForeground(helpSlot, safeHelp)} ${borderPaint(this.borders.horizontal.repeat(rightFill))}${borderPaint(this.borders.bottomRight)}`;
543
+ }
544
+
545
+ private positionTitleSegments(innerWidth: number): PositionedTitleSegment[] {
546
+ if (innerWidth <= 0) {
547
+ return [];
548
+ }
549
+
550
+ const left = this.resolveTitleSegment(this.config.titleBar.left, "left");
551
+ const center = this.resolveTitleSegment(
552
+ this.config.titleBar.center,
553
+ "center",
554
+ );
555
+ const right = this.resolveTitleSegment(this.config.titleBar.right, "right");
556
+
557
+ const placements: PositionedTitleSegment[] = [];
558
+
559
+ if (left) {
560
+ const leftText = this.fitTextToWidth(
561
+ left.text,
562
+ Math.min(innerWidth, left.maxWidth ?? innerWidth),
563
+ left.truncate,
564
+ );
565
+ if (leftText) {
566
+ placements.push({
567
+ start: 0,
568
+ end: Math.min(innerWidth, visibleWidth(leftText)),
569
+ text: leftText,
570
+ color: left.color,
571
+ bold: left.bold ?? false,
572
+ });
573
+ }
574
+ }
575
+
576
+ if (right) {
577
+ const reservedLeft = placements[0]?.end ?? 0;
578
+ const available = Math.max(0, innerWidth - reservedLeft);
579
+ const rightText = this.fitTextToWidth(
580
+ right.text,
581
+ Math.min(available, right.maxWidth ?? available),
582
+ right.truncate,
583
+ );
584
+ const rightWidth = visibleWidth(rightText);
585
+ if (rightText && rightWidth > 0) {
586
+ placements.push({
587
+ start: innerWidth - rightWidth,
588
+ end: innerWidth,
589
+ text: rightText,
590
+ color: right.color,
591
+ bold: right.bold ?? false,
592
+ });
593
+ }
594
+ }
595
+
596
+ if (center) {
597
+ const leftLimit =
598
+ placements.find((placement) => placement.start === 0)?.end ?? 0;
599
+ const rightStart =
600
+ placements.find((placement) => placement.end === innerWidth)?.start ??
601
+ innerWidth;
602
+ const freeWidth = Math.max(0, rightStart - leftLimit);
603
+ if (freeWidth > 0) {
604
+ const centerText = this.fitTextToWidth(
605
+ center.text,
606
+ Math.min(freeWidth, center.maxWidth ?? freeWidth),
607
+ center.truncate,
608
+ );
609
+ const centerWidth = visibleWidth(centerText);
610
+ if (centerText && centerWidth > 0) {
611
+ const centeredStart = Math.floor((innerWidth - centerWidth) / 2);
612
+ const start = clampInt(
613
+ centeredStart,
614
+ leftLimit,
615
+ Math.max(leftLimit, rightStart - centerWidth),
616
+ );
617
+ placements.push({
618
+ start,
619
+ end: start + centerWidth,
620
+ text: centerText,
621
+ color: center.color,
622
+ bold: center.bold ?? false,
623
+ });
624
+ }
625
+ }
626
+ }
627
+
628
+ return placements.sort((a, b) => a.start - b.start);
629
+ }
630
+
631
+ private resolveTitleSegment(
632
+ segment: TitleSegment | string | undefined,
633
+ position: "left" | "center" | "right",
634
+ ): (TitleSegment & { text: string }) | null {
635
+ if (!segment) {
636
+ return null;
637
+ }
638
+
639
+ if (typeof segment === "string") {
640
+ const color: keyof ZellijColorPalette =
641
+ position === "left"
642
+ ? "accent"
643
+ : position === "center"
644
+ ? "muted"
645
+ : "dim";
646
+ return {
647
+ text: ` ${segment} `,
648
+ color,
649
+ bold: position === "left",
650
+ truncate: "end",
651
+ maxWidth: 0,
652
+ };
653
+ }
654
+
655
+ const clean = segment.text.trim();
656
+ if (!clean) {
657
+ return null;
658
+ }
659
+
660
+ return {
661
+ ...segment,
662
+ text: ` ${clean} `,
663
+ truncate: segment.truncate ?? "end",
664
+ bold: segment.bold ?? false,
665
+ };
666
+ }
667
+
668
+ private fitTextToWidth(
669
+ text: string,
670
+ maxWidth: number,
671
+ mode: TitleSegment["truncate"],
672
+ ): string {
673
+ if (maxWidth <= 0) {
674
+ return "";
675
+ }
676
+ if (visibleWidth(text) <= maxWidth) {
677
+ return text;
678
+ }
679
+
680
+ switch (mode) {
681
+ case "none":
682
+ return truncateToWidth(text, maxWidth, "");
683
+ case "start":
684
+ return truncateStart(text, maxWidth);
685
+ case "middle":
686
+ return truncateMiddle(text, maxWidth);
687
+ case "end":
688
+ default:
689
+ return truncateToWidth(text, maxWidth, "…");
690
+ }
691
+ }
692
+
693
+ private resolveHelpText(maxWidth: number): string | null {
694
+ const config = this.config.helpUndertitle;
695
+ if (!config || maxWidth <= 0) {
696
+ return null;
697
+ }
698
+
699
+ if (config.textGenerator) {
700
+ try {
701
+ const generated = config.textGenerator(maxWidth);
702
+ if (generated && generated.trim()) {
703
+ return generated;
704
+ }
705
+ } catch {
706
+ return config.text?.trim() ? config.text : null;
707
+ }
708
+ }
709
+
710
+ if (config.variants && config.variants.length > 0) {
711
+ for (const variant of config.variants) {
712
+ if (visibleWidth(variant) <= maxWidth) {
713
+ return variant;
714
+ }
715
+ }
716
+ return config.variants[config.variants.length - 1] ?? null;
717
+ }
718
+
719
+ if (config.keyHints && config.keyHints.length > 0) {
720
+ const separator = config.keyHintSeparator ?? " • ";
721
+ return config.keyHints
722
+ .map((hint) => `${hint.key} ${hint.description}`)
723
+ .join(separator);
724
+ }
725
+
726
+ return config.text?.trim() ? config.text : null;
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Main Zellij-style modal component wrapper.
732
+ */
733
+ export class ZellijModal implements ZellijModalComponent {
734
+ config: ZellijModalConfig;
735
+ content: ZellijModalContentRenderer;
736
+
737
+ private frame: ZellijModalFrame;
738
+ private palette: ZellijColorPalette;
739
+
740
+ constructor(
741
+ content: ZellijModalContentRenderer,
742
+ config: ZellijModalConfigPartial = {},
743
+ theme?: Theme,
744
+ ) {
745
+ if (!content || typeof content.render !== "function") {
746
+ throw new Error("ZellijModal requires a valid content renderer.");
747
+ }
748
+
749
+ this.config = this.buildConfig(config);
750
+ this.palette = theme ? themeToZellijPalette(theme) : this.config.palette;
751
+ this.content = content;
752
+ this.frame = new ZellijModalFrame({
753
+ ...this.config,
754
+ palette: this.palette,
755
+ });
756
+ }
757
+
758
+ /**
759
+ * Render content only (without frame).
760
+ */
761
+ render(width: number): string[] {
762
+ const contentWidth = Math.max(1, width - 2 - this.config.padding * 2);
763
+ const paddedWidth = contentWidth + this.config.padding * 2;
764
+ const sidePadding = " ".repeat(this.config.padding);
765
+ const lines: string[] = [];
766
+
767
+ try {
768
+ const rawLines = this.content.render(contentWidth);
769
+ const normalized = rawLines.length > 0 ? rawLines : [""];
770
+
771
+ for (let i = 0; i < this.config.padding; i++) {
772
+ lines.push(" ".repeat(paddedWidth));
773
+ }
774
+
775
+ for (const line of normalized) {
776
+ const fitted = truncateToWidth(line, contentWidth, "", true);
777
+ lines.push(`${sidePadding}${fitted}${sidePadding}`);
778
+ }
779
+
780
+ for (let i = 0; i < this.config.padding; i++) {
781
+ lines.push(" ".repeat(paddedWidth));
782
+ }
783
+ } catch (error) {
784
+ const message = error instanceof Error ? error.message : String(error);
785
+ const safe = truncateToWidth(
786
+ ` Render error: ${message} `,
787
+ paddedWidth,
788
+ "…",
789
+ true,
790
+ );
791
+ lines.push(safe);
792
+ }
793
+
794
+ return lines.length > 0 ? lines : [" ".repeat(paddedWidth)];
795
+ }
796
+
797
+ /**
798
+ * Render complete frame + content.
799
+ */
800
+ renderModal(width: number): ZellijModalRenderOutput {
801
+ const frameWidth = this.resolveModalWidth(width);
802
+ const contentLines = this.render(frameWidth);
803
+ return this.frame.renderFrame(contentLines, frameWidth, this.palette);
804
+ }
805
+
806
+ /**
807
+ * Invalidate child renderer state.
808
+ */
809
+ invalidate(): void {
810
+ this.content.invalidate();
811
+ }
812
+
813
+ /**
814
+ * Delegate input to child renderer.
815
+ */
816
+ handleInput(data: string): void {
817
+ this.content.handleInput?.(data);
818
+ }
819
+
820
+ /**
821
+ * Get overlay options for `ctx.ui.custom()`.
822
+ */
823
+ getOverlayOptions(): {
824
+ overlay: true;
825
+ overlayOptions: ZellijModalConfig["overlay"];
826
+ } {
827
+ return {
828
+ overlay: true,
829
+ overlayOptions: this.config.overlay,
830
+ };
831
+ }
832
+
833
+ /**
834
+ * Dispose modal resources.
835
+ */
836
+ dispose(): void {
837
+ this.content.invalidate();
838
+ }
839
+
840
+ private buildConfig(partial: ZellijModalConfigPartial): ZellijModalConfig {
841
+ const borderStyle =
842
+ partial.borderStyle && BORDER_STYLES[partial.borderStyle]
843
+ ? partial.borderStyle
844
+ : "rounded";
845
+ const padding = Math.max(0, partial.padding ?? 1);
846
+ const minWidth = Math.max(4, partial.minWidth ?? 40);
847
+ const maxWidth = Math.max(0, partial.maxWidth ?? 0);
848
+ const helpUndertitle = normalizeHelpUndertitle(
849
+ partial.helpText,
850
+ partial.helpUndertitle,
851
+ );
852
+
853
+ return {
854
+ borderStyle,
855
+ palette: partial.palette ?? DEFAULT_ZELLIJ_PALETTE,
856
+ focused: partial.focused ?? true,
857
+ padding,
858
+ titleBar: partial.titleBar ?? { left: partial.title ?? "Modal" },
859
+ helpUndertitle,
860
+ minWidth,
861
+ maxWidth,
862
+ overlay: {
863
+ anchor: partial.overlay?.anchor ?? "center",
864
+ width: partial.overlay?.width ?? 70,
865
+ maxHeight: partial.overlay?.maxHeight ?? "80%",
866
+ margin: Math.max(0, partial.overlay?.margin ?? 1),
867
+ },
868
+ };
869
+ }
870
+
871
+ private resolveModalWidth(availableWidth: number): number {
872
+ const width = Math.max(4, availableWidth);
873
+ const boundedMax =
874
+ this.config.maxWidth > 0 ? Math.min(width, this.config.maxWidth) : width;
875
+ if (boundedMax >= this.config.minWidth) {
876
+ return boundedMax;
877
+ }
878
+ return Math.max(4, boundedMax);
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Options for the pre-built settings modal content renderer.
884
+ */
885
+ export interface SettingsModalOptions {
886
+ /** Modal heading. */
887
+ title: string;
888
+ /** Optional descriptive subtitle shown above settings. */
889
+ description?: string;
890
+ /** Settings list items. */
891
+ settings: SettingItem[];
892
+ /** Called when a setting value changes. */
893
+ onChange: (id: string, value: string) => void;
894
+ /** Called when modal should close. */
895
+ onClose: () => void;
896
+ /** Optional help text shown below settings. */
897
+ helpText?: string;
898
+ /** Enables in-list search (`/` and typing behavior from SettingsList). */
899
+ enableSearch?: boolean;
900
+ }
901
+
902
+ /**
903
+ * Pre-built Zellij content renderer for configuration modals.
904
+ */
905
+ export class ZellijSettingsModal implements ZellijModalContentRenderer {
906
+ private container: Container;
907
+ private contentBox: Box;
908
+ private settingsList: SettingsList;
909
+ private options: SettingsModalOptions;
910
+ private theme: Theme;
911
+
912
+ constructor(options: SettingsModalOptions, theme: Theme) {
913
+ if (!options.title || !options.title.trim()) {
914
+ throw new Error("ZellijSettingsModal requires a non-empty title.");
915
+ }
916
+
917
+ this.options = options;
918
+ this.theme = theme;
919
+ this.container = new Container();
920
+ this.contentBox = new Box(0, 0);
921
+
922
+ this.contentBox.addChild(
923
+ new Text(this.theme.fg("accent", this.theme.bold(options.title)), 0, 0),
924
+ );
925
+
926
+ if (options.description) {
927
+ this.contentBox.addChild(new Spacer(1));
928
+ this.contentBox.addChild(
929
+ new Text(this.theme.fg("muted", options.description), 0, 0),
930
+ );
931
+ }
932
+
933
+ this.contentBox.addChild(new Spacer(1));
934
+ this.settingsList = new SettingsList(
935
+ options.settings,
936
+ Math.min(Math.max(options.settings.length + 2, 6), 18),
937
+ getSettingsListTheme(),
938
+ (id, value) => {
939
+ this.options.onChange(id, value);
940
+ },
941
+ () => {
942
+ this.options.onClose();
943
+ },
944
+ { enableSearch: options.enableSearch ?? true },
945
+ );
946
+ this.contentBox.addChild(this.settingsList);
947
+
948
+ if (options.helpText) {
949
+ this.contentBox.addChild(new Spacer(1));
950
+ this.contentBox.addChild(
951
+ new Text(this.theme.fg("dim", options.helpText), 0, 0),
952
+ );
953
+ }
954
+
955
+ this.container.addChild(this.contentBox);
956
+ }
957
+
958
+ /**
959
+ * Render settings modal content.
960
+ */
961
+ render(width: number): string[] {
962
+ const safeWidth = Math.max(1, width);
963
+ try {
964
+ return this.container.render(safeWidth);
965
+ } catch (error) {
966
+ const message = error instanceof Error ? error.message : String(error);
967
+ return [
968
+ this.theme.fg(
969
+ "error",
970
+ truncateToWidth(`Settings render error: ${message}`, safeWidth, "…"),
971
+ ),
972
+ ];
973
+ }
974
+ }
975
+
976
+ /**
977
+ * Invalidate internal caches.
978
+ */
979
+ invalidate(): void {
980
+ this.container.invalidate();
981
+ }
982
+
983
+ /**
984
+ * Forward key input to SettingsList.
985
+ */
986
+ handleInput(data: string): void {
987
+ if (isEnterActivationInput(data)) {
988
+ return;
989
+ }
990
+ this.settingsList.handleInput(data);
991
+ }
992
+
993
+ /**
994
+ * Programmatically update one setting value in the list.
995
+ */
996
+ updateValue(id: string, value: string): void {
997
+ this.settingsList.updateValue(id, value);
998
+ }
999
+ }
1000
+
1001
+ function isEnterActivationInput(data: string): boolean {
1002
+ return data === "\r" || data === "\n" || data === "\r\n";
1003
+ }
1004
+
1005
+ function normalizeHelpUndertitle(
1006
+ helpText: ZellijModalConfigPartial["helpText"],
1007
+ helpUndertitle: HelpUndertitleConfig | undefined,
1008
+ ): HelpUndertitleConfig | undefined {
1009
+ if (helpUndertitle) {
1010
+ return helpUndertitle;
1011
+ }
1012
+ if (typeof helpText === "string") {
1013
+ return helpText ? { text: helpText } : undefined;
1014
+ }
1015
+ return helpText;
1016
+ }
1017
+
1018
+ function resolvePaletteColor(
1019
+ color: PaletteColor | keyof ZellijColorPalette,
1020
+ palette: ZellijColorPalette,
1021
+ ): PaletteColor {
1022
+ if (typeof color === "string") {
1023
+ return palette[color];
1024
+ }
1025
+ return color;
1026
+ }
1027
+
1028
+ function parseAnsiForegroundColor(ansi: string): PaletteColor | null {
1029
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape sequence
1030
+ const rgbMatch = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(ansi);
1031
+ if (rgbMatch) {
1032
+ const [, r, g, b] = rgbMatch;
1033
+ return {
1034
+ type: "rgb",
1035
+ r: clampInt(Number.parseInt(r ?? "0", 10), 0, 255),
1036
+ g: clampInt(Number.parseInt(g ?? "0", 10), 0, 255),
1037
+ b: clampInt(Number.parseInt(b ?? "0", 10), 0, 255),
1038
+ };
1039
+ }
1040
+
1041
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape sequence
1042
+ const bit8Match = /\x1b\[38;5;(\d+)m/.exec(ansi);
1043
+ if (bit8Match) {
1044
+ const [, code] = bit8Match;
1045
+ return {
1046
+ type: "8bit",
1047
+ code: clampInt(Number.parseInt(code ?? "255", 10), 0, 255),
1048
+ };
1049
+ }
1050
+
1051
+ return null;
1052
+ }
1053
+
1054
+ function truncateStart(text: string, maxWidth: number): string {
1055
+ if (visibleWidth(text) <= maxWidth) {
1056
+ return text;
1057
+ }
1058
+ if (maxWidth <= 1) {
1059
+ return "…".slice(0, maxWidth);
1060
+ }
1061
+ const chars = Array.from(text);
1062
+ let current = "";
1063
+ for (let index = chars.length - 1; index >= 0; index--) {
1064
+ const candidate = `${chars[index]}${current}`;
1065
+ if (visibleWidth(candidate) >= maxWidth - 1) {
1066
+ current = candidate;
1067
+ break;
1068
+ }
1069
+ current = candidate;
1070
+ }
1071
+ return `…${truncateToWidth(current, Math.max(0, maxWidth - 1), "")}`;
1072
+ }
1073
+
1074
+ function truncateMiddle(text: string, maxWidth: number): string {
1075
+ if (visibleWidth(text) <= maxWidth) {
1076
+ return text;
1077
+ }
1078
+ if (maxWidth <= 1) {
1079
+ return "…".slice(0, maxWidth);
1080
+ }
1081
+
1082
+ const headTarget = Math.floor((maxWidth - 1) / 2);
1083
+ const tailTarget = Math.max(0, maxWidth - 1 - headTarget);
1084
+ const head = truncateToWidth(text, headTarget, "");
1085
+
1086
+ const chars = Array.from(text);
1087
+ let tail = "";
1088
+ for (let index = chars.length - 1; index >= 0; index--) {
1089
+ const candidate = `${chars[index]}${tail}`;
1090
+ if (visibleWidth(candidate) > tailTarget) {
1091
+ continue;
1092
+ }
1093
+ tail = candidate;
1094
+ if (visibleWidth(tail) === tailTarget) {
1095
+ break;
1096
+ }
1097
+ }
1098
+
1099
+ return `${head}…${tail}`;
1100
+ }
1101
+
1102
+ function clampInt(value: number, min: number, max: number): number {
1103
+ if (Number.isNaN(value) || !Number.isFinite(value)) {
1104
+ return min;
1105
+ }
1106
+ return Math.min(max, Math.max(min, Math.round(value)));
1107
+ }
1108
+
1109
+ /**
1110
+ * Extension factory entrypoint for Pi extension loader.
1111
+ *
1112
+ * This extension intentionally registers no commands/events and only exposes
1113
+ * reusable modal primitives for sibling extensions.
1114
+ */
1115
+ export default function zellijModalExtension(_pi: ExtensionAPI): void {
1116
+ // no-op
1117
+ }