@iseoane/agent-depot 0.6.13 → 0.6.14

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/CHANGELOG.md +32 -0
  2. package/README.md +2 -1
  3. package/dist/src/flow-progress.js +6 -0
  4. package/dist/src/git-source.js +18 -6
  5. package/dist/src/preview-model.js +34 -0
  6. package/dist/src/process-runner.js +1 -0
  7. package/dist/src/profile-cli.js +12 -4
  8. package/dist/src/profile-export.js +23 -3
  9. package/dist/src/skill-update.js +1 -0
  10. package/dist/src/source-refresh-session.js +139 -0
  11. package/dist/src/sources.js +4 -4
  12. package/dist/src/tui/adoption-actions.js +21 -2
  13. package/dist/src/tui/app-actions.js +54 -45
  14. package/dist/src/tui/app.js +22 -4
  15. package/dist/src/tui/bulk-actions.js +42 -9
  16. package/dist/src/tui/catalog-actions.js +12 -0
  17. package/dist/src/tui/catalog-flow.js +31 -11
  18. package/dist/src/tui/catalog-panel.js +15 -13
  19. package/dist/src/tui/catalog-tree.js +7 -2
  20. package/dist/src/tui/catalog-view.js +12 -6
  21. package/dist/src/tui/file-picker.js +2 -1
  22. package/dist/src/tui/installations-panel.js +12 -4
  23. package/dist/src/tui/installations-rows.js +1 -1
  24. package/dist/src/tui/installations-view.js +16 -7
  25. package/dist/src/tui/keys.js +27 -4
  26. package/dist/src/tui/manage-actions.js +55 -27
  27. package/dist/src/tui/manage-panel.js +15 -29
  28. package/dist/src/tui/mode-keys.js +11 -1
  29. package/dist/src/tui/package-actions.js +25 -18
  30. package/dist/src/tui/package-rows.js +2 -1
  31. package/dist/src/tui/panel-parts.js +45 -36
  32. package/dist/src/tui/preview-render.js +93 -0
  33. package/dist/src/tui/preview-window.js +141 -10
  34. package/dist/src/tui/profile-view.js +49 -40
  35. package/dist/src/tui/render.js +11 -1
  36. package/dist/src/tui/safe-text.js +25 -0
  37. package/dist/src/tui/sources-mode.js +2 -2
  38. package/dist/src/tui/sources-recipes.js +53 -32
  39. package/dist/src/tui/sources-view.js +86 -39
  40. package/dist/src/tui/unmanaged-actions.js +17 -5
  41. package/dist/src/tui/updates-flow.js +11 -5
  42. package/dist/src/tui/updates-keys.js +2 -2
  43. package/dist/src/tui/updates-model.js +5 -2
  44. package/dist/src/tui/updates-panel.js +10 -9
  45. package/dist/src/tui/updates-preview.js +27 -44
  46. package/dist/src/tui/updates-view.js +8 -10
  47. package/dist/src/tui/updates.js +117 -33
  48. package/dist/src/update-flow.js +31 -15
  49. package/package.json +1 -1
@@ -1,6 +1,46 @@
1
+ import { useWindowSize } from "ink";
1
2
  import stringWidth from "string-width";
2
3
  import { headerTabRowCount } from "./header-layout.js";
3
4
  const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+ // Text shown in the TUI comes from recipes, Sources, Skills and Hosts. Raw, an ESC or carriage return redraws the terminal over the
