@timurproko/a1 0.1.8-dev.544 → 0.1.8-dev.558
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/bin/cli.js +4 -0
- package/dist/app/session-shell/session-shell-root.js +15 -5
- package/dist/cli/dispatch.d.ts +5 -0
- package/dist/cli/dispatch.js +26 -0
- package/dist/cli/session-context.d.ts +9 -0
- package/dist/cli/session-context.js +29 -0
- package/dist/composition/owned-ui.js +6 -2
- package/dist/contracts/owned-ui/model.d.ts +5 -0
- package/dist/contracts/owned-ui/validation.js +6 -0
- package/dist/contracts/presentation/index.d.ts +13 -0
- package/dist/contracts/presentation/index.js +30 -0
- package/dist/features/launch/index.d.ts +2 -0
- package/dist/features/launch/index.js +1 -0
- package/dist/features/launch/session-context.d.ts +8 -0
- package/dist/features/launch/session-context.js +8 -0
- package/dist/features/owned-ui/project-trust-prompt.js +1 -1
- package/dist/features/owned-ui/reference-screen-app.js +7 -8
- package/dist/features/owned-ui/settings-app.js +9 -5
- package/dist/foundation/lifecycle/index.d.ts +2 -0
- package/dist/foundation/lifecycle/index.js +1 -0
- package/dist/foundation/lifecycle/session-repository-context.d.ts +18 -0
- package/dist/foundation/lifecycle/session-repository-context.js +173 -0
- package/dist/foundation/release/index.d.ts +2 -2
- package/dist/foundation/release/index.js +1 -1
- package/dist/foundation/release/update.d.ts +22 -1
- package/dist/foundation/release/update.js +61 -12
- package/dist/integrations/pi/components/models-dialog.js +9 -9
- package/dist/integrations/pi/components/shell-editor-autocomplete.d.ts +0 -6
- package/dist/integrations/pi/components/shell-editor-autocomplete.js +41 -8
- package/dist/integrations/pi/components/shell-extension-ui.js +4 -2
- package/dist/integrations/pi/components/shell-footer-status.js +1 -1
- package/dist/integrations/pi/components/shell-presenters-transcript.js +4 -2
- package/dist/integrations/pi/components/skills-command.d.ts +2 -0
- package/dist/integrations/pi/components/skills-command.js +11 -10
- package/dist/integrations/pi/components/skills-dialog.js +8 -3
- package/dist/integrations/pi/components/theme.d.ts +4 -0
- package/dist/integrations/pi/components/theme.js +10 -0
- package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +4 -4
- package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +2 -2
- package/dist/integrations/pi/components/upstream/components/extension-editor.js +9 -10
- package/dist/integrations/pi/components/upstream/components/extension-input.d.ts +17 -0
- package/dist/integrations/pi/components/upstream/components/extension-input.js +72 -0
- package/dist/integrations/pi/components/upstream/components/extension-selector.d.ts +15 -0
- package/dist/integrations/pi/components/upstream/components/extension-selector.js +92 -0
- package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +3 -2
- package/dist/integrations/pi/components/upstream/components/scoped-models-selector.js +16 -15
- package/dist/integrations/pi/components/upstream/components/session-footer.js +25 -9
- package/dist/integrations/pi/components/upstream/components/session-selector.js +25 -16
- package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +6 -4
- package/dist/integrations/pi/components/upstream/components/thinking-selector.js +22 -12
- package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +3 -2
- package/dist/integrations/pi/components/upstream/components/tree-selector.js +16 -13
- package/dist/integrations/pi/components/upstream/components/trust-selector.js +11 -5
- package/dist/integrations/pi/engine/adapter.d.ts +2 -1
- package/dist/integrations/pi/engine/adapter.js +2 -0
- package/dist/integrations/pi/engine/message-values.js +1 -0
- package/dist/integrations/pi/engine/resource-catalog.js +1 -1
- package/dist/integrations/pi/engine/session-runtime.d.ts +11 -2
- package/dist/integrations/pi/engine/session-runtime.js +136 -33
- package/dist/integrations/pi/engine/transcript-projection.js +35 -0
- package/dist/integrations/pi/startup-public.manifest.json +2 -0
- package/dist/native/darwin-arm64/manifest.json +1 -1
- package/dist/native/linux-x64/manifest.json +1 -1
- package/dist/native/win32-x64/manifest.json +2 -2
- package/dist/native/win32-x64/process-guardian.exe +0 -0
- package/dist/product-identity.d.ts +1 -1
- package/dist/product-identity.js +1 -1
- package/dist/product-identity.json +2 -1
- package/dist/ui/components/dialog-panel.d.ts +2 -1
- package/dist/ui/components/dialog-panel.js +6 -1
- package/dist/ui/components/index.d.ts +2 -0
- package/dist/ui/components/index.js +1 -0
- package/dist/ui/components/shortcut-hints.d.ts +7 -0
- package/dist/ui/components/shortcut-hints.js +12 -0
- package/dist/ui/components/shortcuts.d.ts +2 -0
- package/dist/ui/components/shortcuts.js +5 -2
- package/dist/ui/components/transcript-viewport.d.ts +1 -1
- package/docs/architecture/project-structure.md +6 -3
- package/docs/ci-release-runbook.md +9 -7
- package/docs/openspec-archive-automation.md +6 -0
- package/docs/validation.md +5 -2
- package/package.json +1 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { open, lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { PRODUCT_IDENTITY } from "../../product-identity.js";
|
|
6
|
+
import { resolveProductPaths } from "./paths.js";
|
|
7
|
+
const SCHEMA = "a1-session-repository-context-v1";
|
|
8
|
+
const MAX_RECORD_BYTES = 16 * 1024;
|
|
9
|
+
const MAX_HEADER_BYTES = 64 * 1024;
|
|
10
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
11
|
+
/** Persist one explicit same-repository worktree association for a stable Pi session. */
|
|
12
|
+
export async function setSessionRepositoryContext(identity, worktreePath, options = {}) {
|
|
13
|
+
const header = await readSessionHeader(identity);
|
|
14
|
+
const startup = await readGitWorktreeIdentity(header.cwd, false, options.signal);
|
|
15
|
+
const selected = await readGitWorktreeIdentity(worktreePath, true, options.signal);
|
|
16
|
+
if (!samePath(startup.commonDir, selected.commonDir)) {
|
|
17
|
+
throw new Error("associated worktree belongs to a different Git repository");
|
|
18
|
+
}
|
|
19
|
+
const record = {
|
|
20
|
+
schema: SCHEMA,
|
|
21
|
+
sessionId: header.id,
|
|
22
|
+
sessionFile: header.file,
|
|
23
|
+
startupRoot: startup.root,
|
|
24
|
+
startupCommonDir: startup.commonDir,
|
|
25
|
+
worktreeRoot: selected.root,
|
|
26
|
+
worktreeCommonDir: selected.commonDir,
|
|
27
|
+
worktreeGitDir: selected.gitDir,
|
|
28
|
+
updatedAt: new Date().toISOString(),
|
|
29
|
+
};
|
|
30
|
+
const destination = recordPath(header, options);
|
|
31
|
+
const directory = dirname(destination);
|
|
32
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
33
|
+
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
34
|
+
try {
|
|
35
|
+
await writeFile(temporary, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
36
|
+
await rename(temporary, destination);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
40
|
+
}
|
|
41
|
+
return { cwd: selected.root, branch: requireBranch(selected) };
|
|
42
|
+
}
|
|
43
|
+
/** Remove only the association belonging to the supplied stable session. */
|
|
44
|
+
export async function clearSessionRepositoryContext(identity, options = {}) {
|
|
45
|
+
const header = await readSessionHeader(identity);
|
|
46
|
+
await rm(recordPath(header, options), { force: true });
|
|
47
|
+
}
|
|
48
|
+
/** Read and revalidate a persisted association; malformed or stale state fails closed to absence. */
|
|
49
|
+
export async function readSessionRepositoryContext(identity, options = {}) {
|
|
50
|
+
let header;
|
|
51
|
+
try {
|
|
52
|
+
header = await readSessionHeader(identity);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const path = recordPath(header, options);
|
|
59
|
+
const metadata = await lstat(path);
|
|
60
|
+
if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size === 0 || metadata.size > MAX_RECORD_BYTES)
|
|
61
|
+
return null;
|
|
62
|
+
const bytes = await readFile(path, { signal: options.signal });
|
|
63
|
+
const value = JSON.parse(bytes.toString("utf8"));
|
|
64
|
+
if (!isRecord(value)
|
|
65
|
+
|| value.schema !== SCHEMA
|
|
66
|
+
|| value.sessionId !== header.id
|
|
67
|
+
|| value.sessionFile !== header.file
|
|
68
|
+
|| !validPath(value.startupRoot)
|
|
69
|
+
|| !validPath(value.startupCommonDir)
|
|
70
|
+
|| !validPath(value.worktreeRoot)
|
|
71
|
+
|| !validPath(value.worktreeCommonDir)
|
|
72
|
+
|| !validPath(value.worktreeGitDir)
|
|
73
|
+
|| typeof value.updatedAt !== "string")
|
|
74
|
+
return null;
|
|
75
|
+
const startup = await readGitWorktreeIdentity(header.cwd, false, options.signal);
|
|
76
|
+
const selected = await readGitWorktreeIdentity(value.worktreeRoot, true, options.signal);
|
|
77
|
+
if (!samePath(startup.root, value.startupRoot)
|
|
78
|
+
|| !samePath(startup.commonDir, value.startupCommonDir)
|
|
79
|
+
|| !samePath(selected.root, value.worktreeRoot)
|
|
80
|
+
|| !samePath(selected.commonDir, value.worktreeCommonDir)
|
|
81
|
+
|| !samePath(selected.gitDir, value.worktreeGitDir)
|
|
82
|
+
|| !samePath(startup.commonDir, selected.commonDir))
|
|
83
|
+
return null;
|
|
84
|
+
return { cwd: selected.root, branch: requireBranch(selected) };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function readSessionHeader(identity) {
|
|
91
|
+
if (!validSessionId(identity.sessionId) || !validPath(identity.sessionFile))
|
|
92
|
+
throw new Error("active session identity is unavailable");
|
|
93
|
+
const file = await realpath(resolve(identity.sessionFile));
|
|
94
|
+
const handle = await open(file, "r");
|
|
95
|
+
try {
|
|
96
|
+
const buffer = Buffer.alloc(MAX_HEADER_BYTES);
|
|
97
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0);
|
|
98
|
+
const newline = buffer.subarray(0, bytesRead).indexOf(0x0a);
|
|
99
|
+
if (newline < 0)
|
|
100
|
+
throw new Error("session header is unavailable");
|
|
101
|
+
const value = JSON.parse(buffer.subarray(0, newline).toString("utf8"));
|
|
102
|
+
if (!isRecord(value) || value.type !== "session" || value.id !== identity.sessionId || !validPath(value.cwd)) {
|
|
103
|
+
throw new Error("active session identity does not match its session file");
|
|
104
|
+
}
|
|
105
|
+
return { id: identity.sessionId, cwd: await realpath(resolve(value.cwd)), file };
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function readGitWorktreeIdentity(path, requireWorktreeRoot, signal) {
|
|
112
|
+
if (!validPath(path))
|
|
113
|
+
throw new Error("associated worktree path is invalid");
|
|
114
|
+
const cwd = await realpath(resolve(path));
|
|
115
|
+
const { stdout } = await executeGit(["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir", "--git-dir"], cwd, MAX_RECORD_BYTES, signal);
|
|
116
|
+
const lines = stdout.split(/\r?\n/u).filter(Boolean);
|
|
117
|
+
if (lines.length !== 3)
|
|
118
|
+
throw new Error("associated worktree identity is invalid");
|
|
119
|
+
const [rootValue, commonValue, gitValue] = lines;
|
|
120
|
+
if (!rootValue || !commonValue || !gitValue)
|
|
121
|
+
throw new Error("associated worktree identity is incomplete");
|
|
122
|
+
const root = await realpath(rootValue);
|
|
123
|
+
if (requireWorktreeRoot && !samePath(root, cwd))
|
|
124
|
+
throw new Error("associated path must be a Git worktree root");
|
|
125
|
+
const commonDir = await realpath(commonValue);
|
|
126
|
+
const gitDir = await realpath(gitValue);
|
|
127
|
+
let branch = null;
|
|
128
|
+
if (requireWorktreeRoot) {
|
|
129
|
+
const branchResult = await executeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], cwd, 4_096, signal);
|
|
130
|
+
branch = branchResult.stdout.trim();
|
|
131
|
+
if (branch.length === 0 || branch.length > 256)
|
|
132
|
+
throw new Error("associated worktree has a detached or invalid branch");
|
|
133
|
+
}
|
|
134
|
+
return { root, commonDir, gitDir, branch };
|
|
135
|
+
}
|
|
136
|
+
async function executeGit(arguments_, cwd, maxBuffer, signal) {
|
|
137
|
+
const { execFile } = await import("node:child_process");
|
|
138
|
+
const execute = promisify(execFile);
|
|
139
|
+
return await execute("git", [...arguments_], {
|
|
140
|
+
cwd,
|
|
141
|
+
windowsHide: true,
|
|
142
|
+
timeout: GIT_TIMEOUT_MS,
|
|
143
|
+
maxBuffer,
|
|
144
|
+
encoding: "utf8",
|
|
145
|
+
signal,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
function requireBranch(identity) {
|
|
149
|
+
if (identity.branch === null)
|
|
150
|
+
throw new Error("associated worktree branch is unavailable");
|
|
151
|
+
return identity.branch;
|
|
152
|
+
}
|
|
153
|
+
function recordPath(header, options) {
|
|
154
|
+
const dataDir = options.dataDir ?? resolveProductPaths().dataDir;
|
|
155
|
+
const digest = createHash("sha256").update(header.id).update("\0").update(pathKey(header.file)).digest("hex");
|
|
156
|
+
return join(dataDir, PRODUCT_IDENTITY.state.sessionRepositoryDirectory, `${digest}.json`);
|
|
157
|
+
}
|
|
158
|
+
function samePath(left, right) {
|
|
159
|
+
return pathKey(left) === pathKey(right);
|
|
160
|
+
}
|
|
161
|
+
function pathKey(value) {
|
|
162
|
+
const normalized = resolve(value);
|
|
163
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
164
|
+
}
|
|
165
|
+
function validSessionId(value) {
|
|
166
|
+
return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(value);
|
|
167
|
+
}
|
|
168
|
+
function validPath(value) {
|
|
169
|
+
return typeof value === "string" && value.length > 0 && value.length <= 32_768 && !value.includes("\0") && isAbsolute(value);
|
|
170
|
+
}
|
|
171
|
+
function isRecord(value) {
|
|
172
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
173
|
+
}
|
|
@@ -20,8 +20,8 @@ export { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDoc
|
|
|
20
20
|
export type { RestartSeal, RestartValidationEvent } from "./restart-certification.js";
|
|
21
21
|
export { STABLE_RELEASE_SCHEMA, createStableReleaseEvidence, verifyStableRegistry } from "./stable-release.js";
|
|
22
22
|
export type { StableRegistryState, StableRegistryVerificationOptions, StableReleaseEvidence, StableReleaseEvidenceInput } from "./stable-release.js";
|
|
23
|
-
export { PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
|
|
24
|
-
export type { ProcessRequest, ProcessResult, SelfUpdateOptions, UpdateActivationPhase, UpdateChannel, UpdateFileMetadata, UpdateFileSystem, UpdateLifecycleCoordinator, UpdateMaterializationProgress, UpdateMeasuredPhase, UpdateOutput, UpdateOwnershipAction, UpdatePackageReplacementInput, UpdatePerformanceEvidence, UpdatePhaseTimingEvent, UpdateProcessRunner, UpdateTransactionJournal, } from "./update.js";
|
|
23
|
+
export { PACKAGE_UNLOCK_PATIENCE_MS, PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, isPackageLockError, lockedPackageDiagnostic, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
|
|
24
|
+
export type { ProcessRequest, ProcessResult, SelfUpdateOptions, UpdateActivationPhase, UpdateChannel, UpdateFileMetadata, UpdateFileSystem, UpdateLifecycleCoordinator, UpdateMaterializationProgress, UpdateMeasuredPhase, UpdateOutput, UpdateOwnershipAction, UpdatePackageReplacementInput, UpdatePerformanceEvidence, UpdatePhaseTimingEvent, UpdateProcessRunner, UpdateTransactionJournal, UpdateUnlockPatience, } from "./update.js";
|
|
25
25
|
export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
|
|
26
26
|
export type { UpdateActivationCallbacks, UpdateActivationEvent, UpdateActivationRequest } from "./update-activation.js";
|
|
27
27
|
export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
|
|
@@ -9,7 +9,7 @@ export { collectRelease, prepareReleaseCleanup, runBoundedReleaseCleanup, runRel
|
|
|
9
9
|
export { RELEASE_MANIFEST_FILENAME, assertImmutableExecutionRoot, consumeMaterializationProof, materializeRelease, readCertifiedReleaseManifest, readMaterializedRelease, resolveReleaseEntryPoint, verifyMaterializedRelease, } from "./release-store.js";
|
|
10
10
|
export { createRestartSeal, readRestartCertifiedRelease, releaseCertificationDocument, restartSealDigest } from "./restart-certification.js";
|
|
11
11
|
export { STABLE_RELEASE_SCHEMA, createStableReleaseEvidence, verifyStableRegistry } from "./stable-release.js";
|
|
12
|
-
export { PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
|
|
12
|
+
export { PACKAGE_UNLOCK_PATIENCE_MS, PRODUCT_PACKAGE, assertUpdatePerformanceBudget, createNpmProcessRunner, createUpdateLifecycleCoordinator, isPackageLockError, lockedPackageDiagnostic, planUpdateOwnership, renderUpdateProgressBar, runSelfUpdate, } from "./update.js";
|
|
13
13
|
export { UPDATE_ACTIVATION_CONTRACT, UPDATE_ACTIVATION_ENTRY, UPDATE_ACTIVATION_MANIFEST_FIELD, activateInstalledRelease, delegateActivation, readActivationContracts, runActivationEntry, } from "./update-activation.js";
|
|
14
14
|
export { selectSupervisorLaunchReleaseId, selectUpdateLaunchRelease } from "./update-launch.js";
|
|
15
15
|
export { UPDATE_RECOVERY_SCHEMA, cleanupUpdateRecoveryCapsules, inspectUpdateLauncherSet, npmPrefixForGlobalRoot, prepareUpdateRecoveryCapsule, readUpdateRecoveryCapsule, removeUpdateRecoveryCapsule, runProtectedPackageReplacement, updateLauncherPaths, updateNpmInstallArguments, } from "./update-recovery.js";
|
|
@@ -23,6 +23,16 @@ export interface UpdateFileSystem {
|
|
|
23
23
|
realpath(path: string): Promise<string>;
|
|
24
24
|
lstat?(path: string): Promise<UpdateFileMetadata>;
|
|
25
25
|
access?(path: string): Promise<void>;
|
|
26
|
+
rename?(from: string, to: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* How long the update keeps checking that nothing holds the package tree, and the clock it
|
|
30
|
+
* checks with. Tests inject a clock; the product waits in real time.
|
|
31
|
+
*/
|
|
32
|
+
export interface UpdateUnlockPatience {
|
|
33
|
+
readonly windowMs?: number;
|
|
34
|
+
readonly sleep?: (durationMs: number) => Promise<void>;
|
|
35
|
+
readonly now?: () => number;
|
|
26
36
|
}
|
|
27
37
|
export interface UpdateOutput {
|
|
28
38
|
stdout(message: string): void;
|
|
@@ -103,6 +113,17 @@ export interface UpdatePackageReplacementInput {
|
|
|
103
113
|
readonly environment: NodeJS.ProcessEnv;
|
|
104
114
|
readonly onRecoveryState: (state: UpdateRecoveryState) => Promise<void>;
|
|
105
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* The package tree is checked for holders by renaming it and renaming it back, because on
|
|
118
|
+
* Windows that is the one operation that fails while any file under it is open. A holder is
|
|
119
|
+
* usually brief: a scanner, an indexer, a file browser, or a session still shutting down.
|
|
120
|
+
* The update therefore keeps checking for this long before it calls the package locked.
|
|
121
|
+
*/
|
|
122
|
+
export declare const PACKAGE_UNLOCK_PATIENCE_MS = 15000;
|
|
123
|
+
/** Whether a rename failure means something holds the tree, as opposed to the tree being wrong. */
|
|
124
|
+
export declare function isPackageLockError(error: unknown): boolean;
|
|
125
|
+
/** What the user reads when the package stayed held for the whole patience window. */
|
|
126
|
+
export declare function lockedPackageDiagnostic(packageRoot: string, waitedMs: number, attempts: number, error: unknown): string;
|
|
106
127
|
/** Bound on the child diagnostics an update keeps: enough for npm's failure reason, never a log. */
|
|
107
128
|
export declare const CHILD_DIAGNOSTIC_LIMIT = 8000;
|
|
108
129
|
export declare function createNpmProcessRunner(platform?: NodeJS.Platform): UpdateProcessRunner;
|
|
@@ -114,7 +135,7 @@ export type UpdateOwnershipAction = "clean-dead-record" | "leave-running" | "end
|
|
|
114
135
|
* because that installation is exactly what is about to be replaced under it.
|
|
115
136
|
*/
|
|
116
137
|
export declare function planUpdateOwnership(ownership: "live-verified" | "dead", runsFromRetainedRelease: boolean): UpdateOwnershipAction;
|
|
117
|
-
export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput): UpdateLifecycleCoordinator;
|
|
138
|
+
export declare function createUpdateLifecycleCoordinator(environment?: NodeJS.ProcessEnv, fileSystem?: UpdateFileSystem, output?: UpdateOutput, patience?: UpdateUnlockPatience): UpdateLifecycleCoordinator;
|
|
118
139
|
/** Renders the terminal-only update meter; the visual preview shares this exact frame. */
|
|
119
140
|
export declare function renderUpdateProgressBar(percent: number): string;
|
|
120
141
|
export declare function runSelfUpdate(options: SelfUpdateOptions): Promise<number>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { access, lstat, readFile, realpath } from "node:fs/promises";
|
|
2
|
+
import { access, lstat, readFile, realpath, rename } from "node:fs/promises";
|
|
3
3
|
import { connect } from "node:net";
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
5
|
import crossSpawn from "cross-spawn";
|
|
@@ -23,7 +23,32 @@ const defaultFileSystem = {
|
|
|
23
23
|
realpath,
|
|
24
24
|
lstat,
|
|
25
25
|
access,
|
|
26
|
+
rename,
|
|
26
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* The package tree is checked for holders by renaming it and renaming it back, because on
|
|
30
|
+
* Windows that is the one operation that fails while any file under it is open. A holder is
|
|
31
|
+
* usually brief: a scanner, an indexer, a file browser, or a session still shutting down.
|
|
32
|
+
* The update therefore keeps checking for this long before it calls the package locked.
|
|
33
|
+
*/
|
|
34
|
+
export const PACKAGE_UNLOCK_PATIENCE_MS = 15_000;
|
|
35
|
+
const PACKAGE_UNLOCK_INITIAL_DELAY_MS = 100;
|
|
36
|
+
const PACKAGE_UNLOCK_MAX_DELAY_MS = 1_000;
|
|
37
|
+
// Platform: Windows reports a held directory as EPERM or EBUSY; EACCES and ENOTEMPTY are the other
|
|
38
|
+
// spellings a rename gives to a tree something is using. Anything else is not a lock.
|
|
39
|
+
const PACKAGE_LOCK_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
|
|
40
|
+
const defaultSleep = async (durationMs) => await new Promise(resolvePromise => setTimeout(resolvePromise, durationMs));
|
|
41
|
+
/** Whether a rename failure means something holds the tree, as opposed to the tree being wrong. */
|
|
42
|
+
export function isPackageLockError(error) {
|
|
43
|
+
return error instanceof Error && "code" in error && typeof error.code === "string" && PACKAGE_LOCK_CODES.has(error.code);
|
|
44
|
+
}
|
|
45
|
+
/** What the user reads when the package stayed held for the whole patience window. */
|
|
46
|
+
export function lockedPackageDiagnostic(packageRoot, waitedMs, attempts, error) {
|
|
47
|
+
const seconds = Math.max(1, Math.round(waitedMs / 1_000));
|
|
48
|
+
return PRODUCT_TEXT.diagnostic(`package remained locked for ${seconds}s after verified shutdown (${attempts} checks): ${errorMessage(error)}. `
|
|
49
|
+
+ `Another program is holding a file under ${packageRoot}: an antivirus scan, a file browser or terminal open inside it, `
|
|
50
|
+
+ `or a session still shutting down. Close it and run the update again; nothing was changed.`);
|
|
51
|
+
}
|
|
27
52
|
const defaultOutput = {
|
|
28
53
|
stdout(message) { process.stdout.write(message); },
|
|
29
54
|
stderr(message) { process.stderr.write(message); },
|
|
@@ -79,7 +104,7 @@ async function readActiveEndpoint(paths, activeReleaseId, environment) {
|
|
|
79
104
|
const legacy = await readEndpointMetadata(legacyPaths.endpointMetadataPath);
|
|
80
105
|
return legacy ? { metadata: legacy, paths: legacyPaths } : null;
|
|
81
106
|
}
|
|
82
|
-
export function createUpdateLifecycleCoordinator(environment = process.env, fileSystem = defaultFileSystem, output = defaultOutput) {
|
|
107
|
+
export function createUpdateLifecycleCoordinator(environment = process.env, fileSystem = defaultFileSystem, output = defaultOutput, patience = {}) {
|
|
83
108
|
const paths = resolveProductPaths(environment);
|
|
84
109
|
const stateStore = new CohortStateStore(paths.dataDir);
|
|
85
110
|
return {
|
|
@@ -139,16 +164,40 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
|
|
|
139
164
|
if (fileSystem.access)
|
|
140
165
|
await fileSystem.access(packageRoot);
|
|
141
166
|
const probe = `${packageRoot}.${PRODUCT_IDENTITY.filesystem.slug}-unlock-probe`;
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
const renameTree = fileSystem.rename ?? rename;
|
|
168
|
+
const windowMs = patience.windowMs ?? PACKAGE_UNLOCK_PATIENCE_MS;
|
|
169
|
+
const now = patience.now ?? Date.now;
|
|
170
|
+
const sleep = patience.sleep ?? defaultSleep;
|
|
171
|
+
const startedAt = now();
|
|
172
|
+
let delay = PACKAGE_UNLOCK_INITIAL_DELAY_MS;
|
|
173
|
+
let attempts = 0;
|
|
174
|
+
// Rationale: a holder found right after ownership was released is almost always transient,
|
|
175
|
+
// so one failed rename is a reason to look again, not to abandon an update that has not
|
|
176
|
+
// yet changed anything. The wait is bounded so a genuine lock still fails clearly.
|
|
177
|
+
const withPatience = async (from, to, describeFailure) => {
|
|
178
|
+
while (true) {
|
|
179
|
+
attempts += 1;
|
|
180
|
+
try {
|
|
181
|
+
await renameTree(from, to);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
const elapsed = now() - startedAt;
|
|
186
|
+
if (!isPackageLockError(error) || elapsed >= windowMs)
|
|
187
|
+
throw describeFailure(error);
|
|
188
|
+
// Rationale: the last pause is cut to the window's edge so the wait ends when promised.
|
|
189
|
+
await sleep(Math.min(delay, windowMs - elapsed));
|
|
190
|
+
delay = Math.min(PACKAGE_UNLOCK_MAX_DELAY_MS, delay * 2);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
await withPatience(packageRoot, probe, error => isPackageLockError(error)
|
|
195
|
+
? new Error(lockedPackageDiagnostic(packageRoot, now() - startedAt, attempts, error))
|
|
196
|
+
: new Error(PRODUCT_TEXT.diagnostic(`could not verify that the package is unlocked: ${errorMessage(error)}`)));
|
|
197
|
+
// Invariant: the package tree is put back under its own name before this returns; a holder
|
|
198
|
+
// that appears while it wears the probe name is waited out the same way, and if it never
|
|
199
|
+
// lets go the diagnostic names where the tree is so the user can restore it.
|
|
200
|
+
await withPatience(probe, packageRoot, error => new Error(PRODUCT_TEXT.diagnostic(`could not restore the package after checking it was unlocked: ${errorMessage(error)}. The package is at ${probe}; rename it back to ${packageRoot} before running the update again.`)));
|
|
152
201
|
},
|
|
153
202
|
async activateInstalled(packageRoot, targetVersion, phase, onMaterializing, onWarmup) {
|
|
154
203
|
const request = { packageRoot, dataDir: paths.dataDir, targetVersion, environment };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Input, Key, getKeybindings, matchesKey, truncateToWidth, } from "@earendil-works/pi-tui";
|
|
2
|
-
import { piTheme } from "./theme.js";
|
|
2
|
+
import { piTheme, renderPiModalShortcutHints } from "./theme.js";
|
|
3
3
|
const MAX_VISIBLE_ROWS = 10;
|
|
4
4
|
const MODELS_TITLE = "Models";
|
|
5
5
|
function fullModelId(model) {
|
|
@@ -228,7 +228,7 @@ export class ModelsDialogComponent {
|
|
|
228
228
|
push();
|
|
229
229
|
if (this.#refreshStatus !== undefined)
|
|
230
230
|
push(theme.fg(this.#refreshStatus.kind, ` ${this.#refreshStatus.message}`));
|
|
231
|
-
push(
|
|
231
|
+
push(renderPiModalShortcutHints(this.#hints()));
|
|
232
232
|
push(border);
|
|
233
233
|
return lines;
|
|
234
234
|
}
|
|
@@ -237,13 +237,13 @@ export class ModelsDialogComponent {
|
|
|
237
237
|
const confirm = keyLabel("tui.select.confirm");
|
|
238
238
|
const save = keyLabel("app.models.save");
|
|
239
239
|
return [
|
|
240
|
-
"type to search",
|
|
241
|
-
"↑↓ navigate",
|
|
242
|
-
...(tab.length === 0 ? [] : [
|
|
243
|
-
...(confirm.length === 0 ? [] : [
|
|
244
|
-
"space scope",
|
|
245
|
-
...(save.length === 0 ? [] : [
|
|
246
|
-
"esc close",
|
|
240
|
+
{ action: "type to search" },
|
|
241
|
+
{ key: "↑↓", action: "navigate" },
|
|
242
|
+
...(tab.length === 0 ? [] : [{ key: tab, action: "filter" }]),
|
|
243
|
+
...(confirm.length === 0 ? [] : [{ key: confirm, action: "switch" }]),
|
|
244
|
+
{ key: "space", action: "scope" },
|
|
245
|
+
...(save.length === 0 ? [] : [{ key: save, action: "save" }]),
|
|
246
|
+
{ key: "esc", action: "close" },
|
|
247
247
|
];
|
|
248
248
|
}
|
|
249
249
|
#emptyText() {
|
|
@@ -16,14 +16,8 @@ export declare const PINNED_PI_BUILTIN_SLASH_COMMANDS: ({
|
|
|
16
16
|
export declare const OWNED_BUILTIN_SLASH_COMMANDS: ({
|
|
17
17
|
readonly name: "thinking";
|
|
18
18
|
readonly description: "Set thinking level";
|
|
19
|
-
readonly argumentHint: "<level>";
|
|
20
|
-
} | {
|
|
21
|
-
name: string;
|
|
22
|
-
description: string;
|
|
23
|
-
argumentHint?: never;
|
|
24
19
|
} | {
|
|
25
20
|
name: string;
|
|
26
21
|
description: string;
|
|
27
|
-
argumentHint: string;
|
|
28
22
|
})[];
|
|
29
23
|
export declare function createPiShellEditor(options: PiShellEditorOptions): PiShellEditorPort;
|
|
@@ -9,12 +9,14 @@ import { createTuiFacade, ensureTheme, isAutocompleteProvider, } from "./shell-s
|
|
|
9
9
|
import { SKILLS_COMMAND_NAME, collapseSkillCommands, createSkillsTunnelProvider, } from "./skills-command.js";
|
|
10
10
|
/** A selected autocomplete row: the accent primary column, then the aligned description. */
|
|
11
11
|
const SELECTED_DESCRIBED_ROW = /^(→ .*?\S)(\s{2,}.*)$/u;
|
|
12
|
-
const
|
|
12
|
+
const PINNED_THINKING_SLASH_COMMAND = { name: "thinking", description: "Set thinking level", argumentHint: "<level>" };
|
|
13
|
+
const OWNED_THINKING_SLASH_COMMAND = { name: "thinking", description: "Set thinking level" };
|
|
14
|
+
const OWNED_LOGIN_SLASH_COMMAND = { name: "login", description: "Configure provider authentication" };
|
|
13
15
|
export const PINNED_PI_BUILTIN_SLASH_COMMANDS = [
|
|
14
16
|
{ name: "settings", description: "Open settings menu" },
|
|
15
17
|
{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },
|
|
16
18
|
{ name: "tree", description: "Navigate session tree (switch branches)" },
|
|
17
|
-
|
|
19
|
+
PINNED_THINKING_SLASH_COMMAND,
|
|
18
20
|
{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
|
|
19
21
|
{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
|
|
20
22
|
{ name: "import", description: "Import and resume a session from a JSONL file" },
|
|
@@ -36,9 +38,16 @@ export const PINNED_PI_BUILTIN_SLASH_COMMANDS = [
|
|
|
36
38
|
{ name: "quit", description: "Quit" },
|
|
37
39
|
];
|
|
38
40
|
/** Bare A1's built-in catalog: `models` replaces the pinned pair and is followed by `thinking`. */
|
|
39
|
-
export const OWNED_BUILTIN_SLASH_COMMANDS = PINNED_PI_BUILTIN_SLASH_COMMANDS.flatMap(command =>
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
export const OWNED_BUILTIN_SLASH_COMMANDS = PINNED_PI_BUILTIN_SLASH_COMMANDS.flatMap(command => {
|
|
42
|
+
if (command.name === "model") {
|
|
43
|
+
return [{ name: "models", description: "Switch models and manage scoped model cycling" }, OWNED_THINKING_SLASH_COMMAND];
|
|
44
|
+
}
|
|
45
|
+
if (command.name === "thinking" || command.name === "scoped-models")
|
|
46
|
+
return [];
|
|
47
|
+
if (command.name === "login")
|
|
48
|
+
return [OWNED_LOGIN_SLASH_COMMAND];
|
|
49
|
+
return [command];
|
|
50
|
+
});
|
|
42
51
|
export function createPiShellEditor(options) {
|
|
43
52
|
ensureTheme();
|
|
44
53
|
const tui = createTuiFacade(options);
|
|
@@ -132,9 +141,17 @@ export function createPiShellEditor(options) {
|
|
|
132
141
|
const commands = collapsed.commands;
|
|
133
142
|
const additions = new Map(commands.map(command => [command.name, command]));
|
|
134
143
|
const builtInNames = new Set(builtInCatalog.map(command => command.name));
|
|
135
|
-
const builtIns = builtInCatalog.
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
const builtIns = builtInCatalog.flatMap(command => [
|
|
145
|
+
autocompleteCommand(command, additions.get(command.name)),
|
|
146
|
+
// Rationale: the collapsed browser is a primary bare-A1 command, not a resource after quit.
|
|
147
|
+
...(command.name === "settings" && collapsed.collapsedCommand !== undefined
|
|
148
|
+
? [autocompleteCommand(collapsed.collapsedCommand)]
|
|
149
|
+
: []),
|
|
150
|
+
]);
|
|
151
|
+
// Invariant: built-in additions and the promoted collapsed browser never reappear as resources.
|
|
152
|
+
const resources = commands
|
|
153
|
+
.filter(command => command.name !== collapsed.collapsedCommand?.name && command.source !== "builtin" && !builtInNames.has(command.name))
|
|
154
|
+
.map(command => autocompleteCommand(command));
|
|
138
155
|
const combined = new CombinedAutocompleteProvider([...builtIns, ...resources], options.cwd ?? process.cwd());
|
|
139
156
|
autocompleteProvider = tunnelSkills.length === 0 ? combined : createSkillsTunnelProvider(combined, tunnelSkills);
|
|
140
157
|
editor.setAutocompleteProvider(autocompleteProvider);
|
|
@@ -229,10 +246,26 @@ export function createPiShellEditor(options) {
|
|
|
229
246
|
if (editorUx !== undefined && matchesKey(data, "shift+tab")
|
|
230
247
|
&& !Object.values(keybindings.getEffectiveConfig()).some(keys => (Array.isArray(keys) ? keys : [keys]).some(key => key !== undefined && matchesKey(data, key))))
|
|
231
248
|
return;
|
|
249
|
+
const hadDraft = editor.getText().length > 0;
|
|
232
250
|
if (editorUx === undefined)
|
|
233
251
|
editor.handleInput(data);
|
|
234
252
|
else
|
|
235
253
|
editorUx.handleInput(data);
|
|
254
|
+
if (editorUx === undefined || !hadDraft || editor.getText().length > 0)
|
|
255
|
+
return;
|
|
256
|
+
// Invariant: Pi's deletion paths can leave a visible or debounced autocomplete request owned by
|
|
257
|
+
// the removed draft. Reapplying empty text cancels both forms without a duplicate change notice.
|
|
258
|
+
const onChange = editor.onChange;
|
|
259
|
+
delete editor.onChange;
|
|
260
|
+
try {
|
|
261
|
+
editor.setText("");
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
if (onChange === undefined)
|
|
265
|
+
delete editor.onChange;
|
|
266
|
+
else
|
|
267
|
+
editor.onChange = onChange;
|
|
268
|
+
}
|
|
236
269
|
},
|
|
237
270
|
invalidate: () => editor.invalidate(),
|
|
238
271
|
setFocused: focused => {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { ExtensionEditorComponent
|
|
1
|
+
import { getSelectListTheme, } from "../startup-public.js";
|
|
2
|
+
import { ExtensionEditorComponent } from "./upstream/components/extension-editor.js";
|
|
3
|
+
import { ExtensionInputComponent } from "./upstream/components/extension-input.js";
|
|
4
|
+
import { ExtensionSelectorComponent } from "./upstream/components/extension-selector.js";
|
|
3
5
|
import { setKeybindings, Text, } from "@earendil-works/pi-tui";
|
|
4
6
|
import { KeybindingsManager, } from "./upstream/adjacent/core/keybindings.js";
|
|
5
7
|
import { PINNED_PI_LAYOUT, applyPiTheme, applyPiThemeInstance, getAvailablePiThemes, loadPiTheme, piTheme, } from "./theme.js";
|
|
@@ -165,7 +165,7 @@ function liveWorkingText(view, workingOverride, progressPresentation) {
|
|
|
165
165
|
const message = workingOverride ?? view.status.workingMessage ?? "Working";
|
|
166
166
|
const progress = view.status.workingProgress;
|
|
167
167
|
return workingOverride === undefined && progressPresentation === "custom-viewport" && typeof progress === "number"
|
|
168
|
-
? `${message}
|
|
168
|
+
? `${message}(${progress}%)`
|
|
169
169
|
: message;
|
|
170
170
|
}
|
|
171
171
|
function statusComponent(view, ui, outputPad, liveStatusText, placement) {
|
|
@@ -191,9 +191,11 @@ export function renderPiShellPackageUpdateNotice(packages, width) {
|
|
|
191
191
|
function transcriptComponent(block, cwd, expanded, extensions, submittedPrompt, outputPad, hideThinkingBlock, mermaidRenderingMode, showImages, imageWidthCells, imageAssets, tui) {
|
|
192
192
|
switch (block.kind) {
|
|
193
193
|
case "user": {
|
|
194
|
-
const
|
|
194
|
+
const visibleBlock = submittedPrompt === undefined || block.userPresentation === undefined ? block
|
|
195
|
+
: { ...block, text: block.userPresentation.visibleText };
|
|
196
|
+
const skill = parseSkillBlock(visibleBlock.text);
|
|
195
197
|
if (!skill)
|
|
196
|
-
return submittedPrompt ? createPiSubmittedPromptComponent(
|
|
198
|
+
return submittedPrompt ? createPiSubmittedPromptComponent(visibleBlock, submittedPrompt) : new UserMessageComponent(visibleBlock.text);
|
|
197
199
|
const invocation = new SkillInvocationMessageComponent(skill, getMarkdownTheme());
|
|
198
200
|
invocation.setExpanded(expanded);
|
|
199
201
|
if (!skill.userMessage)
|
|
@@ -11,6 +11,8 @@ export interface PiShellSkillSummary {
|
|
|
11
11
|
}
|
|
12
12
|
export interface PiShellCollapsedSkillCommands {
|
|
13
13
|
readonly commands: readonly PiShellAutocompleteCommand[];
|
|
14
|
+
/** The synthetic command replacing the withheld entries, when skill commands were present. */
|
|
15
|
+
readonly collapsedCommand?: PiShellAutocompleteCommand;
|
|
14
16
|
/** The skills withheld from the top-level menu, in the engine's discovery order. */
|
|
15
17
|
readonly skills: readonly PiShellSkillSummary[];
|
|
16
18
|
}
|
|
@@ -27,6 +27,15 @@ export function collapseSkillCommands(commands) {
|
|
|
27
27
|
if (skills.length === 0)
|
|
28
28
|
return { commands, skills };
|
|
29
29
|
const collapsed = [];
|
|
30
|
+
const collapsedCommand = {
|
|
31
|
+
name: SKILLS_COMMAND_NAME,
|
|
32
|
+
description: SKILLS_COMMAND_DESCRIPTION,
|
|
33
|
+
argumentOptions: skills.map(skill => ({
|
|
34
|
+
id: skill.name,
|
|
35
|
+
label: skill.name,
|
|
36
|
+
...(skill.description.length === 0 ? {} : { description: skill.description }),
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
30
39
|
let inserted = false;
|
|
31
40
|
for (const command of commands) {
|
|
32
41
|
if (!command.name.startsWith(SKILL_COMMAND_PREFIX)) {
|
|
@@ -36,17 +45,9 @@ export function collapseSkillCommands(commands) {
|
|
|
36
45
|
if (inserted)
|
|
37
46
|
continue;
|
|
38
47
|
inserted = true;
|
|
39
|
-
collapsed.push(
|
|
40
|
-
name: SKILLS_COMMAND_NAME,
|
|
41
|
-
description: SKILLS_COMMAND_DESCRIPTION,
|
|
42
|
-
argumentOptions: skills.map(skill => ({
|
|
43
|
-
id: skill.name,
|
|
44
|
-
label: skill.name,
|
|
45
|
-
...(skill.description.length === 0 ? {} : { description: skill.description }),
|
|
46
|
-
})),
|
|
47
|
-
});
|
|
48
|
+
collapsed.push(collapsedCommand);
|
|
48
49
|
}
|
|
49
|
-
return { commands: collapsed, skills };
|
|
50
|
+
return { commands: collapsed, collapsedCommand, skills };
|
|
50
51
|
}
|
|
51
52
|
/** Case-insensitive substring match on the name or description; a leading `skill:`/`skills:` in the query is ignored for the name. */
|
|
52
53
|
export function skillMatchesQuery(skill, query) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { DynamicBorder
|
|
1
|
+
import { DynamicBorder } from "../startup-public.js";
|
|
2
2
|
import { Container, getKeybindings, Input, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
|
-
import { PINNED_PI_LAYOUT, piTheme } from "./theme.js";
|
|
3
|
+
import { PINNED_PI_LAYOUT, piTheme, renderPiModalShortcutHints } from "./theme.js";
|
|
4
4
|
import { componentPort, ensureTheme, piShellTruncateToWidth, piShellVisibleWidth } from "./shell-shared-facade.js";
|
|
5
5
|
import { SKILL_COMMAND_PREFIX, skillMatchesQuery } from "./skills-command.js";
|
|
6
6
|
/** A single line clipped at the viewport width: no wrap, no ellipsis, padded to the full width. */
|
|
@@ -44,7 +44,12 @@ class SkillsSelectorComponent extends Container {
|
|
|
44
44
|
this.addChild(new Spacer(1));
|
|
45
45
|
this.addChild(this.#listContainer);
|
|
46
46
|
this.addChild(new Spacer(1));
|
|
47
|
-
|
|
47
|
+
const bindings = getKeybindings();
|
|
48
|
+
this.addChild(new Text(renderPiModalShortcutHints([
|
|
49
|
+
{ key: "↑↓", action: "navigate" },
|
|
50
|
+
{ key: bindings.getKeys("tui.select.confirm").join("/"), action: "select" },
|
|
51
|
+
{ key: bindings.getKeys("tui.select.cancel").join("/"), action: "cancel" },
|
|
52
|
+
]), 0, 0));
|
|
48
53
|
this.addChild(new DynamicBorder());
|
|
49
54
|
this.#updateList();
|
|
50
55
|
}
|
|
@@ -1 +1,5 @@
|
|
|
1
|
+
import { type SemanticShortcutHint } from "../../../contracts/presentation/index.js";
|
|
1
2
|
export * from "./upstream/theme/theme.js";
|
|
3
|
+
export type PiModalShortcutHint = SemanticShortcutHint;
|
|
4
|
+
/** The common bare-A1 modal footer: quiet display-cased keys, muted names, and whitespace-only gaps. */
|
|
5
|
+
export declare function renderPiModalShortcutHints(entries: readonly PiModalShortcutHint[], indent?: number): string;
|
|
@@ -1 +1,11 @@
|
|
|
1
|
+
import { renderSemanticShortcutHints, } from "../../../contracts/presentation/index.js";
|
|
2
|
+
import { piTheme } from "./upstream/theme/theme.js";
|
|
1
3
|
export * from "./upstream/theme/theme.js";
|
|
4
|
+
/** The common bare-A1 modal footer: quiet display-cased keys, muted names, and whitespace-only gaps. */
|
|
5
|
+
export function renderPiModalShortcutHints(entries, indent = 0) {
|
|
6
|
+
const theme = piTheme();
|
|
7
|
+
return renderSemanticShortcutHints(entries, {
|
|
8
|
+
key: text => theme.fg("dim", text),
|
|
9
|
+
action: text => theme.fg("muted", text),
|
|
10
|
+
}, indent);
|
|
11
|
+
}
|