@iseoane/agent-depot 0.6.13 → 0.6.15

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 (55) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +3 -1
  3. package/dist/src/app-flow.js +8 -1
  4. package/dist/src/atomic-file.js +29 -3
  5. package/dist/src/cli.js +2 -0
  6. package/dist/src/flow-progress.js +9 -0
  7. package/dist/src/git-source.js +257 -15
  8. package/dist/src/preview-model.js +37 -0
  9. package/dist/src/process-runner.js +1 -0
  10. package/dist/src/profile-cli.js +12 -4
  11. package/dist/src/profile-export.js +23 -3
  12. package/dist/src/skill-update.js +1 -0
  13. package/dist/src/source-refresh-session.js +139 -0
  14. package/dist/src/sources.js +4 -4
  15. package/dist/src/tui/adoption-actions.js +21 -2
  16. package/dist/src/tui/app-actions.js +57 -47
  17. package/dist/src/tui/app.js +22 -4
  18. package/dist/src/tui/bulk-actions.js +45 -11
  19. package/dist/src/tui/catalog-actions.js +12 -0
  20. package/dist/src/tui/catalog-flow.js +31 -11
  21. package/dist/src/tui/catalog-panel.js +15 -13
  22. package/dist/src/tui/catalog-tree.js +7 -2
  23. package/dist/src/tui/catalog-view.js +12 -6
  24. package/dist/src/tui/file-picker.js +2 -1
  25. package/dist/src/tui/installations-browse.js +1 -1
  26. package/dist/src/tui/installations-panel.js +12 -4
  27. package/dist/src/tui/installations-rows.js +2 -2
  28. package/dist/src/tui/installations-tree.js +1 -1
  29. package/dist/src/tui/installations-view.js +16 -7
  30. package/dist/src/tui/keys.js +35 -4
  31. package/dist/src/tui/manage-actions.js +55 -27
  32. package/dist/src/tui/manage-panel.js +15 -29
  33. package/dist/src/tui/mode-keys.js +11 -1
  34. package/dist/src/tui/package-actions.js +25 -18
  35. package/dist/src/tui/package-rows.js +9 -4
  36. package/dist/src/tui/panel-parts.js +54 -37
  37. package/dist/src/tui/preview-render.js +93 -0
  38. package/dist/src/tui/preview-window.js +185 -20
  39. package/dist/src/tui/profile-view.js +54 -44
  40. package/dist/src/tui/render.js +13 -1
  41. package/dist/src/tui/safe-text.js +25 -0
  42. package/dist/src/tui/sources-mode.js +2 -2
  43. package/dist/src/tui/sources-recipes.js +53 -32
  44. package/dist/src/tui/sources-view.js +85 -39
  45. package/dist/src/tui/text-wrap.js +47 -3
  46. package/dist/src/tui/unmanaged-actions.js +17 -5
  47. package/dist/src/tui/updates-flow.js +11 -5
  48. package/dist/src/tui/updates-keys.js +2 -2
  49. package/dist/src/tui/updates-model.js +5 -2
  50. package/dist/src/tui/updates-panel.js +10 -9
  51. package/dist/src/tui/updates-preview.js +27 -44
  52. package/dist/src/tui/updates-view.js +15 -11
  53. package/dist/src/tui/updates.js +117 -33
  54. package/dist/src/update-flow.js +31 -15
  55. package/package.json +1 -1
@@ -1,21 +1,23 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { readFile } from "node:fs/promises";
3
- import { Box, Text, useWindowSize } from "ink";
3
+ import path from "node:path";
4
+ import { Box, useWindowSize } from "ink";
5
+ import { PreEscapedText, Text } from "./safe-text.js";
4
6
  import stringWidth from "string-width";
5
7
  import { useCallback, useMemo, useRef, useState } from "react";
6
8
  import { createAppOperations } from "../app-flow.js";
7
- import { buildProfileExport, describeProfileExportExclusion, describeProfilePackage, describeProfileReplica, describeProfileSkill, profileExportPreview, SKILL_EXPORT_EXPLANATION, writeProfileExport, } from "../profile-export.js";
9
+ import { buildProfileExport, describeProfileExportExclusion, describeProfilePackage, describeProfileReplica, describeProfileSkill, profileExportPreview, requireOverwritableProfileExport, SKILL_EXPORT_EXPLANATION, writeProfileExport, } from "../profile-export.js";
8
10
  import { applyProfileImport, buildProfileImport, } from "../profile-import.js";
9
11
  import { PROFILE_FORMAT, PROFILE_FORMAT_V2, parseProfile } from "../profile.js";
10
12
  import { selectionKey } from "../package-model.js";
11
13
  import { packageOperationsOf } from "./environment.js";
12
14
  import { DEFAULT_PROFILE_FILE_NAME, exportDestination, fileNameNotice, pathExists, pickerStartDirectory, resolveExportFileName, } from "./file-picker-model.js";
13
15
  import { FilePicker } from "./file-picker.js";