6
+ // lines under review and a bidi override reorders them; both also change the width the `y` gate was computed for. The zero-width joiner
7
+ // stays because emoji sequences such as a person at a laptop are made with it.
8
+ const UNSAFE_PATTERN = "(?!\\u200D)[\\p{Cc}\\p{Cf}\\u2028\\u2029]";
9
+ const UNSAFE_CHARACTER = new RegExp(UNSAFE_PATTERN, "u");
10
+ // A backslash run before text shaped like an escape we print (`\x1b`, `\u202e`, `\u{1f600}`), or before a character we escape.
11
+ const ESCAPE_TEXT = "x[0-9a-f]{2}|u[0-9a-f]{4}|u\\{[0-9a-f]{1,6}\\}";
12
+ const ESCAPABLE = new RegExp(`(\\\\+)(?:(${ESCAPE_TEXT})|(?=${UNSAFE_PATTERN}))|${UNSAFE_PATTERN}`, "giu");
13
+ /** Whether `escape` (`x1b`, `u202e`, `u{1f600}`) names a character this module would print as an escape. */
14
+ function namesEscapedCharacter(escape) {
15
+ const code = Number.parseInt(escape.replace(/^[xu]\{?|\}$/gu, ""), 16);
16
+ return code <= 0x10ffff && UNSAFE_CHARACTER.test(String.fromCodePoint(code));
17
+ }
18
+ /**
19
+ * `text` with control, format and line-separator characters replaced by visible escapes (`\x1b`, `\u202e`). A backslash run right
20
+ * before text that looks like one of those escapes is doubled, so a typed `\x1b` cannot pass for a real ESC; other backslashes,
21
+ * such as those of a Windows path, are left alone. Not idempotent: escape text once, where it enters a view.
22
+ */
23
+ export function escapeControlText(text) {
24
+ return text.replace(ESCAPABLE, (match, backslashes, typed) => {
25
+ if (backslashes !== undefined) {
26
+ return typed !== undefined && !namesEscapedCharacter(typed) ? match : backslashes.repeat(2) + (typed ?? "");
27
+ }
28
+ const code = match.codePointAt(0);
29
+ const hex = code.toString(16);
30
+ if (code <= 0xff)
31
+ return `\\x${hex.padStart(2, "0")}`;
32
+ return code <= 0xffff ? `\\u${hex.padStart(4, "0")}` : `\\u{${hex}}`;
33
+ });
34
+ }
35
+ function escapeChrome(chrome) {
36
+ return {
37
+ ...chrome,
38
+ title: escapeControlText(chrome.title),
39
+ ...(chrome.action === undefined ? {} : { action: escapeControlText(chrome.action) }),
40
+ ...(chrome.consequence === undefined ? {} : { consequence: escapeControlText(chrome.consequence) }),
41
+ ...(chrome.cancel === undefined ? {} : { cancel: escapeControlText(chrome.cancel) }),
42
+ };
43
+ }
4
44
  function wrapLine(line, width) {
5
45
  const segments = [...graphemes.segment(line)];
6
46
  if (segments.length === 0)
@@ -23,29 +63,120 @@ function wrapLine(line, width) {
23
63
  wrapped.push({ row, offset: rowOffset });
24
64
  return wrapped;
25
65
  }
26
- export function previewViewport(lines, cursor, terminalRows, terminalColumns, chrome = { title: "Confirmation", questions: ["Scroll to end before y; n cancels.", "Confirm? y/n"] }) {
66
+ const MAX_BAR_WIDTH = 12;
67
+ /** The consequence with its `y/n` keys picked out, as in a one-screen confirmation. */
68
+ function consequenceSpans(consequence) {
69
+ const marker = consequence.indexOf("y/n");
70
+ if (marker < 0)
71
+ return [{ text: consequence, role: "consequence" }];
72
+ return [
73
+ { text: consequence.slice(0, marker), role: "consequence" },
74
+ { text: "y", role: "yes" },
75
+ { text: "/", role: "position" },
76
+ { text: "n", role: "no" },
77
+ ...(marker + 3 < consequence.length ? [{ text: consequence.slice(marker + 3), role: "position" }] : []),
78
+ ];
79
+ }
80
+ /** The progress line and the action line, before wrapping. */
81
+ function footerSpans(state, chrome, width) {
82
+ const range = (total, start, end) => ` · ${start}–${end} of ${total}`;
83
+ const position = (cursor, start, end) => `Review ${cursor} of ${state.total}${state.windowed ? range(state.total, start, end) : ""}`;
84
+ // Sized for the widest position text so the bar, and so the footer height, never changes while paging.
85
+ const widest = position(state.total, state.total, state.total);
86
+ const barWidth = Math.min(Math.max(width - stringWidth(`read 100% · ${widest}`), 0), MAX_BAR_WIDTH);
87
+ const read = state.total === 0 ? 1 : state.end / state.total;
88
+ const filled = Math.floor(read * barWidth);
89
+ const bar = barWidth === 0 ? "" : `${"█".repeat(filled)}${"░".repeat(barWidth - filled)} `;
90
+ const progress = state.reachedEnd
91
+ ? [{ text: "read ✓", role: "read" }]
92
+ : [{ text: `read ${bar}${Math.floor(read * 100)}%`, role: "reading" }];
93
+ progress.push({ text: ` · ${position(state.cursor + 1, state.start + 1, state.end)}`, role: "position" });
94
+ if (!state.reachedEnd)
95
+ progress.push({ text: " · arrows/Pg page", role: "position" });
96
+ else if (chrome.consequence !== undefined)
97
+ progress.push({ text: " · ", role: "position" }, ...consequenceSpans(chrome.consequence));
98
+ const actions = chrome.action === undefined ? [] : [{ text: `[y] ${chrome.action}`, role: "action" }, { text: " ", role: "cancel" }];
99
+ actions.push({ text: chrome.cancel ?? "[n] cancel", role: "cancel" });
100
+ return [progress, actions];
101
+ }
102
+ /** The spans covering `length` UTF-16 units of their joined text from `offset`. */
103
+ export function sliceSpans(spans, offset, length) {
104
+ const end = offset + length;
105
+ let spanStart = 0;
106
+ const visible = [];
107
+ for (const span of spans) {
108
+ const spanEnd = spanStart + span.text.length;
109
+ const start = Math.max(offset, spanStart);
110
+ const stop = Math.min(end, spanEnd);
111
+ if (start < stop)
112
+ visible.push({ ...span, text: span.text.slice(start - spanStart, stop - spanStart) });
113
+ spanStart = spanEnd;
114
+ }
115
+ return visible;
116
+ }
117
+ function wrapSpans(spans, width) {
118
+ return wrapLine(spans.map(({ text }) => text).join(""), width).map(({ row, offset }) => sliceSpans(spans, offset, row.length));
119
+ }
120
+ /** `rows` cut to `maxRows`, the last kept row ending in an ellipsis when something was cut. */
121
+ function clipRows(rows, maxRows) {
122
+ if (rows.length <= maxRows)
123
+ return rows;
124
+ const last = [...rows[maxRows - 1]];
125
+ const tail = last.pop();
126
+ const shortened = [...graphemes.segment(tail.text)].slice(0, -1).map(({ segment }) => segment).join("");
127
+ return [...rows.slice(0, maxRows - 1), [...last, ...(shortened ? [{ ...tail, text: shortened }] : []), { ...tail, text: "…" }]];
128
+ }
129
+ /**
130
+ * The footer lines wrapped to `width` within `maxRows`: the progress row, which carries the
131
+ * consequence, keeps all but one row if it needs them, the action row the rest. A cut row ends in an
132
+ * ellipsis instead of pushing the panel past the terminal.
133
+ */
134
+ function layoutFooter([progressLine, actionLine], width, maxRows) {
135
+ const progress = wrapSpans(progressLine, width);
136
+ const actions = wrapSpans(actionLine, width);
137
+ const progressRows = Math.min(progress.length, Math.max(maxRows - 1, 1));
138
+ return [...clipRows(progress, progressRows), ...clipRows(actions, Math.max(maxRows - progressRows, 1))];
139
+ }
140
+ /** Footer rows for the widest position and both reading states, so paging never changes the height. */
141
+ function reservedFooterRows(total, windowed, chrome, width, maxRows) {
142
+ const widest = { cursor: Math.max(total - 1, 0), start: Math.max(total - 1, 0), end: total, total, windowed };
143
+ return Math.max(layoutFooter(footerSpans({ ...widest, reachedEnd: false }, chrome, width), width, maxRows).length, layoutFooter(footerSpans({ ...widest, reachedEnd: true }, chrome, width), width, maxRows).length);
144
+ }
145
+ export function previewViewport(rawLines, cursor, terminalRows, terminalColumns, rawChrome) {
146
+ const lines = rawLines.map(escapeControlText);
147
+ const chrome = escapeChrome(rawChrome);
27
148
  const contentWidth = Math.max(terminalColumns - 8, 1);
28
149
  const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth).map(({ row, offset }) => ({ row, sourceLine, offset })));
29
150
  const previewRows = wrapped.map(({ row }) => row);
151
+ const total = previewRows.length;
30
152
  const headerRows = headerTabRowCount(contentWidth) + 3;
31
153
  const titleRows = wrapLine(chrome.title, contentWidth).length;
32
- const questionRows = Math.max(...chrome.questions.map(question => wrapLine(question, contentWidth).length));
33
- const panelChromeRows = 2 + titleRows + questionRows + (chrome.footerRows ?? 1);
34
154
  const reservedRows = chrome.reservedRows !== undefined && chrome.reservedRows > 0 ? Math.ceil(chrome.reservedRows) : 0;
35
- const availableRows = Math.max(terminalRows - headerRows - 1 - panelChromeRows - reservedRows, 1);
36
- const height = previewRows.length > availableRows ? Math.max(availableRows - 1, 1) : availableRows;
37
- const selected = Math.min(Math.max(cursor, 0), Math.max(previewRows.length - 1, 0));
38
- const start = Math.min(Math.max(selected - Math.floor(height / 2), 0), Math.max(previewRows.length - height, 0));
39
- const end = Math.min(start + height, previewRows.length);
155
+ const fixedRows = headerRows + 1 + 2 + titleRows + reservedRows;
156
+ // At least one preview row stays visible; the footer is cut to what remains.
157
+ const maxFooterRows = Math.max(terminalRows - fixedRows - 1, 2);
158
+ const rowsWithFooter = (windowed) => Math.max(terminalRows - fixedRows - reservedFooterRows(total, windowed, chrome, contentWidth, maxFooterRows), 1);
159
+ const windowed = total > rowsWithFooter(false);
160
+ const height = rowsWithFooter(windowed);
161
+ const selected = Math.min(Math.max(cursor, 0), Math.max(total - 1, 0));
162
+ const start = Math.min(Math.max(selected - Math.floor(height / 2), 0), Math.max(total - height, 0));
163
+ const end = Math.min(start + height, total);
164
+ const reachedEnd = end === total;
40
165
  return {
41
166
  rows: previewRows.slice(start, end),
42
167
  sourceLines: wrapped.slice(start, end).map(({ sourceLine }) => sourceLine),
43
168
  sourceOffsets: wrapped.slice(start, end).map(({ offset }) => offset),
44
169
  start,
45
170
  end,
46
- total: previewRows.length,
171
+ total,
47
172
  cursor: selected,
48
173
  height,
49
- reachedEnd: end === previewRows.length,
174
+ reachedEnd,
175
+ footer: layoutFooter(footerSpans({ reachedEnd, cursor: selected, start, end, total, windowed }, chrome, contentWidth), contentWidth, maxFooterRows),
50
176
  };
51
177
  }
