@f5-sales-demo/xcsh 21.45.3 → 21.46.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +8 -8
  2. package/src/config/settings-schema.ts +10 -0
  3. package/src/debug/log-viewer.ts +6 -6
  4. package/src/extensibility/extensions/bundled/herdr-reporter.ts +29 -0
  5. package/src/extensibility/extensions/types.ts +7 -0
  6. package/src/internal-urls/api-catalog-qmd-index.generated.ts +2 -2
  7. package/src/internal-urls/build-info.generated.ts +8 -8
  8. package/src/internal-urls/docs-index.generated.ts +1 -1
  9. package/src/modes/components/agent-dashboard.ts +13 -3
  10. package/src/modes/components/btw-panel.ts +5 -2
  11. package/src/modes/components/context-add-wizard.ts +5 -3
  12. package/src/modes/components/copy-selector.ts +6 -3
  13. package/src/modes/components/extensions/extension-dashboard.ts +8 -2
  14. package/src/modes/components/history-search.ts +2 -1
  15. package/src/modes/components/hook-editor.ts +8 -2
  16. package/src/modes/components/hook-input.ts +2 -1
  17. package/src/modes/components/hook-selector.ts +3 -2
  18. package/src/modes/components/login-dialog.ts +20 -10
  19. package/src/modes/components/mcp-add-wizard.ts +5 -3
  20. package/src/modes/components/model-selector.ts +70 -22
  21. package/src/modes/components/oauth-selector.ts +3 -2
  22. package/src/modes/components/plan-preview.ts +2 -2
  23. package/src/modes/components/plugin-settings.ts +2 -2
  24. package/src/modes/components/plugins/plugin-dashboard.ts +2 -1
  25. package/src/modes/components/question-transcript.ts +2 -2
  26. package/src/modes/components/recap-message.ts +23 -0
  27. package/src/modes/components/request-user-input.ts +6 -6
  28. package/src/modes/components/reviewed-action-dialog.ts +6 -3
  29. package/src/modes/components/searchable-select-list.ts +3 -2
  30. package/src/modes/components/selector-frame.ts +88 -21
  31. package/src/modes/components/session-observer-overlay.ts +2 -1
  32. package/src/modes/components/session-selector.ts +13 -5
  33. package/src/modes/components/settings-browser.ts +8 -2
  34. package/src/modes/components/settings-editors.ts +11 -4
  35. package/src/modes/components/settings-selector.ts +8 -2
  36. package/src/modes/components/transcript-notice.ts +15 -5
  37. package/src/modes/components/tree-selector.ts +11 -5
  38. package/src/modes/components/user-message-selector.ts +4 -2
  39. package/src/modes/components/welcome.ts +3 -3
  40. package/src/modes/controllers/event-controller.ts +5 -0
  41. package/src/modes/controllers/selector-controller.ts +1 -0
  42. package/src/modes/interactive-mode.ts +1 -0
  43. package/src/modes/utils/ui-helpers.ts +10 -0
  44. package/src/remote-control/protocol-methods.json +3 -0
  45. package/src/remote-control/router.ts +2 -0
  46. package/src/remote-control/session.ts +24 -0
  47. package/src/session/agent-session.ts +73 -2
  48. package/src/session/recap.ts +341 -0
  49. package/src/slash-commands/builtin-registry.ts +19 -1
@@ -8,7 +8,6 @@ import {
8
8
  type Tab,
9
9
  TabBar,
10
10
  type TUI,
11
- truncateToWidth,
12
11
  wrapTextWithAnsi,
13
12
  } from "@f5-sales-demo/pi-tui";
14
13
  import { filterCurrentBrowserModels } from "../../config/model-catalog";
@@ -38,6 +37,7 @@ import {
38
37
  selectorFrameContentWidth,
39
38
  selectorKeys,
40
39
  selectorNavigationHint,
40
+ selectorProse,
41
41
  selectorRow,
42
42
  } from "./selector-frame";
43
43
 