14
- import { useKeys } from "./keys.js";
15
- import { previewViewport } from "./preview-window.js";
16
+ import { isYes, useKeys } from "./keys.js";
17
+ import { escapeControlText, usePreviewGeometry } from "./preview-window.js";
16
18
  import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
17
- import { BorderPanel, PreviewConfirm, PreviewLines, Working } from "./panel-parts.js";
18
- import { clipText, elideMiddle, fitLines, wrapText } from "./text-wrap.js";
19
+ import { BorderPanel, PagedPreview, PreviewLines, Working } from "./panel-parts.js";
20
+ import { clipText, elideMiddle, fitLines, textUnits, wrapText } from "./text-wrap.js";
19
21
  import { theme } from "./theme.js";
20
22
  import { useMirrored, useMounted } from "./view-state.js";
21
23
  import { computeWindow, useListHeight } from "./window.js";
@@ -136,32 +138,36 @@ function physicalListWindow(rows, rowHeights, selected, height) {
136
138
  /** An error naming a long path keeps its first and last rows, where the path starts and the reason ends. */
137
139
  const NAME_ERROR_ROWS = 3;
138
140
  const NAME_HINT = "Type to edit · Ctrl+U clear · Enter continue · Esc back to folder";
141
+ const NOTE_CHROME = { title: "Export note", cancel: "[Esc] close" };
139
142
  /**
140
143
  * A `Label: value` line of the export name stage, wrapped to the panel and kept within `rows` rows. The
141
144
  * end of a path (its last folder, the file name) and the end of the typed name (where the cursor is) are
142
145
  * what matter, so with fewer than three rows the field keeps its label and its tail behind a `…`; with
143
146
  * three or more a path keeps its first row too, around a count of the rows left out.
144
147
  */
145
- function NameField({ label, value, rows, width, tail = false }) {
146
- const wrapped = wrapText(`${label}${value}`, width);
148
+ export function NameField({ label, value, rows, width, tail = false }) {
149
+ // Measured and drawn as escaped, so a hostile path or name takes the rows it is shown on.
150
+ const escapedValue = escapeControlText(value);
151
+ const wrapped = wrapText(`${label}${escapedValue}`, width);
147
152
  const cut = wrapped.length > rows;
148
153
  const keepsMiddleCount = cut && !tail && rows > 2;
149
154
  let shown = wrapped;
150
155
  if (keepsMiddleCount)
151
156
  shown = elideMiddle(wrapped, rows, width);
152
157
  else if (cut) {
153
- const characters = Array.from(value);
154
- for (let dropped = 1; dropped <= characters.length; dropped += 1) {
155
- shown = wrapText(`${label}…${characters.slice(dropped).join("")}`, width);
158
+ // Dropped by units, so the cut cannot leave half of a doubled backslash that would read as a real escape.
159
+ const units = textUnits(escapedValue).map(({ segment }) => segment);
160
+ for (let dropped = 1; dropped <= units.length; dropped += 1) {
161
+ shown = wrapText(`${label}…${units.slice(dropped).join("")}`, width);
156
162
  if (shown.length <= rows)
157
163
  break;
158
164
  }
159
165
  }
160
166
  return _jsx(_Fragment, { children: shown.map((line, index) => {
161
167
  if (index === 0 && line.startsWith(label)) {
162
- return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsx(Text, { color: theme.value, children: line.slice(label.length) })] }, index);
168
+ return _jsxs(PreEscapedText, { children: [_jsx(PreEscapedText, { color: theme.field, children: label }), _jsx(PreEscapedText, { color: theme.value, children: line.slice(label.length) })] }, index);
163
169
  }
164
- return _jsx(Text, { color: keepsMiddleCount && index === 1 ? theme.muted : theme.value, children: line }, index);
170
+ return _jsx(PreEscapedText, { color: keepsMiddleCount && index === 1 ? theme.muted : theme.value, children: line }, index);
165
171
  }) });
166
172
  }