178
+ /** The viewport of a preview at the current terminal size; key handling and rendering share it. */
179
+ export function usePreviewGeometry() {
180
+ const { rows, columns } = useWindowSize();
181
+ return (lines, cursor, chrome) => previewViewport(lines, cursor, rows || 24, columns || 80, chrome);
182
+ }
@@ -1,20 +1,22 @@
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";
19
+ import { BorderPanel, PagedPreview, PreviewLines, Working } from "./panel-parts.js";
18
20
  import { clipText, elideMiddle, fitLines, wrapText } from "./text-wrap.js";
19
21
  import { theme } from "./theme.js";
20
22
  import { useMirrored, useMounted } from "./view-state.js";
@@ -136,6 +138,7 @@ 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
@@ -143,14 +146,16 @@ const NAME_HINT = "Type to edit · Ctrl+U clear · Enter continue · Esc back to
143
146
  * three or more a path keeps its first row too, around a count of the rows left out.
144
147
  */
145
148
  function NameField({ label, value, rows, width, tail = false }) {
146
- const wrapped = wrapText(`${label}${value}`, width);
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);
158
+ const characters = Array.from(escapedValue);
154
159
  for (let dropped = 1; dropped <= characters.length; dropped += 1) {
155
160
  shown = wrapText(`${label}…${characters.slice(dropped).join("")}`, width);
156
161
  if (shown.length <= rows)
@@ -159,9 +164,9 @@ function NameField({ label, value, rows, width, tail = false }) {
159
164
  }
160
165
  return _jsx(_Fragment, { children: shown.map((line, index) => {
161
166
  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);
167
+ return _jsxs(PreEscapedText, { children: [_jsx(PreEscapedText, { color: theme.field, children: label }), _jsx(PreEscapedText, { color: theme.value, children: line.slice(label.length) })] }, index);
163
168
  }
164
- return _jsx(Text, { color: keepsMiddleCount && index === 1 ? theme.muted : theme.value, children: line }, index);
169
+ return _jsx(PreEscapedText, { color: keepsMiddleCount && index === 1 ? theme.muted : theme.value, children: line }, index);
165
170
  }) });
