@iseoane/agent-depot 0.6.12 → 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.
- package/CHANGELOG.md +45 -0
- package/README.md +2 -1
- package/dist/src/flow-progress.js +6 -0
- package/dist/src/git-source.js +18 -6
- package/dist/src/preview-model.js +34 -0
- package/dist/src/process-runner.js +1 -0
- package/dist/src/profile-cli.js +12 -4
- package/dist/src/profile-export.js +23 -3
- package/dist/src/self-update.js +9 -2
- package/dist/src/skill-update.js +1 -0
- package/dist/src/source-refresh-session.js +139 -0
- package/dist/src/sources.js +4 -4
- package/dist/src/tui/adoption-actions.js +21 -2
- package/dist/src/tui/app-actions.js +54 -45
- package/dist/src/tui/app.js +22 -4
- package/dist/src/tui/bulk-actions.js +42 -9
- package/dist/src/tui/catalog-actions.js +12 -0
- package/dist/src/tui/catalog-flow.js +31 -11
- package/dist/src/tui/catalog-panel.js +15 -13
- package/dist/src/tui/catalog-tree.js +7 -2
- package/dist/src/tui/catalog-view.js +12 -6
- package/dist/src/tui/file-picker.js +2 -1
- package/dist/src/tui/installations-panel.js +12 -4
- package/dist/src/tui/installations-rows.js +1 -1
- package/dist/src/tui/installations-view.js +16 -7
- package/dist/src/tui/keys.js +27 -4
- package/dist/src/tui/manage-actions.js +55 -27
- package/dist/src/tui/manage-panel.js +15 -29
- package/dist/src/tui/mode-keys.js +11 -1
- package/dist/src/tui/package-actions.js +25 -18
- package/dist/src/tui/package-rows.js +2 -1
- package/dist/src/tui/panel-parts.js +45 -36
- package/dist/src/tui/preview-render.js +93 -0
- package/dist/src/tui/preview-window.js +141 -10
- package/dist/src/tui/profile-view.js +49 -40
- package/dist/src/tui/render.js +11 -1
- package/dist/src/tui/safe-text.js +25 -0
- package/dist/src/tui/sources-mode.js +2 -2
- package/dist/src/tui/sources-recipes.js +53 -32
- package/dist/src/tui/sources-view.js +86 -39
- package/dist/src/tui/unmanaged-actions.js +17 -5
- package/dist/src/tui/updates-flow.js +11 -5
- package/dist/src/tui/updates-keys.js +2 -2
- package/dist/src/tui/updates-model.js +5 -2
- package/dist/src/tui/updates-panel.js +10 -9
- package/dist/src/tui/updates-preview.js +27 -44
- package/dist/src/tui/updates-view.js +8 -10
- package/dist/src/tui/updates.js +117 -33
- package/dist/src/update-flow.js +31 -15
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { commandLine, itemWarnings } from "../preview-model.js";
|
|
2
|
+
import { theme } from "./theme.js";
|
|
3
|
+
export const span = (text, color, bold = false) => bold ? { text, color, bold } : { text, color };
|
|
4
|
+
// Ink drops the height of an empty Text, so a blank separator keeps one space.
|
|
5
|
+
const BLANK = [span(" ", theme.value)];
|
|
6
|
+
export function lineText(line) {
|
|
7
|
+
return line.map(({ text }) => text).join("");
|
|
8
|
+
}
|
|
9
|
+
export function textLine(text) {
|
|
10
|
+
return [span(text, theme.value)];
|
|
11
|
+
}
|
|
12
|
+
export function fieldLine(label, value) {
|
|
13
|
+
return [span(label, theme.field), span(` ${value}`, theme.value)];
|
|
14
|
+
}
|
|
15
|
+
const ROW_INDENT = " ";
|
|
16
|
+
// The views escape control characters once, as they draw.
|
|
17
|
+
const TERMINAL = { terminal: true };
|
|
18
|
+
// A recipe's manual text can span lines; escaped inside one line, its breaks would show as `\x0a`.
|
|
19
|
+
const LINE_BREAK = /\r\n|\r|\n/u;
|
|
20
|
+
function rowLines(row) {
|
|
21
|
+
switch (row.kind) {
|
|
22
|
+
case "field": {
|
|
23
|
+
const [first = "", ...rest] = row.value.split(LINE_BREAK);
|
|
24
|
+
return [
|
|
25
|
+
[span(`${ROW_INDENT}${row.label}:`, theme.field), ...(first === "" ? [] : [span(` ${first}`, theme.value)])],
|
|
26
|
+
...rest.map((line) => [span(`${ROW_INDENT}${line}`, theme.value)]),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
case "text":
|
|
30
|
+
return [[span(`${ROW_INDENT}${" ".repeat(row.indent ?? 0)}${row.text}`, theme.value)]];
|
|
31
|
+
case "warning":
|
|
32
|
+
return [[span(`${ROW_INDENT}⚠ ${row.text}`, theme.warning)]];
|
|
33
|
+
case "command": {
|
|
34
|
+
const where = [row.executable, row.cwd === undefined ? undefined : `in ${row.cwd}`].filter((part) => part !== undefined);
|
|
35
|
+
return [
|
|
36
|
+
[span(`${ROW_INDENT}$ ${commandLine(row.argv, TERMINAL)}`, theme.value)],
|
|
37
|
+
...(where.length === 0 ? [] : [[span(`${ROW_INDENT}${where.join(" · ")}`, theme.legend)]]),
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function itemLines(item) {
|
|
43
|
+
const title = [span(` ${item.title}`, theme.value, true)];
|
|
44
|
+
if (item.detail !== undefined)
|
|
45
|
+
title.push(span(` ${item.detail}`, theme.legend));
|
|
46
|
+
if (item.version !== undefined)
|
|
47
|
+
title.push(span(` ${item.version.from} → ${item.version.to}`, theme.version));
|
|
48
|
+
return [title, ...item.rows.flatMap(rowLines)];
|
|
49
|
+
}
|
|
50
|
+
/** One block per step joined by a tree gutter: what the step runs or asks for, then dimmed where it runs. */
|
|
51
|
+
export function stepBlockLines(steps) {
|
|
52
|
+
return steps.flatMap((step, index) => {
|
|
53
|
+
const last = index === steps.length - 1;
|
|
54
|
+
const branch = last ? "└" : index === 0 ? "┌" : "├";
|
|
55
|
+
const row = (...spans) => [span(last ? " " : " │ ", theme.field), ...spans];
|
|
56
|
+
const where = [`in ${step.cwd}`, ...(step.environment ? [`on ${step.environment}`] : [])].join(" · ");
|
|
57
|
+
return [
|
|
58
|
+
[span(` ${branch} ${step.step}`, theme.field)],
|
|
59
|
+
...(step.argv ? [row(span(`$ ${commandLine(step.argv, TERMINAL)}`, theme.value))] : []),
|
|
60
|
+
...(step.manual ? step.manual.split(LINE_BREAK).map((line, position) => row(span(position === 0 ? `Manual: ${line}` : line, theme.value))) : []),
|
|
61
|
+
...(step.declaration ? [row(span(step.declaration, theme.value))] : []),
|
|
62
|
+
...(step.pattern ? [row(span(`Version pattern: ${step.pattern}`, theme.value))] : []),
|
|
63
|
+
step.argv && step.executable === undefined
|
|
64
|
+
? row(span(`${commandLine(step.argv.slice(0, 1), TERMINAL)} not found in PATH`, theme.warning), span(` · ${where}`, theme.legend))
|
|
65
|
+
: row(span(step.executable ? `${step.executable} · ${where}` : where, theme.legend)),
|
|
66
|
+
...(step.warning ? [row(span(`⚠ ${step.warning}`, theme.warning))] : []),
|
|
67
|
+
];
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** The document as preview lines: action and counts, context, every warning, one section per type, then the failures. */
|
|
71
|
+
export function renderPreviewDocument(document) {
|
|
72
|
+
const warnings = [
|
|
73
|
+
...document.warnings,
|
|
74
|
+
...document.sections.flatMap(({ items }) => items.flatMap((item) => itemWarnings(item).map((warning) => `${item.title}: ${warning}`))),
|
|
75
|
+
];
|
|
76
|
+
const lines = [
|
|
77
|
+
[span(document.action, theme.accent, true), span(` ${document.summary}`, theme.legend)],
|
|
78
|
+
...document.context.map((line) => [span(` ${line}`, theme.legend)]),
|
|
79
|
+
...warnings.map((warning) => [span(` ⚠ ${warning}`, theme.warning)]),
|
|
80
|
+
];
|
|
81
|
+
for (const section of document.sections) {
|
|
82
|
+
lines.push(BLANK, [span(section.heading, theme.field, true)]);
|
|
83
|
+
section.items.forEach((item, position) => {
|
|
84
|
+
if (position > 0)
|
|
85
|
+
lines.push(BLANK);
|
|
86
|
+
lines.push(...itemLines(item));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (document.failures.length > 0) {
|
|
90
|
+
lines.push(BLANK, [span("FAILED", theme.error, true)], ...document.failures.map((failure) => [span(` ${failure}`, theme.error)]));
|
|
91
|
+
}
|
|
92
|
+
return lines;
|
|
93
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const
|
|
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
|
|
171
|
+
total,
|
|
47
172
|
cursor: selected,
|
|
48
173
|
height,
|
|
49
|
-
reachedEnd
|
|
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
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
633
|
-
? _jsx(
|
|
634
|
-
: elideMiddle(nameErrorLines, NAME_ERROR_ROWS, panelWidth).map((line, index) => _jsx(
|
|
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
|
}
|
package/dist/src/tui/render.js
CHANGED
|
@@ -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
|
|
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"
|
|
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":
|