167
173
  function planEntries(plan) {
@@ -238,7 +244,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
238
244
  const [profile, setProfile] = useState();
239
245
  const [plan, setPlan] = useState();
240
246
  const diagnostics = useRef([]);
241
- const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
247
+ const { columns: terminalColumns } = useWindowSize();
242
248
  const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
243
249
  const hintCursorRow = listRows(mode, rows, expandedGroupsRef.current)[cursorRef.current];
244
250
  const enterHint = hintCursorRow === undefined ? undefined
@@ -255,13 +261,17 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
255
261
  const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
256
262
  const hintRows = Math.max(Math.ceil(stringWidth(hintText) / panelWidth), 1);
257
263
  const height = useListHeight(listHeight, 4 + titleRows + hintRows);
258
- const confirmChrome = () => ({
259
- title: "Confirmation",
260
- questions: [confirmRequest.current?.kind === "export"
261
- ? "Save/Export Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"
262
- : "Apply Profile? (y/n) · arrows/PgUp/PgDn page · j/k line"],
263
- footerRows: 2,
264
- });
264
+ const previewGeometry = usePreviewGeometry();
265
+ const confirmChrome = () => {
266
+ const request = confirmRequest.current;
267
+ if (request?.kind !== "export")
268
+ return { title: "Confirmation", action: "Apply Profile", consequence: "Apply Profile? (y/n)" };
269
+ if (!request.overwrite)
270
+ return { title: "Confirmation", action: "Save/Export Profile", consequence: "Save/Export Profile? (y/n)" };
271
+ // The full path is on the Destination line; the base name keeps the footer short on narrow terminals.
272
+ const name = path.basename(request.file);
273
+ return { title: "Confirmation", action: `Overwrite ${name}`, consequence: `Overwrite ${name}? (y/n)` };
274
+ };
265
275
  const showLines = (next, nextMode) => {
266
276
  setLines(next);
267
277
  cursorTo(0);
@@ -348,6 +358,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
348
358
  let exists;
349
359
  try {
350
360
  exists = await existsCheck(finalPath);
361
+ if (exists)
362
+ await requireOverwritableProfileExport(finalPath);
351
363
  }
352
364
  catch (error) {
353
365
  if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
@@ -363,13 +375,10 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
363
375
  return;
364
376
  if (current.kind !== "ok" || current.fileName !== fileName)
365
377
  return;
366
- if (exists) {
367
- setNameError(`${finalPath} already exists; choose another name`);
368
- return;
369
- }
370
- confirmRequest.current = { kind: "export", file: finalPath };
378
+ confirmRequest.current = { kind: "export", file: finalPath, overwrite: exists };
371
379
  setNameError(undefined);
372
- showLines([`Destination: ${finalPath}`, ...profileExportPreview(chosenProfile(), diagnostics.current)], "confirm");
380
+ const destination = exists ? `Destination: ${finalPath} (exists; it will be replaced)` : `Destination: ${finalPath}`;
381
+ showLines([destination, ...profileExportPreview(chosenProfile(), diagnostics.current)], "confirm");
373
382
  })();
374
383
  };
375
384
  const confirmApply = () => {
@@ -379,7 +388,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
379
388
  void run(request.kind === "export" ? "Exporting…" : "Applying import…", async () => {
380
389
  let result;
381
390
  if (request.kind === "export") {
382
- await writeProfileExport(request.file, chosenProfile());
391
+ await writeProfileExport(request.file, chosenProfile(), { overwrite: request.overwrite });
383
392
  result = [`Exported profile to ${request.file}`];
384
393
  }
385
394
  else {
@@ -495,20 +504,21 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
495
504
  setMode("export-list");
496
505
  return;
497
506
  }
498
- const next = movePreviewCursor(cursorRef.current, lines.length, input, key, height);
507
+ const viewport = previewGeometry(lines, cursorRef.current, NOTE_CHROME);
508
+ const next = movePreviewCursor(cursorRef.current, viewport.total, input, key, viewport.height);
499
509
  if (next !== undefined)
500
510
  cursorTo(Math.max(0, next));
501
511
  return;
502
512
  }
503
513
  if (current === "confirm") {
504
- answerYesNo(input, key, confirmApply, () => setMode(confirmRequest.current?.kind === "import" ? "import-list" : "export-name"));
505
- if (input === "y" || input === "n" || key.escape)
506
- return;
507
514
  // The preview cursor counts wrapped rows, not source lines, or a long destination would cap the paging.
508
- const viewport = previewViewport(lines, cursorRef.current, terminalRows || 24, terminalColumns || 80, confirmChrome());
515
+ const viewport = previewGeometry(lines, cursorRef.current, confirmChrome());
509
516
  const next = movePreviewCursor(cursorRef.current, viewport.total, input, key, viewport.height);
510
517
  if (next !== undefined)
511
518
  cursorTo(Math.max(0, next));
519
+ else if (!isYes(input, key) || viewport.reachedEnd) {
520
+ answerYesNo(input, key, confirmApply, () => setMode(confirmRequest.current?.kind === "import" ? "import-list" : "export-name"));
521
+ }
512
522
  return;
513
523
  }
514
524
  if (key.escape) {
@@ -574,7 +584,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
574
584
  if ("kind" in row)
575
585
  return 1;
576
586
  const prefixWidth = row.block === "excluded" ? 2 : stringWidth(`> [ ] ${row.ordinal ?? row.index + 1} `);
577
- return wrapText(row.label, Math.max(panelWidth - prefixWidth, 1)).length;
587
+ return wrapText(escapeControlText(row.label), Math.max(panelWidth - prefixWidth, 1)).length;
578
588
  });
579
589
  const listWindow = physicalListWindow(displayRows, rowHeights, cursor, height);
580
590
  const window = listing ? listWindow : computeWindow(lines.length, cursor, height);
@@ -592,24 +602,24 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
592
602
  const prefix = `${marker}${" ".repeat(row.depth * 2)}${open ? "▾" : "▸"} `;
593
603
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
594
604
  remainingRows -= 1;
595
- return _jsxs(Text, { color: theme.group, bold: true, children: [prefix, clipText(row.label, width)] }, row.id);
605
+ return _jsxs(PreEscapedText, { color: theme.group, bold: true, children: [prefix, clipText(escapeControlText(row.label), width)] }, row.id);
596
606
  }