@@ -350,6 +350,10 @@ export class ModelSelectorComponent extends Container {
350
350
  #spinnerFrame = 0;
351
351
  #spinnerTimer?: ReturnType<typeof setInterval>;
352
352
  #pendingRefreshes = 0;
353
+ #detailOffset = 0;
354
+ #detailCapacity = 1;
355
+ #detailLength = 0;
356
+ #detailWidth = 0;
353
357
 
354
358
  #disposed = false;
355
359
  #initialProvider?: string;
@@ -383,7 +387,7 @@ export class ModelSelectorComponent extends Container {
383
387
  let selectedBodyIndex: number | undefined;
384
388
  let stickyBodyRows = 0;
385
389
  let minimumDetailRows = 0;
386
- const details: string[] = selected ? wrapTextWithAnsi(selected.selector, inner) : [];
390
+ const details: string[] = [];
387
391
  let navigation: string[] = [];
388
392
  let title = "Choose a model";
389
393
  let purpose = "";
@@ -392,6 +396,7 @@ export class ModelSelectorComponent extends Container {
392
396
  `Ctrl+R: refresh${this.#onLogin ? " · Ctrl+L: providers" : ""} · ${selectorCancelHint(searching ? "clear search" : "back")}`,
393
397
  ];
394
398
  if (this.#isMenuOpen && selected) {
399
+ details.push(...wrapTextWithAnsi(selected.selector, inner));
395
400
  title = getModelDisplayName(selected.model);
396
401
  const labels =
397
402
  this.#menuStep === "scope"
@@ -429,25 +434,42 @@ export class ModelSelectorComponent extends Container {
429
434
  ...this.#searchInput.render(inner),
430
435
  ];
431
436
  if (this.#scopedModels.length) purpose = "Showing models from --models scope";
432
- const status = this.#renderProviderStatus(inner)
433
- .filter(line => line.trim())
434
- .slice(0, 1);
435
437
  if (selected) {
436
- details.push(...wrapTextWithAnsi(assignments(selected.model, true), inner));
437
- if (this.#isItemDisabled(selected) && !this.#refreshingProvider) {
438
- details.push("Unavailable · reconnect or refresh this provider.");
439
- } else if (selected.model.description) details.push(...wrapTextWithAnsi(selected.model.description, inner));
438
+ const providerRowLabel = getProviderDisplayName(selected.model.provider);
439
+ const providerLabel =
440
+ this.#modelRegistry.getProviderPickerMetadata?.(selected.model.provider)?.groupLabel ?? providerRowLabel;
441
+ const detailValues = [
442
+ `Model: ${getModelDisplayName(selected.model)}`,
443
+ `Provider: ${providerLabel}`,
444
+ ...(providerRowLabel !== providerLabel ? [`Provider row label: ${providerRowLabel}`] : []),
445
+ `Selector: ${selected.selector}`,
446
+ `Assignments: ${assignments(selected.model, true)}`,
447
+ ...(this.#isItemDisabled(selected) && !this.#refreshingProvider
448
+ ? ["Status: Unavailable · reconnect or refresh this provider."]
449
+ : []),
450
+ ...(selected.model.description ? [`Description: ${selected.model.description}`] : []),
451
+ ...this.#renderProviderStatus().filter(line => line.trim()),
452
+ ...(this.#errorMessage ? [`Error: ${String(this.#errorMessage)}`] : []),
453
+ ];
454
+ const wrappedDetails = detailValues.flatMap(value => wrapTextWithAnsi(value, inner));
455
+ if (this.#detailWidth !== inner) this.#detailOffset = 0;
456
+ this.#detailWidth = inner;
457
+ this.#detailCapacity = Math.max(1, height - 15);
458
+ this.#detailLength = wrappedDetails.length;
459
+ const lastPageOffset =
460
+ Math.max(0, Math.ceil(this.#detailLength / this.#detailCapacity) - 1) * this.#detailCapacity;
461
+ this.#detailOffset = Math.min(this.#detailOffset, lastPageOffset);
462
+ details.push(...wrappedDetails.slice(this.#detailOffset, this.#detailOffset + this.#detailCapacity));
463
+ }
464
+ // Keep the selected compact row stable while status and detail pages change.
465
+ minimumDetailRows = this.#detailLength > this.#detailCapacity ? this.#detailCapacity : details.length + 2;
466
+ if (this.#detailLength > this.#detailCapacity) {
467
+ const first = this.#detailOffset + 1;
468
+ const last = Math.min(this.#detailLength, this.#detailOffset + this.#detailCapacity);
469
+ footer.push(
470
+ `${selectorKeys("pageUp")}/${selectorKeys("pageDown")}: details ${first}–${last} of ${this.#detailLength}`,
471
+ );
440
472
  }
441
- // Two live-status rows are intentional reserved space; spinner/error updates must not shift the table.
442
- minimumDetailRows = details.length + 2;
443
- const selectedStatus = selected
444
- ? this.#renderProviderStatus(inner).find(line =>
445
- line.includes(`${getProviderDisplayName(selected.model.provider)}:`),
446
- )
447
- : undefined;
448
- if (status[0]) details.push(status[0]);
449
- if (selectedStatus && selectedStatus !== status[0]) details.push(selectedStatus);
450
- if (this.#errorMessage) details.push(String(this.#errorMessage));
451
473
  const showProvider = searching;
452
474
  const widths = showProvider ? [inner - 42, 18, 18] : [inner - 24, 20];
453
475
  if (wide) {
@@ -477,11 +499,13 @@ export class ModelSelectorComponent extends Container {
477
499
  ),
478
500
  );
479
501
  }
480
- if (!this.#filteredModels.length) body.push(this.#getProviderEmptyStateMessage() ?? "No matching models");
502
+ if (!this.#filteredModels.length)
503
+ body.push(selectorProse(this.#getProviderEmptyStateMessage() ?? "No matching models"));
481
504
  }
482
505
  return selectorFrame(width, height, title, purpose, navigation, body, details, footer, {
483
506
  minimumDetailRows,
484
507
  selectedBodyIndex,
508
+ selectedDetail: this.#isMenuOpen ? "automatic" : "provided",
485
509
  stickyBodyRows,
486
510
  });
487
511
  }
@@ -496,7 +520,7 @@ export class ModelSelectorComponent extends Container {
496
520
  this.#stopSpinner();
497
521
  }
498
522
 
499
- #renderProviderStatus(width: number): string[] {
523
+ #renderProviderStatus(): string[] {
500
524
  if (this.#isMenuOpen) return [];
501
525
  const group = this.#providerGroups[this.#activeTabIndex];
502
526
  // Reserve the same status area for every provider. Updating status never moves the catalog.
@@ -544,7 +568,11 @@ export class ModelSelectorComponent extends Container {
544
568
  message = `${label}: empty catalog · Ctrl+R: refresh`;
545
569
  rows[index + 1] = theme.fg(color, message);
546
570
  }
547
- return rows.map(row => truncateToWidth(row, width));
571
+ return rows;
572
+ }
573
+
574
+ #resetDetailPage(): void {
575
+ this.#detailOffset = 0;
548
576
  }
549
577
 
550
578
  #menuRoleActions: MenuRoleAction[] = [];
@@ -1094,6 +1122,7 @@ export class ModelSelectorComponent extends Container {
1094
1122
  const retainedIndex = this.#filteredModels.findIndex(item => item.selector === selectedSelector);
1095
1123
  this.#selectedIndex =
1096
1124
  retainedIndex >= 0 ? retainedIndex : Math.min(this.#selectedIndex, Math.max(0, visibleCount - 1));
1125
+ this.#resetDetailPage();
1097
1126
  this.#updateList();
1098
1127
  }
1099
1128
 
@@ -1179,6 +1208,7 @@ export class ModelSelectorComponent extends Container {
1179
1208
  }
1180
1209
 
1181
1210
  #openMenu(): void {
1211
+ this.#resetDetailPage();
1182
1212
  this.#menuItem = this.#getSelectedItem();
1183
1213
  if (!this.#menuItem || this.#isItemDisabled(this.#menuItem)) return;
1184
1214
  this.#isMenuOpen = true;
@@ -1201,6 +1231,7 @@ export class ModelSelectorComponent extends Container {
1201
1231
  }
1202
1232
 
1203
1233
  #closeMenu(): void {
1234
+ this.#resetDetailPage();
1204
1235
  this.#isMenuOpen = false;
1205
1236
  this.#menuStep = "role";
1206
1237
  this.#menuSelectedRole = null;
@@ -1231,6 +1262,7 @@ export class ModelSelectorComponent extends Container {
1231
1262
  }
1232
1263
 
1233
1264
  if (matchesKey(keyData, "ctrl+r")) {
1265
+ this.#resetDetailPage();
1234
1266
  void this.#refreshSelectedProvider().catch(error => {
1235
1267
  this.#errorMessage = error instanceof Error ? error.message : String(error);
1236
1268
  this.#updateList();
@@ -1239,11 +1271,26 @@ export class ModelSelectorComponent extends Container {
1239
1271
  return;
1240
1272
  }
1241
1273
 
1274
+ if (matchesSelectorKey(keyData, "pageDown")) {
1275
+ const lastPageOffset =
1276
+ Math.max(0, Math.ceil(this.#detailLength / this.#detailCapacity) - 1) * this.#detailCapacity;
1277
+ this.#detailOffset = Math.min(lastPageOffset, this.#detailOffset + this.#detailCapacity);
1278
+ this.#updateList();
1279
+ return;
1280
+ }
1281
+
1282
+ if (matchesSelectorKey(keyData, "pageUp")) {
1283
+ this.#detailOffset = Math.max(0, this.#detailOffset - this.#detailCapacity);
1284
+ this.#updateList();
1285
+ return;
1286
+ }
1287
+
1242
1288
  // Up arrow - navigate list (wrap to bottom when at top)
1243
1289
  if (matchesSelectorKey(keyData, "up")) {
1244
1290
  const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
1245
1291
  if (itemCount === 0) return;
1246
1292
  this.#selectedIndex = this.#selectedIndex === 0 ? itemCount - 1 : this.#selectedIndex - 1;
1293
+ this.#resetDetailPage();
1247
1294
  this.#updateList();
1248
1295
  return;
1249
1296
  }
@@ -1253,6 +1300,7 @@ export class ModelSelectorComponent extends Container {
1253
1300
  const itemCount = this.#isCanonicalTab() ? this.#filteredCanonicalModels.length : this.#filteredModels.length;
1254
1301
  if (itemCount === 0) return;
1255
1302
  this.#selectedIndex = this.#selectedIndex === itemCount - 1 ? 0 : this.#selectedIndex + 1;
1303
+ this.#resetDetailPage();
1256
1304
  this.#updateList();
1257
1305
  return;
1258
1306
  }
@@ -13,6 +13,7 @@ import {
13
13
  selectorFrameContentWidth,
14
14
  selectorKeys,
15
15
  selectorNavigationHint,
16
+ selectorProse,
16
17
  selectorRow,
17
18
  } from "./selector-frame";
18
19
  /**
@@ -329,7 +330,7 @@ export class OAuthSelectorComponent extends Container {
329
330
  const meta = providerPresentation(provider.id);
330
331
  if (this.#catalogMode && !this.#searchInput.getValue() && meta.category !== category) {
331
332
  category = meta.category;
332
- body.push(theme.fg("muted", category));
333
+ body.push(selectorProse(theme.fg("muted", category)));
333
334
  }
334
335
  if (i === this.#selectedIndex) selectedBodyIndex = body.length;
335
336
  body.push(
@@ -346,7 +347,7 @@ export class OAuthSelectorComponent extends Container {
346
347
  ),
347
348
  );
348
349
  }
349
- if (!this.#filteredProviders.length) body.push("No matching providers");
350
+ if (!this.#filteredProviders.length) body.push(selectorProse("No matching providers"));
350
351
  }
351
352
  if (this.#statusMessage) details.push(this.#statusMessage);
352
353
  return selectorFrame(
@@ -1,6 +1,6 @@
1
1
  import { Container, Markdown } from "@f5-sales-demo/pi-tui";
2
2
  import { getMarkdownTheme } from "../theme/theme";
3
- import { selectorFrame, selectorFrameContentWidth } from "./selector-frame";
3
+ import { selectorFrame, selectorFrameContentWidth, selectorProse } from "./selector-frame";
4
4
 
5
5
  /** Static transcript-adjacent plan preview; approval controls live in the separate review overlay. */
6
6
  export class PlanPreviewComponent extends Container {
@@ -17,7 +17,7 @@ export class PlanPreviewComponent extends Container {
17
17
  "Plan review",
18
18
  "Prepared execution plan; use the active review overlay to approve, revise, or cancel.",
19
19
  [],
20
- body,
20
+ body.map(line => selectorProse(line)),
21
21
  [],
22
22
  [],
23
23
  );
@@ -16,7 +16,7 @@ import {
16
16
  type PluginSettingsStorage,
17
17
  pluginSettingsStorage,
18
18
  } from "./plugin-settings-drafts";
19
- import { matchesSelectorKey, selectorFrame } from "./selector-frame";
19
+ import { matchesSelectorKey, selectorFrame, selectorProse } from "./selector-frame";
20
20
  import { SettingsBrowser } from "./settings-browser";
21
21
  import { SettingsChoiceEditor, SettingsTextEditor } from "./settings-editors";
22
22
 
@@ -375,7 +375,7 @@ export class PluginSettingsComponent extends Container {
375
375
  "Plugin settings",
376
376
  "User defaults",
377
377
  [],
378
- [this.#loadError || "Loading plugin settings…"],
378
+ [selectorProse(this.#loadError || "Loading plugin settings…")],
379
379
  [],
380
380
  this.#loadError ? ["Ctrl+R: retry · Esc: back"] : [],
381
381
  )
@@ -20,6 +20,7 @@ import {
20
20
  type SelectorFrameLine,
21
21
  selectorFrame,
22
22
  selectorFrameContentWidth,
23
+ selectorProse,
23
24
  selectorRow,
24
25
  } from "../selector-frame";
25
26
  import { applySearch, buildTabs, filterByTab, loadAllPlugins, pluginSelectionKey } from "./state-manager";
@@ -818,7 +819,7 @@ export class PluginDashboard extends Container {
818
819
  ),
819
820
  );
820
821
  }
821
- if (!body.length || (wide && body.length === 1)) body.push(this.#emptyMessage());
822
+ if (!body.length || (wide && body.length === 1)) body.push(selectorProse(this.#emptyMessage()));
822
823
  return { body, selectedBodyIndex };
823
824
  }
824
825
 
@@ -1,7 +1,7 @@
1
1
  import type { Component } from "@f5-sales-demo/pi-tui";
2
2
  import type { AsyncInputQuestion, AsyncQuestionItem } from "../../../../chat-ui/src/interactions/contract";
3
3
  import { theme } from "../theme/theme";
4
- import { selectorFrame, selectorFrameContentWidth, selectorRow } from "./selector-frame";
4
+ import { selectorFrame, selectorFrameContentWidth, selectorProse, selectorRow } from "./selector-frame";
5
5
 
6
6
  export interface AsyncInputReply {
7
7
  type: "user_input_reply";
@@ -123,7 +123,7 @@ export class QuestionTranscriptComponent implements Component {
123
123
  "Answer recorded",
124
124
  `Question ${position + 1} of ${total}`,
125
125
  [question.title],
126
- [{ content: `${theme.symbol("checkbox.checked")} ${answer}`, selected: false }],
126
+ [selectorProse(`${theme.symbol("checkbox.checked")} ${answer}`)],
127
127
  [],
128
128
  [],
129
129
  );
@@ -0,0 +1,23 @@
1
+ import { type Component, padding, wrapTextWithAnsi } from "@f5-sales-demo/pi-tui";
2
+ import type { RecapRecord } from "../../session/recap";
3
+ import { theme } from "../theme/theme";
4
+
5
+ /** A compact, secondary transcript line whose wrapped text starts below its first word. */
6
+ export class RecapMessageComponent implements Component {
7
+ constructor(readonly recap: RecapRecord) {}
8
+ invalidate(): void {}
9
+
10
+ render(width: number): string[] {
11
+ const lines: string[] = [];
12
+ const add = (prefix: string, content: string) => {
13
+ const available = Math.max(1, width - prefix.length);
14
+ for (const [index, segment] of wrapTextWithAnsi(content, available).entries()) {
15
+ const lead = index === 0 ? prefix : padding(prefix.length);
16
+ lines.push(theme.italic(theme.fg("dim", lead + segment)));
17
+ }
18
+ };
19
+ add(" ↳ Recap: ", this.recap.summary);
20
+ if (this.recap.nextAction) add(" Next: ", this.recap.nextAction);
21
+ return lines;
22
+ }
23
+ }
@@ -10,6 +10,7 @@ import {
10
10
  selectorFrame,
11
11
  selectorFrameContentWidth,
12
12
  selectorKeys,
13
+ selectorProse,
13
14
  selectorRow,
14
15
  } from "./selector-frame";
15
16
 
@@ -66,9 +67,9 @@ export class RequestUserInputComponent implements Component {
66
67
  if (this.form.confirming) {
67
68
  const body: SelectorFrameLine[] = [
68
69
  selectorRow([`1. ${INPUT_COPY.proceed}`], [inner - 2], this.#confirmation === 0),
69
- { content: theme.fg("muted", ` Submit with unanswered questions.`), selected: false },
70
+ selectorProse(theme.fg("muted", ` Submit with unanswered questions.`)),
70
71
  selectorRow([`2. ${INPUT_COPY.back}`], [inner - 2], this.#confirmation === 1),
71
- { content: theme.fg("muted", ` ${INPUT_COPY.backDescription}`), selected: false },
72
+ selectorProse(theme.fg("muted", ` ${INPUT_COPY.backDescription}`)),
72
73
  ];
73
74
  return selectorFrame(
74
75
  width,
@@ -97,10 +98,9 @@ export class RequestUserInputComponent implements Component {
97
98
  );
98
99
  if (option.description.trim())
99
100
  body.push(
100
- ...readOnlyTextLines(option.description, inner - 3).map(content => ({
101
- content: theme.fg("muted", ` ${content}`),
102
- selected: false,
103
- })),
101
+ ...readOnlyTextLines(option.description, inner - 3).map(content =>
102
+ selectorProse(theme.fg("muted", ` ${content}`)),
103
+ ),
104
104
  );
105
105
  }
106
106
  const details: string[] = [];
@@ -11,6 +11,7 @@ import {
11
11
  selectorFrameContentWidth,
12
12
  selectorKeys,
13
13
  selectorNavigationHint,
14
+ selectorProse,
14
15
  selectorRow,
15
16
  } from "./selector-frame";
16
17
 
@@ -67,9 +68,11 @@ export class ReviewedActionDialog<T> extends Container {
67
68
  [],
68
69
  this.#running
69
70
  ? [
70
- this.#abort?.signal.aborted
71
- ? "Interruption requested; waiting for acknowledgement…"
72
- : "Operation in progress…",
71
+ selectorProse(
72
+ this.#abort?.signal.aborted
73
+ ? "Interruption requested; waiting for acknowledgement…"
74
+ : "Operation in progress…",
75
+ ),
73
76
  ]
74
77
  : [
75
78
  this.#executed ? "Close unresolved result" : "Cancel",
@@ -13,6 +13,7 @@ import {
13
13
  selectorFrameContentWidth,
14
14
  selectorKeys,
15
15
  selectorNavigationHint,
16
+ selectorProse,
16
17
  selectorRow,
17
18
  } from "./selector-frame";
18
19
 
@@ -35,7 +36,7 @@ export class SearchableSelectList extends SelectList {
35
36
  private readonly maxVisibleRows: number,
36
37
  theme: SelectListTheme,
37
38
  ) {
38
- super(sourceItems, maxVisibleRows, theme);
39
+ super(sourceItems, maxVisibleRows, theme, { presentation: "compact-with-selected-detail" });
39
40
  }
40
41
 
41
42
  override setFilter(filter: string): void {
@@ -75,7 +76,7 @@ export class SearchableSelectList extends SelectList {
75
76
  const item = this.sourceItems[index]!;
76
77
  return selectorRow([item.label], [inner - 2], index === this.#selectedIndex);
77
78
  })
78
- : [this.sourceItems.length ? "No matching options." : "No options available."];
79
+ : [selectorProse(this.sourceItems.length ? "No matching options." : "No options available.")];
79
80
  const lines = selectorFrame(
80
81
  width,
81
82
  rows,
@@ -10,11 +10,26 @@ import {
10
10
  import { theme } from "../theme/theme";
11
11
 
12
12
  export interface SelectorFrameRow {
13
+ kind: "compact-row";
13
14
  content: string;
14
15
  selected: boolean;
16
+ fullDetail: string;
17
+ truncated: boolean;
15
18
  }
16
19
 
17
- export type SelectorFrameLine = string | SelectorFrameRow;
20
+ export interface SelectorFrameProse {
21
+ kind: "wrapped-prose";
22
+ content: string;
23
+ tone: "text" | "muted";
24
+ }
25
+
26
+ export type SelectorFrameLine = SelectorFrameRow | SelectorFrameProse;
27
+
28
+ interface RenderedBodyLine {
29
+ content: string;
30
+ selected: boolean;
31
+ sourceIndex: number;
32
+ }
18
33
 
19
34
  export interface SelectorFrameOptions {
20
35
  /** Index of the selected row in `body`; used to retain it when the frame is height-constrained. */
@@ -27,6 +42,8 @@ export interface SelectorFrameOptions {
27
42
  minimumDetailRows?: number;
28
43
  /** Contextual paging controls, included only when the body actually overflows. */
29
44
  overflowHint?: string;
45
+ /** The caller supplies and pages the selected compact row's complete detail text. */
46
+ selectedDetail?: "automatic" | "provided";
30
47
  }
31
48
 
32
49
  function selectorFrameColumns(width: number): number {
@@ -59,11 +76,11 @@ function wrapSection(values: string[], width: number): string[] {
59
76
  }
60
77
 
61
78
  function windowBody(
62
- body: SelectorFrameLine[],
79
+ body: RenderedBodyLine[],
63
80
  visibleRows: number,
64
81
  selectedIndex: number | undefined,
65
82
  stickyRows: number,
66
- ): SelectorFrameLine[] {
83
+ ): RenderedBodyLine[] {
67
84
  if (visibleRows <= 0) return [];
68
85
  if (body.length <= visibleRows) return body;
69
86
  const stickyCount = Math.min(stickyRows, visibleRows - 1, body.length);
@@ -95,13 +112,12 @@ export function selectorFrame(
95
112
  const top = border(`${box.topLeft}${box.horizontal.repeat(columns - 2)}${box.topRight}`);
96
113
  const divider = border(`${sharp.teeRight}${box.horizontal.repeat(columns - 2)}${sharp.teeLeft}`);
97
114
  const bottom = border(`${box.bottomLeft}${box.horizontal.repeat(columns - 2)}${box.bottomRight}`);
98
- const line = (value: SelectorFrameLine) => {
99
- const row = typeof value === "string" ? undefined : value;
100
- const fitted = truncateToWidth(normalizeLine(typeof value === "string" ? value : value.content), contentWidth);
115
+ const line = (value: string, selected = false) => {
116
+ const fitted = truncateToWidth(normalizeLine(value), contentWidth);
101
117
  const content = `${" ".repeat(padding)}${fitted}${" ".repeat(
102
118
  Math.max(0, contentWidth - visibleWidth(fitted)),
103
119
  )}${" ".repeat(padding)}`;
104
- return row?.selected
120
+ return selected
105
121
  ? `${edge}${theme.bg("selectedBg", theme.fg("text", content))}${edge}`
106
122
  : `${edge}${content}${edge}`;
107
123
  };
@@ -111,10 +127,43 @@ export function selectorFrame(
111
127
  .slice(0, 2)
112
128
  .map(value => theme.fg("muted", value));
113
129
  const navigationLines = navigation.filter(hasVisibleContent).map(normalizeLine);
114
- const detailLines = wrapSection(details, contentWidth);
130
+ const selectedSourceIndex =
131
+ options.selectedBodyIndex ?? body.findIndex(value => value.kind === "compact-row" && value.selected);
132
+ const selectedRow = selectedSourceIndex >= 0 ? body[selectedSourceIndex] : undefined;
133
+ if (
134
+ options.selectedDetail === "provided" &&
135
+ selectedRow?.kind === "compact-row" &&
136
+ (selectedRow.truncated || visibleWidth(normalizeLine(selectedRow.content)) > contentWidth) &&
137
+ !details.some(hasVisibleContent)
138
+ ) {
139
+ throw new TypeError("A truncating compact row requires complete selected detail text.");
140
+ }
141
+ const selectedDetail =
142
+ options.selectedDetail !== "provided" &&
143
+ selectedRow?.kind === "compact-row" &&
144
+ (selectedRow.truncated || visibleWidth(normalizeLine(selectedRow.content)) > contentWidth)
145
+ ? [selectedRow.fullDetail]
146
+ : [];
147
+ const detailLines = wrapSection([...selectedDetail, ...details], contentWidth);
115
148
  while (detailLines.length < (options.minimumDetailRows ?? 0)) detailLines.push("");
116
149
  const footerLines = wrapSection(footer, contentWidth).map(value => theme.fg("muted", value));
117
- const normalizedBody = body.filter(value => typeof value !== "string" || hasVisibleContent(value));
150
+ const normalizedBody = body.flatMap<RenderedBodyLine>((value, sourceIndex) => {
151
+ if (!value || typeof value !== "object" || !("kind" in value)) {
152
+ throw new TypeError("Frame body text must use selectorProse() or selectorRow().");
153
+ }
154
+ if (value.kind === "wrapped-prose") {
155
+ return wrapSection([value.content], contentWidth).map(content => ({
156
+ content: theme.fg(value.tone, content),
157
+ selected: false,
158
+ sourceIndex,
159
+ }));
160
+ }
161
+ if (value.kind !== "compact-row") {
162
+ throw new TypeError("Frame body text must use selectorProse() or selectorRow().");
163
+ }
164
+ return [{ content: value.content, selected: value.selected, sourceIndex }];
165
+ });
166
+ const selectedRenderedIndex = normalizedBody.findIndex(value => value.sourceIndex === selectedSourceIndex);
118
167
 
119
168
  const gaps = {
120
169
  headingToNavigation: navigationLines.length > 0,
@@ -143,7 +192,7 @@ export function selectorFrame(
143
192
  let visibleBody = windowBody(
144
193
  normalizedBody,
145
194
  bodyRows,
146
- options.selectedBodyIndex,
195
+ selectedRenderedIndex >= 0 ? selectedRenderedIndex : undefined,
147
196
  Math.max(0, options.stickyBodyRows ?? 0),
148
197
  );
149
198
 
@@ -156,7 +205,7 @@ export function selectorFrame(
156
205
  visibleBody = windowBody(
157
206
  normalizedBody,
158
207
  bodyRows,
159
- options.selectedBodyIndex,
208
+ selectedRenderedIndex >= 0 ? selectedRenderedIndex : undefined,
160
209
  Math.max(0, options.stickyBodyRows ?? 0),
161
210
  );
162
211
  }
@@ -176,35 +225,53 @@ export function selectorFrame(
176
225
 
177
226
  return [
178
227
  top,
179
- ...heading.map(line),
180
- ...purposeLines.map(line),
228
+ ...heading.map(value => line(value)),
229
+ ...purposeLines.map(value => line(value)),
181
230
  ...(gaps.headingToNavigation ? [line("")] : []),
182
- ...navigationLines.map(line),
231
+ ...navigationLines.map(value => line(value)),
183
232
  divider,
184
- ...visibleBody.map(line),
233
+ ...visibleBody.map(value => line(value.content, value.selected)),
185
234
  ...(gaps.bodyToDetails ? [line("")] : []),
186
- ...detailLines.map(line),
235
+ ...detailLines.map(value => line(value)),
187
236
  ...(gaps.detailsToFooter ? [line("")] : []),
188
- ...footerLines.map(line),
237
+ ...footerLines.map(value => line(value)),
189
238
  bottom,
190
239
  ];
191
240
  }
192
241
 
242
+ export function selectorProse(content: string, tone: "text" | "muted" = "text"): SelectorFrameProse {
243
+ return { kind: "wrapped-prose", content, tone };
244
+ }
245
+
246
+ export function selectorCompactRow(
247
+ content: string,
248
+ selected = false,
249
+ fullDetail = content,
250
+ truncated = false,
251
+ ): SelectorFrameRow {
252
+ if (truncated && !hasVisibleContent(fullDetail)) {
253
+ throw new TypeError("A truncating compact row requires complete selected detail text.");
254
+ }
255
+ return { kind: "compact-row", content, selected, fullDetail, truncated };
256
+ }
257
+
193
258
  export function selectorRow(
194
259
  cells: string[],
195
260
  widths: number[],
196
261
  selected = false,
197
262
  tone: "text" | "muted" = "text",
198
263
  ): SelectorFrameRow {
264
+ let truncated = false;
199
265
  const row = cells
200
266
  .map((cell, index) => {
201
267
  const width = Math.max(0, widths[index] ?? 0);
268
+ truncated ||= visibleWidth(cell) > width;
202
269
  const fitted = truncateToWidth(cell, width);
203
270
  return fitted + " ".repeat(Math.max(0, width - visibleWidth(fitted)));
204
271
  })
205
272
  .join(" ");
206
273
  const text = `${selected ? theme.nav.cursor : " "} ${row}`;
207
- return { content: theme.fg(tone, text), selected };
274
+ return selectorCompactRow(theme.fg(tone, text), selected, cells.join(" "), truncated);
208
275
  }
209
276
 
210
277
  type SelectorAction = "up" | "down" | "confirm" | "cancel" | "pageUp" | "pageDown";
@@ -298,7 +365,7 @@ export class ReportDetailsComponent extends Container {
298
365
  this.title,
299
366
  this.purpose,
300
367
  [],
301
- lines.slice(this.#offset, this.#offset + this.#capacity).map(content => ({ content, selected: false })),
368
+ lines.slice(this.#offset, this.#offset + this.#capacity).map(content => selectorCompactRow(content)),
302
369
  [],
303
370
  [
304
371
  ...(overflow
@@ -388,8 +455,8 @@ export class ConnectionInputComponent extends Container {
388
455
  this.title,
389
456
  "",
390
457
  [],
391
- this.content.render(inner),
392
- [...wrapTextWithAnsi(this.purpose, inner).slice(0, 3), ...this.input.render(inner)],
458
+ this.content.render(inner).map(content => selectorProse(content)),
459
+ [this.purpose, ...this.input.render(inner)],
393
460
  [selectorNavigationHint("submit"), selectorCancelHint("cancel")],
394
461
  );
395
462
  }
@@ -29,6 +29,7 @@ import {
29
29
  selectorFrame,
30
30
  selectorFrameContentWidth,
31
31
  selectorKeys,
32
+ selectorProse,
32
33
  } from "./selector-frame";
33
34
 
34
35
  type Mode = "picker" | "viewer";
@@ -120,7 +121,7 @@ export class SessionObserverOverlayComponent extends Container {
120
121
  "Session observer",
121
122
  session ? `Live read-only view · ${session.label} · identity ${session.id}` : "Live session is unavailable.",
122
123
  [],
123
- content.slice(this.#viewerOffset, this.#viewerOffset + this.#viewerCapacity),
124
+ content.slice(this.#viewerOffset, this.#viewerOffset + this.#viewerCapacity).map(line => selectorProse(line)),
124
125
  [],
125
126
  [
126
127
  ...(this.#viewerLength > this.#viewerCapacity