@homer0/pi-config 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/window.ts ADDED
@@ -0,0 +1,593 @@
1
+ import {
2
+ highlightCode,
3
+ type ExtensionCommandContext,
4
+ type Theme,
5
+ } from '@earendil-works/pi-coding-agent';
6
+ import {
7
+ type Component,
8
+ Editor,
9
+ type EditorTheme,
10
+ Key,
11
+ matchesKey,
12
+ truncateToWidth,
13
+ type TUI,
14
+ visibleWidth,
15
+ } from '@earendil-works/pi-tui';
16
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
17
+ import type { PiCoreConfig } from './types.js';
18
+ import {
19
+ ensureErrorMessage,
20
+ getConfig,
21
+ getSessionConfigOverride,
22
+ readConfigFiles,
23
+ setSessionConfigOverride,
24
+ } from './utils.js';
25
+
26
+ /** The title shown in the top border of the window. */
27
+ const WINDOW_TITLE = 'Config';
28
+ /** The labels of the tabs, in order. */
29
+ const TAB_LABELS = ['Effective config', 'Override'] as const;
30
+ /** The key that toggles the override on the effective config tab. */
31
+ const TOGGLE_OVERRIDE_KEY = 't';
32
+ /** The language used to highlight the config. */
33
+ const YAML_LANGUAGE = 'yaml';
34
+ /** The lines shown on the effective config tab while the config is loading. */
35
+ const LOADING_LINES = ['Loading…'];
36
+
37
+ /** The theme color for the border of the window. */
38
+ const BORDER_COLOR = 'borderAccent';
39
+ /** Columns spent on the left/right border chars and their padding: `│ ` + ` │`. */
40
+ const BORDER_OVERHEAD_WIDTH = 4;
41
+ /** Columns spent on the top/bottom border corner chars: `┌`/`┐` or `└`/`┘`. */
42
+ const BORDER_CORNER_WIDTH = 2;
43
+
44
+ /** The width of the window, relative to the terminal. */
45
+ const OVERLAY_WIDTH = '90%';
46
+ /** The max height of the window, relative to the terminal. */
47
+ const OVERLAY_MAX_HEIGHT = '85%';
48
+ /**
49
+ * The same max height as {@link OVERLAY_MAX_HEIGHT}, as a ratio, to calculate how many
50
+ * lines of content fit in the window.
51
+ */
52
+ const OVERLAY_HEIGHT_RATIO = 0.85;
53
+ /** The minimum number of content lines to show, even in very short terminals. */
54
+ const MIN_VISIBLE_CONTENT_LINES = 5;
55
+ /**
56
+ * Chrome lines around tab 1's scrollable YAML body: outer border/tab-bar/blanks (4) plus
57
+ * tab 1's own status/blank/blank/hint (4).
58
+ */
59
+ const EFFECTIVE_TAB_CHROME_LINES = 8;
60
+
61
+ /** Which `/config` window tab is focused: 0 = effective config, 1 = override editor. */
62
+ type ConfigTab = 0 | 1;
63
+
64
+ /** Feedback shown under the override editor after an Enter-submit attempt. */
65
+ type OverrideMessage = {
66
+ /** Whether the submit worked, to pick the color of the message. */
67
+ kind: 'success' | 'error';
68
+ /** The text to show. */
69
+ text: string;
70
+ };
71
+
72
+ /** Result of parsing tab 2's editor content into a new session override. */
73
+ type OverrideParseResult = {
74
+ /** The new override. It's `undefined` when the override should be cleared, or on error. */
75
+ override?: PiCoreConfig;
76
+ /** The feedback for the user. */
77
+ message: OverrideMessage;
78
+ };
79
+
80
+ /**
81
+ * Mutable state for one open `/config` window. Held as a plain object (rather than
82
+ * closure-local variables) so the render/input-handling functions can stay pure and be
83
+ * composed instead of nesting all logic inside the window factory.
84
+ */
85
+ type ConfigWindowState = {
86
+ /** The tab that is focused. */
87
+ activeTab: ConfigTab;
88
+ /** Whether the effective config tab shows the session override applied. */
89
+ showOverride: boolean;
90
+ /** First visible line index into tab 1's current YAML text (manual scroll position). */
91
+ scrollOffset: number;
92
+ /** Syntax-highlighted lines for the file-based config, once loaded. */
93
+ fileConfigLines?: string[];
94
+ /** Syntax-highlighted lines for the override-inclusive effective config, once loaded. */
95
+ effectiveConfigLines?: string[];
96
+ /** The feedback of the last submit on the override tab, if there's one. */
97
+ overrideMessage?: OverrideMessage;
98
+ };
99
+
100
+ /** Shared render context passed to the `/config` window's tab renderers. */
101
+ type ConfigWindowRenderContext = {
102
+ /** The theme to color the window with. */
103
+ theme: Theme;
104
+ /** The state of the window. */
105
+ state: ConfigWindowState;
106
+ /** The editor of the override tab. */
107
+ editor: Editor;
108
+ /** Usable width inside the window's left/right border. */
109
+ innerWidth: number;
110
+ /** How many lines of the config fit in the effective config tab. */
111
+ visibleContentLines: number;
112
+ };
113
+
114
+ /** Options for {@link createConfigWindowInputHandler}. */
115
+ type ConfigWindowInputHandlerOptions = {
116
+ /** The state of the window, which the handler updates. */
117
+ state: ConfigWindowState;
118
+ /** The editor of the override tab, which receives the input of that tab. */
119
+ editor: Editor;
120
+ /** The TUI, to know the size of the terminal. */
121
+ tui: TUI;
122
+ /** Re-renders the window. */
123
+ refresh: () => void;
124
+ /** Closes the window. */
125
+ close: () => void;
126
+ };
127
+
128
+ /** Options for {@link createConfigWindow}. */
129
+ type CreateConfigWindowOptions = {
130
+ /** The TUI that will render the window. */
131
+ tui: TUI;
132
+ /** The theme to color the window with. */
133
+ theme: Theme;
134
+ /** Closes the window. */
135
+ done: (result: unknown) => void;
136
+ };
137
+
138
+ /**
139
+ * Adapts Pi's theme object to the embedded editor theme.
140
+ *
141
+ * @param theme Pi's theme.
142
+ * @returns The theme for the editor.
143
+ */
144
+ const createEditorTheme = (theme: Theme): EditorTheme => ({
145
+ borderColor: (text) => theme.fg('accent', text),
146
+ selectList: {
147
+ selectedPrefix: (text) => theme.fg('accent', text),
148
+ selectedText: (text) => theme.fg('accent', text),
149
+ description: (text) => theme.fg('muted', text),
150
+ scrollInfo: (text) => theme.fg('dim', text),
151
+ noMatch: (text) => theme.fg('warning', text),
152
+ },
153
+ });
154
+
155
+ /**
156
+ * Renders the current session-override status line for tab 1.
157
+ *
158
+ * @param showOverride Whether the tab is showing the override applied.
159
+ * @returns The text of the status line.
160
+ */
161
+ const getStatusText = (showOverride: boolean): string => {
162
+ const override = getSessionConfigOverride();
163
+ if (!override) return 'No session override set';
164
+ return showOverride
165
+ ? 'Session override set — shown (t to hide)'
166
+ : 'Session override set — hidden (t to show)';
167
+ };
168
+
169
+ /**
170
+ * Parses tab 2's editor content into a new override, or reports why it was rejected.
171
+ *
172
+ * @param value The content of the editor.
173
+ * @returns The new override (or none, if it should be cleared) and the feedback for the
174
+ * user.
175
+ */
176
+ const parseOverrideInput = (value: string): OverrideParseResult => {
177
+ const trimmed = value.trim();
178
+ if (!trimmed) {
179
+ return {
180
+ override: undefined,
181
+ message: { kind: 'success', text: 'Session override cleared.' },
182
+ };
183
+ }
184
+
185
+ let parsed: unknown;
186
+ try {
187
+ parsed = parseYaml(trimmed);
188
+ } catch (error) {
189
+ return {
190
+ override: undefined,
191
+ message: { kind: 'error', text: `Invalid YAML: ${ensureErrorMessage(error)}` },
192
+ };
193
+ }
194
+
195
+ if (typeof parsed === 'undefined' || parsed === null) {
196
+ return {
197
+ override: undefined,
198
+ message: { kind: 'success', text: 'Session override cleared.' },
199
+ };
200
+ }
201
+
202
+ if (typeof parsed === 'object' && !Array.isArray(parsed)) {
203
+ return {
204
+ override: parsed as PiCoreConfig,
205
+ message: { kind: 'success', text: 'Session override applied.' },
206
+ };
207
+ }
208
+
209
+ return {
210
+ override: undefined,
211
+ message: { kind: 'error', text: 'Override must be a YAML mapping (object).' },
212
+ };
213
+ };
214
+
215
+ /**
216
+ * Loads the file-based config, syntax-highlighted, or an error message on failure.
217
+ *
218
+ * @returns The lines to show.
219
+ */
220
+ const loadFileConfigLines = async (): Promise<string[]> => {
221
+ try {
222
+ const { config } = await readConfigFiles();
223
+ return highlightCode(stringifyYaml(config), YAML_LANGUAGE);
224
+ } catch (error) {
225
+ return [`Failed to load config: ${ensureErrorMessage(error)}`];
226
+ }
227
+ };
228
+
229
+ /**
230
+ * Loads the override-inclusive effective config, syntax-highlighted, or an error message
231
+ * on failure.
232
+ *
233
+ * @returns The lines to show.
234
+ */
235
+ const loadEffectiveConfigLines = async (): Promise<string[]> => {
236
+ try {
237
+ return highlightCode(stringifyYaml(await getConfig()), YAML_LANGUAGE);
238
+ } catch (error) {
239
+ return [`Failed to load config: ${ensureErrorMessage(error)}`];
240
+ }
241
+ };
242
+
243
+ /**
244
+ * Gets how many YAML content lines fit in the window, given the terminal's height.
245
+ *
246
+ * @param tui The TUI, to know the size of the terminal.
247
+ * @returns The number of lines.
248
+ */
249
+ const getVisibleContentLineCount = (tui: TUI): number =>
250
+ Math.max(
251
+ MIN_VISIBLE_CONTENT_LINES,
252
+ Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO) - EFFECTIVE_TAB_CHROME_LINES,
253
+ );
254
+
255
+ /**
256
+ * Gets tab 1's current syntax-highlighted YAML lines.
257
+ *
258
+ * @param state The state of the window.
259
+ * @returns The lines, or a loading message if they are not ready yet.
260
+ */
261
+ const getEffectiveTabLines = (state: ConfigWindowState): string[] =>
262
+ (state.showOverride ? state.effectiveConfigLines : state.fileConfigLines) ??
263
+ LOADING_LINES;
264
+
265
+ /**
266
+ * Clamps a scroll offset to the valid range for the given content/viewport sizes.
267
+ *
268
+ * @param scrollOffset The offset to clamp.
269
+ * @param lineCount The total number of lines of the content.
270
+ * @param visibleContentLines How many lines of the content fit in the window.
271
+ * @returns The offset, within the valid range.
272
+ */
273
+ const clampScrollOffset = (
274
+ scrollOffset: number,
275
+ lineCount: number,
276
+ visibleContentLines: number,
277
+ ): number =>
278
+ Math.min(Math.max(0, scrollOffset), Math.max(0, lineCount - visibleContentLines));
279
+
280
+ /**
281
+ * Renders the window's top border row, with the title embedded.
282
+ *
283
+ * @param theme The theme to color the border with.
284
+ * @param width The width of the window.
285
+ * @returns The line.
286
+ */
287
+ const renderTopBorder = (theme: Theme, width: number): string => {
288
+ const prefix = `┌─ ${WINDOW_TITLE} `;
289
+ const fillCount = Math.max(0, width - visibleWidth(prefix) - 1);
290
+ return theme.fg(BORDER_COLOR, `${prefix}${'─'.repeat(fillCount)}┐`);
291
+ };
292
+
293
+ /**
294
+ * Renders the window's bottom border row.
295
+ *
296
+ * @param theme The theme to color the border with.
297
+ * @param width The width of the window.
298
+ * @returns The line.
299
+ */
300
+ const renderBottomBorder = (theme: Theme, width: number): string =>
301
+ theme.fg(BORDER_COLOR, `└${'─'.repeat(Math.max(0, width - BORDER_CORNER_WIDTH))}┘`);
302
+
303
+ /**
304
+ * Wraps one body line with the window's left/right border, padded to `innerWidth`.
305
+ *
306
+ * @param theme The theme to color the border with.
307
+ * @param line The line to wrap.
308
+ * @param innerWidth The usable width inside the borders.
309
+ * @returns The wrapped line.
310
+ */
311
+ const wrapBodyLine = (theme: Theme, line: string, innerWidth: number): string => {
312
+ const truncated = truncateToWidth(line, innerWidth);
313
+ const padding = ' '.repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
314
+ return `${theme.fg(BORDER_COLOR, '│')} ${truncated}${padding} ${theme.fg(BORDER_COLOR, '│')}`;
315
+ };
316
+
317
+ /**
318
+ * Renders the tab bar row, highlighting the active tab.
319
+ *
320
+ * @param theme The theme to color the tabs with.
321
+ * @param activeTab The tab that is focused.
322
+ * @returns The line.
323
+ */
324
+ const renderTabBar = (theme: Theme, activeTab: ConfigTab): string =>
325
+ TAB_LABELS.map((label, index) => {
326
+ const active = index === activeTab;
327
+ return theme.fg(active ? 'accent' : 'muted', active ? `[${label}]` : ` ${label} `);
328
+ }).join(' ');
329
+
330
+ /**
331
+ * Renders tab 1 ("Effective config") body lines, scrolled to the current offset.
332
+ *
333
+ * @param context The render context, with only the properties this tab uses.
334
+ * @returns The lines of the tab.
335
+ */
336
+ const renderEffectiveConfigTab = ({
337
+ theme,
338
+ state,
339
+ visibleContentLines,
340
+ }: Pick<
341
+ ConfigWindowRenderContext,
342
+ 'theme' | 'state' | 'visibleContentLines'
343
+ >): string[] => {
344
+ const lines: string[] = [];
345
+ lines.push(theme.fg('muted', getStatusText(state.showOverride)));
346
+ lines.push('');
347
+
348
+ const allLines = getEffectiveTabLines(state);
349
+ const offset = clampScrollOffset(
350
+ state.scrollOffset,
351
+ allLines.length,
352
+ visibleContentLines,
353
+ );
354
+ lines.push(...allLines.slice(offset, offset + visibleContentLines));
355
+
356
+ lines.push('');
357
+ const scrollHint =
358
+ allLines.length > visibleContentLines
359
+ ? `(${offset + 1}-${Math.min(offset + visibleContentLines, allLines.length)}/${allLines.length}) ↑↓/PgUp/PgDn scroll • `
360
+ : '';
361
+ lines.push(
362
+ theme.fg('dim', `${scrollHint}Tab switch tab • t toggle override • Esc close`),
363
+ );
364
+ return lines;
365
+ };
366
+
367
+ /**
368
+ * Renders tab 2 ("Override") body lines.
369
+ *
370
+ * @param context The render context.
371
+ * @returns The lines of the tab.
372
+ */
373
+ const renderOverrideTab = ({
374
+ theme,
375
+ state,
376
+ editor,
377
+ innerWidth,
378
+ }: ConfigWindowRenderContext): string[] => {
379
+ const lines: string[] = [];
380
+ lines.push(theme.fg('muted', 'Session override (YAML). Empty clears it.'));
381
+ lines.push('');
382
+ lines.push(...editor.render(innerWidth));
383
+ if (state.overrideMessage) {
384
+ lines.push('');
385
+ lines.push(
386
+ theme.fg(
387
+ state.overrideMessage.kind === 'error' ? 'warning' : 'success',
388
+ state.overrideMessage.text,
389
+ ),
390
+ );
391
+ }
392
+ lines.push('');
393
+ lines.push(
394
+ theme.fg(
395
+ 'dim',
396
+ 'Enter apply/clear • Shift+Enter newline • Tab switch tab • Esc close',
397
+ ),
398
+ );
399
+ return lines;
400
+ };
401
+
402
+ /**
403
+ * Renders the full `/config` window for the given terminal width.
404
+ *
405
+ * @param context The render context.
406
+ * @param width The width of the window.
407
+ * @returns The lines of the window.
408
+ */
409
+ const renderConfigWindow = (
410
+ context: ConfigWindowRenderContext,
411
+ width: number,
412
+ ): string[] => {
413
+ const { theme, state } = context;
414
+ const bodyLines = [
415
+ renderTabBar(theme, state.activeTab),
416
+ '',
417
+ ...(state.activeTab === 0
418
+ ? renderEffectiveConfigTab(context)
419
+ : renderOverrideTab(context)),
420
+ ];
421
+ const lines = [
422
+ renderTopBorder(theme, width),
423
+ ...bodyLines.map((line) => wrapBodyLine(theme, line, context.innerWidth)),
424
+ renderBottomBorder(theme, width),
425
+ ];
426
+ return lines.map((line) => truncateToWidth(line, width));
427
+ };
428
+
429
+ /**
430
+ * Builds the `handleInput` handler for an open `/config` window.
431
+ *
432
+ * @param options The state and the actions the handler needs.
433
+ * @returns The handler, which receives the raw input of the terminal.
434
+ */
435
+ const createConfigWindowInputHandler =
436
+ ({
437
+ state,
438
+ editor,
439
+ tui,
440
+ refresh,
441
+ close,
442
+ }: ConfigWindowInputHandlerOptions): ((data: string) => void) =>
443
+ (data) => {
444
+ if (matchesKey(data, Key.escape)) {
445
+ close();
446
+ return;
447
+ }
448
+ if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift('tab'))) {
449
+ state.activeTab = state.activeTab === 0 ? 1 : 0;
450
+ refresh();
451
+ return;
452
+ }
453
+ if (state.activeTab === 0) {
454
+ if (data.toLowerCase() === TOGGLE_OVERRIDE_KEY) {
455
+ state.showOverride = !state.showOverride;
456
+ state.scrollOffset = 0;
457
+ refresh();
458
+ return;
459
+ }
460
+
461
+ const visibleContentLines = getVisibleContentLineCount(tui);
462
+ const lineCount = getEffectiveTabLines(state).length;
463
+ let scrollOffset: number | undefined;
464
+ if (matchesKey(data, Key.up)) scrollOffset = state.scrollOffset - 1;
465
+ else if (matchesKey(data, Key.down)) scrollOffset = state.scrollOffset + 1;
466
+ else if (matchesKey(data, Key.pageUp))
467
+ scrollOffset = state.scrollOffset - visibleContentLines;
468
+ else if (matchesKey(data, Key.pageDown))
469
+ scrollOffset = state.scrollOffset + visibleContentLines;
470
+ else if (matchesKey(data, Key.home)) scrollOffset = 0;
471
+ else if (matchesKey(data, Key.end)) scrollOffset = lineCount;
472
+
473
+ if (typeof scrollOffset !== 'undefined') {
474
+ state.scrollOffset = clampScrollOffset(
475
+ scrollOffset,
476
+ lineCount,
477
+ visibleContentLines,
478
+ );
479
+ refresh();
480
+ }
481
+ return;
482
+ }
483
+ editor.handleInput(data);
484
+ refresh();
485
+ };
486
+
487
+ /**
488
+ * Creates the `/config` window component: a 2-tab effective-config/override viewer.
489
+ *
490
+ * @param options The TUI, the theme, and the function to close the window.
491
+ * @returns The component.
492
+ */
493
+ const createConfigWindow = ({
494
+ tui,
495
+ theme,
496
+ done,
497
+ }: CreateConfigWindowOptions): Component => {
498
+ const state: ConfigWindowState = { activeTab: 0, showOverride: false, scrollOffset: 0 };
499
+ let cachedLines: string[] | undefined;
500
+ let cachedWidth: number | undefined;
501
+
502
+ const editor = new Editor(tui, createEditorTheme(theme));
503
+ const currentOverride = getSessionConfigOverride();
504
+ if (currentOverride) editor.setText(stringifyYaml(currentOverride));
505
+
506
+ const refresh = (): void => {
507
+ cachedLines = undefined;
508
+ tui.requestRender();
509
+ };
510
+
511
+ loadFileConfigLines().then((lines) => {
512
+ state.fileConfigLines = lines;
513
+ refresh();
514
+ });
515
+ loadEffectiveConfigLines().then((lines) => {
516
+ state.effectiveConfigLines = lines;
517
+ refresh();
518
+ });
519
+
520
+ editor.onSubmit = (value) => {
521
+ const { override, message } = parseOverrideInput(value);
522
+ if (message.kind === 'success') {
523
+ setSessionConfigOverride(override);
524
+ editor.setText(override ? stringifyYaml(override) : '');
525
+ } else {
526
+ // submitValue() clears the editor before calling onSubmit; restore the rejected
527
+ // text so the user can fix it instead of retyping from scratch.
528
+ editor.setText(value);
529
+ }
530
+ state.overrideMessage = message;
531
+ loadEffectiveConfigLines().then((lines) => {
532
+ state.effectiveConfigLines = lines;
533
+ refresh();
534
+ });
535
+ refresh();
536
+ };
537
+
538
+ const handleInput = createConfigWindowInputHandler({
539
+ state,
540
+ editor,
541
+ tui,
542
+ refresh,
543
+ close: () => done(undefined),
544
+ });
545
+
546
+ return {
547
+ render: (width) => {
548
+ if (cachedLines && cachedWidth === width) return cachedLines;
549
+ const innerWidth = Math.max(1, width - BORDER_OVERHEAD_WIDTH);
550
+ cachedLines = renderConfigWindow(
551
+ {
552
+ theme,
553
+ state,
554
+ editor,
555
+ innerWidth,
556
+ visibleContentLines: getVisibleContentLineCount(tui),
557
+ },
558
+ width,
559
+ );
560
+ cachedWidth = width;
561
+ return cachedLines;
562
+ },
563
+ invalidate: () => {
564
+ cachedLines = undefined;
565
+ },
566
+ handleInput,
567
+ };
568
+ };
569
+
570
+ /**
571
+ * Opens the `/config` window, or reports that interactive UI is required.
572
+ *
573
+ * @param ctx The context of the command that opened the window.
574
+ */
575
+ const showConfigWindow = async (ctx: ExtensionCommandContext): Promise<void> => {
576
+ if (!ctx.hasUI) {
577
+ ctx.ui.notify('/config requires interactive mode UI', 'warning');
578
+ return;
579
+ }
580
+ await ctx.ui.custom<unknown>(
581
+ (tui, theme, _keybindings, done) => createConfigWindow({ tui, theme, done }),
582
+ {
583
+ overlay: true,
584
+ overlayOptions: {
585
+ width: OVERLAY_WIDTH,
586
+ maxHeight: OVERLAY_MAX_HEIGHT,
587
+ anchor: 'center',
588
+ },
589
+ },
590
+ );
591
+ };
592
+
593
+ export { showConfigWindow };