597
607
  if (row.block === "excluded") {
598
608
  const width = Math.max(panelWidth - stringWidth(marker), 1);
599
- const label = fitLines(wrapText(row.label, width), remainingRows, width)
609
+ const label = fitLines(wrapText(escapeControlText(row.label), width), remainingRows, width)
600
610
  .join(`\n${" ".repeat(stringWidth(marker))}`);
601
611
  remainingRows -= label.split("\n").length;
602
- return _jsxs(Text, { color: theme.value, children: [marker, label] }, row.id);
612
+ return _jsxs(PreEscapedText, { color: theme.value, children: [marker, label] }, row.id);
603
613
  }
604
614
  // A row the import never changes has no checkbox: unchanged items rest, conflicts warn.
605
615
  const box = row.selectable ? `[${selected.some(choice => choice.id === row.id) ? "x" : " "}]` : " ";
606
616
  const prefix = `${marker}${box} ${row.ordinal ?? row.index + 1} `;
607
617
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
608
- const label = fitLines(wrapText(row.label, width), remainingRows, width)
618
+ const label = fitLines(wrapText(escapeControlText(row.label), width), remainingRows, width)
609
619
  .join(`\n${" ".repeat(stringWidth(prefix))}`);
610
620
  remainingRows -= label.split("\n").length;
611
621
  const color = row.status === "conflict" ? theme.warning : row.status === "same" ? theme.muted : theme.value;
612
- return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: prefix }), _jsx(Text, { color: color, children: label })] }, row.id);
622
+ return _jsxs(PreEscapedText, { children: [_jsx(PreEscapedText, { color: theme.field, children: prefix }), _jsx(PreEscapedText, { color: color, children: label })] }, row.id);
613
623
  });
614
624
  const nameOutcome = resolveExportFileName(text);
615
625
  const liveDestination = nameOutcome.kind === "ok"
@@ -617,8 +627,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
617
627
  const nameNote = nameOutcome.kind === "ok" ? fileNameNotice(nameOutcome.fileName) : undefined;
618
628
  const extensionNote = nameOutcome.kind === "ok" && nameOutcome.appendedExtension
619
629
  ? `A name without an extension is saved as ${nameOutcome.fileName}.` : undefined;
620
- const textRows = (value) => value === undefined ? 0 : wrapText(value, panelWidth).length;
621
- const nameErrorLines = nameError === undefined ? [] : wrapText(nameError, panelWidth);
630
+ const textRows = (value) => value === undefined ? 0 : wrapText(escapeControlText(value), panelWidth).length;
631
+ const nameErrorLines = nameError === undefined ? [] : wrapText(escapeControlText(nameError), panelWidth);
622
632
  // Keys type into the name, so the stage cannot scroll; instead each long field keeps its end (and, given
623
633
  // three rows or more, its first row) within a share of the terminal. The confirmation pages the full destination.
624
634
  const nameFixedRows = 3 + textRows(nameNote) + textRows(extensionNote) + Math.min(nameErrorLines.length, NAME_ERROR_ROWS)
@@ -629,7 +639,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
629
639
  const destinationRows = Math.floor(fieldRows / 3) + (fieldRows % 3 >= 1 ? 1 : 0);
630
640
  const nameRows = fieldRows - folderRows - destinationRows;