166
171
  }
167
172
  function planEntries(plan) {
@@ -238,7 +243,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
238
243
  const [profile, setProfile] = useState();
239
244
  const [plan, setPlan] = useState();
240
245
  const diagnostics = useRef([]);
241
- const { rows: terminalRows, columns: terminalColumns } = useWindowSize();
246
+ const { columns: terminalColumns } = useWindowSize();
242
247
  const panelWidth = Math.max((terminalColumns || 80) - 4, 1);
243
248
  const hintCursorRow = listRows(mode, rows, expandedGroupsRef.current)[cursorRef.current];
244
249
  const enterHint = hintCursorRow === undefined ? undefined
@@ -255,13 +260,17 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
255
260
  const titleRows = Math.max(Math.ceil(stringWidth("Export Profile") / panelWidth), 1);
256
261
  const hintRows = Math.max(Math.ceil(stringWidth(hintText) / panelWidth), 1);
257
262
  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
- });
263
+ const previewGeometry = usePreviewGeometry();
264
+ const confirmChrome = () => {
265
+ const request = confirmRequest.current;
266
+ if (request?.kind !== "export")
267
+ return { title: "Confirmation", action: "Apply Profile", consequence: "Apply Profile? (y/n)" };
268
+ if (!request.overwrite)
269
+ return { title: "Confirmation", action: "Save/Export Profile", consequence: "Save/Export Profile? (y/n)" };
270
+ // The full path is on the Destination line; the base name keeps the footer short on narrow terminals.
271
+ const name = path.basename(request.file);
272
+ return { title: "Confirmation", action: `Overwrite ${name}`, consequence: `Overwrite ${name}? (y/n)` };
273
+ };
265
274
  const showLines = (next, nextMode) => {
266
275
  setLines(next);
267
276
  cursorTo(0);
@@ -348,6 +357,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
348
357
  let exists;
349
358
  try {
350
359
  exists = await existsCheck(finalPath);
360
+ if (exists)
361
+ await requireOverwritableProfileExport(finalPath);
351
362
  }
352
363
  catch (error) {
353
364
  if (!mounted.current || nameRequest.current !== id || modeRef.current !== "export-name")
@@ -363,13 +374,10 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
363
374
  return;
364
375
  if (current.kind !== "ok" || current.fileName !== fileName)
365
376
  return;
366
- if (exists) {
367
- setNameError(`${finalPath} already exists; choose another name`);
368
- return;
369
- }
370
- confirmRequest.current = { kind: "export", file: finalPath };
377
+ confirmRequest.current = { kind: "export", file: finalPath, overwrite: exists };
371
378
  setNameError(undefined);
372
- showLines([`Destination: ${finalPath}`, ...profileExportPreview(chosenProfile(), diagnostics.current)], "confirm");
379
+ const destination = exists ? `Destination: ${finalPath} (exists; it will be replaced)` : `Destination: ${finalPath}`;
380
+ showLines([destination, ...profileExportPreview(chosenProfile(), diagnostics.current)], "confirm");
373
381
  })();
