theclawbay 0.5.1 → 0.6.2

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.
@@ -3,12 +3,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.buildConversationScope = buildConversationScope;
6
7
  exports.migrateSessionProviders = migrateSessionProviders;
7
8
  exports.migrateStateDbProviders = migrateStateDbProviders;
8
9
  const promises_1 = __importDefault(require("node:fs/promises"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
10
11
  const node_child_process_1 = require("node:child_process");
11
12
  const STATE_DB_FILE_PATTERN = /^state_\d+\.sqlite$/;
13
+ // Rough IO model for the "~time" labels. Each session is read and, when it
14
+ // carries a provider tag we neutralize, rewritten — so budget ~1.4x its bytes,
15
+ // plus a small per-file syscall overhead. Deliberately conservative.
16
+ const MIGRATION_BYTES_PER_SEC = 45 * 1024 * 1024;
17
+ const MIGRATION_PER_FILE_SECONDS = 0.004;
18
+ function estimateMigrationSeconds(count, bytes) {
19
+ if (count <= 0)
20
+ return 0;
21
+ return (bytes * 1.4) / MIGRATION_BYTES_PER_SEC + count * MIGRATION_PER_FILE_SECONDS;
22
+ }
12
23
  function resolvePythonCommand() {
13
24
  const candidates = [
14
25
  { command: "python3", args: ["-c", "import sqlite3"] },
@@ -26,16 +37,6 @@ function resolvePythonCommand() {
26
37
  }
27
38
  return null;
28
39
  }
29
- function renderProgressBar(current, total) {
30
- if (total <= 0)
31
- return "";
32
- const width = 24;
33
- const ratio = Math.max(0, Math.min(1, current / total));
34
- const filled = Math.round(width * ratio);
35
- return `[${"#".repeat(filled)}${"-".repeat(width - filled)}] ${Math.round(ratio * 100)
36
- .toString()
37
- .padStart(3, " ")}%`;
38
- }
39
40
  function inferRolloutUpdatedAt(source) {
40
41
  const lines = source.split(/\r?\n/);
41
42
  for (let i = lines.length - 1; i >= 0; i--) {
@@ -84,6 +85,51 @@ async function listSessionRollouts(root) {
84
85
  await walk(root);
85
86
  return results;
86
87
  }
88
+ // Newest-first rollout paths. Codex names sessions
89
+ // `rollout-<ISO timestamp>-<uuid>.jsonl` inside sessions/YYYY/MM/DD/, so a
90
+ // descending lexicographic sort is chronological without statting every file.
91
+ function sortNewestFirst(paths) {
92
+ return [...paths].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0));
93
+ }
94
+ async function statCandidates(paths) {
95
+ const out = [];
96
+ for (const p of paths) {
97
+ try {
98
+ const stats = await promises_1.default.stat(p);
99
+ out.push({ path: p, size: stats.size, mtimeMs: stats.mtimeMs });
100
+ }
101
+ catch {
102
+ // ignore transient missing file / race
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ // Inspect the local Codex session store and produce the pickable scope tiers
108
+ // (Last 10 / 30 / 100 / All) with per-tier time estimates. Only a bounded
109
+ // sample is statted so this stays instant even for huge histories.
110
+ async function buildConversationScope(codexHome) {
111
+ const sessionsRoot = node_path_1.default.join(codexHome, "sessions");
112
+ const paths = sortNewestFirst(await listSessionRollouts(sessionsRoot));
113
+ const total = paths.length;
114
+ const sampleSize = Math.min(total, 100);
115
+ const sample = await statCandidates(paths.slice(0, sampleSize));
116
+ const sampledBytes = sample.reduce((sum, candidate) => sum + candidate.size, 0);
117
+ const avgSize = sample.length > 0 ? sampledBytes / sample.length : 0;
118
+ const bytesForCount = (count) => {
119
+ if (count <= sample.length) {
120
+ return sample.slice(0, count).reduce((sum, candidate) => sum + candidate.size, 0);
121
+ }
122
+ return sampledBytes + avgSize * (count - sample.length);
123
+ };
124
+ const options = [];
125
+ for (const limit of [10, 30, 100]) {
126
+ if (total <= limit)
127
+ break; // a tier that already covers everything is just "All"
128
+ options.push({ limit, count: limit, estSeconds: estimateMigrationSeconds(limit, bytesForCount(limit)) });
129
+ }
130
+ options.push({ limit: null, count: total, estSeconds: estimateMigrationSeconds(total, bytesForCount(total)) });
131
+ return { total, paths, options };
132
+ }
87
133
  async function readMigrationState(migrationStateFile) {
88
134
  try {
89
135
  const raw = await promises_1.default.readFile(migrationStateFile, "utf8");
@@ -126,27 +172,45 @@ async function computeSessionSnapshot(files) {
126
172
  }
127
173
  async function migrateSessionProviders(params) {
128
174
  const sessionsRoot = node_path_1.default.join(params.codexHome, "sessions");
129
- const files = await listSessionRollouts(sessionsRoot);
130
- const snapshot = await computeSessionSnapshot(files);
131
- const previousState = await readMigrationState(params.migrationStateFile);
132
- if (previousState &&
133
- previousState.fileCount === snapshot.fileCount &&
134
- previousState.maxMtimeMs === snapshot.maxMtimeMs) {
135
- return { scanned: 0, rewritten: 0, skipped: 0, retimed: 0, fastSkipped: true };
175
+ const scoped = Array.isArray(params.files);
176
+ const files = scoped ? params.files : await listSessionRollouts(sessionsRoot);
177
+ // The mtime/count fast-skip only makes sense for a full pass — a bounded
178
+ // subset should always run so a user who picked "Last 30" isn't told there's
179
+ // nothing to do just because a prior full pass recorded the snapshot.
180
+ if (!scoped) {
181
+ const snapshot = await computeSessionSnapshot(files);
182
+ const previousState = await readMigrationState(params.migrationStateFile);
183
+ if (previousState &&
184
+ previousState.fileCount === snapshot.fileCount &&
185
+ previousState.maxMtimeMs === snapshot.maxMtimeMs) {
186
+ return { scanned: 0, rewritten: 0, skipped: 0, retimed: 0, fastSkipped: true };
187
+ }
136
188
  }
137
189
  let rewritten = 0;
138
190
  let skipped = 0;
139
191
  let retimed = 0;
140
- const useProgress = process.stdout.isTTY && files.length > 0;
141
- const drawProgress = (current) => {
142
- if (!useProgress)
192
+ const startedAt = Date.now();
193
+ let lastEmit = 0;
194
+ const emit = (processed, currentFile) => {
195
+ if (!params.onProgress)
143
196
  return;
144
- const bar = renderProgressBar(current, files.length);
145
- process.stdout.write(`\rMigrating conversations ${current}/${files.length} ${bar}`);
146
- if (current >= files.length)
147
- process.stdout.write("\n");
197
+ const now = Date.now();
198
+ if (processed < files.length && now - lastEmit < 60)
199
+ return; // throttle repaints
200
+ lastEmit = now;
201
+ const elapsedMs = now - startedAt;
202
+ const perFile = processed > 0 ? elapsedMs / processed : 0;
203
+ const etaSeconds = perFile > 0 ? (perFile * (files.length - processed)) / 1000 : 0;
204
+ params.onProgress({
205
+ processed,
206
+ total: files.length,
207
+ rewritten,
208
+ elapsedMs,
209
+ etaSeconds,
210
+ currentFile,
211
+ });
148
212
  };
149
- drawProgress(0);
213
+ emit(0, "");
150
214
  for (let i = 0; i < files.length; i++) {
151
215
  const file = files[i];
152
216
  let source = "";
@@ -157,7 +221,7 @@ async function migrateSessionProviders(params) {
157
221
  }
158
222
  catch {
159
223
  skipped++;
160
- drawProgress(i + 1);
224
+ emit(i + 1, file);
161
225
  continue;
162
226
  }
163
227
  const desiredMtime = inferRolloutUpdatedAt(source) ?? originalStats.mtime;
@@ -177,14 +241,14 @@ async function migrateSessionProviders(params) {
177
241
  if (!source.trim()) {
178
242
  skipped++;
179
243
  await maybeRetainMtime();
180
- drawProgress(i + 1);
244
+ emit(i + 1, file);
181
245
  continue;
182
246
  }
183
247
  const lines = source.split(/\r?\n/);
184
248
  const firstNonEmptyIndex = lines.findIndex((line) => line.trim().length > 0);
185
249
  if (firstNonEmptyIndex < 0) {
186
250
  skipped++;
187
- drawProgress(i + 1);
251
+ emit(i + 1, file);
188
252
  continue;
189
253
  }
190
254
  let parsed;
@@ -193,29 +257,29 @@ async function migrateSessionProviders(params) {
193
257
  }
194
258
  catch {
195
259
  skipped++;
196
- drawProgress(i + 1);
260
+ emit(i + 1, file);
197
261
  continue;
198
262
  }
199
263
  if ((parsed.type ?? "") !== "session_meta") {
200
264
  skipped++;
201
- drawProgress(i + 1);
265
+ emit(i + 1, file);
202
266
  continue;
203
267
  }
204
268
  const payload = parsed.payload;
205
269
  if (!payload || typeof payload !== "object") {
206
270
  skipped++;
207
- drawProgress(i + 1);
271
+ emit(i + 1, file);
208
272
  continue;
209
273
  }
210
274
  const currentProvider = payload.model_provider;
211
275
  if (typeof currentProvider !== "string") {
212
276
  skipped++;
213
- drawProgress(i + 1);
277
+ emit(i + 1, file);
214
278
  continue;
215
279
  }
216
280
  if (!params.neutralizeSources.has(currentProvider)) {
217
281
  skipped++;
218
- drawProgress(i + 1);
282
+ emit(i + 1, file);
219
283
  continue;
220
284
  }
221
285
  // Remove explicit provider tag so the same thread stays visible across provider contexts.
@@ -225,19 +289,24 @@ async function migrateSessionProviders(params) {
225
289
  if (next === source) {
226
290
  skipped++;
227
291
  await maybeRetainMtime();
228
- drawProgress(i + 1);
292
+ emit(i + 1, file);
229
293
  continue;
230
294
  }
231
295
  await promises_1.default.writeFile(file, next, "utf8");
232
296
  await maybeRetainMtime();
233
297
  rewritten++;
234
- drawProgress(i + 1);
298
+ emit(i + 1, file);
299
+ }
300
+ // Only record the fast-skip snapshot after a full pass; a bounded subset
301
+ // hasn't seen every session, so it must not claim the whole store is done.
302
+ if (!scoped) {
303
+ const snapshot = await computeSessionSnapshot(files);
304
+ await writeMigrationState(params.migrationStateFile, {
305
+ version: 1,
306
+ fileCount: snapshot.fileCount,
307
+ maxMtimeMs: snapshot.maxMtimeMs,
308
+ });
235
309
  }
236
- await writeMigrationState(params.migrationStateFile, {
237
- version: 1,
238
- fileCount: snapshot.fileCount,
239
- maxMtimeMs: snapshot.maxMtimeMs,
240
- });
241
310
  return { scanned: files.length, rewritten, skipped, retimed, fastSkipped: false };
242
311
  }
243
312
  async function listStateDbFiles(codexHome) {
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.promptMainMenu = promptMainMenu;
4
4
  const prompts_1 = require("./shared/prompts");
5
+ const app_shell_1 = require("./shared/app-shell");
5
6
  const tui_1 = require("./shared/tui");
6
7
  const ITEMS = [
7
8
  { icon: tui_1.glyph.claw, label: "Set up / connect tools", desc: "Point Codex, Claude & others at The Claw Bay", argv: ["setup"] },
@@ -12,6 +13,7 @@ const ITEMS = [
12
13
  async function promptMainMenu() {
13
14
  if (!process.stdin.isTTY || !process.stdout.isTTY)
14
15
  return null;
16
+ app_shell_1.AppShell.printHeaderOnce("Your relay for Codex, Claude & friends");
15
17
  let cursor = 0;
16
18
  const quitIndex = ITEMS.length;
17
19
  const total = ITEMS.length + 1;
@@ -29,7 +31,8 @@ async function promptMainMenu() {
29
31
  return [
30
32
  "",
31
33
  ...(0, tui_1.renderBox)({
32
- title: "The Claw Bay",
34
+ title: "Home",
35
+ accent: "coral",
33
36
  lines: [
34
37
  tui_1.tint.dim("What would you like to do?"),
35
38
  "",
@@ -0,0 +1,11 @@
1
+ export type ProgressHandle = {
2
+ update(message: string): void;
3
+ succeed(message?: string): void;
4
+ fail(message?: string): void;
5
+ stop(): void;
6
+ };
7
+ export declare const AppShell: {
8
+ printHeaderOnce(subtitle: string): void;
9
+ progress(enabled: boolean): ProgressHandle;
10
+ commit(lines: string[]): void;
11
+ };
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AppShell = void 0;
4
+ const tui_1 = require("./tui");
5
+ const NOOP_PROGRESS = {
6
+ update() { },
7
+ succeed() { },
8
+ fail() { },
9
+ stop() { },
10
+ };
11
+ let headerPrinted = false;
12
+ function createSpinnerProgress() {
13
+ const stdout = process.stdout;
14
+ let label = "";
15
+ let frameIndex = 0;
16
+ let timer = null;
17
+ let live = false;
18
+ const frame = () => tui_1.SPINNER_FRAMES[frameIndex % tui_1.SPINNER_FRAMES.length] ?? tui_1.SPINNER_FRAMES[0];
19
+ const renderLive = () => stdout.write(`\r\x1b[2K ${tui_1.tint.accent(frame())} ${tui_1.tint.foam(label)}`);
20
+ const startTimer = () => {
21
+ if (timer)
22
+ return;
23
+ timer = setInterval(() => {
24
+ frameIndex = (frameIndex + 1) % tui_1.SPINNER_FRAMES.length;
25
+ renderLive();
26
+ }, 80);
27
+ if (typeof timer.unref === "function")
28
+ timer.unref();
29
+ };
30
+ const stopTimer = () => {
31
+ if (!timer)
32
+ return;
33
+ clearInterval(timer);
34
+ timer = null;
35
+ };
36
+ // Freeze the active line into permanent scrollback with a final glyph.
37
+ const commit = (mark, text) => {
38
+ stopTimer();
39
+ stdout.write(`\r\x1b[2K ${mark} ${text}\n`);
40
+ live = false;
41
+ };
42
+ return {
43
+ update(message) {
44
+ if (live)
45
+ commit(tui_1.tint.green(tui_1.glyph.tick), tui_1.tint.foam(label));
46
+ label = message;
47
+ live = true;
48
+ startTimer();
49
+ renderLive();
50
+ },
51
+ succeed(message) {
52
+ // Finalize the active step under its own name, then add the headline —
53
+ // never rename the last step to the summary text.
54
+ if (live)
55
+ commit(tui_1.tint.green(tui_1.glyph.tick), tui_1.tint.foam(label));
56
+ if (message)
57
+ stdout.write(` ${tui_1.tint.green(tui_1.glyph.tick)} ${tui_1.tint.bold(message)}\n`);
58
+ },
59
+ fail(message) {
60
+ // Mark the step that actually failed with its own name.
61
+ if (live)
62
+ commit(tui_1.tint.red(tui_1.glyph.cross), tui_1.tint.red(label));
63
+ if (message)
64
+ stdout.write(` ${tui_1.tint.red(tui_1.glyph.cross)} ${tui_1.tint.red(message)}\n`);
65
+ },
66
+ stop() {
67
+ if (live)
68
+ commit(tui_1.tint.green(tui_1.glyph.tick), tui_1.tint.foam(label));
69
+ },
70
+ };
71
+ }
72
+ exports.AppShell = {
73
+ // Print the brand lockup a single time per process. The menu and every
74
+ // command call this; only the first one actually paints, so launching setup
75
+ // from the menu never double-stamps the header.
76
+ printHeaderOnce(subtitle) {
77
+ if (headerPrinted)
78
+ return;
79
+ headerPrinted = true;
80
+ if (!process.stdout.isTTY)
81
+ return;
82
+ process.stdout.write(`\n${(0, tui_1.banner)(subtitle)}\n\n`);
83
+ },
84
+ // A growing, persistent checklist backed by a single animated line.
85
+ progress(enabled) {
86
+ if (!enabled || !process.stdout.isTTY)
87
+ return NOOP_PROGRESS;
88
+ return createSpinnerProgress();
89
+ },
90
+ // Commit standalone lines straight into scrollback (used by summaries and
91
+ // one-off notices) so they share the shell's left-gutter rhythm.
92
+ commit(lines) {
93
+ for (const line of lines)
94
+ process.stdout.write(`${line}\n`);
95
+ },
96
+ };
@@ -1,5 +1,13 @@
1
1
  import { SetupClient, SetupClientId } from "./client-registry";
2
2
  import { ConfiguredModelSelection, ModelResolution } from "./models";
3
+ import { ConversationScopeOption } from "../codex-history-migration";
4
+ export type ConversationScopeChoice = {
5
+ limit: number | null;
6
+ } | null;
7
+ export declare function promptConversationScope(scope: {
8
+ total: number;
9
+ options: ConversationScopeOption[];
10
+ }): Promise<ConversationScopeChoice>;
3
11
  export type SetupAuthMethod = "device-session" | "api-key";
4
12
  export declare function promptAuthMethod(): Promise<SetupAuthMethod>;
5
13
  export declare function promptApiKey(): Promise<string>;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.promptConversationScope = promptConversationScope;
3
4
  exports.promptAuthMethod = promptAuthMethod;
4
5
  exports.promptApiKey = promptApiKey;
5
6
  exports.formatSetupClientLabel = formatSetupClientLabel;
@@ -17,6 +18,80 @@ const promises_1 = require("node:readline/promises");
17
18
  const fsx_1 = require("./fsx");
18
19
  const models_1 = require("./models");
19
20
  const tui_1 = require("./tui");
21
+ // Ask how much Codex history to convert. Every tier shows a count and a time
22
+ // estimate up front, so nobody unknowingly kicks off an hours-long full pass —
23
+ // the runaway that used to look like a hang. "Skip" is always first, and the
24
+ // smallest bounded tier is preselected as the safe default.
25
+ async function promptConversationScope(scope) {
26
+ if (!process.stdin.isTTY || !process.stdout.isTTY || scope.total === 0)
27
+ return null;
28
+ const items = [
29
+ { limit: 0, label: "Skip for now", hint: "Leave existing Codex history untouched", recommended: false },
30
+ ];
31
+ // Preselect the first bounded tier (e.g. "Last 10/30") — fast and safe.
32
+ const firstBounded = scope.options.find((option) => option.limit !== null)?.limit ?? null;
33
+ for (const option of scope.options) {
34
+ const isAll = option.limit === null;
35
+ const label = isAll ? `All ${(0, tui_1.formatCount)(option.count)} conversations` : `Last ${option.limit} conversations`;
36
+ const est = `~${(0, tui_1.formatEta)(option.estSeconds)}`;
37
+ const hint = isAll
38
+ ? `${(0, tui_1.formatCount)(option.count)} total · ${est}${option.estSeconds > 120 ? " — this can take a while" : ""}`
39
+ : `${est} to convert`;
40
+ items.push({ limit: option.limit, label, hint, recommended: option.limit === firstBounded });
41
+ }
42
+ let cursor = Math.max(0, items.findIndex((item) => item.recommended));
43
+ if (cursor < 0)
44
+ cursor = 0;
45
+ const render = () => {
46
+ const rows = items.map((item, index) => {
47
+ const active = index === cursor;
48
+ const pointer = active ? tui_1.tint.accentBold(tui_1.glyph.pointer) : " ";
49
+ const mark = active ? tui_1.tint.accent(tui_1.glyph.radioOn) : tui_1.tint.gray(tui_1.glyph.radioOff);
50
+ const label = active ? tui_1.tint.accentBold(item.label) : tui_1.tint.foam(item.label);
51
+ const badge = item.recommended ? ` ${tui_1.tint.sea("recommended")}` : "";
52
+ const body = `${pointer} ${mark} ${label}${badge}`;
53
+ const detail = tui_1.tint.dim(`— ${item.hint}`);
54
+ const width = (0, tui_1.terminalColumns)() - 6;
55
+ const pad = Math.max(1, width - (0, tui_1.visibleWidth)(body) - (0, tui_1.visibleWidth)(detail));
56
+ return `${body}${" ".repeat(pad)}${detail}`;
57
+ });
58
+ return [
59
+ "",
60
+ ...(0, tui_1.renderBox)({
61
+ title: "Convert old Codex conversations?",
62
+ accent: "coral",
63
+ lines: [
64
+ tui_1.tint.dim("Keeps past threads visible under The Claw Bay. Newest conversations are converted first."),
65
+ "",
66
+ ...rows,
67
+ ],
68
+ footer: (0, tui_1.keyHints)([
69
+ ["↑↓", "move"],
70
+ ["enter", "select"],
71
+ ]),
72
+ }),
73
+ ];
74
+ };
75
+ return runKeyLoop({
76
+ render,
77
+ onKey: (key, finish, repaint) => {
78
+ if (key.name === "up" || key.name === "k") {
79
+ cursor = (cursor - 1 + items.length) % items.length;
80
+ repaint();
81
+ return;
82
+ }
83
+ if (key.name === "down" || key.name === "j") {
84
+ cursor = (cursor + 1) % items.length;
85
+ repaint();
86
+ return;
87
+ }
88
+ if (key.name === "return" || key.name === "enter") {
89
+ const chosen = items[cursor];
90
+ finish(chosen.limit === 0 ? null : { limit: chosen.limit });
91
+ }
92
+ },
93
+ });
94
+ }
20
95
  // Ask how the user wants to authenticate this machine. Browser sign-in is the
21
96
  // recommended default; an API key is offered for people who prefer to paste one.
22
97
  async function promptAuthMethod() {
@@ -1,9 +1,14 @@
1
1
  export declare const tint: {
2
2
  accent: (text: string) => string;
3
3
  accentBold: (text: string) => string;
4
+ sea: (text: string) => string;
5
+ seaBold: (text: string) => string;
6
+ ink: (text: string) => string;
7
+ foam: (text: string) => string;
4
8
  bold: (text: string) => string;
5
9
  dim: (text: string) => string;
6
10
  gray: (text: string) => string;
11
+ line: (text: string) => string;
7
12
  green: (text: string) => string;
8
13
  yellow: (text: string) => string;
9
14
  red: (text: string) => string;
@@ -21,21 +26,49 @@ export declare const glyph: {
21
26
  readonly cross: "✗";
22
27
  readonly bullet: "·";
23
28
  readonly claw: "🦞";
29
+ readonly wave: "≈";
30
+ readonly diamond: "◆";
31
+ readonly diamondOpen: "◇";
32
+ readonly arrow: "→";
33
+ readonly barFull: "█";
34
+ readonly barEmpty: "░";
35
+ readonly dotActive: "◈";
24
36
  };
37
+ export declare const SPINNER_FRAMES: readonly ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
25
38
  export declare function visibleWidth(text: string): number;
26
39
  export declare function terminalColumns(): number;
27
40
  export declare function truncateVisible(text: string, maxWidth: number): string;
28
- export type BoxLine = string;
41
+ export declare function padVisible(text: string, width: number): string;
42
+ export type BoxAccent = "coral" | "sea" | "danger" | "line";
29
43
  export declare function renderBox(params: {
30
44
  title?: string;
31
45
  lines: string[];
32
46
  footer?: string;
33
47
  width?: number;
48
+ accent?: BoxAccent;
34
49
  }): string[];
50
+ export declare function brandWordmark(text?: string): string;
35
51
  export declare function banner(subtitle: string): string;
52
+ export declare function gauge(params: {
53
+ value: number;
54
+ max: number;
55
+ width?: number;
56
+ color?: (text: string) => string;
57
+ }): string;
58
+ export declare function formatEta(seconds: number): string;
59
+ export declare function formatCount(n: number): string;
60
+ export declare function formatBytes(bytes: number): string;
61
+ export type StepState = "pending" | "active" | "done" | "failed" | "skipped";
62
+ export type RailStep = {
63
+ label: string;
64
+ state: StepState;
65
+ detail?: string;
66
+ };
67
+ export declare function renderStepRail(steps: RailStep[], spinnerFrame: string): string[];
36
68
  export declare function createFramePainter(stdout: NodeJS.WriteStream): {
37
69
  paint: (lines: string[]) => void;
38
70
  clear: () => void;
71
+ commit: (lines: string[]) => void;
39
72
  done: () => void;
40
73
  };
41
74
  export declare function keyHints(pairs: Array<[string, string]>): string;