631
641
  return _jsxs(Box, { flexDirection: "column", children: [mode === "export-folder" ? _jsx(FilePicker, { purpose: "directory", listHeight: listHeight, startDirectory: exportDirectory ?? startDirectory, onCancel: () => setMode("export-list"), onChoose: selection => { if (selection.kind === "directory")
632
- chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" && working ? _jsx(Working, { label: working.label, since: working.startedAt }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsx(NameField, { label: "Folder: ", value: exportDirectory ?? startDirectory, rows: folderRows, width: panelWidth }), _jsx(NameField, { label: "Name: ", value: `${text}_`, rows: nameRows, width: panelWidth, tail: true }), liveDestination === undefined ? null : _jsx(NameField, { label: "Destination: ", value: liveDestination, rows: destinationRows, width: panelWidth }), nameNote === undefined ? null : _jsx(Text, { color: theme.warning, children: nameNote }), extensionNote === undefined ? null : _jsx(Text, { color: theme.muted, children: extensionNote }), nameError === undefined ? null : nameErrorLines.length <= NAME_ERROR_ROWS
633
- ? _jsx(Text, { color: theme.error, children: nameError })
634
- : elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(Text, { color: theme.error, children: line }, index)), nameChecking === undefined ? null : _jsx(Working, { label: "Checking name\u2026", since: nameChecking }), _jsx(Text, { color: theme.legend, children: NAME_HINT })] }) : null, mode === "confirm" ? _jsx(PreviewConfirm, { lines: lines, cursor: cursor, chrome: confirmChrome(), question: confirmChrome().questions[0] }) : null, mode === "detail" ? _jsx(PreviewConfirm, { title: "Export note", lines: lines, cursor: cursor, question: "Esc close \u00B7 arrows/PgUp/PgDn page \u00B7 j/k line" }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
642
+ chooseExportDirectory(selection.directory); } }) : null, mode === "import-browse" ? _jsx(FilePicker, { purpose: "file", listHeight: listHeight, startDirectory: importDirectory ?? startDirectory, notice: importNotice, onCancel: () => setMode("browse"), onChoose: chooseImportFile }) : null, listing ? _jsxs(BorderPanel, { title: mode === "export-list" ? "Export Profile" : "Import Profile", children: [listContent, _jsx(Text, { color: theme.legend, children: hintText }), window.indicator ? _jsx(Text, { color: theme.muted, children: window.indicator }) : null] }) : null, mode === "browse" ? _jsx(BorderPanel, { title: "Portable Profile", children: _jsx(Text, { color: theme.legend, children: "e export \u00B7 i import" }) }) : null, mode === "busy" && working ? _jsx(Working, { label: working.label, since: working.startedAt }) : null, mode === "export-name" ? _jsxs(BorderPanel, { title: "Export file name", children: [_jsx(NameField, { label: "Folder: ", value: exportDirectory ?? startDirectory, rows: folderRows, width: panelWidth }), _jsx(NameField, { label: "Name: ", value: `${text}_`, rows: nameRows, width: panelWidth, tail: true }), liveDestination === undefined ? null : _jsx(NameField, { label: "Destination: ", value: liveDestination, rows: destinationRows, width: panelWidth }), nameNote === undefined ? null : _jsx(PreEscapedText, { color: theme.warning, children: escapeControlText(nameNote) }), extensionNote === undefined ? null : _jsx(PreEscapedText, { color: theme.muted, children: escapeControlText(extensionNote) }), nameError === undefined ? null : nameErrorLines.length <= NAME_ERROR_ROWS
643
+ ? _jsx(PreEscapedText, { color: theme.error, children: escapeControlText(nameError) })
644
+ : elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(PreEscapedText, { color: theme.error, children: line }, index)), nameChecking === undefined ? null : _jsx(Working, { label: "Checking name\u2026", since: nameChecking }), _jsx(Text, { color: theme.legend, children: NAME_HINT })] }) : null, mode === "confirm" ? _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, cursor, confirmChrome()) }) : null, mode === "detail" ? _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, cursor, NOTE_CHROME) }) : null, mode === "results" ? _jsxs(_Fragment, { children: [_jsx(PreviewLines, { lines: lines.slice(window.start, window.end) }), _jsx(Text, { children: "j/k scroll" })] }) : null, window.indicator && mode === "results" ? _jsx(Text, { children: window.indicator }) : null] });
635
645
  }
@@ -1,8 +1,20 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
+ import { createSourceRefreshSession } from "../source-refresh-session.js";
3
4
  import { App } from "./app.js";
4
5
  /** Renders the TUI and resolves once the user exits it. */