374
382
  };
375
383
  const confirmApply = () => {
@@ -379,7 +387,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
379
387
  void run(request.kind === "export" ? "Exporting…" : "Applying import…", async () => {
380
388
  let result;
381
389
  if (request.kind === "export") {
382
- await writeProfileExport(request.file, chosenProfile());
390
+ await writeProfileExport(request.file, chosenProfile(), { overwrite: request.overwrite });
383
391
  result = [`Exported profile to ${request.file}`];
384
392
  }
385
393
  else {
@@ -495,20 +503,21 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
495
503
  setMode("export-list");
496
504
  return;
497
505
  }
498
- const next = movePreviewCursor(cursorRef.current, lines.length, input, key, height);
506
+ const viewport = previewGeometry(lines, cursorRef.current, NOTE_CHROME);
507
+ const next = movePreviewCursor(cursorRef.current, viewport.total, input, key, viewport.height);
499
508
  if (next !== undefined)
500
509
  cursorTo(Math.max(0, next));
501
510
  return;
502
511
  }
503
512
  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
513
  // 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());
514
+ const viewport = previewGeometry(lines, cursorRef.current, confirmChrome());
509
515
  const next = movePreviewCursor(cursorRef.current, viewport.total, input, key, viewport.height);
510
516
  if (next !== undefined)
511
517
  cursorTo(Math.max(0, next));
518
+ else if (!isYes(input, key) || viewport.reachedEnd) {
519
+ answerYesNo(input, key, confirmApply, () => setMode(confirmRequest.current?.kind === "import" ? "import-list" : "export-name"));
520
+ }
512
521
  return;
513
522
  }
514
523
  if (key.escape) {
@@ -574,7 +583,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
574
583
  if ("kind" in row)
575
584
  return 1;
576
585
  const prefixWidth = row.block === "excluded" ? 2 : stringWidth(`> [ ] ${row.ordinal ?? row.index + 1} `);
577
- return wrapText(row.label, Math.max(panelWidth - prefixWidth, 1)).length;
586
+ return wrapText(escapeControlText(row.label), Math.max(panelWidth - prefixWidth, 1)).length;
578
587
  });
