@iseoane/agent-depot 0.6.14 → 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.
- package/CHANGELOG.md +29 -0
- package/README.md +2 -1
- package/dist/src/app-flow.js +8 -1
- package/dist/src/atomic-file.js +29 -3
- package/dist/src/cli.js +2 -0
- package/dist/src/flow-progress.js +6 -3
- package/dist/src/git-source.js +239 -9
- package/dist/src/preview-model.js +6 -3
- package/dist/src/tui/app-actions.js +4 -3
- package/dist/src/tui/bulk-actions.js +3 -2
- package/dist/src/tui/catalog-panel.js +1 -1
- package/dist/src/tui/installations-browse.js +1 -1
- package/dist/src/tui/installations-panel.js +1 -1
- package/dist/src/tui/installations-rows.js +1 -1
- package/dist/src/tui/installations-tree.js +1 -1
- package/dist/src/tui/keys.js +10 -2
- package/dist/src/tui/manage-panel.js +1 -1
- package/dist/src/tui/package-actions.js +1 -1
- package/dist/src/tui/package-rows.js +7 -3
- package/dist/src/tui/panel-parts.js +16 -8
- package/dist/src/tui/preview-window.js +54 -20
- package/dist/src/tui/profile-view.js +7 -6
- package/dist/src/tui/render.js +6 -4
- package/dist/src/tui/sources-recipes.js +3 -3
- package/dist/src/tui/sources-view.js +2 -3
- package/dist/src/tui/text-wrap.js +47 -3
- package/dist/src/tui/updates-panel.js +1 -1
- package/dist/src/tui/updates-view.js +7 -1
- package/dist/src/tui/updates.js +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.15] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Sources marks a refreshed Source as `✓ refreshed` without the elapsed time;
|
|
15
|
+
`… refreshing Ns` keeps its counter (#123).
|
|
16
|
+
- Progress lines share one format; a one-item batch no longer shows `(1/1)` (#123).
|
|
17
|
+
- Update previews say "No updates can be applied" when nothing applies, empty
|
|
18
|
+
previews no longer show "Review 1 of 0", and commit-only Package versions read
|
|
19
|
+
`commit xxxxxxx` everywhere (#124).
|
|
20
|
+
- Wrapped preview rows keep their indent and step guide; long titles are shortened
|
|
21
|
+
so the confirmation question always stays on screen (#125).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Installations marks a tracked App whose recipe changed as needing approval and
|
|
26
|
+
Enter opens the recipe review; an invalid recipe shows its reason (#127).
|
|
27
|
+
- A Source cache lock left by a process that died is recovered safely, and a busy
|
|
28
|
+
lock names its holder (#120, #122).
|
|
29
|
+
- Escapes typed in text can no longer be cut so they look like real ones, uppercase
|
|
30
|
+
`\X`/`\U` are handled, and quoted paths mark invisible characters (#128).
|
|
31
|
+
- State files are flushed to disk before and after replacing them, so a power cut
|
|
32
|
+
does not leave them empty (#129).
|
|
33
|
+
|
|
34
|
+
### Changed (development)
|
|
35
|
+
|
|
36
|
+
- The TUI start refresh is an injected option; confirmation and file-picker tests no
|
|
37
|
+
longer depend on terminal size, and review coverage gaps are closed (#119, #121, #126).
|
|
38
|
+
|
|
10
39
|
## [0.6.14] - 2026-11-12
|
|
11
40
|
|
|
12
41
|
### Added
|
package/README.md
CHANGED
|
@@ -972,7 +972,8 @@ independently. Unknown selections and combining a block's inclusion filter with
|
|
|
972
972
|
|
|
973
973
|
Stdout contains JSON only; stderr shows the preview and exclusion reasons.
|
|
974
974
|
`--out` requires a new file unless `--overwrite` is given, which atomically replaces an
|
|
975
|
-
existing regular file; directories and symlinks are never replaced.
|
|
975
|
+
existing regular file; directories and symlinks are never replaced. Overwriting creates a
|
|
976
|
+
new file, so it does not keep the original's permissions, owner or hard links.
|
|
976
977
|
Profiles contain canonical Git URLs with discovery inclusion, individual Skill
|
|
977
978
|
selections with recorded version policies, Hosts and user methods, complete App
|
|
978
979
|
recipes (including recipes for other platforms), and Package selections as
|
package/dist/src/app-flow.js
CHANGED
|
@@ -156,7 +156,14 @@ export function createAppOperations(environment = {}) {
|
|
|
156
156
|
if (!entry.applicable)
|
|
157
157
|
return { status: "not applicable here" };
|
|
158
158
|
if (cached?.installedVersion) {
|
|
159
|
-
|
|
159
|
+
const approval = await approvalStatus(entry);
|
|
160
|
+
if (approval.status === "invalid") {
|
|
161
|
+
return { status: "invalid", installedVersion: cached.installedVersion, evidence: "recorded", reason: approval.reason };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: "installed", installedVersion: cached.installedVersion, evidence: "recorded",
|
|
165
|
+
...(approval.status === "needs approval" ? { needsApproval: true } : {}),
|
|
166
|
+
};
|
|
160
167
|
}
|
|
161
168
|
const approval = await approvalProblem(entry);
|
|
162
169
|
if (approval)
|
package/dist/src/atomic-file.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { open, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
5
|
export const WINDOWS_RENAME_ATTEMPTS = 20;
|
|
5
6
|
const WINDOWS_RENAME_RETRY_MS = 5;
|
|
@@ -23,11 +24,35 @@ export async function renameWithWindowsRetry(from, to, deps = { rename, platform
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
+
/** Persist a directory entry change; Windows cannot open a directory for fsync. */
|
|
28
|
+
async function syncDirectory(directory) {
|
|
29
|
+
if (process.platform === "win32")
|
|
30
|
+
return;
|
|
31
|
+
const handle = await open(directory, "r");
|
|
32
|
+
try {
|
|
33
|
+
await handle.sync();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// Some filesystems do not implement fsync on directories.
|
|
37
|
+
if (!["EINVAL", "ENOTSUP"].includes(error.code ?? ""))
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await handle.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Write a unique temporary sibling, fsync it, publish it with rename, then fsync the directory. */
|
|
27
45
|
export async function writeFileAtomically(file, contents, options = {}) {
|
|
28
46
|
const temporaryPath = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
29
47
|
try {
|
|
30
|
-
await
|
|
48
|
+
const handle = await open(temporaryPath, "wx", options.mode);
|
|
49
|
+
try {
|
|
50
|
+
await handle.writeFile(contents, "utf8");
|
|
51
|
+
await handle.sync();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
31
56
|
try {
|
|
32
57
|
await renameWithWindowsRetry(temporaryPath, file);
|
|
33
58
|
}
|
|
@@ -39,6 +64,7 @@ export async function writeFileAtomically(file, contents, options = {}) {
|
|
|
39
64
|
// Source state holds its read-modify-write lock while using this Windows fallback.
|
|
40
65
|
await writeFile(file, contents, "utf8");
|
|
41
66
|
}
|
|
67
|
+
await syncDirectory(path.dirname(file));
|
|
42
68
|
}
|
|
43
69
|
finally {
|
|
44
70
|
await unlink(temporaryPath).catch((error) => {
|
package/dist/src/cli.js
CHANGED
|
@@ -155,6 +155,8 @@ const COMMANDS = new Map([
|
|
|
155
155
|
// Loaded lazily so non-interactive commands never import Ink/React.
|
|
156
156
|
const renderTui = dependencies.renderTui ?? (await import("./tui/render.js")).renderTui;
|
|
157
157
|
await renderTui(operations, {
|
|
158
|
+
// The one place the opt-out is read: E2E tests start the real binary and cannot inject options.
|
|
159
|
+
refreshOnStart: process.env.AGENT_DEPOT_NO_BACKGROUND_REFRESH !== "1",
|
|
158
160
|
homeDirectory: dependencies.homeDirectory,
|
|
159
161
|
projectRoot,
|
|
160
162
|
sourceAccess: dependencies.sourceAccess,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
const KIND_LABEL = { skill: "Skill", app: "App", package: "Package" };
|
|
2
|
-
/** `
|
|
2
|
+
/** `Removing demo (2/5)…`: the shared busy label of a batch; a batch of one has no position, and the `…` marks work in progress. */
|
|
3
|
+
export function formatStepLabel(text, position, total) {
|
|
4
|
+
return `${text}${total > 1 ? ` (${position}/${total})` : ""}…`;
|
|
5
|
+
}
|
|
6
|
+
/** `Updating Skill tdd (2/5)…`. */
|
|
3
7
|
export function formatProgressLabel(event) {
|
|
4
|
-
|
|
5
|
-
return `${event.verb} ${KIND_LABEL[event.kind]} ${event.name}${position}…`;
|
|
8
|
+
return formatStepLabel(`${event.verb} ${KIND_LABEL[event.kind]} ${event.name}`, event.position, event.total);
|
|
6
9
|
}
|
package/dist/src/git-source.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, rename, rm, rmdir } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { link, lstat, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { isLockContention } from "./atomic-file.js";
|
|
6
6
|
import { assertNoSymlinkPath } from "./path-safety.js";
|
|
@@ -91,6 +91,15 @@ const BACKGROUND_REFRESH_OPTIONS = Object.freeze({
|
|
|
91
91
|
timeoutMs: 60_000,
|
|
92
92
|
});
|
|
93
93
|
const SOURCE_LOCK_RETRY_MS = 10;
|
|
94
|
+
const SOURCE_LOCK_OWNER_FILE = "owner.json";
|
|
95
|
+
const SOURCE_LOCK_OWNER_MAX_BYTES = 4096;
|
|
96
|
+
const SOURCE_RECOVERY_MUTEX_ID_FILE = "id";
|
|
97
|
+
const LOCK_REMOVAL_ATTEMPTS = 5;
|
|
98
|
+
const LOCK_REMOVAL_RETRY_MS = 50;
|
|
99
|
+
/** A recovery takes milliseconds, so a mutex this old was left by a process that died mid-recovery. */
|
|
100
|
+
const SOURCE_RECOVERY_MUTEX_MAX_AGE_MS = 60_000;
|
|
101
|
+
/** Covers locks from versions without an owner record and an acquisition interrupted before writing it. */
|
|
102
|
+
const SOURCE_LOCK_OWNERLESS_MAX_AGE_MS = 10 * 60_000;
|
|
94
103
|
/**
|
|
95
104
|
* Clones each Source into a local mirror and refreshes it from the registered
|
|
96
105
|
* URL. Mirrors are intentionally not checked out: discovery owns later reads.
|
|
@@ -98,7 +107,9 @@ const SOURCE_LOCK_RETRY_MS = 10;
|
|
|
98
107
|
export class GitSourceAccessAdapter {
|
|
99
108
|
cachePath;
|
|
100
109
|
runner;
|
|
110
|
+
lockHooks;
|
|
101
111
|
constructor(options = {}) {
|
|
112
|
+
this.lockHooks = options.lockHooks ?? {};
|
|
102
113
|
this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
|
|
103
114
|
this.runner = options.runner ?? new NodeGitCommandRunner();
|
|
104
115
|
}
|
|
@@ -112,12 +123,14 @@ export class GitSourceAccessAdapter {
|
|
|
112
123
|
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
113
124
|
await mkdir(this.cachePath, { recursive: true });
|
|
114
125
|
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
115
|
-
const
|
|
126
|
+
const lock = await acquireSourceLock(`${destination}.lock`, this.lockHooks);
|
|
116
127
|
try {
|
|
128
|
+
await this.lockHooks.afterLockAcquired?.();
|
|
129
|
+
await lock.assertHeld();
|
|
117
130
|
await this.refreshLocked(source, destination, options.background ? BACKGROUND_REFRESH_OPTIONS : undefined);
|
|
118
131
|
}
|
|
119
132
|
finally {
|
|
120
|
-
await release();
|
|
133
|
+
await lock.release();
|
|
121
134
|
}
|
|
122
135
|
}
|
|
123
136
|
catch (error) {
|
|
@@ -441,14 +454,60 @@ async function pathType(candidate) {
|
|
|
441
454
|
throw error;
|
|
442
455
|
}
|
|
443
456
|
}
|
|
444
|
-
|
|
457
|
+
const LOCK_TAKEN_OVER_MESSAGE = "the Source cache lock was taken over by another process; this refresh stopped without touching the cache";
|
|
458
|
+
/** Identifies a directory across its lifetime; a recreated path gets a new inode or birth time. */
|
|
459
|
+
async function directoryIdentity(directory) {
|
|
460
|
+
try {
|
|
461
|
+
const { dev, ino, birthtimeNs } = await lstat(directory, { bigint: true });
|
|
462
|
+
return `${dev}:${ino}:${birthtimeNs}`;
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (isMissing(error)) {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Removes `directory` if `stillOurs` holds, then retries a failed removal only on that same directory.
|
|
473
|
+
* `rm` deletes the owner record before the directory, so after a partial failure ownership can no longer
|
|
474
|
+
* be re-read; identity stands in for it, and a successor's directory recreated at the path never matches.
|
|
475
|
+
* `rm`'s own retry option would repeat the removal on the path blindly instead. Windows reports EBUSY,
|
|
476
|
+
* EPERM or ENOTEMPTY while another process still holds a handle on something inside, so only those are retried.
|
|
477
|
+
* Residual limit: the checks and the removal are separate calls (the filesystem has no atomic conditional
|
|
478
|
+
* remove), so a successor that takes the path inside that window, microseconds wide, is still removed;
|
|
479
|
+
* two reclaimers of an aged-out recovery mutex can race the same way.
|
|
480
|
+
*/
|
|
481
|
+
async function removeWhileOurs(directory, stillOurs, hooks) {
|
|
482
|
+
if (!(await stillOurs())) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const identity = await directoryIdentity(directory);
|
|
486
|
+
for (let attempt = 1;; attempt += 1) {
|
|
487
|
+
try {
|
|
488
|
+
// rm unlinks links instead of following them, so nothing outside the directory is touched.
|
|
489
|
+
await (hooks.removeDirectory ?? ((target) => rm(target, { recursive: true, force: true })))(directory);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
const transient = ["EBUSY", "EPERM", "ENOTEMPTY"].includes(error.code ?? "");
|
|
494
|
+
if (!transient || attempt >= LOCK_REMOVAL_ATTEMPTS) {
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_REMOVAL_RETRY_MS));
|
|
498
|
+
if (identity === undefined || (await directoryIdentity(directory)) !== identity) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function acquireSourceLock(lockPath, hooks) {
|
|
445
505
|
const deadline = Date.now() + SOURCE_LOCK_TIMEOUT_MS;
|
|
446
506
|
while (true) {
|
|
447
507
|
try {
|
|
448
508
|
await mkdir(lockPath);
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
};
|
|
509
|
+
await hooks.afterLockCreated?.();
|
|
510
|
+
return await publishLockOwner(lockPath, hooks);
|
|
452
511
|
}
|
|
453
512
|
catch (error) {
|
|
454
513
|
if (!isLockContention(error)) {
|
|
@@ -457,13 +516,184 @@ async function acquireSourceLock(lockPath) {
|
|
|
457
516
|
if ((await pathType(lockPath)) === "symlink") {
|
|
458
517
|
throw new Error("the Source cache lock is a symbolic link");
|
|
459
518
|
}
|
|
519
|
+
if (await recoverOrphanedLock(lockPath, hooks)) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
460
522
|
if (Date.now() >= deadline) {
|
|
461
|
-
|
|
523
|
+
const record = await readLockOwner(lockPath);
|
|
524
|
+
const holder = record.kind === "owner"
|
|
525
|
+
? ` (held by process ${record.owner.pid} on host ${record.owner.hostname})`
|
|
526
|
+
: record.kind === "none"
|
|
527
|
+
? ` (held by an unknown process: it has no owner record and will be recovered once it is ${SOURCE_LOCK_OWNERLESS_MAX_AGE_MS / 60_000} minutes old)`
|
|
528
|
+
: "";
|
|
529
|
+
throw new Error(`the Source cache lock directory is busy: ${lockPath}${holder}; another refresh might be active. Wait for it to finish before retrying`);
|
|
462
530
|
}
|
|
463
531
|
await new Promise((resolve) => setTimeout(resolve, SOURCE_LOCK_RETRY_MS));
|
|
464
532
|
}
|
|
465
533
|
}
|
|
466
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Publishes the owner record without ever replacing one: a holder that stalled and lost its lock
|
|
537
|
+
* would otherwise overwrite its successor's record and let both believe they hold it.
|
|
538
|
+
* A hard link fails with EEXIST when the record exists. Where links are unsupported, an exclusive
|
|
539
|
+
* create gives the same no-overwrite guarantee, but a reader can briefly see a partial record,
|
|
540
|
+
* which it treats as unreadable and so never as a reason to remove the lock.
|
|
541
|
+
*/
|
|
542
|
+
async function publishLockOwner(lockPath, hooks) {
|
|
543
|
+
const owner = { pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString(), id: randomUUID() };
|
|
544
|
+
const ownerPath = path.join(lockPath, SOURCE_LOCK_OWNER_FILE);
|
|
545
|
+
const temporaryPath = `${ownerPath}.${owner.id}.tmp`;
|
|
546
|
+
const contents = JSON.stringify(owner);
|
|
547
|
+
try {
|
|
548
|
+
await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
|
|
549
|
+
try {
|
|
550
|
+
await link(temporaryPath, ownerPath);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (!["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EPERM"].includes(error.code ?? "")) {
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
await writeFile(ownerPath, contents, { encoding: "utf8", flag: "wx" });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
561
|
+
if (error.code === "EEXIST") {
|
|
562
|
+
throw new Error(LOCK_TAKEN_OVER_MESSAGE);
|
|
563
|
+
}
|
|
564
|
+
if (error.code === "ENOENT") {
|
|
565
|
+
// The directory this call created is gone: the lock was recovered, and a successor may already hold a new one.
|
|
566
|
+
throw new Error(LOCK_TAKEN_OVER_MESSAGE);
|
|
567
|
+
}
|
|
568
|
+
// rmdir only removes an empty directory: ours, or a successor's before it published, whose own write then
|
|
569
|
+
// reports the takeover. A successor with an owner record is never empty.
|
|
570
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
571
|
+
throw new Error(`could not record the Source cache lock owner: ${error instanceof Error ? error.message : String(error)}`);
|
|
572
|
+
}
|
|
573
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
574
|
+
return {
|
|
575
|
+
async assertHeld() {
|
|
576
|
+
const record = await readLockOwner(lockPath);
|
|
577
|
+
if (record.kind !== "owner" || record.owner.id !== owner.id) {
|
|
578
|
+
throw new Error(LOCK_TAKEN_OVER_MESSAGE);
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
async release() {
|
|
582
|
+
await removeWhileOurs(lockPath, async () => {
|
|
583
|
+
const record = await readLockOwner(lockPath);
|
|
584
|
+
return record.kind === "owner" && record.owner.id === owner.id;
|
|
585
|
+
}, hooks);
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** Reads the owner record without following links. */
|
|
590
|
+
async function readLockOwner(lockPath) {
|
|
591
|
+
const ownerPath = path.join(lockPath, SOURCE_LOCK_OWNER_FILE);
|
|
592
|
+
try {
|
|
593
|
+
const information = await lstat(ownerPath);
|
|
594
|
+
if (!information.isFile() || information.size > SOURCE_LOCK_OWNER_MAX_BYTES) {
|
|
595
|
+
return { kind: "unreadable" };
|
|
596
|
+
}
|
|
597
|
+
const parsed = JSON.parse(await readFile(ownerPath, "utf8"));
|
|
598
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
599
|
+
return { kind: "unreadable" };
|
|
600
|
+
}
|
|
601
|
+
const { pid, hostname: host, startedAt, id } = parsed;
|
|
602
|
+
return Number.isInteger(pid) && pid > 0 && typeof host === "string" && typeof startedAt === "string" && typeof id === "string"
|
|
603
|
+
? { kind: "owner", owner: { pid: pid, hostname: host, startedAt, id } }
|
|
604
|
+
: { kind: "unreadable" };
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
return isMissing(error) ? { kind: "none" } : { kind: "unreadable" };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function isProcessAlive(pid) {
|
|
611
|
+
try {
|
|
612
|
+
process.kill(pid, 0);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
// EPERM means the process exists but belongs to someone else.
|
|
617
|
+
return error.code !== "ESRCH";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async function isOldLock(lockPath, maxAgeMs) {
|
|
621
|
+
try {
|
|
622
|
+
return Date.now() - (await lstat(lockPath)).mtimeMs > maxAgeMs;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** The orphan a recoverer judged: a dead owner on this host (by id) or an old lock with no owner record. */
|
|
629
|
+
async function judgeOrphan(lockPath) {
|
|
630
|
+
const record = await readLockOwner(lockPath);
|
|
631
|
+
if (record.kind === "owner") {
|
|
632
|
+
return record.owner.hostname === hostname() && !isProcessAlive(record.owner.pid) ? record : undefined;
|
|
633
|
+
}
|
|
634
|
+
return record.kind === "none" && await isOldLock(lockPath, SOURCE_LOCK_OWNERLESS_MAX_AGE_MS) ? record : undefined;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Removes a lock whose holder is provably gone. Recoverers serialize on a short-lived `.recovering`
|
|
638
|
+
* mutex and re-judge the lock in place while holding it, so a judgment made before another process
|
|
639
|
+
* recovered and re-took the lock can never remove (or move) that live lock: nothing but a recoverer
|
|
640
|
+
* removes a lock whose owner is dead, and acquirers cannot replace a lock that still exists.
|
|
641
|
+
* Returns true when the caller should retry the acquisition at once.
|
|
642
|
+
*/
|
|
643
|
+
async function recoverOrphanedLock(lockPath, hooks) {
|
|
644
|
+
const judged = await judgeOrphan(lockPath);
|
|
645
|
+
if (judged === undefined) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
await hooks.afterJudging?.();
|
|
649
|
+
const mutexPath = `${lockPath}.recovering`;
|
|
650
|
+
try {
|
|
651
|
+
await mkdir(mutexPath);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (!isLockContention(error)) {
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
// A recoverer that died holding the mutex would otherwise block recovery for good.
|
|
658
|
+
if (await isOldLock(mutexPath, SOURCE_RECOVERY_MUTEX_MAX_AGE_MS)) {
|
|
659
|
+
await removeWhileOurs(mutexPath, () => isOldLock(mutexPath, SOURCE_RECOVERY_MUTEX_MAX_AGE_MS), hooks);
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const mutexId = randomUUID();
|
|
665
|
+
const mutexIdPath = path.join(mutexPath, SOURCE_RECOVERY_MUTEX_ID_FILE);
|
|
666
|
+
const ownsMutex = async () => (await readFile(mutexIdPath, "utf8").catch(() => undefined)) === mutexId;
|
|
667
|
+
try {
|
|
668
|
+
await writeFile(mutexIdPath, mutexId, { encoding: "utf8", flag: "wx" });
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
// The mutex was reclaimed before it was identified; give up this recovery attempt.
|
|
672
|
+
if (!["EEXIST", "ENOENT"].includes(error.code ?? "")) {
|
|
673
|
+
await rmdir(mutexPath).catch(() => undefined);
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const isJudgedOrphan = async () => {
|
|
680
|
+
const current = await judgeOrphan(lockPath);
|
|
681
|
+
return current !== undefined && (current.kind === "owner"
|
|
682
|
+
? judged.kind === "owner" && judged.owner.id === current.owner.id
|
|
683
|
+
: judged.kind === "none");
|
|
684
|
+
};
|
|
685
|
+
if (await isJudgedOrphan()) {
|
|
686
|
+
await hooks.beforeOrphanRemoval?.();
|
|
687
|
+
// A recoverer paused past the mutex's age limit may have had the mutex reclaimed and the lock re-taken,
|
|
688
|
+
// so the mutex and the orphan are re-checked before every removal attempt.
|
|
689
|
+
await removeWhileOurs(lockPath, async () => await ownsMutex() && await isJudgedOrphan(), hooks);
|
|
690
|
+
}
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
await removeWhileOurs(mutexPath, ownsMutex, hooks);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
467
697
|
export function validateGitRef(candidate) {
|
|
468
698
|
if (typeof candidate !== "string" || candidate.length === 0 || candidate.trim() !== candidate ||
|
|
469
699
|
Array.from(candidate).some((character) => (character.codePointAt(0) ?? 0) <= 0x20) ||
|
|
@@ -9,16 +9,19 @@ const INVISIBLE = /(?! )[\p{Cc}\p{Cf}\p{Zs}\p{Default_Ignorable_Code_Point}\u202
|
|
|
9
9
|
function quoted(argument, terminal) {
|
|
10
10
|
// The TUI escapes control and format characters once, when it draws; escaping them here too would show a real RLO as typed text.
|
|
11
11
|
if (terminal)
|
|
12
|
-
return quoteText(argument)
|
|
12
|
+
return quoteText(argument);
|
|
13
13
|
return JSON.stringify(argument).replace(INVISIBLE, escapeCharacter);
|
|
14
14
|
}
|
|
15
15
|
function escapeCharacter(character) {
|
|
16
16
|
const code = character.codePointAt(0).toString(16);
|
|
17
17
|
return code.length > 4 ? `\\u{${code}}` : `\\u${code.padStart(4, "0")}`;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* `text` in double quotes with quotes and backslashes escaped. Control and format characters stay as they are for the TUI to escape;
|
|
21
|
+
* spaces other than U+0020 and ignorable characters are written out, as in `commandLine`.
|
|
22
|
+
*/
|
|
20
23
|
export function quoteText(text) {
|
|
21
|
-
return `"${text.replace(/[\\"]/gu, "\\$&")}"`;
|
|
24
|
+
return `"${text.replace(/[\\"]/gu, "\\$&").replace(NOT_ESCAPED_BY_TUI, escapeCharacter)}"`;
|
|
22
25
|
}
|
|
23
26
|
/**
|
|
24
27
|
* The argv as one shell-like line: arguments with spaces, quotes, shell metacharacters or invisible characters are JSON-quoted.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text } from "./safe-text.js";
|
|
3
|
+
import { formatStepLabel } from "../flow-progress.js";
|
|
3
4
|
import { commandLine } from "../preview-model.js";
|
|
4
5
|
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
5
6
|
import { errorText, runBatch } from "./batch.js";
|
|
@@ -76,9 +77,9 @@ function appPreviewLines(mode) {
|
|
|
76
77
|
function appBusy(label, previous, done) {
|
|
77
78
|
return { kind: "app-busy", label, startedAt: operationStart(previous), ...(done?.length ? { done } : {}) };
|
|
78
79
|
}
|
|
79
|
-
/** `Running App step: name (2/5)…`;
|
|
80
|
+
/** `Running App step: name (2/5)…`; `finished` counts the steps already done. */
|
|
80
81
|
function stepLabel(verb, entry, finished, total) {
|
|
81
|
-
return `${verb} App step: ${appName(entry)}
|
|
82
|
+
return formatStepLabel(`${verb} App step: ${appName(entry)}`, finished + 1, total);
|
|
82
83
|
}
|
|
83
84
|
async function prepareNext(context, requests, results = [], failures = 0, previous) {
|
|
84
85
|
const { manage, apps } = context;
|
|
@@ -229,5 +230,5 @@ export function AppPanel({ mode }) {
|
|
|
229
230
|
const styled = appPreviewLines(mode);
|
|
230
231
|
const lines = styled.map(lineText);
|
|
231
232
|
const chrome = appPreviewChrome(mode);
|
|
232
|
-
return _jsx(PagedPreview, {
|
|
233
|
+
return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: geometry(lines, mode.cursor, chrome) });
|
|
233
234
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { PROJECT_HOSTS } from "../project-manifest.js";
|
|
2
2
|
import { assertManagedInstallationRecordsUnchanged } from "../skill-removal.js";
|
|
3
|
+
import { formatStepLabel } from "../flow-progress.js";
|
|
3
4
|
import { errorText, runBatch } from "./batch.js";
|
|
4
5
|
import { missingHosts, prepareHostAddition, prepareUninstall, runHostAddition, runUninstall, UNINSTALL_REFUSALS, } from "./catalog-installs.js";
|
|
5
6
|
import { answerPaged, answerYesNo, applyChecklistOutcome, hostChecklistKey } from "./mode-keys.js";
|
|
@@ -80,12 +81,12 @@ function resultOf(outcomes, skipped) {
|
|
|
80
81
|
applied: outcomes.filter((outcome) => outcome.ok).map((outcome) => outcome.item.id),
|
|
81
82
|
};
|
|
82
83
|
}
|
|
83
|
-
/** `Removing name (2/5)…` for each item as it starts. */
|
|
84
|
+
/** `Removing name (2/5)…` (no position for one item) for each item as it starts. */
|
|
84
85
|
function startOf(verb, progress) {
|
|
85
86
|
if (!progress)
|
|
86
87
|
return undefined;
|
|
87
88
|
return (item, position, total, finished) => {
|
|
88
|
-
progress(`${verb} ${item.label}
|
|
89
|
+
progress(formatStepLabel(`${verb} ${item.label}`, position, total), finished.map(outcome => outcome.line));
|
|
89
90
|
};
|
|
90
91
|
}
|
|
91
92
|
/** Execute step: each item is rechecked and applied on its own against the records the previous items were expected to leave. */
|
|
@@ -33,7 +33,7 @@ export function CatalogSelectionHelp({ row, height }) {
|
|
|
33
33
|
/** The install confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
|
|
34
34
|
export function InstallConfirmPanel({ mode }) {
|
|
35
35
|
const { lines, chrome } = installPreview(mode.items);
|
|
36
|
-
return _jsx(PagedPreview, {
|
|
36
|
+
return _jsx(PagedPreview, { lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
|
|
37
37
|
}
|
|
38
38
|
/** The prompt of the install flow; browsing shows nothing. */
|
|
39
39
|
export function ActionPanel({ mode }) {
|
|
@@ -107,7 +107,7 @@ function actOnRow(context, snapshot, row, input, key) {
|
|
|
107
107
|
else if (item.kind === "app") {
|
|
108
108
|
if (input === "A")
|
|
109
109
|
context.setMessage({ kind: "error", text: "Adoption applies to unmanaged Skills, not Apps" });
|
|
110
|
-
else if (key.return && item.row.inspection.status !== "needs approval") {
|
|
110
|
+
else if (key.return && item.row.inspection.status !== "needs approval" && !item.row.inspection.needsApproval) {
|
|
111
111
|
const { entry, inspection } = item.row;
|
|
112
112
|
const approved = inspection.status === "installed" || inspection.status === "not installed";
|
|
113
113
|
context.setMessage({
|
|
@@ -10,7 +10,7 @@ import { theme } from "./theme.js";
|
|
|
10
10
|
/** An adoptable preview with the shared footer; the view shows it alone so no tree rows count against its height. */
|
|
11
11
|
export function AdoptionConfirmPanel({ mode }) {
|
|
12
12
|
const { lines, chrome } = adoptionPreview(mode);
|
|
13
|
-
return _jsx(PagedPreview, {
|
|
13
|
+
return _jsx(PagedPreview, { lines: lines, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
|
|
14
14
|
}
|
|
15
15
|
/** The prompt of the adoption and manage modes; browsing shows nothing. */
|
|
16
16
|
export function ModePanel({ mode }) {
|
|
@@ -6,7 +6,7 @@ function AppDetails({ row }) {
|
|
|
6
6
|
const version = row.inspection.installedVersion;
|
|
7
7
|
const statusColor = row.inspection.status === "invalid" ? theme.error : theme.warning;
|
|
8
8
|
const invalid = row.inspection.status === "invalid" ? ` · invalid recipe: ${row.inspection.reason ?? "unknown error"}` : "";
|
|
9
|
-
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: version === undefined ? statusColor : theme.version, children: version ?? row.inspection.status }), row.inspection.evidence === "recorded" ? _jsx(Text, { color: theme.value, children: " (recorded)" }) : null, invalid === "" ? null : _jsx(Text, { color: theme.error, children: invalid })] }));
|
|
9
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.legend, children: " " }), _jsx(Text, { color: version === undefined ? statusColor : theme.version, children: version ?? row.inspection.status }), row.inspection.evidence === "recorded" ? _jsx(Text, { color: theme.value, children: " (recorded)" }) : null, row.inspection.needsApproval ? _jsx(Text, { color: theme.warning, children: " \u00B7 needs approval" }) : null, invalid === "" ? null : _jsx(Text, { color: theme.error, children: invalid })] }));
|
|
10
10
|
}
|
|
11
11
|
function PackageDetails({ row }) {
|
|
12
12
|
const version = row.installedVersion;
|
|
@@ -79,7 +79,7 @@ export function describeNode(row) {
|
|
|
79
79
|
return `${name} invalid recipe: ${inspection.reason}`;
|
|
80
80
|
const version = inspection.installedVersion ?? inspection.status;
|
|
81
81
|
const invalid = inspection.status === "invalid" ? ` · invalid recipe: ${inspection.reason}` : "";
|
|
82
|
-
return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${invalid}`;
|
|
82
|
+
return `${name} ${version}${inspection.evidence === "recorded" ? " (recorded)" : ""}${inspection.needsApproval ? " · needs approval" : ""}${invalid}`;
|
|
83
83
|
}
|
|
84
84
|
return `${data.group.name} [${data.group.hosts.join(", ")}]`;
|
|
85
85
|
}
|
package/dist/src/tui/keys.js
CHANGED
|
@@ -26,18 +26,26 @@ function grouped(key) {
|
|
|
26
26
|
groupedKeys.add(copy);
|
|
27
27
|
return copy;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The most events any hook has read this turn. A hook that mounts mid-read starts its own count at 0, but the hooks that were already
|
|
31
|
+
* listening have counted the keys it missed, so this tells it that it is not at the start of the turn.
|
|
32
|
+
*/
|
|
33
|
+
let eventsInTurn = 0;
|
|
29
34
|
/**
|
|
30
35
|
* `useInput` that treats several plain characters delivered in one write (fast
|
|
31
36
|
* typing, a paste) as the separate key presses they are, in order. Ink also hands
|
|
32
37
|
* the escape sequences of one read over as separate events within one turn; every key
|
|
33
|
-
* after the first of a turn counts as grouped, as do all characters of a split block.
|
|
38
|
+
* after the first of a turn counts as grouped, in any hook, as do all characters of a split block.
|
|
34
39
|
*/
|
|
35
40
|
export function useKeys(handler) {
|
|
36
41
|
const eventsThisTurn = useRef(0);
|
|
37
42
|
useInput((input, key) => {
|
|
43
|
+
if (eventsInTurn === 0)
|
|
44
|
+
queueMicrotask(() => { eventsInTurn = 0; });
|
|
38
45
|
if (eventsThisTurn.current++ === 0)
|
|
39
46
|
queueMicrotask(() => { eventsThisTurn.current = 0; });
|
|
40
|
-
|
|
47
|
+
eventsInTurn = Math.max(eventsInTurn, eventsThisTurn.current);
|
|
48
|
+
const later = eventsThisTurn.current > 1 || eventsInTurn > 1;
|
|
41
49
|
if (ESCAPE_REMNANT.test(input)) {
|
|
42
50
|
if (!PASTE_MARKERS.has(input))
|
|
43
51
|
handler(input, later ? grouped(key) : key);
|
|
@@ -14,7 +14,7 @@ function SkippedLines({ skipped }) {
|
|
|
14
14
|
/** A Skill confirmation with the shared footer; the view shows it alone so no tree rows count against its height. */
|
|
15
15
|
export function PagedConfirmPanel({ mode }) {
|
|
16
16
|
const { lines, styled, chrome } = pagedConfirmPreview(mode);
|
|
17
|
-
return _jsx(PagedPreview, {
|
|
17
|
+
return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
|
|
18
18
|
}
|
|
19
19
|
/** Renders the prompt of a manage mode: host lists, previews and confirmations. */
|
|
20
20
|
export function ManagePanel({ mode }) {
|
|
@@ -200,5 +200,5 @@ export function handlePackageKey(context, mode, input, key) {
|
|
|
200
200
|
export function PackagePanel({ mode }) {
|
|
201
201
|
const { lines, chrome } = packagePreview(mode);
|
|
202
202
|
const viewport = usePreviewGeometry()(lines, mode.cursor, chrome);
|
|
203
|
-
return _jsx(PagedPreview, {
|
|
203
|
+
return _jsx(PagedPreview, { lines: lines, viewport: viewport });
|
|
204
204
|
}
|
|
@@ -5,7 +5,7 @@ import { displayPackageCoordinates, displayPackageSelection, } from "../package-
|
|
|
5
5
|
export function packageVersionLabel(evidence, commit) {
|
|
6
6
|
if (evidence === undefined)
|
|
7
7
|
return "unknown";
|
|
8
|
-
return evidence.kind === "manifest-version" ? versionWithCommit(evidence.version, commit) : evidence.commit.slice(0, 7)
|
|
8
|
+
return evidence.kind === "manifest-version" ? versionWithCommit(evidence.version, commit) : `commit ${evidence.commit.slice(0, 7)}`;
|
|
9
9
|
}
|
|
10
10
|
function versionWithCommit(version, commit) {
|
|
11
11
|
return `${version}${commit === undefined ? "" : ` (${commit.slice(0, 7)})`}`;
|
|
@@ -47,9 +47,13 @@ export function recordedVersionLabel(record) {
|
|
|
47
47
|
if (record.verified !== undefined)
|
|
48
48
|
return packageVersionLabel(record.verified);
|
|
49
49
|
if (record.lastDelegatedCommit !== undefined)
|
|
50
|
-
return record.lastDelegatedCommit.slice(0, 7)
|
|
50
|
+
return `commit ${record.lastDelegatedCommit.slice(0, 7)}`;
|
|
51
51
|
return "unknown";
|
|
52
52
|
}
|
|
53
|
+
/** The version the host reports when it states one, else what the selection record remembers. */
|
|
54
|
+
export function installedVersionLabel({ installed, observed }) {
|
|
55
|
+
return observed?.kind === "installed" && observed.version !== undefined ? observed.version : recordedVersionLabel(installed);
|
|
56
|
+
}
|
|
53
57
|
export function packageRow(record, inspection) {
|
|
54
58
|
return {
|
|
55
59
|
selection: record.selection,
|
|
@@ -58,7 +62,7 @@ export function packageRow(record, inspection) {
|
|
|
58
62
|
...(inspection.installed.kind === "installed" && inspection.installed.version !== undefined
|
|
59
63
|
? { installedVersion: versionWithCommit(inspection.installed.version, inspection.installed.commit) }
|
|
60
64
|
: inspection.installed.kind === "installed" && inspection.installed.commit !== undefined
|
|
61
|
-
? { installedVersion: inspection.installed.commit.slice(0, 7) }
|
|
65
|
+
? { installedVersion: `commit ${inspection.installed.commit.slice(0, 7)}` }
|
|
62
66
|
: {}),
|
|
63
67
|
available: packageVersionLabel(inspection.available, inspection.availableCommit),
|
|
64
68
|
status: packageRowStatus(record.selection, inspection),
|
|
@@ -7,7 +7,9 @@ import { escapeControlText, sliceSpans } from "./preview-window.js";
|
|
|
7
7
|
import { animationEnabled, formatElapsed, SPINNER_FRAMES, SPINNER_TICK_MS, STATIC_GLYPH } from "./working-model.js";
|
|
8
8
|
/** A titled, color-independent boundary for a focused interaction or result. */
|
|
9
9
|
export function BorderPanel({ title, children }) {
|
|
10
|
-
return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [
|
|
10
|
+
return (_jsxs(Box, { borderStyle: "round", borderColor: theme.border, paddingX: 1, flexDirection: "column", children: [typeof title === "string"
|
|
11
|
+
? _jsx(Text, { color: theme.title, bold: true, children: title })
|
|
12
|
+
: title.map((row, position) => _jsx(PreEscapedText, { color: theme.title, bold: true, wrap: "truncate", children: row }, position)), children] }));
|
|
11
13
|
}
|
|
12
14
|
export function FieldLine({ label, value }) {
|
|
13
15
|
return _jsxs(Text, { children: [_jsx(Text, { color: theme.field, children: label }), _jsxs(Text, { color: theme.value, children: [" ", value] })] });
|
|
@@ -113,16 +115,22 @@ function noticeColor(line) {
|
|
|
113
115
|
return theme.warning;
|
|
114
116
|
return text.startsWith("Cannot install ") ? theme.error : undefined;
|
|
115
117
|
}
|
|
116
|
-
|
|
118
|
+
/** The hanging prefix of a continuation row, drawn in the gutter colour, then the part of the line's spans the row shows. */
|
|
119
|
+
function rowSpans(spans, prefix, offset, length) {
|
|
120
|
+
return [...(prefix === "" ? [] : [previewSpan(prefix, theme.field)]), ...sliceSpans(spans, offset, length)];
|
|
121
|
+
}
|
|
122
|
+
function PreviewLine({ line, position, logicalLine, offset = 0, prefixLength = 0 }) {
|
|
123
|
+
const prefix = line.slice(0, prefixLength);
|
|
124
|
+
const textLength = line.length - prefixLength;
|
|
117
125
|
const profileSpans = logicalLine === undefined ? profilePreviewSpans(line) : profilePreviewSpans(logicalLine);
|
|
118
126
|
if (profileSpans) {
|
|
119
|
-
const spans = logicalLine === undefined ? profileSpans :
|
|
127
|
+
const spans = logicalLine === undefined ? profileSpans : rowSpans(profileSpans, prefix, offset, textLength);
|
|
120
128
|
return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
|
|
121
129
|
}
|
|
122
130
|
const field = previewField(logicalLine ?? line);
|
|
123
131
|
if (field) {
|
|
124
132
|
const fieldSpans = [previewSpan(field.label, theme.field), previewSpan(field.value, theme.value)];
|
|
125
|
-
const spans = logicalLine === undefined ? fieldSpans :
|
|
133
|
+
const spans = logicalLine === undefined ? fieldSpans : rowSpans(fieldSpans, prefix, offset, textLength);
|
|
126
134
|
return _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { color: span.color, children: span.text }, index)) }, position);
|
|
127
135
|
}
|
|
128
136
|
return _jsx(PreEscapedText, { color: noticeColor(logicalLine ?? line) ?? theme.value, children: line }, position);
|
|
@@ -150,16 +158,16 @@ function footerColor(role, reachedEnd) {
|
|
|
150
158
|
* A paged preview with the shared two-row footer: reading progress, then what `y` does. `[y]` stays
|
|
151
159
|
* dimmed until `viewport` reaches the end; callers ignore `y` until then using the same viewport.
|
|
152
160
|
*/
|
|
153
|
-
export function PagedPreview({
|
|
161
|
+
export function PagedPreview({ lines: rawLines, styled: rawStyled, viewport }) {
|
|
154
162
|
// The same escape `previewViewport` applied, so rows, offsets and spans describe the same text. The rows themselves are already
|
|
155
163
|
// escaped, hence `PreEscapedText`; only the title goes through `BorderPanel`.
|
|
156
164
|
const lines = rawLines.map(escapeControlText);
|
|
157
165
|
const styled = rawStyled?.map((line) => line?.map((span) => ({ ...span, text: escapeControlText(span.text) })));
|
|
158
|
-
return (_jsxs(BorderPanel, { title:
|
|
166
|
+
return (_jsxs(BorderPanel, { title: viewport.titleRows, children: [viewport.rows.map((line, position) => {
|
|
159
167
|
const logical = viewport.sourceLines[position];
|
|
160
168
|
const custom = styled?.[logical];
|
|
161
169
|
return custom === undefined
|
|
162
|
-
? _jsx(PreviewLine, { line: line, position: position, logicalLine: lines[logical], offset: viewport.sourceOffsets[position] }, position)
|
|
163
|
-
: _jsx(PreEscapedText, { children:
|
|
170
|
+
? _jsx(PreviewLine, { line: line, position: position, logicalLine: lines[logical], offset: viewport.sourceOffsets[position], prefixLength: viewport.prefixLengths[position] }, position)
|
|
171
|
+
: _jsx(PreEscapedText, { children: rowSpans(custom, line.slice(0, viewport.prefixLengths[position]), viewport.sourceOffsets[position], line.length - viewport.prefixLengths[position]).map((span, index) => _jsx(PreEscapedText, { color: span.color, bold: span.bold, children: span.text }, index)) }, position);
|
|
164
172
|
}), viewport.footer.map((spans, row) => _jsx(PreEscapedText, { children: spans.map((span, index) => _jsx(PreEscapedText, { ...footerColor(span.role, viewport.reachedEnd), children: span.text }, index)) }, `footer-${row}`))] }));
|
|
165
173
|
}
|
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { useWindowSize } from "ink";
|
|
2
2
|
import stringWidth from "string-width";
|
|
3
3
|
import { headerTabRowCount } from "./header-layout.js";
|
|
4
|
+
import { ESCAPE_TEXT, namesEscapedCharacter, textUnits, UNSAFE_PATTERN } from "./text-wrap.js";
|
|
4
5
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
5
6
|
// Text shown in the TUI comes from recipes, Sources, Skills and Hosts. Raw, an ESC or carriage return redraws the terminal over the
|
|
6
7
|
// 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
8
|
// 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
9
|
// 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
10
|
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
11
|
/**
|
|
19
12
|
* `text` with control, format and line-separator characters replaced by visible escapes (`\x1b`, `\u202e`). A backslash run right
|
|
20
13
|
* before text that looks like one of those escapes is doubled, so a typed `\x1b` cannot pass for a real ESC; other backslashes,
|
|
@@ -41,26 +34,44 @@ function escapeChrome(chrome) {
|
|
|
41
34
|
...(chrome.cancel === undefined ? {} : { cancel: escapeControlText(chrome.cancel) }),
|
|
42
35
|
};
|
|
43
36
|
}
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
const TREE_GUTTER = /^( *)([│┌├└])?( *)/u;
|
|
38
|
+
/**
|
|
39
|
+
* What continuation rows of a wrapped line start with: its indent and, in a step tree, the gutter that
|
|
40
|
+
* keeps running below its branch (`│` under `┌` and `├`, nothing under `└`). Empty when the prefix would
|
|
41
|
+
* leave less than half the width for text.
|
|
42
|
+
*/
|
|
43
|
+
function hangingPrefix(line, width) {
|
|
44
|
+
const [, before = "", glyph = "", after = ""] = TREE_GUTTER.exec(line) ?? [];
|
|
45
|
+
const continued = glyph === "" ? "" : glyph === "└" ? " " : "│";
|
|
46
|
+
const prefix = `${before}${continued}${after}`;
|
|
47
|
+
return stringWidth(prefix) * 2 <= width ? prefix : "";
|
|
48
|
+
}
|
|
49
|
+
function wrapLine(line, width, hang = false) {
|
|
50
|
+
const segments = textUnits(line, width);
|
|
46
51
|
if (segments.length === 0)
|
|
47
|
-
return [{ row: "", offset: 0 }];
|
|
52
|
+
return [{ row: "", offset: 0, prefixLength: 0 }];
|
|
53
|
+
const prefix = hang ? hangingPrefix(line, width) : "";
|
|
48
54
|
const wrapped = [];
|
|
49
55
|
let row = "";
|
|
50
56
|
let rowWidth = 0;
|
|
51
57
|
let rowOffset = 0;
|
|
58
|
+
let prefixLength = 0;
|
|
59
|
+
let hasText = false;
|
|
52
60
|
for (const { segment, index } of segments) {
|
|
53
61
|
const segmentWidth = stringWidth(segment);
|
|
54
|
-
if (
|
|
55
|
-
wrapped.push({ row, offset: rowOffset });
|
|
56
|
-
row =
|
|
57
|
-
rowWidth =
|
|
62
|
+
if (hasText && rowWidth + segmentWidth > width) {
|
|
63
|
+
wrapped.push({ row, offset: rowOffset, prefixLength });
|
|
64
|
+
row = prefix;
|
|
65
|
+
rowWidth = stringWidth(prefix);
|
|
66
|
+
prefixLength = prefix.length;
|
|
67
|
+
hasText = false;
|
|
58
68
|
rowOffset = index;
|
|
59
69
|
}
|
|
60
70
|
row += segment;
|
|
61
71
|
rowWidth += segmentWidth;
|
|
72
|
+
hasText = true;
|
|
62
73
|
}
|
|
63
|
-
wrapped.push({ row, offset: rowOffset });
|
|
74
|
+
wrapped.push({ row, offset: rowOffset, prefixLength });
|
|
64
75
|
return wrapped;
|
|
65
76
|
}
|
|
66
77
|
const MAX_BAR_WIDTH = 12;
|
|
@@ -90,7 +101,8 @@ function footerSpans(state, chrome, width) {
|
|
|
90
101
|
const progress = state.reachedEnd
|
|
91
102
|
? [{ text: "read ✓", role: "read" }]
|
|
92
103
|
: [{ text: `read ${bar}${Math.floor(read * 100)}%`, role: "reading" }];
|
|
93
|
-
|
|
104
|
+
if (state.total > 0)
|
|
105
|
+
progress.push({ text: ` · ${position(state.cursor + 1, state.start + 1, state.end)}`, role: "position" });
|
|
94
106
|
if (!state.reachedEnd)
|
|
95
107
|
progress.push({ text: " · arrows/Pg page", role: "position" });
|
|
96
108
|
else if (chrome.consequence !== undefined)
|
|
@@ -142,17 +154,37 @@ function reservedFooterRows(total, windowed, chrome, width, maxRows) {
|
|
|
142
154
|
const widest = { cursor: Math.max(total - 1, 0), start: Math.max(total - 1, 0), end: total, total, windowed };
|
|
143
155
|
return Math.max(layoutFooter(footerSpans({ ...widest, reachedEnd: false }, chrome, width), width, maxRows).length, layoutFooter(footerSpans({ ...widest, reachedEnd: true }, chrome, width), width, maxRows).length);
|
|
144
156
|
}
|
|
157
|
+
/** `title` cut, with an ellipsis, to the longest start that wraps to at most `maxRows` once escaped. */
|
|
158
|
+
function clipTitle(title, width, maxRows) {
|
|
159
|
+
const fits = (text) => wrapLine(escapeControlText(text), width).length <= maxRows;
|
|
160
|
+
if (fits(title))
|
|
161
|
+
return title;
|
|
162
|
+
const parts = [...graphemes.segment(title)].map(({ segment }) => segment);
|
|
163
|
+
let low = 0;
|
|
164
|
+
let high = parts.length;
|
|
165
|
+
while (low < high) {
|
|
166
|
+
const middle = Math.ceil((low + high) / 2);
|
|
167
|
+
if (fits(`${parts.slice(0, middle).join("")}…`))
|
|
168
|
+
low = middle;
|
|
169
|
+
else
|
|
170
|
+
high = middle - 1;
|
|
171
|
+
}
|
|
172
|
+
return `${parts.slice(0, low).join("")}…`;
|
|
173
|
+
}
|
|
145
174
|
export function previewViewport(rawLines, cursor, terminalRows, terminalColumns, rawChrome) {
|
|
146
175
|
const lines = rawLines.map(escapeControlText);
|
|
147
176
|
const chrome = escapeChrome(rawChrome);
|
|
148
177
|
const contentWidth = Math.max(terminalColumns - 8, 1);
|
|
149
|
-
const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth).map((
|
|
178
|
+
const wrapped = lines.flatMap((line, sourceLine) => wrapLine(line, contentWidth, true).map((wrappedRow) => ({ ...wrappedRow, sourceLine })));
|
|
150
179
|
const previewRows = wrapped.map(({ row }) => row);
|
|
151
180
|
const total = previewRows.length;
|
|
152
181
|
const headerRows = headerTabRowCount(contentWidth) + 3;
|
|
153
|
-
const titleRows = wrapLine(chrome.title, contentWidth).length;
|
|
154
182
|
const reservedRows = chrome.reservedRows !== undefined && chrome.reservedRows > 0 ? Math.ceil(chrome.reservedRows) : 0;
|
|
155
|
-
|
|
183
|
+
// The footer, with its question and its action, is reserved first; the title takes what remains after a row of content.
|
|
184
|
+
const footerRows = Math.max(reservedFooterRows(total, false, chrome, contentWidth, Infinity), reservedFooterRows(total, true, chrome, contentWidth, Infinity));
|
|
185
|
+
const titleBudget = Math.max(terminalRows - headerRows - 1 - 2 - reservedRows - 1 - footerRows, 1);
|
|
186
|
+
const titleRows = wrapLine(escapeControlText(clipTitle(rawChrome.title, contentWidth, titleBudget)), contentWidth).map(({ row }) => row);
|
|
187
|
+
const fixedRows = headerRows + 1 + 2 + titleRows.length + reservedRows;
|
|
156
188
|
// At least one preview row stays visible; the footer is cut to what remains.
|
|
157
189
|
const maxFooterRows = Math.max(terminalRows - fixedRows - 1, 2);
|
|
158
190
|
const rowsWithFooter = (windowed) => Math.max(terminalRows - fixedRows - reservedFooterRows(total, windowed, chrome, contentWidth, maxFooterRows), 1);
|
|
@@ -163,9 +195,11 @@ export function previewViewport(rawLines, cursor, terminalRows, terminalColumns,
|
|
|
163
195
|
const end = Math.min(start + height, total);
|
|
164
196
|
const reachedEnd = end === total;
|
|
165
197
|
return {
|
|
198
|
+
titleRows,
|
|
166
199
|
rows: previewRows.slice(start, end),
|
|
167
200
|
sourceLines: wrapped.slice(start, end).map(({ sourceLine }) => sourceLine),
|
|
168
201
|
sourceOffsets: wrapped.slice(start, end).map(({ offset }) => offset),
|
|
202
|
+
prefixLengths: wrapped.slice(start, end).map(({ prefixLength }) => prefixLength),
|
|
169
203
|
start,
|
|
170
204
|
end,
|
|
171
205
|
total,
|
|
@@ -17,7 +17,7 @@ import { isYes, useKeys } from "./keys.js";
|
|
|
17
17
|
import { escapeControlText, usePreviewGeometry } from "./preview-window.js";
|
|
18
18
|
import { answerYesNo, moveCursor, movePreviewCursor, toggleChoice } from "./mode-keys.js";
|
|
19
19
|
import { BorderPanel, PagedPreview, PreviewLines, Working } from "./panel-parts.js";
|
|
20
|
-
import { clipText, elideMiddle, fitLines, wrapText } from "./text-wrap.js";
|
|
20
|
+
import { clipText, elideMiddle, fitLines, textUnits, wrapText } from "./text-wrap.js";
|
|
21
21
|
import { theme } from "./theme.js";
|
|
22
22
|
import { useMirrored, useMounted } from "./view-state.js";
|
|
23
23
|
import { computeWindow, useListHeight } from "./window.js";
|
|
@@ -145,7 +145,7 @@ const NOTE_CHROME = { title: "Export note", cancel: "[Esc] close" };
|
|
|
145
145
|
* what matter, so with fewer than three rows the field keeps its label and its tail behind a `…`; with
|
|
146
146
|
* three or more a path keeps its first row too, around a count of the rows left out.
|
|
147
147
|
*/
|
|
148
|
-
function NameField({ label, value, rows, width, tail = false }) {
|
|
148
|
+
export function NameField({ label, value, rows, width, tail = false }) {
|
|
149
149
|
// Measured and drawn as escaped, so a hostile path or name takes the rows it is shown on.
|
|
150
150
|
const escapedValue = escapeControlText(value);
|
|
151
151
|
const wrapped = wrapText(`${label}${escapedValue}`, width);
|
|
@@ -155,9 +155,10 @@ function NameField({ label, value, rows, width, tail = false }) {
|
|
|
155
155
|
if (keepsMiddleCount)
|
|
156
156
|
shown = elideMiddle(wrapped, rows, width);
|
|
157
157
|
else if (cut) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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);
|
|
161
162
|
if (shown.length <= rows)
|
|
162
163
|
break;
|
|
163
164
|
}
|
|
@@ -640,5 +641,5 @@ export function ProfileView({ operations, environment = {}, onCapturingChange, l
|
|
|
640
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")
|
|
641
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
|
|
642
643
|
? _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, {
|
|
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] });
|
|
644
645
|
}
|
package/dist/src/tui/render.js
CHANGED
|
@@ -5,14 +5,16 @@ import { App } from "./app.js";
|
|
|
5
5
|
/** Renders the TUI and resolves once the user exits it. */
|
|
6
6
|
export async function renderTui(operations, environment) {
|
|
7
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 });
|
|
8
|
+
const instance = render(_jsx(App, { operations: operations, environment: environment, refreshSession: refreshSession, refreshOnStart: environment?.refreshOnStart ?? true }), { alternateScreen: true });
|
|
11
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) {
|
|
12
14
|
refreshSession.stop();
|
|
13
15
|
// Killing a refresh would leave its Source cache lock behind, so wait for the running ones instead.
|
|
14
16
|
if ([...refreshSession.snapshot().values()].some((state) => state.status === "refreshing")) {
|
|
15
|
-
|
|
17
|
+
write("Waiting for the Source refreshes still running to finish…\n");
|
|
16
18
|
await refreshSession.settled();
|
|
17
19
|
}
|
|
18
20
|
}
|
|
@@ -364,16 +364,16 @@ export function useSourcesRecipes({ operations, environment, onCapturingChange,
|
|
|
364
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" ? (() => {
|
|
365
365
|
const chrome = recipeApprovalChrome(mode.entry);
|
|
366
366
|
const lines = mode.lines.map(lineText);
|
|
367
|
-
return _jsx(PagedPreview, {
|
|
367
|
+
return _jsx(PagedPreview, { lines: lines, styled: mode.lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
|
|
368
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" ? (() => {
|
|
369
369
|
const chrome = uninstallChrome(mode);
|
|
370
370
|
const styled = appPlanLines(mode.plan);
|
|
371
371
|
const lines = styled.map(lineText);
|
|
372
|
-
return _jsx(PagedPreview, {
|
|
372
|
+
return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: previewGeometry(lines, mode.cursor, chrome) });
|
|
373
373
|
})() : null, mode.kind === "remove" ? (() => {
|
|
374
374
|
const chrome = removeChrome(mode.removal);
|
|
375
375
|
const lines = removeLines(mode.removal);
|
|
376
|
-
return _jsx(PagedPreview, {
|
|
376
|
+
return _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
|
|
377
377
|
})() : null] }));
|
|
378
378
|
return { rows, loadingSince, handleKey, startAdd, reload, clearMessage: () => { setError(undefined); setNotice(undefined); }, panel, directory: apps.directory, capturing: mode.kind !== "browse" };
|
|
379
379
|
}
|
|
@@ -11,7 +11,6 @@ import { groupRecipeRows, useSourcesRecipes } from "./sources-recipes.js";
|
|
|
11
11
|
import { BorderPanel, PagedPreview, Working } from "./panel-parts.js";
|
|
12
12
|
import { usePreviewGeometry } from "./preview-window.js";
|
|
13
13
|
import { movePreviewCursor } from "./mode-keys.js";
|
|
14
|
-
import { formatAgo } from "./updates.js";
|
|
15
14
|
import { animationEnabled, formatElapsed } from "./working-model.js";
|
|
16
15
|
/** Footer keys for the selected row; App seeds the footer before the view reports a selection. */
|
|
17
16
|
export function sourceRowHints(row) {
|
|
@@ -69,7 +68,7 @@ function RefreshMark({ state, now }) {
|
|
|
69
68
|
case "refreshing":
|
|
70
69
|
return _jsxs(Text, { color: theme.warning, children: ["\u2026 refreshing ", formatElapsed(now - (state.startedAt ?? now))] });
|
|
71
70
|
case "refreshed":
|
|
72
|
-
return
|
|
71
|
+
return _jsx(Text, { color: theme.success, children: "\u2713 refreshed" });
|
|
73
72
|
case "failed":
|
|
74
73
|
return _jsx(Text, { color: theme.error, children: "\u2717 refresh failed" });
|
|
75
74
|
}
|
|
@@ -408,7 +407,7 @@ function Prompt({ mode, previewGeometry }) {
|
|
|
408
407
|
return _jsx(BorderPanel, { title: "Sources", children: _jsx(Working, { label: mode.label, since: mode.startedAt }) });
|
|
409
408
|
if (mode.kind === "confirm" && previewGeometry !== undefined) {
|
|
410
409
|
const { lines, chrome } = confirmPreview(mode);
|
|
411
|
-
return _jsx(PagedPreview, {
|
|
410
|
+
return _jsx(PagedPreview, { lines: lines, viewport: previewGeometry(lines, mode.cursor, chrome) });
|
|
412
411
|
}
|
|
413
412
|
return null;
|
|
414
413
|
}
|
|
@@ -1,11 +1,55 @@
|
|
|
1
1
|
import stringWidth from "string-width";
|
|
2
2
|
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
3
|
+
// Control, format and line-separator characters the TUI prints as escapes; the zero-width joiner stays for emoji sequences.
|
|
4
|
+
export const UNSAFE_PATTERN = "(?!\\u200D)[\\p{Cc}\\p{Cf}\\u2028\\u2029]";
|
|
5
|
+
const UNSAFE_CHARACTER = new RegExp(UNSAFE_PATTERN, "u");
|
|
6
|
+
/** Text shaped like an escape the TUI prints (`x1b`, `u202e`, `u{1f600}`), ignoring case. */
|
|
7
|
+
export const ESCAPE_TEXT = "x[0-9a-f]{2}|u[0-9a-f]{4}|u\\{[0-9a-f]{1,6}\\}";
|
|
8
|
+
const ESCAPE_UNIT = new RegExp(`(\\\\+)(${ESCAPE_TEXT})`, "giu");
|
|
9
|
+
/** Whether `escape` (`x1b`, `u202e`, `u{1f600}`) names a character the TUI would print as an escape. */
|
|
10
|
+
export function namesEscapedCharacter(escape) {
|
|
11
|
+
const code = Number.parseInt(escape.replace(/^[xu]\{?|\}$/giu, ""), 16);
|
|
12
|
+
return code <= 0x10ffff && UNSAFE_CHARACTER.test(String.fromCodePoint(code));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The units text is cut at: graphemes, except around an escape written as text that names a character the TUI escapes. Cut inside,
|
|
16
|
+
* its doubled backslash would leave a `\x1b` that reads as a real ESC. The backslash run splits into pairs from the left, so a cut
|
|
17
|
+
* between pairs keeps its parity, and the last one or two backslashes stay with the escape text: a unit is at most 12 columns. A unit
|
|
18
|
+
* wider than `maxWidth` falls back to graphemes. `index` is the UTF-16 offset of the unit.
|
|
19
|
+
*/
|
|
20
|
+
export function textUnits(value, maxWidth = Infinity) {
|
|
21
|
+
const units = [];
|
|
22
|
+
let from = 0;
|
|
23
|
+
const addGraphemes = (text, offset) => {
|
|
24
|
+
for (const { segment, index } of graphemes.segment(text))
|
|
25
|
+
units.push({ segment, index: offset + index });
|
|
26
|
+
};
|
|
27
|
+
for (const match of value.matchAll(ESCAPE_UNIT)) {
|
|
28
|
+
const [whole, backslashes = "", escape = ""] = match;
|
|
29
|
+
if (!namesEscapedCharacter(escape))
|
|
30
|
+
continue;
|
|
31
|
+
const tail = backslashes.length % 2 === 1 ? 1 : 2;
|
|
32
|
+
const pairs = backslashes.length - tail;
|
|
33
|
+
const unit = backslashes.slice(pairs) + escape;
|
|
34
|
+
addGraphemes(value.slice(from, match.index), from);
|
|
35
|
+
if (stringWidth(unit) > maxWidth)
|
|
36
|
+
addGraphemes(whole, match.index);
|
|
37
|
+
else {
|
|
38
|
+
for (let offset = 0; offset < pairs; offset += 2)
|
|
39
|
+
units.push({ segment: "\\\\", index: match.index + offset });
|
|
40
|
+
units.push({ segment: unit, index: match.index + pairs });
|
|
41
|
+
}
|
|
42
|
+
from = match.index + whole.length;
|
|
43
|
+
}
|
|
44
|
+
addGraphemes(value.slice(from), from);
|
|
45
|
+
return units;
|
|
46
|
+
}
|
|
3
47
|
/** Split text on grapheme boundaries so wide and combining characters wrap at `width` columns. */
|
|
4
48
|
export function wrapText(value, width) {
|
|
5
49
|
const lines = [];
|
|
6
50
|
let line = "";
|
|
7
51
|
let lineWidth = 0;
|
|
8
|
-
for (const { segment } of
|
|
52
|
+
for (const { segment } of textUnits(value, width)) {
|
|
9
53
|
const segmentWidth = stringWidth(segment);
|
|
10
54
|
if (line && lineWidth + segmentWidth > width) {
|
|
11
55
|
lines.push(line);
|
|
@@ -26,7 +70,7 @@ export function fitLines(lines, maxRows, width) {
|
|
|
26
70
|
const visible = lines.slice(0, maxRows);
|
|
27
71
|
let last = "";
|
|
28
72
|
let used = 0;
|
|
29
|
-
for (const { segment } of
|
|
73
|
+
for (const { segment } of textUnits(visible[maxRows - 1])) {
|
|
30
74
|
const segmentWidth = stringWidth(segment);
|
|
31
75
|
if (used + segmentWidth > width - 1)
|
|
32
76
|
break;
|
|
@@ -55,7 +99,7 @@ export function clipText(value, width) {
|
|
|
55
99
|
return value;
|
|
56
100
|
let last = "";
|
|
57
101
|
let used = 0;
|
|
58
|
-
for (const { segment } of
|
|
102
|
+
for (const { segment } of textUnits(value)) {
|
|
59
103
|
const segmentWidth = stringWidth(segment);
|
|
60
104
|
if (used + segmentWidth > Math.max(width - 1, 0))
|
|
61
105
|
break;
|
|
@@ -59,7 +59,7 @@ function CapturedUpdatePreview({ mode }) {
|
|
|
59
59
|
const styled = updateModePreviewLines(mode);
|
|
60
60
|
const lines = styled.map(lineText);
|
|
61
61
|
const chrome = updateModePreviewChrome(mode);
|
|
62
|
-
return _jsx(PagedPreview, {
|
|
62
|
+
return _jsx(PagedPreview, { lines: lines, styled: styled, viewport: usePreviewGeometry()(lines, mode.cursor, chrome) });
|
|
63
63
|
}
|
|
64
64
|
export function ModePanel({ mode }) {
|
|
65
65
|
switch (mode.kind) {
|
|
@@ -16,6 +16,12 @@ import { lineText } from "./preview-render.js";
|
|
|
16
16
|
import { updateModePreviewChrome, updateModePreviewLines } from "./updates-preview.js";
|
|
17
17
|
import { useKeys } from "./keys.js";
|
|
18
18
|
import { BorderPanel, Working } from "./panel-parts.js";
|
|
19
|
+
/** Whether the working panel is drawn. A preview owns the screen: its height was computed without the panel, so the two never share it. */
|
|
20
|
+
export function showsWorkingPanel(state, mode) {
|
|
21
|
+
if (mode.kind === "preview" || mode.kind === "manual" || mode.kind === "confirm-paths")
|
|
22
|
+
return false;
|
|
23
|
+
return state.status === "loading" || mode.kind === "busy";
|
|
24
|
+
}
|
|
19
25
|
const NO_ENVIRONMENT = {};
|
|
20
26
|
/** How often the age of the check is redrawn. */
|
|
21
27
|
const CLOCK_TICK_MS = 10_000;
|
|
@@ -116,6 +122,6 @@ export function UpdatesView({ operations, environment, onCapturingChange, listHe
|
|
|
116
122
|
const hasErrors = data?.appError !== undefined || (data?.results ?? []).some((result) => result.error !== undefined);
|
|
117
123
|
const showingBrowse = mode.kind === "browse";
|
|
118
124
|
// One indicator for the whole operation: a check's own phase label, else the busy mode's.
|
|
119
|
-
const working = state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
|
|
125
|
+
const working = !showsWorkingPanel(state, mode) ? undefined : state.status === "loading" ? state : mode.kind === "busy" ? mode : undefined;
|
|
120
126
|
return (_jsxs(Box, { flexDirection: "column", children: [showingBrowse ? _jsxs(Text, { color: theme.title, bold: true, children: ["Updates (scope: ", scope, ") t all \u00B7 p project \u00B7 g user-global \u00B7 u update"] }) : null, working ? _jsx(BorderPanel, { title: state.status === "loading" ? "Update check" : "Updates", children: _jsx(Working, { label: working.label, since: working.startedAt, now: clock }) }) : null, showingBrowse && data ? _jsx(CheckStatus, { data: data, now: clock() }) : null, showingBrowse && data && !hasErrors && shown.length === 0 ? _jsx(Text, { children: "No installations" }) : null, showingBrowse ? entries.slice(window.start, window.end).map((entry, offset) => (_jsx(EntryLine, { entry: entry, selected: window.start + offset === index, checked: checked, view: view, showUnknown: showUnknown }, entryId(entry)))) : null, showingBrowse ? _jsx(UpdatesFooter, { indicator: window.indicator, upToDate: upToDate, updatableCount: updatable.length, checkedCount: checked.length, message: message }) : null, _jsx(ModePanel, { mode: mode })] }));
|
|
121
127
|
}
|
package/dist/src/tui/updates.js
CHANGED
|
@@ -5,7 +5,7 @@ import { sourceIdForUrl } from "../git-source.js";
|
|
|
5
5
|
import { applyConfirmedUpdates, loadUpdates, previewUpdateCandidates, readScopeInstallations, skillUpdatePreviewItem, } from "../update-flow.js";
|
|
6
6
|
import { packageOperationsOf } from "./environment.js";
|
|
7
7
|
import { formatVersionEvidence } from "./installations.js";
|
|
8
|
-
import { describePackageComponent, installedEvidenceLabel, packageStatusLabel, packageVersionLabel, recordedVersionLabel } from "./package-rows.js";
|
|
8
|
+
import { describePackageComponent, installedEvidenceLabel, packageStatusLabel, packageVersionLabel, installedVersionLabel, recordedVersionLabel } from "./package-rows.js";
|
|
9
9
|
import { quoteText } from "../preview-model.js";
|
|
10
10
|
/** Every scope the view can assess, in display order. */
|
|
11
11
|
export const UPDATE_SCOPES = ["project", "user-global"];
|
|
@@ -213,7 +213,7 @@ function packagePreviewItem(row, { plan, approval }) {
|
|
|
213
213
|
return {
|
|
214
214
|
title: descriptor.name,
|
|
215
215
|
detail: selection.host,
|
|
216
|
-
version: { from:
|
|
216
|
+
version: { from: installedVersionLabel(row.check), to: packageVersionLabel(descriptor.version) },
|
|
217
217
|
rows,
|
|
218
218
|
};
|
|
219
219
|
}
|
|
@@ -291,7 +291,7 @@ export async function prepareUpdates(data, selected) {
|
|
|
291
291
|
groups, apps: data.apps, appPlans, appFailures,
|
|
292
292
|
packages: data.packages, packagePlans, packageFailures,
|
|
293
293
|
document: {
|
|
294
|
-
action: `Apply ${counted(applicable.length, "update")}`,
|
|
294
|
+
action: applicable.length === 0 ? "No updates can be applied" : `Apply ${counted(applicable.length, "update")}`,
|
|
295
295
|
summary: sections.map(({ items, noun }) => counted(items.length, noun)).join(" · "),
|
|
296
296
|
context: [...context, ...[...environments].map((environment) => `Environment: ${environment}`)],
|
|
297
297
|
warnings,
|