5
6
  export async function renderTui(operations, environment) {
6
- const instance = render(_jsx(App, { operations: operations, environment: environment }), { alternateScreen: true });
7
+ const refreshSession = createSourceRefreshSession(operations);
8
+ const instance = render(_jsx(App, { operations: operations, environment: environment, refreshSession: refreshSession, refreshOnStart: environment?.refreshOnStart ?? true }), { alternateScreen: true });
7
9
  await instance.waitUntilExit();
10
+ await finishRunningRefreshes(refreshSession, (message) => process.stderr.write(message));
11
+ }
12
+ /** Drops the queued refreshes and waits for the running ones, telling the user only when it has to wait. */
13
+ export async function finishRunningRefreshes(refreshSession, write) {
14
+ refreshSession.stop();
15
+ // Killing a refresh would leave its Source cache lock behind, so wait for the running ones instead.
16
+ if ([...refreshSession.snapshot().values()].some((state) => state.status === "refreshing")) {
17
+ write("Waiting for the Source refreshes still running to finish…\n");
18
+ await refreshSession.settled();
19
+ }
8
20
  }
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text as InkText } from "ink";
3
+ import { cloneElement, Fragment, isValidElement } from "react";
4
+ import { escapeControlText } from "./preview-window.js";
5
+ /** Ink's `Text` for text that was already passed through `escapeControlText`; escaping it again would double its backslashes. */
6
+ export const PreEscapedText = InkText;
7
+ // Messages and errors span lines on purpose and Ink lays them out, so a line break is the one control character left alone.
8
+ const LINE_BREAK = /\r?\n/u;
9
+ function escapeNode(node) {
10
+ if (typeof node === "string")
11
+ return node.split(LINE_BREAK).map(escapeControlText).join("\n");
12
+ if (Array.isArray(node))
13
+ return node.map(escapeNode);
14
+ if (isValidElement(node) && node.type === Fragment) {
15
+ return cloneElement(node, undefined, escapeNode(node.props.children));
16
+ }
17
+ return node;
18
+ }
19
+ /**
20
+ * The `Text` every view uses. Skill, recipe and App text can come from a remote Source or a user-written recipe; raw, an ESC
21
+ * sequence in it can retitle the window, write the clipboard or redraw the screen. Nested `Text` escapes its own children.
22
+ */
23
+ export function Text({ children, ...props }) {
24
+ return _jsx(InkText, { ...props, children: escapeNode(children) });
25
+ }
@@ -11,9 +11,9 @@ export function modeReducer(mode, event) {
11
11
  case "confirm":
12
12
  return event.action === "refresh"
13
13
  ? { kind: "confirm", action: "refresh", sources: event.sources, cursor: 0 }
14
- : { kind: "confirm", action: "remove", source: event.source };
14
+ : { kind: "confirm", action: "remove", source: event.source, cursor: 0 };
15
15
  case "preview-cursor":
16
- return mode.kind === "confirm" && mode.action === "refresh" ? { ...mode, cursor: event.cursor } : mode;
16
+ return mode.kind === "confirm" ? { ...mode, cursor: event.cursor } : mode;
17
17
  case "busy":
18
18
  return busy(event.label, mode, () => event.startedAt);
19
19
  case "done":
@@ -1,5 +1,6 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
2
+ import { Box } from "ink";
3
+ import { Text } from "./safe-text.js";
3
4
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
5
  import { createAppOperations } from "../app-flow.js";
5
6
  import { planAppRecipeRemoval, removeAppRecipe } from "../app-recipe-removal.js";
@@ -7,12 +8,14 @@ import { BUILT_IN_SOURCE } from "../sources.js";
7
8
  import { handleCatalogActionKey } from "./catalog-flow.js";
8
9
  import { ActionPanel } from "./catalog-panel.js";
9
10
  import { hasUnmanagedCopy, isInstalled, loadInstalledSkills } from "./catalog-installs.js";
10
- import { appName, appRecipePreviewLines, appStepLines } from "./app-actions.js";
11
+ import { appName, appPlanLines, appRecipePreviewLines, recipeApprovalChrome } from "./app-actions.js";
11
12
  import { errorText } from "./batch.js";
12
13
  import { useMounted } from "./view-state.js";
13
- import { BorderPanel, DecisionPrompt, PreviewConfirm, Working } from "./panel-parts.js";
14
+ import { BorderPanel, DecisionPrompt, PagedPreview, Working } from "./panel-parts.js";
15
+ import { isYes } from "./keys.js";
14
16
  import { answerYesNo, movePreviewCursor } from "./mode-keys.js";
15
- import { previewViewport } from "./preview-window.js";
17
+ import { usePreviewGeometry } from "./preview-window.js";
18
+ import { lineText, textLine } from "./preview-render.js";
16
19
  import { theme } from "./theme.js";
17
20
  import { busy } from "./working-model.js";
18
21
  /**
@@ -34,24 +37,24 @@ export function groupRecipeRows(rows) {
34
37
  function recipeMemberLine(row) {
35
38
  return `${row.entry.recipe?.platform ?? "all platforms"} · ${row.status} · ${row.entry.file}${row.entry.applicable ? " (used here)" : ""}`;
36
39
  }
37
- function recipeApprovalChrome(entry) {
38
- return {
39
- title: "Recipe approval · does not install",
40
- questions: [`Approve ${appName(entry)}? y/n · recipe approval does not install the App.`, "Scroll to end before y; n cancels."],
41
- footerRows: 2,
42
- };
43
- }
44
40
  function uninstallChrome(mode) {
45
- return {
46
- title: mode.kind === "uninstall-manual" ? "App manual uninstall" : "App uninstall confirmation",
47
- questions: [mode.kind === "uninstall-manual" ? "Complete the manual uninstall, then check now? y/n" : `Uninstall ${appName(mode.removal.entry)}? y/n`, "Scroll to end before y; n cancels."],
48
- footerRows: 2,
49
- };
41
+ const name = appName(mode.removal.entry);
42
+ return mode.kind === "uninstall-manual"
43
+ ? { title: "App manual uninstall", action: "Check the uninstall now", consequence: "Complete the manual uninstall, then check now? y/n" }
44
+ : { title: "App uninstall confirmation", action: `Uninstall ${name}`, consequence: `Uninstall ${name}? y/n` };
45
+ }
46
+ function removeChrome(removal) {
47
+ const name = appName(removal.entry);
48
+ return { title: "Recipe removal", action: `Remove the ${name} recipe`, consequence: `Remove recipe for ${name}? y/n` };
49
+ }
50
+ function removeLines(removal) {
51
+ return [`Delete recipe: ${removal.file}`, "Only this recipe file or symlink will be removed; App data and Host configuration are not deleted."];
50
52
  }
51
53
  /**
52
54
  * Recipe loading and captured actions; Sources owns the single list and cursor.
53
55
  */
54
- export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, terminalRows, terminalColumns, }) {
56
+ export function useSourcesRecipes({ operations, environment, onCapturingChange, highlightedFile, }) {
57
+ const previewGeometry = usePreviewGeometry();
55
58
  const homeDirectory = environment?.homeDirectory;
56
59
  const appEnvironment = environment?.appEnvironment;
57
60
  const apps = useMemo(() => createAppOperations({
@@ -90,7 +93,9 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
90
93
  kind: "approve",
91
94
  entry, removeAfterApproval, cursor: 0,
92
95
  lines: [
93
- ...(members.length > 1 ? [`Recipes named ${appName(entry)}:`, ...members.map(row => ` ${recipeMemberLine(row)}`), ""] : []),
96
+ ...(members.length > 1
97
+ ? [textLine(`Recipes named ${appName(entry)}:`), ...members.map(row => textLine(` ${recipeMemberLine(row)}`)), textLine(" ")]
98
+ : []),
94
99
  ...appRecipePreviewLines(entry, status, steps),
95
100
  ],
96
101
  });
@@ -106,6 +111,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
106
111
  const flow = {
107
112
  operations,
108
113
  env: environment ?? {},
114
+ previewGeometry,
109
115
  isMounted: () => mounted.current,
110
116
  setAction: action => setMode(action.kind === "browse"
111
117
  ? { kind: "browse" }
@@ -161,7 +167,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
161
167
  }
162
168
  const removal = await planAppRecipeRemoval(apps, entry);
163
169
  if (mounted.current)
164
- setMode({ kind: removal.installed ? "uninstall-question" : "remove", removal });
170
+ setMode(removal.installed ? { kind: "uninstall-question", removal } : { kind: "remove", removal, cursor: 0 });
165
171
  }
166
172
  catch (error) {
167
173
  if (mounted.current) {
@@ -181,7 +187,7 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
181
187
  if (result.status === "manual required")
182
188
  setMode({ ...current, kind: "uninstall-manual" });
183
189
  else if (result.status === "untracked")
184
- setMode({ kind: "remove", removal: current.removal });
190
+ setMode({ kind: "remove", removal: current.removal, cursor: 0 });
185
191
  else {
186
192
  setError(`Uninstall failed: ${result.reason ?? result.status}; recipe retained`);
187
193
  setMode({ kind: "browse" });
@@ -229,13 +235,13 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
229
235
  return true;
230
236
  }
231
237
  if (current.kind === "approve") {
232
- const viewport = previewViewport(current.lines, current.cursor, terminalRows, terminalColumns, recipeApprovalChrome(current.entry));
238
+ const viewport = previewGeometry(current.lines.map(lineText), current.cursor, recipeApprovalChrome(current.entry));
233
239
  const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
234
240
  if (cursor !== undefined)
235
241
  setMode({ ...current, cursor });
236
242
  else if (input === "n" || key.escape)
237
243
  cancel();
238
- else if (input === "y" && viewport.reachedEnd) {
244
+ else if (isYes(input, key) && viewport.reachedEnd) {
239
245
  setBusy("Approving App recipe…");
240
246
  void apps.approve(current.entry).then(async () => {
241
247
  await reload();
@@ -268,18 +274,26 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
268
274
  return true;
269
275
  }
270
276
  if (current.kind === "uninstall-confirm" || current.kind === "uninstall-manual") {
271
- const lines = appStepLines(current.plan);
272
- const viewport = previewViewport(lines, current.cursor, terminalRows, terminalColumns, uninstallChrome(current));
277
+ const lines = appPlanLines(current.plan).map(lineText);
278
+ const viewport = previewGeometry(lines, current.cursor, uninstallChrome(current));
273
279
  const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
274
280
  if (cursor !== undefined)
275
281
  setMode({ ...current, cursor });
276
282
  else if (input === "n" || key.escape)
277
283
  cancel();
278
- else if (input === "y" && viewport.reachedEnd)
284
+ else if (isYes(input, key) && viewport.reachedEnd)
279
285
  void uninstall(current);
280
286
  return true;
281
287
  }
282
288
  if (current.kind === "remove") {
289
+ const viewport = previewGeometry(removeLines(current.removal), current.cursor, removeChrome(current.removal));
290
+ const cursor = movePreviewCursor(current.cursor, viewport.total, input, key, viewport.height);
291
+ if (cursor !== undefined) {
292
+ setMode({ ...current, cursor });
293
+ return true;
294
+ }
295
+ if (isYes(input, key) && !viewport.reachedEnd)
296
+ return true;
283
297
  answerYesNo(input, key, () => {
284
298
  setBusy("Removing App recipe…");
285
299
  void removeAppRecipe(apps, current.removal, true).then(async () => {
@@ -344,15 +358,22 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
344
358
  }
345
359
  }, [apps, mounted]);
346
360
  useEffect(() => { void reload(); }, [reload]);
347
- const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error ? _jsx(Text, { color: theme.error, children: error }) : null, notice ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) }) : null, mode.kind === "approve" ? (() => {
361
+ // A paged preview sizes itself to the whole terminal, so no message row may sit above it.
362
+ const pagedPreview = (mode.kind === "install" && mode.action.kind === "confirm-install")
363
+ || ["approve", "remove", "uninstall-confirm", "uninstall-manual"].includes(mode.kind);
364
+ const panel = (_jsxs(Box, { flexDirection: "column", children: [loadError && !pagedPreview ? _jsxs(Text, { color: theme.error, children: ["Error loading App recipes: ", loadError] }) : null, error && !pagedPreview ? _jsx(Text, { color: theme.error, children: error }) : null, notice && !pagedPreview ? _jsx(Text, { color: theme.success, children: notice }) : null, mode.kind === "guide" ? (_jsxs(BorderPanel, { title: "Add an App recipe", children: [_jsx(Text, { children: "1 With the AI skill \u00B7 install if missing, then copy the prompt" }), _jsx(Text, { children: "2 Manual \u00B7 write a recipe using the schema" }), _jsx(Text, { children: "n or Esc closes" })] })) : null, mode.kind === "manual" ? (_jsxs(BorderPanel, { title: "Write an App recipe", children: [_jsxs(Text, { children: ["Write a JSON recipe in ", apps.directory] }), _jsx(Text, { children: "Schema: agent-depot app schema \u00B7 n or Esc closes" })] })) : null, mode.kind === "prompt" ? (_jsxs(BorderPanel, { title: "App recipe authoring prompt", children: [_jsx(Text, { children: "Use the agent-depot-apprecipe skill to create the recipe for <app>" }), _jsxs(Text, { children: ["Recipes: ", apps.directory, " \u00B7 n or Esc closes"] })] })) : null, mode.kind === "install" ? _jsx(ActionPanel, { mode: mode.action }) : null, mode.kind === "busy" ? _jsx(BorderPanel, { title: "App recipe workflow", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) }) : null, mode.kind === "approve" ? (() => {
348
365
  const chrome = recipeApprovalChrome(mode.entry);
349
- const viewport = previewViewport(mode.lines, mode.cursor, terminalRows, terminalColumns, chrome);
350
- return _jsx(PreviewConfirm, { title: chrome.title, lines: mode.lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
366
+ const lines = mode.lines.map(lineText);
367
+ return _jsx(PagedPreview, { lines: lines, styled: mode.lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
351
368
  })() : null, mode.kind === "pick" ? (_jsxs(BorderPanel, { title: "Remove which recipe?", children: [mode.members.map((row, index) => _jsxs(Text, { children: [index + 1, " ", recipeMemberLine(row)] }, row.entry.file)), _jsx(Text, { color: theme.legend, children: "Press a number to review that one file for removal \u00B7 n or Esc cancels" })] })) : null, mode.kind === "uninstall-question" ? (_jsxs(BorderPanel, { title: "Uninstall before recipe removal", children: [_jsxs(Text, { children: ["App ", appName(mode.removal.entry), " is installed or tracked", mode.removal.installedVersion ? ` (${mode.removal.installedVersion})` : "", "."] }), _jsxs(Text, { children: [_jsx(DecisionPrompt, { children: "Uninstall it before removing the recipe?" }), _jsx(Text, { color: theme.legend, children: "(n cancels removal)" })] })] })) : null, mode.kind === "uninstall-confirm" || mode.kind === "uninstall-manual" ? (() => {
352
369
  const chrome = uninstallChrome(mode);
353
- const lines = appStepLines(mode.plan);
354
- const viewport = previewViewport(lines, mode.cursor, terminalRows, terminalColumns, chrome);
355
- return _jsx(PreviewConfirm, { title: chrome.title, lines: lines, cursor: mode.cursor, question: viewport.reachedEnd ? chrome.questions[0] : chrome.questions[1], chrome: chrome });
356
- })() : null, mode.kind === "remove" ? (_jsx(PreviewConfirm, { title: "Recipe removal", lines: [`Delete recipe: ${mode.removal.file}`, "Only this recipe file or symlink will be removed; App data and Host configuration are not deleted."], question: `Remove recipe for ${appName(mode.removal.entry)}? y/n` })) : null] }));
370
+ const styled = appPlanLines(mode.plan);
371
+ const lines = styled.map(lineText);
372
+ return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: previewGeometry(lines, mode.cursor, chrome) });
373
+ })() : null, mode.kind === "remove" ? (() => {
374
+ const chrome = removeChrome(mode.removal);
375
+ const lines = removeLines(mode.removal);
376
+ return _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
377
+ })() : null] }));
357
378
  return { rows, loadingSince, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
358
379
  }