579
588
  const listWindow = physicalListWindow(displayRows, rowHeights, cursor, height);
580
589
  const window = listing ? listWindow : computeWindow(lines.length, cursor, height);
@@ -592,24 +601,24 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
592
601
  const prefix = `${marker}${" ".repeat(row.depth * 2)}${open ? "▾" : "▸"} `;
593
602
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
594
603
  remainingRows -= 1;
595
- return _jsxs(Text, { color: theme.group, bold: true, children: [prefix, clipText(row.label, width)] }, row.id);
604
+ return _jsxs(PreEscapedText, { color: theme.group, bold: true, children: [prefix, clipText(escapeControlText(row.label), width)] }, row.id);
596
605
  }
597
606
  if (row.block === "excluded") {
598
607
  const width = Math.max(panelWidth - stringWidth(marker), 1);
599
- const label = fitLines(wrapText(row.label, width), remainingRows, width)
608
+ const label = fitLines(wrapText(escapeControlText(row.label), width), remainingRows, width)
600
609
  .join(`\n${" ".repeat(stringWidth(marker))}`);
601
610
  remainingRows -= label.split("\n").length;
602
- return _jsxs(Text, { color: theme.value, children: [marker, label] }, row.id);
611
+ return _jsxs(PreEscapedText, { color: theme.value, children: [marker, label] }, row.id);
603
612
  }
