@aiwayds/dsh-tui-pi 0.28.0 → 1.0.0

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.
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Corrupted-resume-log repair orchestration — the /resume safety net.
3
+ *
4
+ * When a persisted session's log was forked by a historical double-writer
5
+ * (two dsh processes appending to one jsonl), the loader refuses it with
6
+ * "corrupt session log: seq gap …" and /resume dead-ends. The offline
7
+ * surgery itself lives in scripts/repair-session-log.mjs (deterministic
8
+ * dedupe + dense renumbering; the original is NEVER modified there). This
9
+ * module is the TUI-free bridge around it: it runs the script, verifies the
10
+ * result, and — only after an explicit user confirmation upstream — swaps
11
+ * the repaired copy in beside a kept backup, under the single-writer lock.
12
+ *
13
+ * Contract of the script (its own header is authoritative):
14
+ * exit 0 → the log is CLEAN (nothing written), or — with --apply — a
15
+ * `<stem>.repaired.jsonl[.zstd]` artifact was written BESIDE the
16
+ * original (the CLEAN verdict line distinguishes the two);
17
+ * exit 3 → corrupt diagnosed, nothing written (dry-run only by contract);
18
+ * exit 2 → usage/environment error (missing zstd, torn lines without
19
+ * --skip-bad-lines, …).
20
+ *
21
+ * Safety invariants (all enforced here, testable without a terminal):
22
+ * - The original log is never edited in place — swapping moves it aside to
23
+ * `<name>.corrupt-bak` first and only then renames the verified repaired
24
+ * copy over the canonical name.
25
+ * - A repaired copy that does not itself verify CLEAN is never swapped in.
26
+ * - The writer lock beside the log (<sessionDir>/writer.lock, same file the
27
+ * cold arms and the feishu guard compete on) is claimed for the whole
28
+ * apply→verify→swap window and always released, success or not.
29
+ */
30
+ import { spawnSync } from 'node:child_process';
31
+ import { chmodSync, existsSync, renameSync, statSync } from 'node:fs';
32
+ import { basename, dirname, join } from 'node:path';
33
+ import { SESSION_LOG_FILE_NAMES } from "./retention.js";
34
+ import { acquireWriterLock, projectKeyFor, releaseOwnedWriterLock, } from "./writer-lock.js";
35
+ /** The repair script rides the published package (`files` includes scripts/). */
36
+ export function repairScriptPath() {
37
+ return new URL('../scripts/repair-session-log.mjs', import.meta.url).pathname;
38
+ }
39
+ /** Hard wall-clock budget for one script invocation (big logs still finish; a hung zstd must not freeze /resume). */
40
+ export const REPAIR_TIMEOUT_MS = 120_000;
41
+ /**
42
+ * The path the repair script writes its artifact to (mirrors the script's
43
+ * own `<stem>.repaired.jsonl[.zstd]` naming, stem = name minus the jsonl[.zstd]
44
+ * suffix). Exported so tests and callers can predict the artifact location.
45
+ */
46
+ export function repairedArtifactPath(logPath) {
47
+ const isZstd = /\.jsonl\.zstd$/.test(logPath);
48
+ const stem = basename(logPath).replace(/\.jsonl(\.zstd)?$/, '');
49
+ return join(dirname(logPath), `${stem}.repaired.jsonl${isZstd ? '.zstd' : ''}`);
50
+ }
51
+ function firstLine(text) {
52
+ const line = text.split('\n').map(l => l.trim()).find(l => l !== '');
53
+ return line ?? '';
54
+ }
55
+ /**
56
+ * Run scripts/repair-session-log.mjs against one log. Never touches the log
57
+ * itself (the script cannot); classifies the outcome per the exit contract
58
+ * above. A missing zstd binary surfaces as the script's exit 2 — or, should
59
+ * the spawn itself fail with ENOENT, as a failed result with an install hint.
60
+ */
61
+ export function runRepair(logPath, options = {}) {
62
+ const apply = options.apply ?? false;
63
+ const spawn = options.spawnSyncFn ?? spawnSync;
64
+ let proc;
65
+ try {
66
+ proc = spawn(process.execPath, [repairScriptPath(), logPath, ...(apply ? ['--apply'] : [])], { encoding: 'utf8', timeout: REPAIR_TIMEOUT_MS, maxBuffer: 1 << 26 });
67
+ }
68
+ catch (error) {
69
+ const message = error instanceof Error ? error.message : String(error);
70
+ return { status: 'failed', detail: `cannot run the repair script: ${message}` };
71
+ }
72
+ if (proc.error !== undefined) {
73
+ const code = proc.error.code;
74
+ const hint = code === 'ENOENT' ? ' — is zstd installed and on PATH?' : '';
75
+ return {
76
+ status: 'failed',
77
+ detail: `cannot run the repair script: ${proc.error.message}${hint}`,
78
+ };
79
+ }
80
+ const out = proc.stdout ?? '';
81
+ const err = proc.stderr ?? '';
82
+ if (proc.status === 0) {
83
+ // Exit 0 is ambiguous by contract (CLEAN vs. repaired+written); the
84
+ // CLEAN verdict line disambiguates without guessing at files.
85
+ if (out.includes('verdict: CLEAN'))
86
+ return { status: 'clean' };
87
+ const repairedPath = repairedArtifactPath(logPath);
88
+ if (existsSync(repairedPath)) {
89
+ return { status: 'repaired', repairedPath, detail: firstLine(out) };
90
+ }
91
+ return {
92
+ status: 'failed',
93
+ detail: 'repair script exited 0 but wrote no repaired artifact beside the log',
94
+ };
95
+ }
96
+ if (proc.status === 3) {
97
+ // By the script's contract exit 3 means "corrupt, dry-run, nothing
98
+ // written" — with --apply this is a contract violation, but either way
99
+ // there is nothing to swap in, so it is a failure for our caller.
100
+ return {
101
+ status: 'failed',
102
+ detail: firstLine(out) || 'log is corrupt — the script diagnosed it without writing a repair',
103
+ };
104
+ }
105
+ return {
106
+ status: 'failed',
107
+ detail: firstLine(err) || `repair script exited ${proc.status ?? 'by signal'}`,
108
+ };
109
+ }
110
+ /**
111
+ * Verify one log loads clean (dry-run diagnosis, never writes). Used on the
112
+ * repaired artifact BEFORE it replaces the canonical log.
113
+ */
114
+ export function verifyClean(logPath, options = {}) {
115
+ const run = runRepair(logPath, { ...options, apply: false });
116
+ return run.status === 'clean' ? { ok: true } : { ok: false, detail: run.detail };
117
+ }
118
+ /**
119
+ * Swap the verified repaired copy in over the canonical log name: the
120
+ * original moves aside to `<name>.corrupt-bak` (suffixed with the epoch
121
+ * millisecond when that name is taken — never overwritten), the repaired
122
+ * copy takes its place with owner-only permissions, matching the store's
123
+ * private-file hygiene. Synchronous on purpose: the swap window sits under
124
+ * the writer lock and must be a tight rename-rename pair. A failed swap-in
125
+ * restores the original best-effort (mirroring the feishu surface) before
126
+ * rethrowing, so the session is never stranded without its canonical log.
127
+ */
128
+ export function swapRepaired(logPath, repairedPath, options = {}) {
129
+ let backupPath = `${logPath}.corrupt-bak`;
130
+ try {
131
+ statSync(backupPath);
132
+ backupPath = `${backupPath}.${Date.now()}`;
133
+ }
134
+ catch {
135
+ // Free — the plain .corrupt-bak name is ours.
136
+ }
137
+ renameSync(logPath, backupPath);
138
+ try {
139
+ renameSync(repairedPath, logPath);
140
+ }
141
+ catch (error) {
142
+ // Best-effort restore first — the canonical name must not stay missing
143
+ // with the original parked in the backup; the rethrow is the signal.
144
+ try {
145
+ renameSync(backupPath, logPath);
146
+ }
147
+ catch {
148
+ // Nothing further is recoverable here; the caller reports the swap
149
+ // failure and the .corrupt-bak copy still holds the original.
150
+ }
151
+ throw error;
152
+ }
153
+ try {
154
+ ;
155
+ (options.chmodFn ?? chmodSync)(logPath, 0o600);
156
+ }
157
+ catch {
158
+ // Permissions are store hygiene, not log integrity: the swap already
159
+ // succeeded, so a chmod failure never flips the outcome. A later
160
+ // successful repair (or any manual chmod) fixes the mode.
161
+ }
162
+ return { backupPath };
163
+ }
164
+ /**
165
+ * End-to-end repair of one session log, under the single-writer lock beside
166
+ * it: claim → apply → verify the repaired copy → swap → release. ANY failure
167
+ * leaves the canonical log untouched, releases the lock, and reports
168
+ * {kind:'failed'}; only a verified artifact is ever swapped in. The lock dir
169
+ * is the log's own directory — the same `<sessionDir>/writer.lock` the cold
170
+ * arms and the feishu guard compete on.
171
+ */
172
+ export async function repairSessionLog(logPath, options = {}) {
173
+ const dir = dirname(logPath);
174
+ let claim;
175
+ try {
176
+ claim = await acquireWriterLock(dir);
177
+ }
178
+ catch (error) {
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ return { kind: 'failed', detail: `cannot claim the writer lock: ${message}` };
181
+ }
182
+ if (!claim.ok)
183
+ return { kind: 'locked', holder: claim.holder };
184
+ try {
185
+ const run = runRepair(logPath, { apply: true, spawnSyncFn: options.spawnSyncFn });
186
+ if (run.status === 'failed')
187
+ return { kind: 'failed', detail: run.detail };
188
+ if (run.status === 'clean')
189
+ return { kind: 'clean' };
190
+ const verdict = verifyClean(run.repairedPath, options);
191
+ if (!verdict.ok) {
192
+ return {
193
+ kind: 'failed',
194
+ detail: `the repaired copy still fails verification: ${verdict.detail}`,
195
+ };
196
+ }
197
+ return {
198
+ kind: 'repaired',
199
+ backupPath: swapRepaired(logPath, run.repairedPath, options).backupPath,
200
+ };
201
+ }
202
+ catch (error) {
203
+ // swapRepaired restored the original best-effort before rethrowing; map
204
+ // the residue to a plain failure so no exception ever escapes toward the
205
+ // command dispatch — this module's contract is results, not throws.
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ return { kind: 'failed', detail: `swap failed: ${message}` };
208
+ }
209
+ finally {
210
+ await releaseOwnedWriterLock(dir);
211
+ }
212
+ }
213
+ /**
214
+ * The /resume corrupt-log fingerprint: the loader's "corrupt session log:
215
+ * seq gap …" refusal, plus the raw-decompression twin ("corrupt zstandard
216
+ * log"). Case-insensitive; anything else (locked session, missing
217
+ * persistence, network) must NOT route into the repair flow.
218
+ */
219
+ const CORRUPT_LOG_PATTERN = /corrupt .*(session|zstandard) log/i;
220
+ export function isCorruptLogError(message) {
221
+ return CORRUPT_LOG_PATTERN.test(message);
222
+ }
223
+ /**
224
+ * User-facing notice for a repair attempt that did NOT swap anything;
225
+ * undefined when the flow should proceed to resume ('repaired' | 'clean').
226
+ * Pure so the wording is testable without a terminal (English-only).
227
+ */
228
+ export function repairFailureNotice(result) {
229
+ if (result.kind === 'locked') {
230
+ return `session is driven by pid ${result.holder.pid} — close it on the other side first`;
231
+ }
232
+ if (result.kind === 'failed') {
233
+ return `repair failed: ${result.detail} — log untouched`;
234
+ }
235
+ return undefined;
236
+ }
237
+ /**
238
+ * Locate one session's canonical log file on disk the same way the
239
+ * single-writer guard derives it: `<root>/<projectKeyFor(cwd)>/<sessionId>/<name>`,
240
+ * with the cwd taken from the persisted header list (the log lives under
241
+ * THAT project key). Best-effort: no header, no cwd, or no log file under
242
+ * the derived dir yields undefined — the caller reports a failure instead
243
+ * of repairing a decoy. When both suffixes coexist (a kept raw copy beside
244
+ * the compressed log) the compressed name wins: it is what the writer
245
+ * appends to and what the loader reads.
246
+ */
247
+ export async function locateSessionLog(persistence, sessionId, root) {
248
+ let cwd;
249
+ try {
250
+ const stored = (await persistence?.list?.().catch(() => [])) ?? [];
251
+ const header = stored.find(candidate => String(candidate.id) === sessionId);
252
+ if (typeof header?.cwd === 'string' && header.cwd !== '')
253
+ cwd = header.cwd;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ if (cwd === undefined)
259
+ return undefined;
260
+ const dir = join(root, projectKeyFor(cwd), sessionId);
261
+ for (const name of SESSION_LOG_FILE_NAMES.slice().reverse()) {
262
+ const file = join(dir, name);
263
+ try {
264
+ statSync(file);
265
+ return file;
266
+ }
267
+ catch {
268
+ // Not this suffix — try the next one.
269
+ }
270
+ }
271
+ return undefined;
272
+ }
273
+ //# sourceMappingURL=log-repair.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-repair.js","sourceRoot":"","sources":["../src/log-repair.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * RemoteSessionTail — a READ-ONLY view over a session log that another
3
+ * dsh PROCESS is driving. Firehose events do not cross the process
4
+ * boundary, so a surface refusing to fork the log (single-writer guard)
5
+ * can still satisfy "both ends eventually show the final reply" by
6
+ * watching persisted bytes: poll-decode the whole jsonl.zstd (frames are
7
+ * independent concatenations, so full decode is append-correct), and emit
8
+ * only the durable suffix rows that appeared since the previous tick.
9
+ *
10
+ * Display contract (deliberately lossy): streaming deltas are skipped;
11
+ * durable rows only — user/message, assistant/message (the FINAL LLM text),
12
+ * tool/call + tool/result, plus turn/start|end so the viewer can
13
+ * synthesize a working/idle indicator from the driver's lifecycle.
14
+ * Latency is the poll interval; detail may be coarser than the driving
15
+ * surface's live view — but every turn's final assistant message arrives.
16
+ */
17
+ export interface RemoteTailEvent {
18
+ type?: string;
19
+ seq?: number;
20
+ }
21
+ export interface RemoteTailCallbacks {
22
+ /** New durable events, in log-append order (possibly empty per tick). */
23
+ onEvents(events: RemoteTailEvent[]): void;
24
+ /** Decode failure tick — transient IO hiccups are non-fatal noise. */
25
+ onError?(error: unknown): void;
26
+ }
27
+ export interface RemoteTailOptions {
28
+ /** Poll cadence; defaults to 2s (cross-process latency is accepted). */
29
+ intervalMs?: number;
30
+ /** Decoder seam — tests substitute a pure function; default shells zstd. */
31
+ decode?(file: string): Promise<string>;
32
+ }
33
+ export declare class RemoteSessionTail {
34
+ private timer;
35
+ private ticking;
36
+ /** Rows already emitted — append-only watermark across ticks. */
37
+ private seenRows;
38
+ private readonly file;
39
+ private readonly callbacks;
40
+ private readonly options;
41
+ constructor(file: string, callbacks: RemoteTailCallbacks, options?: RemoteTailOptions);
42
+ start(): void;
43
+ stop(): void;
44
+ /** Force one poll immediately — the deterministic backfill entry point. */
45
+ tickOnce(): Promise<void>;
46
+ /** Where the watched log lives — tests assert on it. */
47
+ getWatchedFile(): string;
48
+ private tick;
49
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * RemoteSessionTail — a READ-ONLY view over a session log that another
3
+ * dsh PROCESS is driving. Firehose events do not cross the process
4
+ * boundary, so a surface refusing to fork the log (single-writer guard)
5
+ * can still satisfy "both ends eventually show the final reply" by
6
+ * watching persisted bytes: poll-decode the whole jsonl.zstd (frames are
7
+ * independent concatenations, so full decode is append-correct), and emit
8
+ * only the durable suffix rows that appeared since the previous tick.
9
+ *
10
+ * Display contract (deliberately lossy): streaming deltas are skipped;
11
+ * durable rows only — user/message, assistant/message (the FINAL LLM text),
12
+ * tool/call + tool/result, plus turn/start|end so the viewer can
13
+ * synthesize a working/idle indicator from the driver's lifecycle.
14
+ * Latency is the poll interval; detail may be coarser than the driving
15
+ * surface's live view — but every turn's final assistant message arrives.
16
+ */
17
+ import { execFile } from 'node:child_process';
18
+ import { join } from 'node:path';
19
+ const TURN_START = 'turn/start';
20
+ const TURN_END = 'turn/end';
21
+ /** Durable row types rendered in a remote view, in log order — streaming
22
+ * deltas excluded (see module doc for the display contract). */
23
+ const DURABLE_TYPES = new Set([
24
+ 'user/message',
25
+ 'assistant/message',
26
+ 'tool/call',
27
+ 'tool/result',
28
+ TURN_START,
29
+ TURN_END,
30
+ ]);
31
+ function defaultDecode(file) {
32
+ return new Promise((resolvePromise, rejectPromise) => {
33
+ execFile('zstd', ['-dc', file], { maxBuffer: 1 << 30 }, (error, stdout) => {
34
+ if (error !== null && error !== undefined)
35
+ rejectPromise(error);
36
+ else
37
+ resolvePromise(Buffer.from(stdout, 'utf8').toString('utf8'));
38
+ });
39
+ });
40
+ }
41
+ export class RemoteSessionTail {
42
+ timer;
43
+ ticking = false;
44
+ /** Rows already emitted — append-only watermark across ticks. */
45
+ seenRows = 0;
46
+ file;
47
+ callbacks;
48
+ options;
49
+ constructor(file, callbacks, options = {}) {
50
+ this.file = file;
51
+ this.callbacks = callbacks;
52
+ this.options = options;
53
+ }
54
+ start() {
55
+ if (this.timer !== undefined)
56
+ return;
57
+ this.timer = setInterval(() => void this.tick(), this.options.intervalMs ?? 2000);
58
+ }
59
+ stop() {
60
+ if (this.timer === undefined)
61
+ return;
62
+ clearInterval(this.timer);
63
+ this.timer = undefined;
64
+ }
65
+ /** Force one poll immediately — the deterministic backfill entry point. */
66
+ tickOnce() {
67
+ return this.tick(true);
68
+ }
69
+ /** Where the watched log lives — tests assert on it. */
70
+ getWatchedFile() {
71
+ return join(this.file);
72
+ }
73
+ async tick(force = false) {
74
+ if (this.ticking)
75
+ return;
76
+ if (!force && this.timer === undefined)
77
+ return;
78
+ this.ticking = true;
79
+ try {
80
+ const decode = this.options.decode ?? defaultDecode;
81
+ const text = await decode(this.file);
82
+ const lines = text.split('\n').filter(line => line.trim() !== '');
83
+ // Log shrank underneath us (repair/rename) — treat as a fresh stream
84
+ // rather than emitting a bogus negative diff.
85
+ if (lines.length < this.seenRows)
86
+ this.seenRows = 0;
87
+ const fresh = [];
88
+ for (let i = this.seenRows; i < lines.length; i++) {
89
+ let value;
90
+ try {
91
+ value = JSON.parse(lines[i]);
92
+ }
93
+ catch {
94
+ continue; // torn trailing frame mid-decode — next tick re-reads it
95
+ }
96
+ if (typeof value !== 'object' || value === null)
97
+ continue;
98
+ const event = value;
99
+ if (event.type !== undefined && DURABLE_TYPES.has(event.type))
100
+ fresh.push(event);
101
+ }
102
+ this.seenRows = lines.length;
103
+ if (fresh.length > 0)
104
+ this.callbacks.onEvents(fresh);
105
+ }
106
+ catch (error) {
107
+ this.callbacks.onError?.(error);
108
+ }
109
+ finally {
110
+ this.ticking = false;
111
+ }
112
+ }
113
+ }
114
+ //# sourceMappingURL=remote-tail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-tail.js","sourceRoot":"","sources":["../src/remote-tail.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The /resume repair confirmation dialog — shown when a picked session's
3
+ * log fails to load with a corrupt-log error (src/log-repair.ts
4
+ * `isCorruptLogError`). Repairing rewrites user data on disk, so it is
5
+ * NEVER done implicitly: this dialog is the explicit confirmation gate.
6
+ *
7
+ * Two options, in the spec's button order:
8
+ * 1. Repair & resume — run the guarded in-place repair, then re-enter the
9
+ * selected row's resume path.
10
+ * 2. Cancel — leave the log untouched; /resume reports the original error.
11
+ * ↑↓ moves the selection, `1`/`2` select directly, Enter confirms, Esc
12
+ * cancels — the exact keymap of the submit routing dialog (route-dialog.ts),
13
+ * whose pure-reducer split this file mirrors so the decision matrix stays
14
+ * unit-testable without a terminal. Framing/focus follow the shared overlay
15
+ * contract: PanelHost framing, close re-focuses the CURRENT editor instance
16
+ * through `restoreFocus`.
17
+ */
18
+ import { type Component, type TUI } from '@earendil-works/pi-tui';
19
+ import { type TuiTheme } from './theme/index.ts';
20
+ /** The two choices in display order; index 0 is preselected. */
21
+ export declare const REPAIR_CONFIRM_OPTIONS: ReadonlyArray<{
22
+ id: 'repair' | 'cancel';
23
+ title: string;
24
+ hint: string;
25
+ }>;
26
+ /** The fixed body copy (spec wording): what happened, what repair does, what is kept. */
27
+ export declare const REPAIR_CONFIRM_MESSAGE: string;
28
+ /** Footer hint — hardcoded like every other panel footer (English-only). */
29
+ export declare const REPAIR_CONFIRM_FOOTER = "\u2191\u2193 select \u00B7 1/2 pick \u00B7 Enter confirm \u00B7 Esc cancel";
30
+ /** Pure dialog state: which row is highlighted, and the terminal outcome. */
31
+ export interface RepairConfirmState {
32
+ selected: number;
33
+ /**
34
+ * Set once by a terminal key: `'confirm'` (Enter on a selection) or
35
+ * `'cancel'` (Esc). Further input is ignored afterwards.
36
+ */
37
+ settled?: 'confirm' | 'cancel';
38
+ }
39
+ export declare function initialRepairConfirmState(): RepairConfirmState;
40
+ /**
41
+ * Apply one raw key sequence to the dialog state. Unknown keys are no-ops;
42
+ * anything after a settle is ignored (single terminal outcome guard).
43
+ */
44
+ export declare function updateRepairConfirm(state: RepairConfirmState, data: string): RepairConfirmState;
45
+ /** Resolved dialog outcome: the chosen action, or undefined on cancel. */
46
+ export declare function repairConfirmOutcome(state: RepairConfirmState): 'repair' | 'cancel' | undefined;
47
+ /**
48
+ * The framed overlay component. Renders the fixed body copy plus the two
49
+ * option rows; every key goes through {@link updateRepairConfirm}, and the
50
+ * first terminal key fires `onFinish` exactly once.
51
+ */
52
+ export declare class RepairConfirmPanel implements Component {
53
+ private readonly theme;
54
+ private readonly onFinish;
55
+ private readonly requestRenderFn;
56
+ private state;
57
+ constructor(theme: TuiTheme, onFinish: (outcome: 'repair' | 'cancel' | undefined) => void, requestRender: () => void);
58
+ invalidate(): void;
59
+ render(width: number): string[];
60
+ handleInput(data: string): void;
61
+ }
62
+ /**
63
+ * Open the repair confirmation dialog for one corrupt resume target.
64
+ * Resolves `'repair'` when the user explicitly confirmed, `'cancelled'`
65
+ * otherwise (Esc, or an overlay that failed to mount — treated as cancel so
66
+ * a half-mounted dialog can never imply consent). Closing always hands
67
+ * focus back through `restoreFocus` before the promise settles.
68
+ */
69
+ export declare function openRepairConfirmDialog(tui: TUI, theme: TuiTheme, restoreFocus: () => void): Promise<'repair' | 'cancelled'>;