agy-powerline 1.1.1 → 1.2.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.
@@ -19,8 +19,11 @@ const {
19
19
  PRESETS,
20
20
  PRESET_ORDER,
21
21
  applyPreset,
22
+ resolveQuotaThresholds,
23
+ resolveContextThresholds,
22
24
  } = require("../lib/shared");
23
25
  const { resolveInstallDir } = require("../lib/paths");
26
+ const { WIDGET_TAGS } = require("../lib/widget-style");
24
27
  const { loadEditableConfig } = require("./config-guard");
25
28
  const {
26
29
  parseColor,
@@ -29,7 +32,8 @@ const {
29
32
  getActiveDepth,
30
33
  resolveColorDepth,
31
34
  } = require("../lib/colors");
32
- const { makeUi } = require("./tui-style");
35
+ const { makeUi, wrapText } = require("./tui-style");
36
+ const { visibleWidth, stripControl } = require("../lib/formatters");
33
37
  const {
34
38
  STYLES,
35
39
  STYLE_ORDER,
@@ -115,6 +119,10 @@ let searchQuery = "";
115
119
  let glyphSlot = "separator";
116
120
  // null while browsing the glyph list; a string while typing a code point.
117
121
  let hexInput = null;
122
+ // The widget being edited (its index in the list), and the text being typed
123
+ // into one of its fields (null when not typing).
124
+ let editIndex = 0;
125
+ let textInput = null;
118
126
 
119
127
  function openUrl(url) {
120
128
  const target = String(url || "").trim();
@@ -277,6 +285,7 @@ const TABS = [
277
285
  const TAB_OF_SCREEN = {
278
286
  main: "main",
279
287
  add: "main",
288
+ edit: "main",
280
289
  presets: "presets",
281
290
  themes: "themes",
282
291
  customTheme: "themes",
@@ -287,13 +296,42 @@ const TAB_OF_SCREEN = {
287
296
 
288
297
  const PRESET_ICONS = { default: "🎯", compact: "⚡", workspace: "📂", limits: "🔋", full: "🚀" };
289
298
 
290
- // Set by renderFrame so tests can draw a screen at a fixed width.
299
+ // Set by renderFrame so tests can draw a screen at a fixed size.
291
300
  let columnsOverride = null;
301
+ let rowsOverride = null;
292
302
  let ui;
293
303
 
304
+ // Window sizes where the editor changes shape: from MEDIUM columns the
305
+ // descriptions line up in a column; from WIDE the list gets a details panel
306
+ // next to it.
307
+ const MEDIUM = 80;
308
+ const WIDE = 140;
309
+ // A list taller than the window scrolls, but keeps at least this many rows
310
+ // before the editor gives up its spacing and the preview's toggles.
311
+ const MIN_LIST_ROWS = 6;
312
+ // What a short window gives up, one step at a time, so the list keeps rows.
313
+ const COMPACT_STEPS = [
314
+ { tagline: true, gaps: true, notes: true, chips: true },
315
+ { tagline: false, gaps: true, notes: true, chips: true },
316
+ { tagline: false, gaps: false, notes: true, chips: true },
317
+ { tagline: false, gaps: false, notes: false, chips: true },
318
+ { tagline: false, gaps: false, notes: false, chips: false },
319
+ ];
320
+
321
+ // First visible row of a scrolled list, kept between frames so the list only
322
+ // moves when the cursor leaves the window.
323
+ let scrollTop = 0;
324
+ let scrollScreen = null;
325
+
326
+ // The editor fills the window, so the live preview has the room the real bar
327
+ // gets in agy.
294
328
  function frameWidth() {
295
329
  const columns = columnsOverride || process.stdout.columns || 100;
296
- return Math.max(50, Math.min(columns - 1, 120));
330
+ return Math.max(50, columns - 1);
331
+ }
332
+
333
+ function frameHeight() {
334
+ return Math.max(12, rowsOverride || process.stdout.rows || 40);
297
335
  }
298
336
 
299
337
  function previewConfig() {
@@ -324,7 +362,32 @@ function dim(text) {
324
362
  return ui.muted(text);
325
363
  }
326
364
 
327
- function previewPanel(width) {
365
+ // What the preview shows that isn't applied yet: the hovered theme, preset
366
+ // or layout. Empty when the preview is the saved setup.
367
+ function previewPending() {
368
+ if (screen === "themes") {
369
+ const hovered = THEME_ORDER[cursor];
370
+ return hovered && hovered !== config.theme ? "theme" : "";
371
+ }
372
+ if (screen === "presets") {
373
+ const hovered = PRESET_ORDER[cursor];
374
+ return hovered && hovered !== currentPreset() ? "preset" : "";
375
+ }
376
+ if (screen === "layout") {
377
+ const hovered = LAYOUT_ORDER[cursor];
378
+ return hovered && hovered !== config.layout ? "layout" : "";
379
+ }
380
+ return "";
381
+ }
382
+
383
+ // The preset whose widgets are exactly the current ones, if any.
384
+ function currentPreset() {
385
+ const mine = JSON.stringify(config.widgets);
386
+ return PRESET_ORDER.find((id) => JSON.stringify(applyPreset(config, id).widgets) === mine);
387
+ }
388
+
389
+ // The bar's rows and the toggle chips under them.
390
+ function previewParts(width) {
328
391
  const preview = previewConfig();
329
392
  const { dot } = ui.glyph;
330
393
  previewData.terminal_width = width - 4;
@@ -339,7 +402,9 @@ function previewPanel(width) {
339
402
  const layout = LAYOUTS[preview.layout]?.name ?? preview.layout;
340
403
  const style =
341
404
  preview.layout === "pills" ? ` ${dot} ${detectStyle(preview.powerline) ?? "custom"}` : "";
342
- const title = `Live preview ${dim(`${dot} ${layout} ${dot} ${preview.theme}${style}`)}`;
405
+ const pending = previewPending();
406
+ const note = pending ? ` ${ui.accentText(`${dot} ${pending} preview, enter to apply`)}` : "";
407
+ const title = `Live preview ${dim(`${dot} ${layout} ${dot} ${preview.theme}${style}`)}${note}`;
343
408
  // The toggle keys work on the widget list, so only that screen names them.
344
409
  const key = (letter) => (screen === "main" ? letter : "");
345
410
  const chips = [
@@ -356,31 +421,271 @@ function previewPanel(width) {
356
421
  );
357
422
  }
358
423
 
359
- return ui.panel(title, [...String(rendered).split("\n"), "", chips.join(" ")], width);
424
+ return { title, bar: String(rendered).split("\n"), chips: chips.join(" ") };
360
425
  }
361
426
 
362
- // Every screen: brand line, tabs, the screen's list panel, the live preview
363
- // and the keys for this screen.
364
- function screenFrame({ title, list, notes = [], keys }) {
427
+ // Keeps the cursor's row inside a window of `space` rows; returns the slice
428
+ // of the list to show.
429
+ function windowRows(count, selected, space) {
430
+ if (scrollScreen !== screen) {
431
+ scrollScreen = screen;
432
+ scrollTop = 0;
433
+ }
434
+ if (count <= space) {
435
+ scrollTop = 0;
436
+ return { start: 0, end: count };
437
+ }
438
+ if (selected >= 0) {
439
+ if (selected < scrollTop) {
440
+ scrollTop = selected;
441
+ } else if (selected >= scrollTop + space) {
442
+ scrollTop = selected - space + 1;
443
+ }
444
+ }
445
+ scrollTop = Math.max(0, Math.min(scrollTop, count - space));
446
+ return { start: scrollTop, end: scrollTop + space };
447
+ }
448
+
449
+ // List items are { text, extra, value, number } or a plain line: extra is a
450
+ // description, value a setting's current value. From MEDIUM columns they
451
+ // line up in one column.
452
+ function listRows(items, selected, rowWidth) {
453
+ const shown = (item) => item.value ?? item.extra ?? "";
454
+ const entries = items.filter((item) => typeof item !== "string");
455
+ const labels = entries.filter((item) => shown(item));
456
+ // A badge follows its label, so it counts toward the label's width.
457
+ const labelWidth = (item) =>
458
+ visibleWidth(item.text) + (item.badge ? 1 + visibleWidth(item.badge) : 0);
459
+ const widest = Math.max(0, ...labels.map(labelWidth));
460
+ const pad = frameWidth() >= MEDIUM ? Math.min(widest, Math.floor(rowWidth * 0.45)) : 0;
461
+ const leadWidth = Math.max(0, ...entries.map((item) => visibleWidth(item.lead ?? "")));
462
+ const lead = (item) =>
463
+ leadWidth ? (item.lead ?? "") + " ".repeat(leadWidth - visibleWidth(item.lead ?? "")) : "";
464
+ return items.map((item, index) =>
465
+ typeof item === "string"
466
+ ? item
467
+ : ui.row(index === selected, item.text, shown(item), item.number ?? null, {
468
+ pad,
469
+ lead: lead(item),
470
+ badge: item.badge,
471
+ })
472
+ );
473
+ }
474
+
475
+ // A widget's icon from the bar, when the bar shows icons.
476
+ function widgetIcon(id) {
477
+ return config.emojis !== false && !config.ascii ? (WIDGET_TAGS[id]?.icon ?? "") : "";
478
+ }
479
+
480
+ // On a wide window the list and the details panel share the width evenly.
481
+ function splitWidths(width) {
482
+ const list = Math.floor((width - 1) / 2);
483
+ return { list, details: width - 1 - list };
484
+ }
485
+
486
+ // Details entries are plain strings (wrapped to the panel), { dim: text }
487
+ // (wrapped, in the muted color) or { raw: line } (as is: a rendered widget,
488
+ // a heading, a row of tags).
489
+ function detailsBody(lines, panelWidth) {
490
+ const wrapWidth = Math.min(panelWidth - 4, 88);
491
+ return lines.flatMap((entry) => {
492
+ if (typeof entry === "string") {
493
+ return wrapText(entry, wrapWidth);
494
+ }
495
+ if ("raw" in entry) {
496
+ return [entry.raw];
497
+ }
498
+ return wrapText(entry.dim, wrapWidth).map((line) => dim(line));
499
+ });
500
+ }
501
+
502
+ // Top lines, then blank lines, then bottom lines, in exactly `tall` rows;
503
+ // when both don't fit, the top wins.
504
+ function anchor(top, bottom, tall) {
505
+ const free = tall - top.length - bottom.length;
506
+ if (free < 0) {
507
+ return [...top, ...bottom].slice(0, tall);
508
+ }
509
+ return [...top, ...Array(free).fill(""), ...bottom];
510
+ }
511
+
512
+ // Every screen: brand line and tabs, the screen's list (with a details panel
513
+ // beside it on a wide window, both as tall as the longer one, notes at the
514
+ // bottom), the live preview and the keys. Panels fit their content rather
515
+ // than the window, so a tall window doesn't get rows of empty boxes. A short
516
+ // window gives up blank lines, the tagline, the notes and the preview's
517
+ // toggles before the list starts to scroll.
518
+ function screenFrame({ title, head = [], items, selected = cursor, notes = [], keys, details }) {
365
519
  const width = frameWidth();
366
- const starText = `${ui.glyph.star} star`;
367
- const star = `\x1b]8;;${REPO_URL}\x07${dim(starText)}\x1b]8;;\x07`;
520
+ const height = frameHeight();
521
+ const wide = Boolean(details) && width >= WIDE;
522
+ const widths = wide ? splitWidths(width) : { list: width, details: 0 };
523
+ const preview = previewParts(width);
524
+ const hints = ui.hints(keys, width - 1);
525
+ const notesWidth = wide ? widths.details : widths.list;
526
+ const noteLines = (step) =>
527
+ step.notes && notes.length
528
+ ? [
529
+ "",
530
+ ...detailsBody(
531
+ notes.map((n) => ({ dim: n })),
532
+ notesWidth
533
+ ),
534
+ ]
535
+ : [];
536
+ const detailLines = wide ? detailsBody(details.lines, widths.details) : [];
537
+
538
+ // Rows left for the list panel's body once everything else is drawn: the
539
+ // brand and tabs, both panels' borders, the preview and the key hints.
540
+ const bodySpace = (step) =>
541
+ height -
542
+ 1 -
543
+ (step.gaps ? 3 : 0) -
544
+ (step.tagline ? 1 : 0) -
545
+ 2 -
546
+ 2 -
547
+ (2 + preview.bar.length + (step.chips ? 2 : 0)) -
548
+ hints.length;
549
+ const listSpace = (step) => bodySpace(step) - head.length - (wide ? 0 : noteLines(step).length);
550
+ const fits = (step) =>
551
+ listSpace(step) >= items.length &&
552
+ (!wide || detailLines.length + noteLines(step).length <= bodySpace(step));
553
+ const step =
554
+ COMPACT_STEPS.find(fits) ||
555
+ COMPACT_STEPS.find((s) => listSpace(s) >= MIN_LIST_ROWS) ||
556
+ COMPACT_STEPS[COMPACT_STEPS.length - 1];
557
+
558
+ const content = Math.max(
559
+ head.length + items.length + (wide ? 0 : noteLines(step).length),
560
+ wide ? detailLines.length + noteLines(step).length : 0
561
+ );
562
+ const tall = Math.max(1, Math.min(content, bodySpace(step)));
563
+ const { start, end } = windowRows(items.length, selected, Math.max(1, listSpace(step)));
564
+ const rows = listRows(items, selected, widths.list - 4).slice(start, end);
565
+ const { up, down } = ui.glyph;
566
+ const counts = {
567
+ above: start > 0 ? `${up} ${start} more` : "",
568
+ below: end < items.length ? `${down} ${items.length - end} more` : "",
569
+ };
570
+
571
+ const body = wide
572
+ ? ui.beside(
573
+ ui.panel(title, anchor([...head, ...rows], [], tall), widths.list, counts),
574
+ ui.panel(details.title, anchor(detailLines, noteLines(step), tall), widths.details)
575
+ )
576
+ : ui.panel(title, anchor([...head, ...rows], noteLines(step), tall), width, counts);
577
+
578
+ // The star link sits at the right end of the brand line, shortened to the
579
+ // star alone when the line is tight.
580
+ const link = (text) => `\x1b]8;;${REPO_URL}\x07${text}\x1b]8;;\x07`;
581
+ const star = link(`${ui.glyph.star} ${dim("Star on GitHub")}`);
582
+ const starShort = link(ui.glyph.star);
368
583
  const toast = flashMessage ? ` ${ui.toast(flashMessage)}` : "";
369
- const body = notes.length ? [...list, "", ...notes.map(dim)] : list;
584
+ const brand = ` ${ui.wordmark("agy-powerline")} ${ui.tag(`v${VERSION}`)}${toast}`;
585
+ const gap = step.gaps ? [""] : [];
370
586
 
371
587
  return [
372
- "",
373
- ` ${ui.wordmark("agy-powerline")} ${dim(`v${VERSION}`)} ${star}${toast}`,
374
- ` ${dim(`${ui.glyph.sparkle} make agy feel like yours`)}`,
375
- "",
588
+ ...gap,
589
+ ui.spread(brand, star) ?? ui.spread(brand, starShort) ?? `${brand} ${starShort}`,
590
+ ...(step.tagline ? [` ${dim(`${ui.glyph.sparkle} Make agy feel like yours`)}`] : []),
591
+ ...(step.gaps ? [ui.rule()] : []),
376
592
  ui.tabs(TABS, TAB_OF_SCREEN[screen]),
377
- "",
378
- ...ui.panel(title, body, width),
379
- ...previewPanel(width),
380
- ...ui.hints(keys, width - 1),
593
+ ...gap,
594
+ ...body,
595
+ ...ui.panel(preview.title, [...preview.bar, ...(step.chips ? ["", preview.chips] : [])], width),
596
+ ...hints,
597
+ ];
598
+ }
599
+
600
+ // One widget drawn on its own with the previewed theme and layout, for the
601
+ // details panel; a widget with nothing to show in the sample data says so.
602
+ function widgetPreview(widget) {
603
+ const width = splitWidths(frameWidth()).details - 4;
604
+ let rendered = "";
605
+ try {
606
+ rendered = renderStatusline(
607
+ { ...previewData, terminal_width: width },
608
+ { ...previewConfig(), widgets: [widget] },
609
+ { color: getActiveDepth() }
610
+ );
611
+ } catch {
612
+ // The full preview below reports render errors.
613
+ }
614
+ const lines = String(rendered).split("\n");
615
+ if (lines.every((line) => !line.replace(/\x1b\[[0-9;]*m/g, "").trim())) {
616
+ return [{ dim: "Hidden in this preview: the sample data has nothing for it." }];
617
+ }
618
+ return lines.map((line) => ({ raw: line }));
619
+ }
620
+
621
+ const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
622
+
623
+ const EXEC_NOTE = {
624
+ dim: "Runs only in agy, from your install folder; previews here show [cmd].",
625
+ };
626
+
627
+ const heading = (text) => ({ raw: ui.heading(text) });
628
+
629
+ function presetsUsing(id) {
630
+ const names = PRESET_ORDER.filter((name) =>
631
+ PRESETS[name].widgets.some((widget) => widget.id === id)
632
+ ).map((name) => PRESETS[name].name);
633
+ return [
634
+ heading("In presets"),
635
+ names.length ? { raw: names.map(ui.tag).join(" ") } : { dim: "none" },
381
636
  ];
382
637
  }
383
638
 
639
+ // A widget's own settings from the config file, cleaned of control
640
+ // characters before they reach the terminal.
641
+ function widgetOptions(widget) {
642
+ return Object.entries(widget)
643
+ .filter(([key]) => key !== "id")
644
+ .map(([key, value]) => ({
645
+ raw: `${dim(key)} ${stripControl(typeof value === "string" ? value : JSON.stringify(value))}`,
646
+ }));
647
+ }
648
+
649
+ function widgetDetails(widget) {
650
+ if (!widget) {
651
+ return { title: "No widgets", lines: ["Press a to add your first widget."] };
652
+ }
653
+ if (widget.id === "separator") {
654
+ return {
655
+ title: "Flex space",
656
+ lines: [
657
+ "Grows to fill the free space in its row, pushing the widgets after it to the right. In Pills it also starts a new pill.",
658
+ ],
659
+ };
660
+ }
661
+ if (widget.id === "lineBreak") {
662
+ return {
663
+ title: "New row",
664
+ lines: ["Starts a new row: the widgets after it go on the next line of the bar."],
665
+ };
666
+ }
667
+
668
+ const meta = WIDGET_CATALOG.find((item) => item.id === widget.id);
669
+ const options = widgetOptions(widget);
670
+ return {
671
+ title: [widgetIcon(widget.id), widgetLabel(widget.id)].filter(Boolean).join(" "),
672
+ lines: [
673
+ `${capitalize(meta?.description ?? widget.id)}.`,
674
+ "",
675
+ heading("Preview"),
676
+ ...widgetPreview(widget),
677
+ "",
678
+ heading("Options"),
679
+ ...(options.length
680
+ ? options
681
+ : [{ dim: "Defaults. Colors, labels and thresholds per widget: docs/configuration.md" }]),
682
+ "",
683
+ ...presetsUsing(widget.id),
684
+ ...(widget.id === "exec" ? ["", EXEC_NOTE] : []),
685
+ ],
686
+ };
687
+ }
688
+
384
689
  function widgetCount() {
385
690
  const count = config.widgets.filter((w) => w.id !== "separator" && w.id !== "lineBreak").length;
386
691
  return `${count} widget${count === 1 ? "" : "s"}`;
@@ -388,10 +693,13 @@ function widgetCount() {
388
693
 
389
694
  function drawMain() {
390
695
  const { upDown, shift } = ui.glyph;
391
- const list = config.widgets.length
392
- ? config.widgets.map((widget, index) => {
696
+ // Flex spaces and new rows are layout, not widgets: they get no number, so
697
+ // the numbers match the widget count in the title.
698
+ let number = 0;
699
+ const items = config.widgets.length
700
+ ? config.widgets.map((widget) => {
393
701
  const meta = WIDGET_CATALOG.find((item) => item.id === widget.id);
394
- let extra = meta?.description ?? "";
702
+ let extra = meta?.summary ?? "";
395
703
  if (widget.id === "exec" && widget.cmd) {
396
704
  extra = `cmd: ${widget.cmd}`;
397
705
  } else if (widget.text) {
@@ -409,18 +717,27 @@ function drawMain() {
409
717
  widget.id === "separator"
410
718
  ? `${config.ascii ? "..." : "╌╌"} flex space`
411
719
  : `${config.ascii ? "--" : "↵"} new row`;
412
- return ui.row(index === cursor, dim(divider), "", index + 1);
720
+ return { text: dim(divider), number: "" };
413
721
  }
414
- return ui.row(index === cursor, widgetLabel(widget.id), extra, index + 1);
722
+ number += 1;
723
+ return {
724
+ lead: widgetIcon(widget.id),
725
+ text: widgetLabel(widget.id),
726
+ extra: stripControl(extra),
727
+ number,
728
+ };
415
729
  })
416
730
  : [dim(`${ui.glyph.sparkle} nothing here yet. Press a to add your first widget.`)];
417
731
 
418
732
  return screenFrame({
419
733
  title: `Your widgets ${dim(`${ui.glyph.dot} ${widgetCount()}`)}`,
420
- list,
734
+ items,
735
+ selected: config.widgets.length ? cursor : -1,
736
+ details: widgetDetails(config.widgets[cursor]),
421
737
  keys: [
422
738
  [upDown, "move"],
423
739
  [`${shift}${upDown}`, "reorder"],
740
+ ["enter", "edit"],
424
741
  ["a", "add"],
425
742
  ["s", "flex space"],
426
743
  ["n", "new row"],
@@ -432,25 +749,344 @@ function drawMain() {
432
749
  });
433
750
  }
434
751
 
752
+ // Widgets that take each option, as the layouts read them.
753
+ const QUOTA_WIDGETS = new Set([
754
+ "quota",
755
+ "fiveHourBar",
756
+ "fiveHourPct",
757
+ "weeklyBar",
758
+ "weeklyPct",
759
+ "thirdPartyFiveHour",
760
+ "thirdPartyWeekly",
761
+ "argonWeekly",
762
+ ]);
763
+ const PACED_WIDGETS = new Set([
764
+ "fiveHourBar",
765
+ "weeklyBar",
766
+ "thirdPartyFiveHour",
767
+ "thirdPartyWeekly",
768
+ "argonWeekly",
769
+ ]);
770
+ const CONTEXT_WIDGETS = new Set(["context", "contextPct", "contextTokens"]);
771
+ const RESET_WIDGETS = new Set(["fiveHourReset", "weeklyReset", "argonReset"]);
772
+ const COLOR_SLOTS = ["model", "context", "git", "weekly", "thirdParty", "fiveHour", "meta"];
773
+ // Longest custom badge text; the badge is meant to be a word or two.
774
+ const TEXT_MAX = 40;
775
+
776
+ const onOff = (value) => (value ? "on" : "off");
777
+
778
+ // The options of one widget: cycle fields step through values, number fields
779
+ // step by 5 with the arrows, text fields take typing, and fixed ones only show
780
+ // their value. undefined means "not set": the widget follows the global
781
+ // setting or its default.
782
+ function editFields(widget) {
783
+ const quota = QUOTA_WIDGETS.has(widget.id);
784
+ const context = CONTEXT_WIDGETS.has(widget.id);
785
+ const thresholds = quota
786
+ ? resolveQuotaThresholds({}, config)
787
+ : context
788
+ ? resolveContextThresholds({}, config)
789
+ : null;
790
+ const fields = [
791
+ {
792
+ key: "color",
793
+ label: "Color",
794
+ values: [undefined, ...COLOR_SLOTS],
795
+ show: (value) => (value === undefined ? "theme default" : colorName(value)),
796
+ help: "Draws this widget in another of the theme's colors. Caution and danger colors still take over when a quota or the context runs low.",
797
+ },
798
+ {
799
+ key: "raw",
800
+ label: "Label",
801
+ values: [undefined, false, true],
802
+ show: (value) =>
803
+ value === undefined
804
+ ? `default (${config.minimalist ? "hidden" : "shown"})`
805
+ : value
806
+ ? "hidden, value only"
807
+ : "shown",
808
+ help: "Shows or hides the icon and label before the value. Default follows minimalist mode (m).",
809
+ },
810
+ ];
811
+
812
+ if (widget.id === "model") {
813
+ fields.push({
814
+ key: "short",
815
+ label: "Short name",
816
+ values: [undefined, true],
817
+ show: (value) => onOff(value),
818
+ help: "3.8 Flash·High instead of Gemini 3.8 Flash (High).",
819
+ });
820
+ }
821
+ if (widget.id === "totalTokens") {
822
+ fields.push({
823
+ key: "split",
824
+ label: "Split",
825
+ values: [undefined, true],
826
+ show: (value) => onOff(value),
827
+ help: "Input and output tokens apart (123.1K↑ 355.0K↓) instead of one total.",
828
+ });
829
+ }
830
+ if (RESET_WIDGETS.has(widget.id)) {
831
+ fields.push({
832
+ key: "format",
833
+ label: "Format",
834
+ values: [undefined, "time", "both"],
835
+ show: (value) => value ?? "countdown",
836
+ help: "countdown (2hr 47m), time (6:33pm) or both (6:33pm (2hr 47m)).",
837
+ });
838
+ }
839
+ if (widget.id === "customText") {
840
+ fields.push({
841
+ key: "text",
842
+ label: "Text",
843
+ text: true,
844
+ show: (value) => value || "(empty: hidden)",
845
+ help: "The badge's text. Enter to type, Enter again to keep it, Esc to cancel.",
846
+ });
847
+ }
848
+ if (widget.id === "exec") {
849
+ fields.push({
850
+ key: "cmd",
851
+ label: "Command",
852
+ fixed: true,
853
+ show: (value) => value || "(none)",
854
+ help: `The command runs only in agy, from your install folder. Edit it in ${getConfigPath()}.`,
855
+ });
856
+ }
857
+ if (thresholds) {
858
+ const what = quota ? "left" : "used";
859
+ fields.push(
860
+ {
861
+ key: "warn",
862
+ label: "Caution at",
863
+ number: thresholds.warn,
864
+ show: (value) => (value === undefined ? `default (${thresholds.warn}%)` : `${value}%`),
865
+ help: `Turns to the caution color at this share ${what}.`,
866
+ },
867
+ {
868
+ key: "crit",
869
+ label: "Danger at",
870
+ number: thresholds.crit,
871
+ show: (value) => (value === undefined ? `default (${thresholds.crit}%)` : `${value}%`),
872
+ help: `Turns to the danger color at this share ${what}.`,
873
+ },
874
+ {
875
+ key: "blink",
876
+ label: "Blink",
877
+ values: [undefined, true, "warn", false],
878
+ show: (value) =>
879
+ value === undefined
880
+ ? `default (${thresholds.blink ? "on" : "off"})`
881
+ : { true: "at danger", warn: "at caution and danger", false: "off" }[String(value)],
882
+ help: "Makes the widget blink when it reaches the danger (or caution) level. ASCII mode never blinks.",
883
+ }
884
+ );
885
+ }
886
+ if (PACED_WIDGETS.has(widget.id)) {
887
+ fields.push({
888
+ key: "pace",
889
+ label: "Pace marker",
890
+ values: [undefined, false, true],
891
+ show: (value) =>
892
+ value === undefined ? `default (${onOff(config.pacing !== false)})` : onOff(value),
893
+ help: "Marks where usage would be if it were spread evenly over the window. Default follows r.",
894
+ });
895
+ }
896
+ return fields;
897
+ }
898
+
899
+ // A theme slot's color name, in that color.
900
+ function colorName(slot) {
901
+ const colors = themeColors(config.theme, config.customTheme);
902
+ const block = config.ascii ? "#" : "■";
903
+ return colors[slot] ? `${fg(colors[slot])}${block}${RESET} ${slot}` : String(slot);
904
+ }
905
+
906
+ function editedWidget() {
907
+ return config.widgets[editIndex];
908
+ }
909
+
910
+ function drawEdit() {
911
+ const widget = editedWidget();
912
+ const fields = editFields(widget);
913
+ const field = fields[cursor];
914
+ const typing = textInput !== null;
915
+ const caret = config.ascii ? "_" : "▏";
916
+ const items = fields.map((item) => ({
917
+ text: item.label,
918
+ value:
919
+ typing && item === field
920
+ ? `${BOLD}${textInput}${RESET}${caret}`
921
+ : stripControl(item.show(widget[item.key])),
922
+ }));
923
+ const label = [widgetIcon(widget.id), widgetLabel(widget.id)].filter(Boolean).join(" ");
924
+ const changeKeys = field?.text
925
+ ? [["enter", "type"]]
926
+ : field?.number !== undefined
927
+ ? [[ui.glyph.leftRight, "change"]]
928
+ : field?.fixed
929
+ ? []
930
+ : [[`enter ${ui.glyph.leftRight}`, "change"]];
931
+
932
+ return screenFrame({
933
+ title: `Edit ${dim(`${ui.glyph.dot} ${label}`)}`,
934
+ items,
935
+ // A wide window shows the help in the details panel instead.
936
+ notes: field && frameWidth() < WIDE ? [field.help] : [],
937
+ details: field && {
938
+ title: field.label,
939
+ lines: [field.help, "", heading("Preview"), ...widgetPreview(widget)],
940
+ },
941
+ keys: typing
942
+ ? [
943
+ ["type", "text"],
944
+ ["enter", "keep"],
945
+ ["esc", "cancel"],
946
+ ]
947
+ : [
948
+ [ui.glyph.upDown, "option"],
949
+ ...changeKeys,
950
+ ...(field?.fixed ? [] : [["x", "default"]]),
951
+ ["esc", "back"],
952
+ ],
953
+ });
954
+ }
955
+
956
+ function openEdit() {
957
+ const widget = config.widgets[cursor];
958
+ if (!widget || widget.id === "separator" || widget.id === "lineBreak") {
959
+ return;
960
+ }
961
+ editIndex = cursor;
962
+ textInput = null;
963
+ screen = "edit";
964
+ cursor = 0;
965
+ }
966
+
967
+ function closeEdit() {
968
+ textInput = null;
969
+ screen = "main";
970
+ cursor = editIndex;
971
+ clampCursor(config.widgets.length);
972
+ }
973
+
974
+ // Sets one option, or removes it when the value is undefined, then saves.
975
+ function setOption(key, value) {
976
+ const widget = editedWidget();
977
+ if (value === undefined) {
978
+ delete widget[key];
979
+ } else {
980
+ widget[key] = value;
981
+ }
982
+ persist();
983
+ }
984
+
985
+ function stepOption(field, direction) {
986
+ const current = editedWidget()[field.key];
987
+ if (field.number !== undefined) {
988
+ const from = Number.isFinite(current) ? current : field.number;
989
+ setOption(field.key, Math.max(0, Math.min(100, from + direction * 5)));
990
+ return;
991
+ }
992
+ const index = field.values.findIndex((value) => value === current);
993
+ const next = (Math.max(0, index) + direction + field.values.length) % field.values.length;
994
+ setOption(field.key, field.values[next]);
995
+ }
996
+
997
+ function onEdit(key) {
998
+ const fields = editFields(editedWidget());
999
+ const field = fields[cursor];
1000
+
1001
+ if (textInput !== null) {
1002
+ if (key.name === "escape") {
1003
+ textInput = null;
1004
+ } else if (key.name === "return") {
1005
+ const text = stripControl(textInput).trim();
1006
+ setOption(field.key, text || undefined);
1007
+ textInput = null;
1008
+ } else if (key.name === "backspace" || key.name === "delete") {
1009
+ textInput = textInput.slice(0, -1);
1010
+ } else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
1011
+ const char = key.sequence;
1012
+ if (char >= " " && char !== "\x7f" && textInput.length < TEXT_MAX) {
1013
+ textInput += char;
1014
+ }
1015
+ }
1016
+ return;
1017
+ }
1018
+
1019
+ if (key.name === "escape") {
1020
+ closeEdit();
1021
+ return;
1022
+ }
1023
+ if (key.name === "up") {
1024
+ moveCursor(-1, fields.length);
1025
+ return;
1026
+ }
1027
+ if (key.name === "down") {
1028
+ moveCursor(1, fields.length);
1029
+ return;
1030
+ }
1031
+ if (!field || field.fixed) {
1032
+ return;
1033
+ }
1034
+ if (key.name === "x" || key.name === "delete" || key.name === "backspace") {
1035
+ setOption(field.key, undefined);
1036
+ return;
1037
+ }
1038
+ if (field.text) {
1039
+ if (key.name === "return") {
1040
+ textInput = editedWidget()[field.key] ?? "";
1041
+ }
1042
+ return;
1043
+ }
1044
+ if (key.name === "left" || key.name === "right") {
1045
+ stepOption(field, key.name === "left" ? -1 : 1);
1046
+ return;
1047
+ }
1048
+ if (key.name === "return" && field.values) {
1049
+ stepOption(field, 1);
1050
+ }
1051
+ }
1052
+
435
1053
  function drawAdd() {
436
- const items = filteredAddableWidgets();
1054
+ const widgets = filteredAddableWidgets();
437
1055
  const caret = config.ascii ? "_" : "▏";
438
1056
  const search = searchQuery
439
1057
  ? `${BOLD}${searchQuery}${RESET}${caret}`
440
1058
  : `${caret}${dim("type to filter")}`;
441
- const list = [`${config.ascii ? "search:" : "🔎"} ${search}`, ""];
442
-
443
- if (!items.length) {
444
- list.push(dim(`No widget matches "${searchQuery}". Backspace to edit, Esc to go back.`));
445
- } else {
446
- items.forEach((widget, index) => {
447
- list.push(ui.row(index === cursor, widget.label, widget.description));
448
- });
449
- }
1059
+ const head = [`${config.ascii ? "search:" : "🔎"} ${search}`, ""];
1060
+ const items = widgets.length
1061
+ ? widgets.map((widget) => ({
1062
+ lead: widgetIcon(widget.id),
1063
+ text: widget.label,
1064
+ extra: widget.summary,
1065
+ }))
1066
+ : [dim(`No widget matches "${searchQuery}". Backspace to edit, Esc to go back.`)];
1067
+ const hovered = widgets[cursor];
450
1068
 
451
1069
  return screenFrame({
452
1070
  title: "Add a widget",
453
- list,
1071
+ head,
1072
+ items,
1073
+ selected: widgets.length ? cursor : -1,
1074
+ details: hovered
1075
+ ? {
1076
+ title: [widgetIcon(hovered.id), hovered.label].filter(Boolean).join(" "),
1077
+ lines: [
1078
+ `${capitalize(hovered.description)}.`,
1079
+ "",
1080
+ heading("Preview"),
1081
+ ...widgetPreview({ id: hovered.id }),
1082
+ "",
1083
+ ...presetsUsing(hovered.id),
1084
+ "",
1085
+ { dim: hovered.unique ? "One per bar." : "Can be added more than once." },
1086
+ ...(hovered.id === "exec" ? [EXEC_NOTE] : []),
1087
+ ],
1088
+ }
1089
+ : { title: "No match", lines: ["Backspace edits the search, Esc clears it."] },
454
1090
  keys: [
455
1091
  ["type", "filter"],
456
1092
  [ui.glyph.upDown, "move"],
@@ -461,17 +1097,51 @@ function drawAdd() {
461
1097
  });
462
1098
  }
463
1099
 
1100
+ function presetDetails(id) {
1101
+ const preset = PRESETS[id];
1102
+ if (!preset) {
1103
+ return null;
1104
+ }
1105
+ const rows = [[]];
1106
+ for (const widget of preset.widgets) {
1107
+ if (widget.id === "lineBreak") {
1108
+ rows.push([]);
1109
+ } else if (widget.id !== "separator") {
1110
+ const icon = widgetIcon(widget.id);
1111
+ rows[rows.length - 1].push(
1112
+ icon ? `${icon} ${widgetLabel(widget.id)}` : widgetLabel(widget.id)
1113
+ );
1114
+ }
1115
+ }
1116
+ const count = rows.flat().length;
1117
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1118
+ return {
1119
+ title: preset.name,
1120
+ lines: [
1121
+ `${preset.description}.`,
1122
+ "",
1123
+ heading(`${plural(count, "widget")} on ${plural(rows.length, "row")}`),
1124
+ ...rows.map((labels, index) => ({
1125
+ raw: `${ui.accentText(String(index + 1))} ${labels.join(dim(" · "))}`,
1126
+ })),
1127
+ ],
1128
+ };
1129
+ }
1130
+
464
1131
  function drawPresets() {
465
- const list = PRESET_ORDER.map((id, index) => {
1132
+ const current = currentPreset();
1133
+ const items = PRESET_ORDER.map((id) => {
466
1134
  const preset = PRESETS[id];
467
- const icon = config.ascii ? "" : `${PRESET_ICONS[id] || "•"} `;
468
- return ui.row(index === cursor, `${icon}${preset.name}`, preset.description);
1135
+ const lead = config.ascii ? "" : (PRESET_ICONS[id] ?? "");
1136
+ const badge = id === current ? ui.chip("current", true) : "";
1137
+ return { lead, text: preset.name, badge, extra: preset.description };
469
1138
  });
470
1139
 
471
1140
  return screenFrame({
472
1141
  title: "Presets",
473
- list,
1142
+ items,
474
1143
  notes: ["A preset swaps your widgets; your layout and theme stay."],
1144
+ details: presetDetails(PRESET_ORDER[cursor]),
475
1145
  keys: [
476
1146
  [ui.glyph.upDown, "preview"],
477
1147
  ["enter", "apply"],
@@ -491,23 +1161,89 @@ function themeSwatch(colors) {
491
1161
  );
492
1162
  }
493
1163
 
1164
+ const toHex = (rgb) => `#${rgb.map((part) => part.toString(16).padStart(2, "0")).join("")}`;
1165
+
1166
+ // One color as a pill in that color with the theme's pill text, the way the
1167
+ // bar draws it, then its hex value.
1168
+ function colorLine(label, rgb, ink) {
1169
+ return { raw: `${ui.pill(` ${label.padEnd(9)}`, rgb, ink)} ${dim(toHex(rgb))}` };
1170
+ }
1171
+
1172
+ const PALETTE_SLOTS = [
1173
+ ["model", "Model"],
1174
+ ["context", "Context"],
1175
+ ["git", "Git"],
1176
+ ["weekly", "7d quota"],
1177
+ ["meta", "Details"],
1178
+ ["fiveHour", "OK"],
1179
+ ["caution", "Caution"],
1180
+ ["warn", "Danger"],
1181
+ ];
1182
+
1183
+ function themeDetails(id) {
1184
+ if (id === "custom") {
1185
+ return {
1186
+ title: "Custom",
1187
+ lines: [
1188
+ "Your own colors, on top of a base theme.",
1189
+ "",
1190
+ "Enter opens the color editor; the preview follows every change.",
1191
+ ],
1192
+ };
1193
+ }
1194
+ const theme = THEMES[id];
1195
+ if (!theme) {
1196
+ return null;
1197
+ }
1198
+ const background = { dark: "dark terminals", light: "light terminals" }[theme.kind];
1199
+ return {
1200
+ title: theme.name,
1201
+ lines: [
1202
+ `${theme.description}.`,
1203
+ ...(background ? [{ raw: `${dim("For")} ${ui.tag(background)}` }] : []),
1204
+ "",
1205
+ heading("Palette"),
1206
+ ...PALETTE_SLOTS.map(([slot, label]) =>
1207
+ colorLine(label, theme.colors[slot], theme.colors.text)
1208
+ ),
1209
+ "",
1210
+ heading("Sample"),
1211
+ ...themeSample(theme.colors),
1212
+ ],
1213
+ };
1214
+ }
1215
+
1216
+ // The editor drawn in the hovered theme: a selected and a normal row, and the
1217
+ // three states quotas and the context go through.
1218
+ function themeSample(colors) {
1219
+ const { on } = ui.glyph;
1220
+ const state = (slot, label) => `${fg(colors[slot])}${on} ${label}${RESET}`;
1221
+ return [
1222
+ { raw: ui.row(true, "Selected item", "its description") },
1223
+ { raw: ui.row(false, "Normal item", "its description") },
1224
+ {
1225
+ raw: ` ${state("fiveHour", "ok")} ${state("caution", "caution")} ${state("warn", "danger")}`,
1226
+ },
1227
+ ];
1228
+ }
1229
+
494
1230
  function drawThemes() {
495
- const list = THEME_ORDER.map((id, index) => {
1231
+ const items = THEME_ORDER.map((id) => {
496
1232
  const theme =
497
1233
  THEMES[id] || (id === "custom" ? { name: "Custom", description: "Your own colors" } : null);
498
1234
  if (!theme) {
499
1235
  return "";
500
1236
  }
501
1237
  const swatch = themeSwatch(id === "custom" ? null : theme.colors);
502
- const current = id === config.theme ? ` ${ui.chip("current", true)}` : "";
1238
+ const badge = id === config.theme ? ui.chip("current", true) : "";
503
1239
  const extra = id === "custom" ? `${theme.description}, Enter to edit` : theme.description;
504
- const name = swatch ? `${swatch} ${theme.name}` : theme.name;
505
- return ui.row(index === cursor, name + current, extra);
1240
+ return { lead: swatch, text: theme.name, badge, extra };
506
1241
  });
507
1242
 
508
1243
  return screenFrame({
509
1244
  title: "Themes",
510
- list,
1245
+ items,
1246
+ details: themeDetails(THEME_ORDER[cursor]),
511
1247
  keys: [
512
1248
  [ui.glyph.upDown, "preview"],
513
1249
  ["enter", "apply"],
@@ -525,16 +1261,38 @@ function hexSwatch(hex) {
525
1261
  }
526
1262
  }
527
1263
 
1264
+ // The curated colors in a row, the slot's current one in brackets.
1265
+ function curatedPalette(current) {
1266
+ const block = config.ascii ? "#" : "■";
1267
+ return CURATED_HEX_PALETTE.map((hex) => {
1268
+ const swatch = `${fg(parseColor(hex))}${block}${RESET}`;
1269
+ return hex === current ? `[${swatch}]` : ` ${swatch} `;
1270
+ }).join("");
1271
+ }
1272
+
528
1273
  function drawCustomTheme() {
529
1274
  const customColors = config.customTheme?.colors || {};
530
- const list = CUSTOM_COLOR_SLOTS.map((slot, index) =>
531
- ui.row(index === cursor, slot.label, hexSwatch(customColors[slot.key] || "(base)"))
532
- );
1275
+ const items = CUSTOM_COLOR_SLOTS.map((slot) => ({
1276
+ text: slot.label,
1277
+ value: hexSwatch(customColors[slot.key] || "(base)"),
1278
+ }));
1279
+ const slot = CUSTOM_COLOR_SLOTS[cursor];
1280
+ const current = customColors[slot?.key];
533
1281
 
534
1282
  return screenFrame({
535
1283
  title: "Custom theme",
536
- list,
1284
+ items,
537
1285
  notes: ["Cycle a slot through curated colors; the preview updates as you go."],
1286
+ details: slot && {
1287
+ title: slot.label,
1288
+ lines: [
1289
+ current ? `Now ${current}.` : "Now the base theme's color.",
1290
+ "",
1291
+ curatedPalette(current),
1292
+ "",
1293
+ { dim: "← → and Enter step through these colors." },
1294
+ ],
1295
+ },
538
1296
  keys: [
539
1297
  [ui.glyph.upDown, "slot"],
540
1298
  [ui.glyph.leftRight, "cycle color"],
@@ -578,10 +1336,38 @@ function powerlineRowValue(row) {
578
1336
  return THEMES[config.theme]?.name ?? config.theme;
579
1337
  }
580
1338
 
1339
+ const POWERLINE_HELP = {
1340
+ enabled:
1341
+ "On: adjacent widgets share one pill, with a separator between them. Off: every widget gets its own pill.",
1342
+ rowGap:
1343
+ "Puts a blank line between Pills rows, so stacked pills don't touch. The bar takes more lines (three rows become five).",
1344
+ style: "Sets start caps, end caps and separators in one go. Enter cycles the styles.",
1345
+ separators: "Glyphs between widgets of a pill. With several, they cycle per join, left to right.",
1346
+ startCaps: "Glyphs that open each pill. With several, they cycle per pill, left to right.",
1347
+ endCaps: "Glyphs that close each pill. With several, they cycle per pill, left to right.",
1348
+ theme: "Enter opens the Themes tab.",
1349
+ };
1350
+
1351
+ function powerlineDetails(row) {
1352
+ if (!row) {
1353
+ return null;
1354
+ }
1355
+ const lines = [POWERLINE_HELP[row.id] ?? ""];
1356
+ if (row.id === "style") {
1357
+ const width = Math.max(...STYLE_ORDER.map((id) => STYLES[id].name.length));
1358
+ lines.push(
1359
+ "",
1360
+ ...STYLE_ORDER.map((id) => `${STYLES[id].name.padEnd(width)} ${dim(STYLES[id].description)}`)
1361
+ );
1362
+ }
1363
+ if (row.slot) {
1364
+ lines.push("", "Enter edits the list.");
1365
+ }
1366
+ return { title: row.label, lines };
1367
+ }
1368
+
581
1369
  function drawPowerline() {
582
- const list = POWERLINE_ROWS.map((row, index) =>
583
- ui.row(index === cursor, row.label, powerlineRowValue(row))
584
- );
1370
+ const items = POWERLINE_ROWS.map((row) => ({ text: row.label, value: powerlineRowValue(row) }));
585
1371
  const notes =
586
1372
  config.layout === "lines"
587
1373
  ? ["Powerline shapes apply to Pills. Switch layout with l."]
@@ -592,8 +1378,9 @@ function drawPowerline() {
592
1378
 
593
1379
  return screenFrame({
594
1380
  title: "Powerline",
595
- list,
1381
+ items,
596
1382
  notes,
1383
+ details: powerlineDetails(POWERLINE_ROWS[cursor]),
597
1384
  keys: [
598
1385
  [ui.glyph.upDown, "move"],
599
1386
  ["enter", "toggle / cycle / edit"],
@@ -610,11 +1397,12 @@ function drawGlyphs() {
610
1397
  if (hexInput !== null) {
611
1398
  return screenFrame({
612
1399
  title: `Powerline ${ui.glyph.dot} ${title}`,
613
- list: [
1400
+ items: [
614
1401
  `Code point: ${BOLD}U+${hexInput}${RESET}${dim("_".repeat(6 - hexInput.length))}`,
615
1402
  "",
616
1403
  dim("Type 4-6 hex digits, e.g. E0B0 (Powerline), E0C0 (flame), 2588 (block)."),
617
1404
  ],
1405
+ selected: -1,
618
1406
  keys: [
619
1407
  ["enter", "apply"],
620
1408
  ["esc", "cancel"],
@@ -622,23 +1410,36 @@ function drawGlyphs() {
622
1410
  });
623
1411
  }
624
1412
 
625
- const rows = list.length
1413
+ const items = list.length
626
1414
  ? list.map((hex, index) => {
627
1415
  const inverted = isSeparator && config.powerline.separatorInvert[index] ? "inverted" : "";
628
- return ui.row(index === cursor, glyphLabel(hex, glyphSlot), inverted);
1416
+ return { text: glyphLabel(hex, glyphSlot), value: inverted };
629
1417
  })
630
1418
  : [
631
1419
  dim(
632
1420
  `None: ${isSeparator ? "widgets in a pill touch directly" : "pills have no cap on this side"}. Press a to add one.`
633
1421
  ),
634
1422
  ];
1423
+ const hovered = list[cursor];
635
1424
 
636
1425
  return screenFrame({
637
1426
  title: `Powerline ${ui.glyph.dot} ${title}`,
638
- list: rows,
1427
+ items,
1428
+ selected: list.length ? cursor : -1,
639
1429
  notes: isSeparator
640
1430
  ? ["Invert swaps the separator colors; left-pointing glyphs get it automatically."]
641
1431
  : [],
1432
+ details: {
1433
+ title,
1434
+ lines: hovered
1435
+ ? [
1436
+ `${glyphChar(hovered, glyphSlot, config.ascii)} ${glyphName(hovered)}`,
1437
+ { dim: `U+${hovered}, ${cursor + 1} of ${list.length}` },
1438
+ "",
1439
+ POWERLINE_HELP[key],
1440
+ ]
1441
+ : [POWERLINE_HELP[key]],
1442
+ },
642
1443
  keys: [
643
1444
  [ui.glyph.upDown, "select"],
644
1445
  [ui.glyph.leftRight, "cycle preset"],
@@ -653,17 +1454,28 @@ function drawGlyphs() {
653
1454
  });
654
1455
  }
655
1456
 
1457
+ const LAYOUT_HELP = {
1458
+ pills: "Widgets sit in colored pills. The Powerline tab sets their caps, separators and joins.",
1459
+ lines:
1460
+ "Colored text on the terminal background, with dividers and dot meters. Powerline shapes don't apply.",
1461
+ };
1462
+
656
1463
  function drawLayout() {
657
- const list = LAYOUT_ORDER.map((id, index) => {
1464
+ const items = LAYOUT_ORDER.map((id) => {
658
1465
  const layout = LAYOUTS[id];
659
- const current = id === config.layout ? ` ${ui.chip("current", true)}` : "";
660
- return ui.row(index === cursor, layout.name + current, layout.description);
1466
+ const badge = id === config.layout ? ui.chip("current", true) : "";
1467
+ return { text: layout.name, badge, extra: layout.description };
661
1468
  });
1469
+ const hovered = LAYOUTS[LAYOUT_ORDER[cursor]];
662
1470
 
663
1471
  return screenFrame({
664
1472
  title: "Layout",
665
- list,
1473
+ items,
666
1474
  notes: ["Both layouts use your widgets and theme; Powerline shapes apply to Pills."],
1475
+ details: hovered && {
1476
+ title: hovered.name,
1477
+ lines: [`${hovered.description}.`, "", LAYOUT_HELP[hovered.id] ?? ""],
1478
+ },
667
1479
  keys: [
668
1480
  [ui.glyph.upDown, "preview"],
669
1481
  ["enter", "apply"],
@@ -680,6 +1492,7 @@ function frameLines() {
680
1492
  });
681
1493
 
682
1494
  if (screen === "add") return drawAdd();
1495
+ if (screen === "edit") return drawEdit();
683
1496
  if (screen === "themes") return drawThemes();
684
1497
  if (screen === "customTheme") return drawCustomTheme();
685
1498
  if (screen === "presets") return drawPresets();
@@ -694,7 +1507,9 @@ function draw() {
694
1507
  }
695
1508
 
696
1509
  // Draws one screen without a terminal, for tests. Options: config, screen,
697
- // cursor, columns, color ("truecolor", "256", "none"), search, flash, and
1510
+ // cursor, columns, rows, scroll (the first row shown of a long list), color
1511
+ // ("truecolor", "256", "none"), search, flash, editIndex and textInput (the
1512
+ // widget editor), and
698
1513
  // widgets (a list used as is, even empty, which a saved config never is).
699
1514
  function renderFrame(options = {}) {
700
1515
  config = mergeConfig(options.config || {});
@@ -707,14 +1522,20 @@ function renderFrame(options = {}) {
707
1522
  flashMessage = options.flash || "";
708
1523
  glyphSlot = options.glyphSlot || "separator";
709
1524
  hexInput = options.hexInput ?? null;
1525
+ editIndex = options.editIndex ?? 0;
1526
+ textInput = options.textInput ?? null;
710
1527
  previewData = options.data || sampleData();
711
1528
  columnsOverride = options.columns || 100;
1529
+ rowsOverride = options.rows || 50;
1530
+ scrollTop = options.scroll || 0;
1531
+ scrollScreen = screen;
712
1532
  const previousDepth = getActiveDepth();
713
1533
  setActiveDepth(options.color || "truecolor");
714
1534
  try {
715
1535
  return frameLines().join("\n");
716
1536
  } finally {
717
1537
  columnsOverride = null;
1538
+ rowsOverride = null;
718
1539
  setActiveDepth(previousDepth);
719
1540
  }
720
1541
  }
@@ -838,6 +1659,11 @@ function onMain(key) {
838
1659
  return;
839
1660
  }
840
1661
 
1662
+ if (key.name === "return") {
1663
+ openEdit();
1664
+ return;
1665
+ }
1666
+
841
1667
  if (key.name === "a") {
842
1668
  openAdd();
843
1669
  return;
@@ -1279,7 +2105,8 @@ function onKey(_str, key) {
1279
2105
  return;
1280
2106
  }
1281
2107
 
1282
- if (isQuitKey(key, screen === "add" || hexInput !== null)) {
2108
+ const typing = screen === "add" || hexInput !== null || textInput !== null;
2109
+ if (isQuitKey(key, typing)) {
1283
2110
  quit();
1284
2111
  return;
1285
2112
  }
@@ -1290,7 +2117,7 @@ function onKey(_str, key) {
1290
2117
  return;
1291
2118
  }
1292
2119
 
1293
- if (screen !== "add" && key.name === "g") {
2120
+ if (!typing && key.name === "g") {
1294
2121
  openRepo();
1295
2122
  draw();
1296
2123
  return;
@@ -1305,6 +2132,8 @@ function onKey(_str, key) {
1305
2132
 
1306
2133
  if (screen === "add") {
1307
2134
  onAdd(key);
2135
+ } else if (screen === "edit") {
2136
+ onEdit(key);
1308
2137
  } else if (screen === "themes") {
1309
2138
  onThemes(key);
1310
2139
  } else if (screen === "customTheme") {
@@ -1359,6 +2188,8 @@ function start() {
1359
2188
  process.stdin.on("keypress", onKey);
1360
2189
  process.on("SIGINT", quit);
1361
2190
  process.on("SIGTERM", quit);
2191
+ // The frame follows the window width, so redraw before a resize wraps it.
2192
+ process.stdout.on("resize", draw);
1362
2193
  process.stdout.write(ALT_ON + HIDE);
1363
2194
  draw();
1364
2195
  }