604
613
  // A row the import never changes has no checkbox: unchanged items rest, conflicts warn.
605
614
  const box = row.selectable ? `[${selected.some(choice => choice.id === row.id) ? "x" : " "}]` : " ";
606
615
  const prefix = `${marker}${box} ${row.ordinal ?? row.index + 1} `;
607
616
  const width = Math.max(panelWidth - stringWidth(prefix), 1);
608
- const label = fitLines(wrapText(row.label, width), remainingRows, width)
617
+ const label = fitLines(wrapText(escapeControlText(row.label), width), remainingRows, width)
609
618
  .join(`\n${" ".repeat(stringWidth(prefix))}`);
610
619
  remainingRows -= label.split("\n").length;
611
620
  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);
621
+ return _jsxs(PreEscapedText, { children: [_jsx(PreEscapedText, { color: theme.field, children: prefix }), _jsx(PreEscapedText, { color: color, children: label })] }, row.id);
613
622
  });
614
623
  const nameOutcome = resolveExportFileName(text);
615
624
  const liveDestination = nameOutcome.kind === "ok"
@@ -617,8 +626,8 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
617
626
  const nameNote = nameOutcome.kind === "ok" ? fileNameNotice(nameOutcome.fileName) : undefined;
618
627
  const extensionNote = nameOutcome.kind === "ok" && nameOutcome.appendedExtension
619
628
  ? `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);
629
+ const textRows = (value) => value === undefined ? 0 : wrapText(escapeControlText(value), panelWidth).length;
630
+ const nameErrorLines = nameError === undefined ? [] : wrapText(escapeControlText(nameError), panelWidth);
622
631
  // Keys type into the name, so the stage cannot scroll; instead each long field keeps its end (and, given
623
632
  // three rows or more, its first row) within a share of the terminal. The confirmation pages the full destination.
624
633
  const nameFixedRows = 3 + textRows(nameNote) + textRows(extensionNote) + Math.min(nameErrorLines.length, NAME_ERROR_ROWS)
@@ -629,7 +638,7 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
629
638
  const destinationRows = Math.floor(fieldRows / 3) + (fieldRows % 3 >= 1 ? 1 : 0);
630
639
  const nameRows = fieldRows - folderRows - destinationRows;
631
640
  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] });
641
+ 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
642
+ ? _jsx(PreEscapedText, { color: theme.error, children: escapeControlText(nameError) })
643
+ : 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, { chrome: confirmChrome(), lines: lines, viewport: previewGeometry(lines, cursor, confirmChrome()) }) : null, mode === "detail" ? _jsx(PagedPreview, { chrome: NOTE_CHROME, 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
644
  }
@@ -1,8 +1,18 @@
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,
9
+ // Test-only opt-out (tests/isolated-env.ts); manual and preview refreshes are unaffected.
10
+ refreshOnStart: process.env.AGENT_DEPOT_NO_BACKGROUND_REFRESH !== "1" }), { alternateScreen: true });
7
11
  await instance.waitUntilExit();
12
+ refreshSession.stop();
13
+ // Killing a refresh would leave its Source cache lock behind, so wait for the running ones instead.
14
+ if ([...refreshSession.snapshot().values()].some((state) => state.status === "refreshing")) {
15
+ process.stderr.write("Waiting for the Source refreshes still running to finish…\n");
16
+ await refreshSession.settled();
17
+ }
8
18
  }
@@ -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":