recappi 0.1.1 → 0.1.3

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/dist/index.js CHANGED
@@ -18,22 +18,20 @@ function Header({ active }) {
18
18
  "Recappi",
19
19
  " "
20
20
  ] }),
21
- /* @__PURE__ */ jsx(Tab, { num: "1", label: "Overview", active: active === "overview", enabled: true }),
22
- /* @__PURE__ */ jsx(Tab, { num: "2", label: "Jobs", active: active === "jobs", enabled: true }),
23
- /* @__PURE__ */ jsx(Tab, { num: "3", label: "Recordings", active: active === "recordings", enabled: false })
21
+ /* @__PURE__ */ jsx(Tab, { num: "1", label: "Overview", active: active === "overview" }),
22
+ /* @__PURE__ */ jsx(Tab, { num: "2", label: "Jobs", active: active === "jobs" })
24
23
  ] });
25
24
  }
26
25
  function Tab({
27
26
  num,
28
27
  label,
29
- active,
30
- enabled
28
+ active
31
29
  }) {
32
- const text = ` ${num} ${label}${enabled ? "" : " (soon)"} `;
30
+ const text = ` ${num} ${label} `;
33
31
  if (active) {
34
- return /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: text });
32
+ return /* @__PURE__ */ jsx(Text, { bold: true, inverse: true, color: "cyan", children: text });
35
33
  }
36
- return /* @__PURE__ */ jsx(Text, { dimColor: !enabled, children: text });
34
+ return /* @__PURE__ */ jsx(Text, { children: text });
37
35
  }
38
36
  function Footer({ keys }) {
39
37
  return /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { dimColor: true, children: keys }) });
@@ -80,6 +78,23 @@ function statusStyle(status) {
80
78
  return { label: status, color: "white" };
81
79
  }
82
80
  }
81
+ function spinnerChar(frame) {
82
+ return SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
83
+ }
84
+ function dateBucket(epochMs, nowMs) {
85
+ if (!epochMs) return "Earlier";
86
+ const startOfDay = (ms) => {
87
+ const d = new Date(ms);
88
+ d.setHours(0, 0, 0, 0);
89
+ return d.getTime();
90
+ };
91
+ const days = Math.floor((startOfDay(nowMs) - startOfDay(epochMs)) / 864e5);
92
+ if (days <= 0) return "Today";
93
+ if (days === 1) return "Yesterday";
94
+ if (days < 7) return "Previous 7 days";
95
+ if (days < 30) return "Previous 30 days";
96
+ return "Earlier";
97
+ }
83
98
  function statusGlyph(status, spinnerFrame) {
84
99
  switch (status) {
85
100
  case "running":
@@ -114,6 +129,45 @@ function padCell(text, width) {
114
129
  if (text.length > width) return `${text.slice(0, Math.max(0, width - 1))}\u2026`;
115
130
  return text.padEnd(width);
116
131
  }
132
+ function charWidth(code) {
133
+ if (code >= 4352 && code <= 4447 || // Hangul Jamo
134
+ code === 9001 || code === 9002 || code >= 11904 && code <= 12350 || // CJK radicals … Kangxi
135
+ code >= 12353 && code <= 13311 || // Hiragana … CJK symbols
136
+ code >= 13312 && code <= 19903 || // CJK ext A
137
+ code >= 19968 && code <= 40959 || // CJK unified
138
+ code >= 40960 && code <= 42191 || // Yi
139
+ code >= 44032 && code <= 55203 || // Hangul syllables
140
+ code >= 63744 && code <= 64255 || // CJK compat
141
+ code >= 65072 && code <= 65103 || // CJK compat forms
142
+ code >= 65280 && code <= 65376 || // Fullwidth forms
143
+ code >= 65504 && code <= 65510 || code >= 127744 && code <= 129791 || // emoji
144
+ code >= 131072 && code <= 262141) {
145
+ return 2;
146
+ }
147
+ return 1;
148
+ }
149
+ function displayWidth(text) {
150
+ let width = 0;
151
+ for (const ch of text) width += charWidth(ch.codePointAt(0) ?? 0);
152
+ return width;
153
+ }
154
+ function padDisplay(text, width) {
155
+ const w = displayWidth(text);
156
+ if (w === width) return text;
157
+ if (w < width) return text + " ".repeat(width - w);
158
+ let out = "";
159
+ let acc = 0;
160
+ for (const ch of text) {
161
+ const cw = charWidth(ch.codePointAt(0) ?? 0);
162
+ if (acc + cw > width - 1) break;
163
+ out += ch;
164
+ acc += cw;
165
+ }
166
+ out += "\u2026";
167
+ acc += 1;
168
+ if (acc < width) out += " ".repeat(width - acc);
169
+ return out;
170
+ }
117
171
  function countJobs(items) {
118
172
  const counts = {
119
173
  total: items.length,
@@ -151,6 +205,60 @@ function resolveJobLinks(item, origin) {
151
205
  }
152
206
  return {};
153
207
  }
208
+ function resolveRecordingLinks(recordingId, origin) {
209
+ if (!recordingId) return {};
210
+ return { webUrl: `${origin}/recordings/${recordingId}` };
211
+ }
212
+ function recordingStatusStyle(status) {
213
+ switch (status) {
214
+ case "ready":
215
+ return { label: "Ready", color: "green", glyph: "\u2713" };
216
+ case "uploading":
217
+ return { label: "Uploading", color: "cyan", glyph: "\u2191" };
218
+ case "failed":
219
+ return { label: "Failed", color: "red", glyph: "\u2717" };
220
+ case "aborted":
221
+ return { label: "Aborted", color: "gray", glyph: "\u2022" };
222
+ default:
223
+ return { label: status, color: "white", glyph: "\u2022" };
224
+ }
225
+ }
226
+ function listWindow(selected, total, size) {
227
+ if (size <= 0 || total <= 0) return { start: 0, end: 0 };
228
+ if (total <= size) return { start: 0, end: total };
229
+ let start = selected - Math.floor(size / 2);
230
+ start = Math.max(0, Math.min(start, total - size));
231
+ return { start, end: start + size };
232
+ }
233
+ function groupedListWindow(buckets, selected, budget) {
234
+ const total = buckets.length;
235
+ if (budget <= 0 || total <= 0) return { start: 0, end: 0 };
236
+ const cost = (start, end) => {
237
+ if (end <= start) return 0;
238
+ let boundaries = 0;
239
+ for (let i = start + 1; i < end; i++) {
240
+ if (buckets[i] !== buckets[i - 1]) boundaries += 1;
241
+ }
242
+ return end - start + 1 + boundaries * 2;
243
+ };
244
+ for (let n = Math.min(total, budget); n >= 1; n--) {
245
+ const win = listWindow(selected, total, n);
246
+ if (cost(win.start, win.end) <= budget) return win;
247
+ }
248
+ return listWindow(selected, total, 1);
249
+ }
250
+ function formatBytes2(bytes) {
251
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "";
252
+ const units = ["B", "KB", "MB", "GB", "TB"];
253
+ let value = bytes;
254
+ let unit = 0;
255
+ while (value >= 1024 && unit < units.length - 1) {
256
+ value /= 1024;
257
+ unit += 1;
258
+ }
259
+ const rounded = value < 10 && unit > 0 ? value.toFixed(1) : String(Math.round(value));
260
+ return `${rounded}${units[unit]}`;
261
+ }
154
262
  var SPINNER_FRAMES;
155
263
  var init_format = __esm({
156
264
  "src/tui/format.ts"() {
@@ -212,83 +320,231 @@ var init_JobsView = __esm({
212
320
  }
213
321
  });
214
322
 
215
- // src/tui/OverviewView.tsx
323
+ // src/tui/RecordingRow.tsx
216
324
  import { Box as Box4, Text as Text4 } from "ink";
217
325
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
218
- function overviewActiveItems(items) {
219
- return items.filter((item) => item.status === "running" || item.status === "queued");
326
+ function recordingTitle2(item) {
327
+ const named = (item.title || item.summaryTitle || "").trim();
328
+ if (named && !UUID_RE.test(named)) return named;
329
+ return "Untitled";
330
+ }
331
+ function recordingProcessingState(item, jobStatus, spinnerFrame) {
332
+ if (item.status === "uploading") return { glyph: "\u2191", color: "cyan" };
333
+ if (item.status === "failed" || jobStatus === "failed") return { glyph: "\u2717", color: "red" };
334
+ if (jobStatus === "running") return { glyph: spinnerChar(spinnerFrame), color: "cyan" };
335
+ if (jobStatus === "queued") return { glyph: "\u25CB", color: "yellow" };
336
+ if (item.status === "aborted") return { glyph: "\u2022", color: "gray" };
337
+ if (item.activeTranscriptId) return { glyph: "\u2713", color: "green" };
338
+ return { glyph: "\xB7", color: "gray" };
339
+ }
340
+ function recordingLayout(columns) {
341
+ const usable = Math.max(20, columns - 2);
342
+ const showWhen = usable >= 54;
343
+ const title = Math.max(
344
+ 10,
345
+ usable - MARKER_W - GLYPH_W - LENGTH_W - (showWhen ? WHEN_W : 0)
346
+ );
347
+ return { title, showWhen };
220
348
  }
221
- function OverviewView({
349
+ function RecordingRow({
350
+ item,
351
+ selected,
352
+ nowMs,
353
+ columns,
354
+ jobStatus,
355
+ spinnerFrame = 0
356
+ }) {
357
+ const { title, showWhen } = recordingLayout(columns);
358
+ const { glyph, color } = recordingProcessingState(item, jobStatus, spinnerFrame);
359
+ const duration3 = item.durationMs ? formatClockMs(item.durationMs) : "\u2014";
360
+ return /* @__PURE__ */ jsxs3(Box4, { children: [
361
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: selected ? "\u25B8 " : " " }),
362
+ /* @__PURE__ */ jsx4(Text4, { color, children: `${glyph} ` }),
363
+ /* @__PURE__ */ jsx4(Text4, { bold: selected, children: padDisplay(recordingTitle2(item), title) }),
364
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padDisplay(duration3, LENGTH_W) }),
365
+ showWhen ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: formatAge(item.createdAt, nowMs) }) : null
366
+ ] });
367
+ }
368
+ function RecordingHeader({ columns }) {
369
+ const { title, showWhen } = recordingLayout(columns);
370
+ return /* @__PURE__ */ jsxs3(Box4, { children: [
371
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padDisplay("", MARKER_W + GLYPH_W) }),
372
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padDisplay("TITLE", title) }),
373
+ /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: padDisplay("LENGTH", LENGTH_W) }),
374
+ showWhen ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "WHEN" }) : null
375
+ ] });
376
+ }
377
+ var UUID_RE, MARKER_W, GLYPH_W, LENGTH_W, WHEN_W;
378
+ var init_RecordingRow = __esm({
379
+ "src/tui/RecordingRow.tsx"() {
380
+ "use strict";
381
+ init_format();
382
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
383
+ MARKER_W = 2;
384
+ GLYPH_W = 2;
385
+ LENGTH_W = 9;
386
+ WHEN_W = 9;
387
+ }
388
+ });
389
+
390
+ // src/tui/RecordingsView.tsx
391
+ import React from "react";
392
+ import { Box as Box5, Text as Text5 } from "ink";
393
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
394
+ function RecordingsView({
222
395
  items,
223
396
  selectedIndex,
224
- spinnerFrame,
397
+ nowMs,
398
+ columns,
399
+ jobStatusByRecording,
400
+ spinnerFrame = 0
401
+ }) {
402
+ if (items.length === 0) {
403
+ return /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "No recordings yet \u2014 run: recappi upload <file>" }) });
404
+ }
405
+ return /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "column", children: [
406
+ /* @__PURE__ */ jsx5(RecordingHeader, { columns }),
407
+ items.map((item, index) => {
408
+ const bucket = dateBucket(item.createdAt, nowMs);
409
+ const showHeader = index === 0 || bucket !== dateBucket(items[index - 1].createdAt, nowMs);
410
+ return /* @__PURE__ */ jsxs4(React.Fragment, { children: [
411
+ showHeader ? /* @__PURE__ */ jsx5(Box5, { marginTop: index === 0 ? 0 : 1, children: /* @__PURE__ */ jsx5(Text5, { bold: true, color: "blue", children: bucket }) }) : null,
412
+ /* @__PURE__ */ jsx5(
413
+ RecordingRow,
414
+ {
415
+ item,
416
+ selected: index === selectedIndex,
417
+ nowMs,
418
+ columns,
419
+ jobStatus: jobStatusByRecording?.get(item.recordingId),
420
+ spinnerFrame
421
+ }
422
+ )
423
+ ] }, item.recordingId);
424
+ })
425
+ ] });
426
+ }
427
+ var init_RecordingsView = __esm({
428
+ "src/tui/RecordingsView.tsx"() {
429
+ "use strict";
430
+ init_RecordingRow();
431
+ init_format();
432
+ }
433
+ });
434
+
435
+ // src/tui/RecordingPeek.tsx
436
+ import { Box as Box6, Text as Text6 } from "ink";
437
+ import { Fragment, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
438
+ function RecordingPeek({
439
+ item,
440
+ summary,
441
+ nowMs,
442
+ width
443
+ }) {
444
+ return /* @__PURE__ */ jsx6(Box6, { width, borderStyle: "round", borderColor: "gray", paddingX: 1, flexDirection: "column", children: !item ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "No selection" }) : /* @__PURE__ */ jsx6(PeekBody, { item, summary, nowMs }) });
445
+ }
446
+ function PeekBody({
447
+ item,
448
+ summary,
225
449
  nowMs
226
450
  }) {
227
- const counts = countJobs(items);
228
- const active = overviewActiveItems(items);
229
- const recent = items.filter((item) => item.status === "succeeded" || item.status === "failed").slice(0, 5);
230
- return /* @__PURE__ */ jsxs3(Box4, { marginTop: 1, flexDirection: "column", children: [
231
- /* @__PURE__ */ jsxs3(Text4, { children: [
232
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Jobs " }),
233
- /* @__PURE__ */ jsxs3(Text4, { color: "cyan", children: [
234
- counts.running,
235
- " running"
236
- ] }),
237
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " \xB7 " }),
238
- /* @__PURE__ */ jsxs3(Text4, { color: "yellow", children: [
239
- counts.queued,
240
- " queued"
241
- ] }),
242
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " \xB7 " }),
243
- /* @__PURE__ */ jsxs3(Text4, { color: "green", children: [
244
- counts.succeeded,
245
- " done"
246
- ] }),
247
- /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " \xB7 " }),
248
- /* @__PURE__ */ jsxs3(Text4, { color: "red", children: [
249
- counts.failed,
250
- " failed"
251
- ] })
451
+ const style = recordingStatusStyle(item.status);
452
+ const meta3 = [
453
+ item.durationMs ? formatClockMs(item.durationMs) : null,
454
+ formatBytes2(item.sizeBytes) || null,
455
+ item.contentType || null
456
+ ].filter(Boolean).join(" \xB7 ");
457
+ return /* @__PURE__ */ jsxs5(Fragment, { children: [
458
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "magenta", wrap: "truncate-end", children: recordingTitle2(item) }),
459
+ /* @__PURE__ */ jsx6(Text6, { color: style.color, children: `${style.glyph} ${style.label}` }),
460
+ meta3 ? /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: meta3 }) : null,
461
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: formatAge(item.createdAt, nowMs) }),
462
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, flexDirection: "column", children: /* @__PURE__ */ jsx6(SummarySection, { item, summary }) }),
463
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "\u23CE open \xB7 t transcript \xB7 o web" }) })
464
+ ] });
465
+ }
466
+ function SummarySection({
467
+ item,
468
+ summary
469
+ }) {
470
+ if (!item.activeTranscriptId) return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "No transcript yet" });
471
+ if (summary === "loading" || summary === void 0) return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Loading summary\u2026" });
472
+ if (summary === "error") return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "(summary unavailable)" });
473
+ if (summary.status !== "succeeded" || !summary.tldr) {
474
+ return /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: `Summary ${summary.status}` });
475
+ }
476
+ const points = (summary.keyPoints ?? []).slice(0, 3);
477
+ return /* @__PURE__ */ jsxs5(Fragment, { children: [
478
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Summary" }),
479
+ /* @__PURE__ */ jsx6(Text6, { children: summary.tldr }),
480
+ points.length > 0 ? /* @__PURE__ */ jsx6(Box6, { marginTop: 1, flexDirection: "column", children: points.map((point, i) => /* @__PURE__ */ jsx6(Text6, { dimColor: true, wrap: "truncate-end", children: `\u2022 ${point}` }, i)) }) : null
481
+ ] });
482
+ }
483
+ var init_RecordingPeek = __esm({
484
+ "src/tui/RecordingPeek.tsx"() {
485
+ "use strict";
486
+ init_format();
487
+ init_RecordingRow();
488
+ }
489
+ });
490
+
491
+ // src/tui/OverviewView.tsx
492
+ import { Box as Box7, Text as Text7 } from "ink";
493
+ import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
494
+ function OverviewView({
495
+ recordings,
496
+ jobs,
497
+ stats,
498
+ selectedIndex,
499
+ spinnerFrame,
500
+ nowMs,
501
+ columns,
502
+ jobStatusByRecording,
503
+ peekItem,
504
+ peekSummary,
505
+ showPeek = false,
506
+ peekWidth = 0
507
+ }) {
508
+ const jobCounts = countJobs(jobs);
509
+ const running = stats?.jobs.running ?? jobCounts.running;
510
+ const queued = stats?.jobs.queued ?? jobCounts.queued;
511
+ return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
512
+ /* @__PURE__ */ jsxs6(Box7, { children: [
513
+ /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Recordings " }),
514
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: stats?.recordings.total ?? recordings.length }),
515
+ stats?.recordings.ready != null ? /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: ` \xB7 ${stats.recordings.ready} ready` }) : null,
516
+ stats?.recordings.totalDurationMs != null ? /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: ` \xB7 ${formatClockMs(stats.recordings.totalDurationMs)} transcribed` }) : null,
517
+ running > 0 ? /* @__PURE__ */ jsx7(Text7, { color: "cyan", children: ` \xB7 ${running} transcribing` }) : null,
518
+ queued > 0 ? /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: ` \xB7 ${queued} queued` }) : null
252
519
  ] }),
253
- /* @__PURE__ */ jsxs3(Box4, { marginTop: 1, flexDirection: "column", children: [
254
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Active" }),
255
- active.length === 0 ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Nothing transcribing right now." }) : active.map((item, index) => /* @__PURE__ */ jsx4(
256
- JobRow,
520
+ /* @__PURE__ */ jsxs6(Box7, { flexDirection: "row", alignItems: "flex-start", children: [
521
+ /* @__PURE__ */ jsx7(Box7, { flexGrow: 1, flexDirection: "column", children: /* @__PURE__ */ jsx7(
522
+ RecordingsView,
257
523
  {
258
- item,
259
- selected: index === selectedIndex,
524
+ items: recordings,
525
+ selectedIndex,
526
+ nowMs,
527
+ columns,
528
+ jobStatusByRecording,
260
529
  spinnerFrame
261
- },
262
- item.jobId
263
- ))
264
- ] }),
265
- recent.length > 0 ? /* @__PURE__ */ jsxs3(Box4, { marginTop: 1, flexDirection: "column", children: [
266
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: "Recent" }),
267
- recent.map((item) => {
268
- const style = statusStyle(item.status);
269
- const title = item.recording?.title ?? item.recordingId;
270
- const age = formatAge(item.finishedAt ?? item.enqueuedAt, nowMs);
271
- return /* @__PURE__ */ jsxs3(Box4, { children: [
272
- /* @__PURE__ */ jsx4(Text4, { children: " " }),
273
- /* @__PURE__ */ jsx4(Text4, { color: style.color, children: `${statusGlyph(item.status, 0)} ` }),
274
- /* @__PURE__ */ jsx4(Text4, { children: title }),
275
- age ? /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: ` ${age}` }) : null
276
- ] }, item.jobId);
277
- })
278
- ] }) : null
530
+ }
531
+ ) }),
532
+ showPeek ? /* @__PURE__ */ jsx7(Box7, { marginLeft: 1, marginTop: 1, children: /* @__PURE__ */ jsx7(RecordingPeek, { item: peekItem, summary: peekSummary, nowMs, width: peekWidth }) }) : null
533
+ ] })
279
534
  ] });
280
535
  }
281
536
  var init_OverviewView = __esm({
282
537
  "src/tui/OverviewView.tsx"() {
283
538
  "use strict";
284
- init_JobRow();
539
+ init_RecordingsView();
540
+ init_RecordingPeek();
285
541
  init_format();
286
542
  }
287
543
  });
288
544
 
289
545
  // src/tui/JobDetailView.tsx
290
- import { Box as Box5, Text as Text5 } from "ink";
291
- import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
546
+ import { Box as Box8, Text as Text8 } from "ink";
547
+ import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
292
548
  function JobDetailView({
293
549
  item,
294
550
  origin,
@@ -298,13 +554,13 @@ function JobDetailView({
298
554
  const style = statusStyle(item.status);
299
555
  const links = resolveJobLinks(item, origin);
300
556
  const title = item.recording?.title ?? item.recordingId;
301
- return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 1, children: [
302
- /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
557
+ return /* @__PURE__ */ jsxs7(Box8, { flexDirection: "column", paddingX: 1, children: [
558
+ /* @__PURE__ */ jsxs7(Text8, { dimColor: true, children: [
303
559
  "\u2039 Jobs / ",
304
560
  title
305
561
  ] }),
306
- /* @__PURE__ */ jsxs4(
307
- Box5,
562
+ /* @__PURE__ */ jsxs7(
563
+ Box8,
308
564
  {
309
565
  marginTop: 1,
310
566
  borderStyle: "round",
@@ -312,17 +568,17 @@ function JobDetailView({
312
568
  paddingX: 1,
313
569
  flexDirection: "column",
314
570
  children: [
315
- /* @__PURE__ */ jsxs4(Text5, { color: style.color, bold: true, children: [
571
+ /* @__PURE__ */ jsxs7(Text8, { color: style.color, bold: true, children: [
316
572
  style.label,
317
- item.provider ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: ` ${item.provider}` }) : null
573
+ item.provider ? /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: ` ${item.provider}` }) : null
318
574
  ] }),
319
- /* @__PURE__ */ jsx5(StatusLine, { item, spinnerFrame, nowMs })
575
+ /* @__PURE__ */ jsx8(StatusLine, { item, spinnerFrame, nowMs })
320
576
  ]
321
577
  }
322
578
  ),
323
- /* @__PURE__ */ jsxs4(Box5, { marginTop: 1, flexDirection: "column", children: [
324
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: "Timeline" }),
325
- /* @__PURE__ */ jsx5(
579
+ /* @__PURE__ */ jsxs7(Box8, { marginTop: 1, flexDirection: "column", children: [
580
+ /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Timeline" }),
581
+ /* @__PURE__ */ jsx8(
326
582
  TimelineRow,
327
583
  {
328
584
  label: "Enqueued",
@@ -331,7 +587,7 @@ function JobDetailView({
331
587
  nowMs
332
588
  }
333
589
  ),
334
- /* @__PURE__ */ jsx5(
590
+ /* @__PURE__ */ jsx8(
335
591
  TimelineRow,
336
592
  {
337
593
  label: "Started",
@@ -340,7 +596,7 @@ function JobDetailView({
340
596
  nowMs
341
597
  }
342
598
  ),
343
- /* @__PURE__ */ jsx5(
599
+ /* @__PURE__ */ jsx8(
344
600
  TimelineRow,
345
601
  {
346
602
  label: item.status === "failed" ? "Failed" : item.status === "running" ? "Transcribing" : "Finished",
@@ -352,21 +608,21 @@ function JobDetailView({
352
608
  }
353
609
  )
354
610
  ] }),
355
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text5, { children: [
356
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Recording " }),
611
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text8, { children: [
612
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "Recording " }),
357
613
  title,
358
- item.recording?.durationMs ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: ` \xB7 ${formatClockMs(item.recording.durationMs)}` }) : null
614
+ item.recording?.durationMs ? /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: ` \xB7 ${formatClockMs(item.recording.durationMs)}` }) : null
359
615
  ] }) }),
360
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, children: /* @__PURE__ */ jsxs4(Text5, { children: [
361
- /* @__PURE__ */ jsx5(Text5, { color: links.webUrl ? "cyan" : "gray", children: "o open" }),
362
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " \xB7 " }),
363
- /* @__PURE__ */ jsx5(Text5, { color: links.webUrl ? "cyan" : "gray", children: "w web" }),
364
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " \xB7 " }),
365
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "m mac app (soon)" }),
366
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: " \xB7 " }),
367
- /* @__PURE__ */ jsx5(Text5, { color: links.webUrl ? "cyan" : "gray", children: "c copy" })
616
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, children: /* @__PURE__ */ jsxs7(Text8, { children: [
617
+ /* @__PURE__ */ jsx8(Text8, { color: links.webUrl ? "cyan" : "gray", children: "o open" }),
618
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " \xB7 " }),
619
+ /* @__PURE__ */ jsx8(Text8, { color: links.webUrl ? "cyan" : "gray", children: "w web" }),
620
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " \xB7 " }),
621
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "m mac app (soon)" }),
622
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: " \xB7 " }),
623
+ /* @__PURE__ */ jsx8(Text8, { color: links.webUrl ? "cyan" : "gray", children: "c copy" })
368
624
  ] }) }),
369
- /* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text5, { dimColor: true, children: [
625
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text8, { dimColor: true, children: [
370
626
  "esc back \xB7 t transcript",
371
627
  item.transcriptId ? "" : " (when ready)",
372
628
  " \xB7 q quit"
@@ -383,24 +639,24 @@ function StatusLine({
383
639
  const elapsed = item.startedAt ? ` \xB7 ${formatClockMs(nowMs - item.startedAt)} elapsed` : "";
384
640
  if (fraction != null) {
385
641
  const pct = Math.round(fraction * 100);
386
- return /* @__PURE__ */ jsxs4(Text5, { children: [
642
+ return /* @__PURE__ */ jsxs7(Text8, { children: [
387
643
  `${progressBar(fraction)} ${pct}% ${formatClockMs(item.processedDurationMs)} / ${formatClockMs(
388
644
  item.recording?.durationMs
389
645
  )}`,
390
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: elapsed })
646
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: elapsed })
391
647
  ] });
392
648
  }
393
649
  const spinner = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"][spinnerFrame % 10];
394
- return /* @__PURE__ */ jsxs4(Text5, { children: [
650
+ return /* @__PURE__ */ jsxs7(Text8, { children: [
395
651
  `${spinner} transcribing\u2026`,
396
- /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: elapsed })
652
+ /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: elapsed })
397
653
  ] });
398
654
  }
399
655
  if (item.status === "succeeded")
400
- return /* @__PURE__ */ jsx5(Text5, { children: item.transcriptId ? "transcript ready" : "done" });
401
- if (item.status === "queued") return /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "waiting to start\u2026" });
402
- if (item.status === "failed") return /* @__PURE__ */ jsx5(Text5, { color: "red", children: "transcription failed" });
403
- return /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: item.status });
656
+ return /* @__PURE__ */ jsx8(Text8, { children: item.transcriptId ? "transcript ready" : "done" });
657
+ if (item.status === "queued") return /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "waiting to start\u2026" });
658
+ if (item.status === "failed") return /* @__PURE__ */ jsx8(Text8, { color: "red", children: "transcription failed" });
659
+ return /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: item.status });
404
660
  }
405
661
  function TimelineRow({
406
662
  label,
@@ -413,10 +669,10 @@ function TimelineRow({
413
669
  const glyph = failed ? "\u2717" : done ? "\u2713" : running ? "\u280B" : "\u25CB";
414
670
  const color = failed ? "red" : done ? "green" : running ? "cyan" : "gray";
415
671
  const age = at ? formatAge(at, nowMs) : running ? "now" : "";
416
- return /* @__PURE__ */ jsxs4(Box5, { children: [
417
- /* @__PURE__ */ jsx5(Text5, { color, children: ` ${glyph} ` }),
418
- /* @__PURE__ */ jsx5(Text5, { dimColor: !done && !running, children: label }),
419
- age ? /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: ` ${age}` }) : null
672
+ return /* @__PURE__ */ jsxs7(Box8, { children: [
673
+ /* @__PURE__ */ jsx8(Text8, { color, children: ` ${glyph} ` }),
674
+ /* @__PURE__ */ jsx8(Text8, { dimColor: !done && !running, children: label }),
675
+ age ? /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: ` ${age}` }) : null
420
676
  ] });
421
677
  }
422
678
  var init_JobDetailView = __esm({
@@ -426,46 +682,165 @@ var init_JobDetailView = __esm({
426
682
  }
427
683
  });
428
684
 
685
+ // src/tui/RecordingDetailView.tsx
686
+ import { Box as Box9, Text as Text9 } from "ink";
687
+ import { Fragment as Fragment2, jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
688
+ function RecordingDetailView({
689
+ item,
690
+ nowMs,
691
+ transcript,
692
+ audio
693
+ }) {
694
+ const style = recordingStatusStyle(item.status);
695
+ const links = resolveRecordingLinks(item.recordingId, item.origin);
696
+ const title = recordingTitle2(item);
697
+ const meta3 = [
698
+ item.durationMs ? formatClockMs(item.durationMs) : void 0,
699
+ formatBytes2(item.sizeBytes) || void 0,
700
+ item.contentType || void 0
701
+ ].filter(Boolean).join(" \xB7 ");
702
+ const summary = typeof transcript === "object" ? transcript.summary : void 0;
703
+ const segments = typeof transcript === "object" ? transcript.segments : void 0;
704
+ return /* @__PURE__ */ jsxs8(Box9, { flexDirection: "column", paddingX: 1, children: [
705
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "\u2039 Recordings" }),
706
+ /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { bold: true, color: "magenta", children: title }) }),
707
+ /* @__PURE__ */ jsxs8(Text9, { children: [
708
+ /* @__PURE__ */ jsx9(Text9, { color: style.color, children: `${style.glyph} ${style.label}` }),
709
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: ` ${formatAge(item.createdAt, nowMs) || "\u2014"}` })
710
+ ] }),
711
+ meta3 ? /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: meta3 }) : null,
712
+ /* @__PURE__ */ jsx9(AudioActionRow, { item, audio }),
713
+ /* @__PURE__ */ jsx9(SummaryCard, { summary, transcript, hasTranscript: !!item.activeTranscriptId }),
714
+ /* @__PURE__ */ jsx9(TranscriptPreview, { segments, transcript, hasTranscript: !!item.activeTranscriptId }),
715
+ /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsxs8(Text9, { dimColor: true, children: [
716
+ `esc back \xB7 o open \xB7 d download \xB7 f finder`,
717
+ item.activeTranscriptId ? " \xB7 t transcript" : "",
718
+ links.webUrl ? " \xB7 w web" : "",
719
+ " \xB7 q quit"
720
+ ] }) })
721
+ ] });
722
+ }
723
+ function AudioActionRow({
724
+ item,
725
+ audio
726
+ }) {
727
+ const ready = item.status === "ready";
728
+ const status = audio?.status ?? "idle";
729
+ let line;
730
+ if (!ready) {
731
+ line = /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "Audio available once the recording is ready" });
732
+ } else if (status === "downloading") {
733
+ line = /* @__PURE__ */ jsx9(Text9, { color: "cyan", children: "Downloading audio\u2026" });
734
+ } else if (status === "opening") {
735
+ line = /* @__PURE__ */ jsx9(Text9, { color: "cyan", children: "Opening\u2026" });
736
+ } else if (status === "error") {
737
+ line = /* @__PURE__ */ jsx9(Text9, { color: "red", children: audio?.error ? `Audio failed: ${audio.error}` : "Audio failed" });
738
+ } else if (status === "ready" && audio?.localPath) {
739
+ line = /* @__PURE__ */ jsxs8(Text9, { children: [
740
+ /* @__PURE__ */ jsx9(Text9, { color: "green", children: "\u2713 Downloaded " }),
741
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, wrap: "truncate-middle", children: audio.localPath })
742
+ ] });
743
+ } else {
744
+ line = /* @__PURE__ */ jsxs8(Text9, { children: [
745
+ /* @__PURE__ */ jsx9(Text9, { color: "cyan", children: "o" }),
746
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: " open in player \xB7 " }),
747
+ /* @__PURE__ */ jsx9(Text9, { color: "cyan", children: "d" }),
748
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: " download \xB7 " }),
749
+ /* @__PURE__ */ jsx9(Text9, { color: "cyan", children: "f" }),
750
+ /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: " reveal in Finder" })
751
+ ] });
752
+ }
753
+ return /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, borderStyle: "round", borderColor: "gray", paddingX: 1, children: [
754
+ /* @__PURE__ */ jsx9(Text9, { color: ready ? "cyan" : "gray", children: "\u266A " }),
755
+ line
756
+ ] });
757
+ }
758
+ function SummaryCard({
759
+ summary,
760
+ transcript,
761
+ hasTranscript
762
+ }) {
763
+ if (!hasTranscript) return null;
764
+ return /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, flexDirection: "column", children: [
765
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "Summary" }),
766
+ transcript === "loading" || transcript === void 0 ? /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "Loading\u2026" }) : transcript === "error" ? /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "(summary unavailable)" }) : summary && summary.status === "succeeded" && summary.tldr ? /* @__PURE__ */ jsxs8(Fragment2, { children: [
767
+ /* @__PURE__ */ jsx9(Text9, { children: summary.tldr }),
768
+ (summary.keyPoints ?? []).slice(0, 5).map((point, i) => /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: `\u2022 ${point}` }, i))
769
+ ] }) : /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: `Summary ${summary?.status ?? "unavailable"}` })
770
+ ] });
771
+ }
772
+ function TranscriptPreview({
773
+ segments,
774
+ transcript,
775
+ hasTranscript
776
+ }) {
777
+ if (!hasTranscript) {
778
+ return /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "Transcript not available yet" }) });
779
+ }
780
+ if (transcript === "loading" || transcript === void 0 || transcript === "error") {
781
+ return null;
782
+ }
783
+ const shown = (segments ?? []).slice(0, PREVIEW_SEGMENTS);
784
+ return /* @__PURE__ */ jsxs8(Box9, { marginTop: 1, flexDirection: "column", children: [
785
+ /* @__PURE__ */ jsx9(Text9, { bold: true, children: "Transcript" }),
786
+ shown.length === 0 ? /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "(no segments)" }) : shown.map((seg, i) => /* @__PURE__ */ jsxs8(Text9, { wrap: "truncate-end", children: [
787
+ /* @__PURE__ */ jsx9(Text9, { color: "blue", children: `[${formatClockMs(seg.startMs)}] ` }),
788
+ seg.speaker ? /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: `${seg.speaker} ` }) : null,
789
+ /* @__PURE__ */ jsx9(Text9, { children: seg.text })
790
+ ] }, i)),
791
+ (segments?.length ?? 0) > PREVIEW_SEGMENTS ? /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: ` \u2026${(segments?.length ?? 0) - PREVIEW_SEGMENTS} more \u2014 press t for full transcript` }) : null
792
+ ] });
793
+ }
794
+ var PREVIEW_SEGMENTS;
795
+ var init_RecordingDetailView = __esm({
796
+ "src/tui/RecordingDetailView.tsx"() {
797
+ "use strict";
798
+ init_format();
799
+ init_RecordingRow();
800
+ PREVIEW_SEGMENTS = 6;
801
+ }
802
+ });
803
+
429
804
  // src/tui/TranscriptView.tsx
430
- import { Box as Box6, Text as Text6 } from "ink";
431
- import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
805
+ import { Box as Box10, Text as Text10 } from "ink";
806
+ import { jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
432
807
  function TranscriptView({ loading, data, error: error51 }) {
433
808
  if (loading) {
434
- return /* @__PURE__ */ jsx6(Box6, { paddingX: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Loading transcript\u2026" }) });
809
+ return /* @__PURE__ */ jsx10(Box10, { paddingX: 1, children: /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "Loading transcript\u2026" }) });
435
810
  }
436
811
  if (error51) {
437
- return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: 1, children: [
438
- /* @__PURE__ */ jsxs5(Text6, { color: "red", children: [
812
+ return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", paddingX: 1, children: [
813
+ /* @__PURE__ */ jsxs9(Text10, { color: "red", children: [
439
814
  "! ",
440
815
  error51
441
816
  ] }),
442
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "q / esc / \u2190 back" })
817
+ /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "q / esc / \u2190 back" })
443
818
  ] });
444
819
  }
445
820
  if (!data) {
446
- return /* @__PURE__ */ jsx6(Box6, { paddingX: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "No transcript." }) });
821
+ return /* @__PURE__ */ jsx10(Box10, { paddingX: 1, children: /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "No transcript." }) });
447
822
  }
448
823
  const summary = data.summary;
449
824
  const showSummary = summary?.status === "succeeded";
450
- return /* @__PURE__ */ jsxs5(Box6, { flexDirection: "column", paddingX: 1, children: [
451
- /* @__PURE__ */ jsx6(Text6, { bold: true, color: "magenta", children: summary?.title ?? "Transcript" }),
452
- /* @__PURE__ */ jsx6(Box6, { marginTop: 1, flexDirection: "column", children: data.segments.length === 0 ? /* @__PURE__ */ jsx6(Text6, { children: data.text }) : data.segments.slice(0, 200).map((segment, index) => /* @__PURE__ */ jsxs5(Text6, { children: [
453
- /* @__PURE__ */ jsxs5(Text6, { dimColor: true, children: [
825
+ return /* @__PURE__ */ jsxs9(Box10, { flexDirection: "column", paddingX: 1, children: [
826
+ /* @__PURE__ */ jsx10(Text10, { bold: true, color: "magenta", children: summary?.title ?? "Transcript" }),
827
+ /* @__PURE__ */ jsx10(Box10, { marginTop: 1, flexDirection: "column", children: data.segments.length === 0 ? /* @__PURE__ */ jsx10(Text10, { children: data.text }) : data.segments.slice(0, 200).map((segment, index) => /* @__PURE__ */ jsxs9(Text10, { children: [
828
+ /* @__PURE__ */ jsxs9(Text10, { dimColor: true, children: [
454
829
  "[",
455
830
  formatClockMs(segment.startMs),
456
831
  "] "
457
832
  ] }),
458
- segment.speaker ? /* @__PURE__ */ jsxs5(Text6, { color: "cyan", children: [
833
+ segment.speaker ? /* @__PURE__ */ jsxs9(Text10, { color: "cyan", children: [
459
834
  segment.speaker,
460
835
  ": "
461
836
  ] }) : null,
462
837
  segment.text
463
838
  ] }, index)) }),
464
- showSummary && summary?.tldr ? /* @__PURE__ */ jsxs5(Box6, { marginTop: 1, flexDirection: "column", children: [
465
- /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Summary" }),
466
- /* @__PURE__ */ jsx6(Text6, { children: summary.tldr })
839
+ showSummary && summary?.tldr ? /* @__PURE__ */ jsxs9(Box10, { marginTop: 1, flexDirection: "column", children: [
840
+ /* @__PURE__ */ jsx10(Text10, { bold: true, children: "Summary" }),
841
+ /* @__PURE__ */ jsx10(Text10, { children: summary.tldr })
467
842
  ] }) : null,
468
- /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "q / esc / \u2190 back" }) })
843
+ /* @__PURE__ */ jsx10(Box10, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text10, { dimColor: true, children: "q / esc / \u2190 back" }) })
469
844
  ] });
470
845
  }
471
846
  var init_TranscriptView = __esm({
@@ -475,13 +850,27 @@ var init_TranscriptView = __esm({
475
850
  }
476
851
  });
477
852
 
853
+ // src/tui/terminal.ts
854
+ import { useWindowSize } from "ink";
855
+ function useTerminalSize() {
856
+ return useWindowSize();
857
+ }
858
+ var init_terminal = __esm({
859
+ "src/tui/terminal.ts"() {
860
+ "use strict";
861
+ }
862
+ });
863
+
478
864
  // src/tui/AppShell.tsx
479
865
  import { useCallback, useEffect, useState } from "react";
480
- import { Box as Box7, Text as Text7, useApp, useInput } from "ink";
481
- import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
866
+ import { Box as Box11, Text as Text11, useApp, useInput } from "ink";
867
+ import { jsx as jsx11, jsxs as jsxs10 } from "react/jsx-runtime";
482
868
  function AppShell({
483
869
  fetchJobs,
484
870
  fetchTranscript,
871
+ fetchRecordings,
872
+ fetchDashboardStats,
873
+ recordingAudio,
485
874
  initialView = "overview",
486
875
  openUrl: openUrl2,
487
876
  copyText: copyText2,
@@ -490,41 +879,126 @@ function AppShell({
490
879
  spinnerMs = 80
491
880
  }) {
492
881
  const { exit } = useApp();
493
- const [items, setItems] = useState([]);
882
+ const size = useTerminalSize();
883
+ const [jobs, setJobs] = useState([]);
884
+ const [recordings, setRecordings] = useState([]);
885
+ const [recordingsNextCursor, setRecordingsNextCursor] = useState(null);
886
+ const [recordingsTotalCount, setRecordingsTotalCount] = useState(void 0);
887
+ const [stats, setStats] = useState(void 0);
494
888
  const [origin, setOrigin] = useState("");
495
- const [stack, setStack] = useState([
496
- { kind: initialView === "jobs" ? "jobs" : "overview" }
497
- ]);
889
+ const [stack, setStack] = useState([{ kind: initialView }]);
498
890
  const [selected, setSelected] = useState(0);
499
891
  const [spinnerFrame, setSpinnerFrame] = useState(0);
892
+ const [loadingMoreRecordings, setLoadingMoreRecordings] = useState(false);
500
893
  const [loadError, setLoadError] = useState(void 0);
501
894
  const [notice, setNotice] = useState(void 0);
895
+ const [summaryCache, setSummaryCache] = useState(() => /* @__PURE__ */ new Map());
896
+ const [transcriptCache, setTranscriptCache] = useState(
897
+ () => /* @__PURE__ */ new Map()
898
+ );
899
+ const [audioCache, setAudioCache] = useState(() => /* @__PURE__ */ new Map());
502
900
  const screen = stack[stack.length - 1];
503
- const refresh = useCallback(async () => {
901
+ const selectedRecording = screen.kind === "overview" ? recordings[selected] : void 0;
902
+ const peekTranscriptId = selectedRecording?.activeTranscriptId ?? void 0;
903
+ useEffect(() => {
904
+ if (!peekTranscriptId || summaryCache.has(peekTranscriptId)) return;
905
+ let cancelled = false;
906
+ const timer = setTimeout(() => {
907
+ setSummaryCache((m) => new Map(m).set(peekTranscriptId, "loading"));
908
+ fetchTranscript(peekTranscriptId).then((tr) => {
909
+ if (!cancelled) setSummaryCache((m) => new Map(m).set(peekTranscriptId, tr.summary));
910
+ }).catch(() => {
911
+ if (!cancelled) setSummaryCache((m) => new Map(m).set(peekTranscriptId, "error"));
912
+ });
913
+ }, 200);
914
+ return () => {
915
+ cancelled = true;
916
+ clearTimeout(timer);
917
+ };
918
+ }, [peekTranscriptId, fetchTranscript]);
919
+ const peekSummary = peekTranscriptId ? summaryCache.get(peekTranscriptId) : void 0;
920
+ const refresh = useCallback(async ({ resetRecordings = false } = {}) => {
921
+ const [jobsR, recR, statsR] = await Promise.allSettled([
922
+ fetchJobs(),
923
+ resetRecordings && fetchRecordings ? fetchRecordings({ limit: RECORDINGS_PAGE_SIZE }) : Promise.resolve(void 0),
924
+ fetchDashboardStats ? fetchDashboardStats() : Promise.resolve(void 0)
925
+ ]);
926
+ if (jobsR.status === "fulfilled") {
927
+ setJobs(jobsR.value.items);
928
+ setOrigin(jobsR.value.origin);
929
+ setLoadError(void 0);
930
+ } else {
931
+ setLoadError(jobsR.reason instanceof Error ? jobsR.reason.message : String(jobsR.reason));
932
+ }
933
+ if (recR.status === "fulfilled" && recR.value) {
934
+ setRecordings(recR.value.items);
935
+ setRecordingsNextCursor(recR.value.nextCursor ?? null);
936
+ setRecordingsTotalCount(recR.value.totalCount);
937
+ }
938
+ if (statsR.status === "fulfilled" && statsR.value) setStats(statsR.value);
939
+ }, [fetchJobs, fetchRecordings, fetchDashboardStats]);
940
+ const loadMoreRecordings = useCallback(async () => {
941
+ if (!fetchRecordings || !recordingsNextCursor || loadingMoreRecordings) return;
942
+ setLoadingMoreRecordings(true);
504
943
  try {
505
- const data = await fetchJobs();
506
- setItems(data.items);
507
- setOrigin(data.origin);
944
+ const page = await fetchRecordings({
945
+ limit: RECORDINGS_PAGE_SIZE,
946
+ cursor: recordingsNextCursor
947
+ });
948
+ setRecordings((prev) => {
949
+ const seen = new Set(prev.map((item) => item.recordingId));
950
+ const merged = [...prev];
951
+ for (const item of page.items) {
952
+ if (!seen.has(item.recordingId)) merged.push(item);
953
+ }
954
+ return merged;
955
+ });
956
+ setRecordingsNextCursor(page.nextCursor ?? null);
957
+ setRecordingsTotalCount(page.totalCount);
508
958
  setLoadError(void 0);
509
959
  } catch (error51) {
510
960
  setLoadError(error51 instanceof Error ? error51.message : String(error51));
961
+ } finally {
962
+ setLoadingMoreRecordings(false);
511
963
  }
512
- }, [fetchJobs]);
964
+ }, [fetchRecordings, loadingMoreRecordings, recordingsNextCursor]);
513
965
  useEffect(() => {
514
- void refresh();
966
+ void refresh({ resetRecordings: true });
515
967
  const id = setInterval(() => void refresh(), pollMs);
516
968
  return () => clearInterval(id);
517
969
  }, [refresh, pollMs]);
518
- const hasRunning = items.some((item) => item.status === "running");
970
+ const hasRunning = jobs.some((item) => item.status === "running");
519
971
  useEffect(() => {
520
972
  if (!hasRunning) return;
521
973
  const id = setInterval(() => setSpinnerFrame((f) => f + 1), spinnerMs);
522
974
  return () => clearInterval(id);
523
975
  }, [hasRunning, spinnerMs]);
524
- const currentList = screen.kind === "overview" ? overviewActiveItems(items) : items;
976
+ const jobRank = (s) => s === "running" ? 4 : s === "queued" ? 3 : s === "failed" ? 2 : s === "succeeded" ? 1 : 0;
977
+ const jobStatusByRecording = /* @__PURE__ */ new Map();
978
+ for (const job of jobs) {
979
+ const prev = jobStatusByRecording.get(job.recordingId);
980
+ if (!prev || jobRank(job.status) > jobRank(prev)) {
981
+ jobStatusByRecording.set(job.recordingId, job.status);
982
+ }
983
+ }
984
+ const listLength = screen.kind === "jobs" ? jobs.length : recordings.length;
985
+ useEffect(() => {
986
+ setSelected((i) => Math.max(0, Math.min(i, Math.max(0, listLength - 1))));
987
+ }, [listLength]);
988
+ const visibleRecordingRows = Math.max(3, size.rows - 6);
525
989
  useEffect(() => {
526
- setSelected((i) => Math.max(0, Math.min(i, Math.max(0, currentList.length - 1))));
527
- }, [currentList.length]);
990
+ if (screen.kind !== "overview" || !recordingsNextCursor) return;
991
+ const nearLoadedEnd = recordings.length - selected <= RECORDINGS_PREFETCH_REMAINING;
992
+ const underfilledViewport = recordings.length < visibleRecordingRows;
993
+ if (nearLoadedEnd || underfilledViewport) void loadMoreRecordings();
994
+ }, [
995
+ loadMoreRecordings,
996
+ recordings.length,
997
+ recordingsNextCursor,
998
+ screen.kind,
999
+ selected,
1000
+ visibleRecordingRows
1001
+ ]);
528
1002
  const openTranscript = useCallback(
529
1003
  async (transcriptId) => {
530
1004
  setStack((st) => [...st, { kind: "transcript", loading: true }]);
@@ -544,6 +1018,47 @@ function AppShell({
544
1018
  },
545
1019
  [fetchTranscript]
546
1020
  );
1021
+ const detailTranscriptId = screen.kind === "recordingDetail" ? recordings.find((r) => r.recordingId === screen.recordingId)?.activeTranscriptId : void 0;
1022
+ useEffect(() => {
1023
+ if (!detailTranscriptId || transcriptCache.has(detailTranscriptId)) return;
1024
+ let cancelled = false;
1025
+ setTranscriptCache((m) => new Map(m).set(detailTranscriptId, "loading"));
1026
+ fetchTranscript(detailTranscriptId).then((tr) => {
1027
+ if (!cancelled) setTranscriptCache((m) => new Map(m).set(detailTranscriptId, tr));
1028
+ }).catch(() => {
1029
+ if (!cancelled) setTranscriptCache((m) => new Map(m).set(detailTranscriptId, "error"));
1030
+ });
1031
+ return () => {
1032
+ cancelled = true;
1033
+ };
1034
+ }, [detailTranscriptId, fetchTranscript]);
1035
+ const setAudio = (recordingId, action) => setAudioCache((m) => new Map(m).set(recordingId, action));
1036
+ const runAudio = useCallback(
1037
+ async (recordingId, mode) => {
1038
+ if (!recordingAudio) {
1039
+ setNotice("Audio actions are not available");
1040
+ return;
1041
+ }
1042
+ setAudio(recordingId, { status: "downloading" });
1043
+ try {
1044
+ const localPath = await recordingAudio.downloadRecordingAudio(recordingId);
1045
+ if (mode === "open") {
1046
+ setAudio(recordingId, { status: "opening", localPath });
1047
+ await recordingAudio.openPath(localPath);
1048
+ } else if (mode === "finder") {
1049
+ setAudio(recordingId, { status: "opening", localPath });
1050
+ await recordingAudio.revealInFinder(localPath);
1051
+ }
1052
+ setAudio(recordingId, { status: "ready", localPath });
1053
+ } catch (error51) {
1054
+ setAudio(recordingId, {
1055
+ status: "error",
1056
+ error: error51 instanceof Error ? error51.message : String(error51)
1057
+ });
1058
+ }
1059
+ },
1060
+ [recordingAudio]
1061
+ );
547
1062
  const goTab = (tab2) => {
548
1063
  setStack([{ kind: tab2 }]);
549
1064
  setSelected(0);
@@ -552,33 +1067,32 @@ function AppShell({
552
1067
  const back = () => setStack((st) => st.length > 1 ? st.slice(0, -1) : st);
553
1068
  useInput((input, key) => {
554
1069
  setNotice(void 0);
555
- if (input === "q") {
556
- exit();
557
- return;
558
- }
559
- if (key.escape || key.leftArrow) {
560
- back();
561
- return;
562
- }
1070
+ if (input === "q") return exit();
1071
+ if (key.escape || key.leftArrow) return back();
563
1072
  if (input === "1") return goTab("overview");
564
1073
  if (input === "2") return goTab("jobs");
565
- if (input === "r") {
566
- void refresh();
1074
+ if (input === "r") return void refresh({ resetRecordings: true });
1075
+ if (screen.kind === "overview") {
1076
+ if (key.upArrow || input === "k") setSelected((i) => Math.max(0, i - 1));
1077
+ if (key.downArrow || input === "j") setSelected((i) => Math.min(recordings.length - 1, i + 1));
1078
+ const rec = recordings[selected];
1079
+ if (key.return && rec)
1080
+ setStack((st) => [...st, { kind: "recordingDetail", recordingId: rec.recordingId }]);
1081
+ if (input === "t" && rec?.activeTranscriptId) void openTranscript(rec.activeTranscriptId);
567
1082
  return;
568
1083
  }
569
- if (screen.kind === "overview" || screen.kind === "jobs") {
1084
+ if (screen.kind === "jobs") {
570
1085
  if (key.upArrow || input === "k") setSelected((i) => Math.max(0, i - 1));
571
- if (key.downArrow || input === "j")
572
- setSelected((i) => Math.min(currentList.length - 1, i + 1));
573
- const item = currentList[selected];
574
- if (key.return && item) setStack((st) => [...st, { kind: "jobDetail", jobId: item.jobId }]);
575
- if (input === "t" && item?.transcriptId) void openTranscript(item.transcriptId);
1086
+ if (key.downArrow || input === "j") setSelected((i) => Math.min(jobs.length - 1, i + 1));
1087
+ const job = jobs[selected];
1088
+ if (key.return && job) setStack((st) => [...st, { kind: "jobDetail", jobId: job.jobId }]);
1089
+ if (input === "t" && job?.transcriptId) void openTranscript(job.transcriptId);
576
1090
  return;
577
1091
  }
578
1092
  if (screen.kind === "jobDetail") {
579
- const item = items.find((i) => i.jobId === screen.jobId);
580
- const links = item ? resolveJobLinks(item, origin) : {};
581
- if (input === "t" && item?.transcriptId) void openTranscript(item.transcriptId);
1093
+ const job = jobs.find((j) => j.jobId === screen.jobId);
1094
+ const links = job ? resolveJobLinks(job, origin) : {};
1095
+ if (input === "t" && job?.transcriptId) void openTranscript(job.transcriptId);
582
1096
  else if ((input === "o" || input === "w") && links.webUrl) openUrl2?.(links.webUrl);
583
1097
  else if (input === "m") setNotice("Mac app deeplink not available yet");
584
1098
  else if (input === "c" && links.webUrl) {
@@ -587,47 +1101,119 @@ function AppShell({
587
1101
  }
588
1102
  return;
589
1103
  }
1104
+ if (screen.kind === "recordingDetail") {
1105
+ const rec = recordings.find((r) => r.recordingId === screen.recordingId);
1106
+ const links = rec ? resolveRecordingLinks(rec.recordingId, rec.origin) : {};
1107
+ if (input === "t" && rec?.activeTranscriptId) void openTranscript(rec.activeTranscriptId);
1108
+ else if (input === "o" && rec) void runAudio(rec.recordingId, "open");
1109
+ else if (input === "d" && rec) void runAudio(rec.recordingId, "download");
1110
+ else if (input === "f" && rec) void runAudio(rec.recordingId, "finder");
1111
+ else if (input === "w" && links.webUrl) openUrl2?.(links.webUrl);
1112
+ else if (input === "c" && links.webUrl) {
1113
+ copyText2?.(links.webUrl);
1114
+ setNotice("Link copied");
1115
+ }
1116
+ return;
1117
+ }
590
1118
  });
591
1119
  if (screen.kind === "transcript") {
592
- return /* @__PURE__ */ jsx7(TranscriptView, { loading: screen.loading, data: screen.data, error: screen.error });
1120
+ return /* @__PURE__ */ jsx11(TranscriptView, { loading: screen.loading, data: screen.data, error: screen.error });
593
1121
  }
594
1122
  if (screen.kind === "jobDetail") {
595
- const item = items.find((i) => i.jobId === screen.jobId);
596
- if (!item) {
597
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: 1, children: [
598
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Job no longer in the list." }),
599
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "esc back \xB7 q quit" })
600
- ] });
601
- }
602
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", children: [
603
- /* @__PURE__ */ jsx7(JobDetailView, { item, origin, spinnerFrame, nowMs: now() }),
604
- notice ? /* @__PURE__ */ jsx7(Box7, { paddingX: 1, children: /* @__PURE__ */ jsx7(Text7, { color: "green", children: notice }) }) : null
605
- ] });
1123
+ const job = jobs.find((j) => j.jobId === screen.jobId);
1124
+ if (!job) return /* @__PURE__ */ jsx11(Missing, { label: "Job" });
1125
+ return /* @__PURE__ */ jsx11(Detail, { notice, children: /* @__PURE__ */ jsx11(JobDetailView, { item: job, origin, spinnerFrame, nowMs: now() }) });
1126
+ }
1127
+ if (screen.kind === "recordingDetail") {
1128
+ const rec = recordings.find((r) => r.recordingId === screen.recordingId);
1129
+ if (!rec) return /* @__PURE__ */ jsx11(Missing, { label: "Recording" });
1130
+ const detailTranscript = rec.activeTranscriptId ? transcriptCache.get(rec.activeTranscriptId) : void 0;
1131
+ return /* @__PURE__ */ jsx11(Detail, { notice, children: /* @__PURE__ */ jsx11(
1132
+ RecordingDetailView,
1133
+ {
1134
+ item: rec,
1135
+ nowMs: now(),
1136
+ transcript: detailTranscript,
1137
+ audio: audioCache.get(rec.recordingId)
1138
+ }
1139
+ ) });
606
1140
  }
607
1141
  const tab = screen.kind === "jobs" ? "jobs" : "overview";
608
- return /* @__PURE__ */ jsxs6(Box7, { flexDirection: "column", paddingX: 1, children: [
609
- /* @__PURE__ */ jsx7(Header, { active: tab }),
610
- screen.kind === "overview" ? /* @__PURE__ */ jsx7(
1142
+ let body;
1143
+ let position = "";
1144
+ if (screen.kind === "overview") {
1145
+ const listBudget = Math.max(3, size.rows - 6);
1146
+ const buckets = recordings.map((r) => dateBucket(r.createdAt, now()));
1147
+ const win = groupedListWindow(buckets, selected, listBudget);
1148
+ const totalRecordings = Math.max(
1149
+ recordingsTotalCount ?? stats?.recordings.total ?? recordings.length,
1150
+ recordings.length
1151
+ );
1152
+ position = recordings.length ? `${selected + 1} / ${totalRecordings}${loadingMoreRecordings ? " \xB7 loading" : ""}` : loadingMoreRecordings ? "loading" : "0";
1153
+ const showPeek = size.columns >= 100;
1154
+ const peekWidth = showPeek ? 34 : 0;
1155
+ const listColumns = showPeek ? Math.max(30, size.columns - peekWidth - 3) : size.columns;
1156
+ body = /* @__PURE__ */ jsx11(
611
1157
  OverviewView,
612
1158
  {
613
- items,
614
- selectedIndex: selected,
1159
+ recordings: recordings.slice(win.start, win.end),
1160
+ selectedIndex: selected - win.start,
1161
+ jobs,
1162
+ stats,
1163
+ nowMs: now(),
1164
+ columns: listColumns,
1165
+ jobStatusByRecording,
615
1166
  spinnerFrame,
616
- nowMs: now()
617
- }
618
- ) : /* @__PURE__ */ jsx7(JobsView, { items, selectedIndex: selected, spinnerFrame }),
619
- loadError && items.length === 0 ? /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs6(Text7, { color: "red", children: [
620
- "! ",
621
- loadError
622
- ] }) }) : null,
623
- /* @__PURE__ */ jsx7(
624
- Footer,
1167
+ peekItem: recordings[selected],
1168
+ peekSummary,
1169
+ showPeek,
1170
+ peekWidth
1171
+ }
1172
+ );
1173
+ } else {
1174
+ const win = listWindow(selected, jobs.length, Math.max(3, size.rows - 4));
1175
+ position = jobs.length ? `${selected + 1} / ${jobs.length}` : "0";
1176
+ body = /* @__PURE__ */ jsx11(
1177
+ JobsView,
625
1178
  {
626
- keys: screen.kind === "jobs" ? "1/2 tabs \xB7 \u2191\u2193 select \xB7 \u23CE job \xB7 t transcript \xB7 r refresh \xB7 q quit" : "1/2 tabs \xB7 \u2191\u2193 select \xB7 \u23CE job \xB7 t transcript \xB7 q quit"
1179
+ items: jobs.slice(win.start, win.end),
1180
+ selectedIndex: selected - win.start,
1181
+ spinnerFrame
627
1182
  }
628
- )
1183
+ );
1184
+ }
1185
+ const footerKeys = screen.kind === "jobs" ? `${position} \xB7 \u2191\u2193 select \xB7 \u23CE job \xB7 t transcript \xB7 1 overview \xB7 r refresh \xB7 q quit` : `${position} \xB7 \u2191\u2193 scroll \xB7 \u23CE open \xB7 t transcript \xB7 2 jobs \xB7 r refresh \xB7 q quit`;
1186
+ return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", height: size.rows, paddingX: 1, children: [
1187
+ /* @__PURE__ */ jsx11(Header, { active: tab }),
1188
+ /* @__PURE__ */ jsxs10(Box11, { flexGrow: 1, flexDirection: "column", children: [
1189
+ body,
1190
+ loadError && jobs.length === 0 && recordings.length === 0 ? /* @__PURE__ */ jsx11(Box11, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text11, { color: "red", children: [
1191
+ "! ",
1192
+ loadError
1193
+ ] }) }) : null
1194
+ ] }),
1195
+ /* @__PURE__ */ jsx11(Footer, { keys: footerKeys })
1196
+ ] });
1197
+ }
1198
+ function Detail({
1199
+ notice,
1200
+ children
1201
+ }) {
1202
+ return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", children: [
1203
+ children,
1204
+ notice ? /* @__PURE__ */ jsx11(Box11, { paddingX: 1, children: /* @__PURE__ */ jsx11(Text11, { color: "green", children: notice }) }) : null
1205
+ ] });
1206
+ }
1207
+ function Missing({ label }) {
1208
+ return /* @__PURE__ */ jsxs10(Box11, { flexDirection: "column", paddingX: 1, children: [
1209
+ /* @__PURE__ */ jsxs10(Text11, { dimColor: true, children: [
1210
+ label,
1211
+ " no longer in the list."
1212
+ ] }),
1213
+ /* @__PURE__ */ jsx11(Text11, { dimColor: true, children: "esc back \xB7 q quit" })
629
1214
  ] });
630
1215
  }
1216
+ var RECORDINGS_PAGE_SIZE, RECORDINGS_PREFETCH_REMAINING;
631
1217
  var init_AppShell = __esm({
632
1218
  "src/tui/AppShell.tsx"() {
633
1219
  "use strict";
@@ -635,8 +1221,12 @@ var init_AppShell = __esm({
635
1221
  init_JobsView();
636
1222
  init_OverviewView();
637
1223
  init_JobDetailView();
1224
+ init_RecordingDetailView();
638
1225
  init_TranscriptView();
639
1226
  init_format();
1227
+ init_terminal();
1228
+ RECORDINGS_PAGE_SIZE = 50;
1229
+ RECORDINGS_PREFETCH_REMAINING = 8;
640
1230
  }
641
1231
  });
642
1232
 
@@ -644,41 +1234,49 @@ var init_AppShell = __esm({
644
1234
  var tui_exports = {};
645
1235
  __export(tui_exports, {
646
1236
  AppShell: () => AppShell,
1237
+ DASHBOARD_RENDER_OPTIONS: () => DASHBOARD_RENDER_OPTIONS,
647
1238
  JobDetailView: () => JobDetailView,
648
1239
  JobsView: () => JobsView,
649
1240
  OverviewView: () => OverviewView,
650
- runDashboard: () => runDashboard
1241
+ runDashboard: () => runDashboard,
1242
+ useTerminalSize: () => useTerminalSize
651
1243
  });
652
- import React2 from "react";
1244
+ import React3 from "react";
653
1245
  import { render } from "ink";
654
- import { spawn } from "child_process";
1246
+ import { spawn as spawn2 } from "child_process";
655
1247
  function openUrl(url2) {
656
1248
  const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
657
1249
  try {
658
- spawn(cmd, [url2], { stdio: "ignore", detached: true }).unref();
1250
+ spawn2(cmd, [url2], { stdio: "ignore", detached: true }).unref();
659
1251
  } catch {
660
1252
  }
661
1253
  }
662
1254
  function copyText(text) {
663
1255
  if (process.platform !== "darwin") return;
664
1256
  try {
665
- const child = spawn("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
1257
+ const child = spawn2("pbcopy", { stdio: ["pipe", "ignore", "ignore"] });
666
1258
  child.stdin.end(text);
667
1259
  } catch {
668
1260
  }
669
1261
  }
670
1262
  async function runDashboard(deps) {
671
- const app = render(
672
- React2.createElement(AppShell, {
1263
+ const renderApp = deps.renderApp ?? render;
1264
+ const app = renderApp(
1265
+ React3.createElement(AppShell, {
673
1266
  fetchJobs: deps.fetchJobs,
674
1267
  fetchTranscript: deps.fetchTranscript,
1268
+ fetchRecordings: deps.fetchRecordings,
1269
+ fetchDashboardStats: deps.fetchDashboardStats,
1270
+ recordingAudio: deps.recordingAudio,
675
1271
  initialView: deps.initialView ?? "overview",
676
1272
  openUrl,
677
1273
  copyText
678
- })
1274
+ }),
1275
+ DASHBOARD_RENDER_OPTIONS
679
1276
  );
680
1277
  await app.waitUntilExit();
681
1278
  }
1279
+ var DASHBOARD_RENDER_OPTIONS;
682
1280
  var init_tui = __esm({
683
1281
  "src/tui/index.ts"() {
684
1282
  "use strict";
@@ -687,12 +1285,17 @@ var init_tui = __esm({
687
1285
  init_JobsView();
688
1286
  init_OverviewView();
689
1287
  init_JobDetailView();
1288
+ init_terminal();
1289
+ DASHBOARD_RENDER_OPTIONS = {
1290
+ alternateScreen: true,
1291
+ interactive: true
1292
+ };
690
1293
  }
691
1294
  });
692
1295
 
693
1296
  // src/cli.ts
694
1297
  import { Command, CommanderError, InvalidArgumentError } from "commander/esm.mjs";
695
- import os3 from "os";
1298
+ import os4 from "os";
696
1299
 
697
1300
  // ../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/v4/classic/external.js
698
1301
  var external_exports = {};
@@ -1460,10 +2063,10 @@ function mergeDefs(...defs) {
1460
2063
  function cloneDef(schema) {
1461
2064
  return mergeDefs(schema._zod.def);
1462
2065
  }
1463
- function getElementAtPath(obj, path3) {
1464
- if (!path3)
2066
+ function getElementAtPath(obj, path4) {
2067
+ if (!path4)
1465
2068
  return obj;
1466
- return path3.reduce((acc, key) => acc?.[key], obj);
2069
+ return path4.reduce((acc, key) => acc?.[key], obj);
1467
2070
  }
1468
2071
  function promiseAllObject(promisesObj) {
1469
2072
  const keys = Object.keys(promisesObj);
@@ -1872,11 +2475,11 @@ function explicitlyAborted(x, startIndex = 0) {
1872
2475
  }
1873
2476
  return false;
1874
2477
  }
1875
- function prefixIssues(path3, issues) {
2478
+ function prefixIssues(path4, issues) {
1876
2479
  return issues.map((iss) => {
1877
2480
  var _a3;
1878
2481
  (_a3 = iss).path ?? (_a3.path = []);
1879
- iss.path.unshift(path3);
2482
+ iss.path.unshift(path4);
1880
2483
  return iss;
1881
2484
  });
1882
2485
  }
@@ -2023,16 +2626,16 @@ function flattenError(error51, mapper = (issue2) => issue2.message) {
2023
2626
  }
2024
2627
  function formatError(error51, mapper = (issue2) => issue2.message) {
2025
2628
  const fieldErrors = { _errors: [] };
2026
- const processError = (error52, path3 = []) => {
2629
+ const processError = (error52, path4 = []) => {
2027
2630
  for (const issue2 of error52.issues) {
2028
2631
  if (issue2.code === "invalid_union" && issue2.errors.length) {
2029
- issue2.errors.map((issues) => processError({ issues }, [...path3, ...issue2.path]));
2632
+ issue2.errors.map((issues) => processError({ issues }, [...path4, ...issue2.path]));
2030
2633
  } else if (issue2.code === "invalid_key") {
2031
- processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
2634
+ processError({ issues: issue2.issues }, [...path4, ...issue2.path]);
2032
2635
  } else if (issue2.code === "invalid_element") {
2033
- processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
2636
+ processError({ issues: issue2.issues }, [...path4, ...issue2.path]);
2034
2637
  } else {
2035
- const fullpath = [...path3, ...issue2.path];
2638
+ const fullpath = [...path4, ...issue2.path];
2036
2639
  if (fullpath.length === 0) {
2037
2640
  fieldErrors._errors.push(mapper(issue2));
2038
2641
  } else {
@@ -2059,17 +2662,17 @@ function formatError(error51, mapper = (issue2) => issue2.message) {
2059
2662
  }
2060
2663
  function treeifyError(error51, mapper = (issue2) => issue2.message) {
2061
2664
  const result = { errors: [] };
2062
- const processError = (error52, path3 = []) => {
2665
+ const processError = (error52, path4 = []) => {
2063
2666
  var _a3, _b;
2064
2667
  for (const issue2 of error52.issues) {
2065
2668
  if (issue2.code === "invalid_union" && issue2.errors.length) {
2066
- issue2.errors.map((issues) => processError({ issues }, [...path3, ...issue2.path]));
2669
+ issue2.errors.map((issues) => processError({ issues }, [...path4, ...issue2.path]));
2067
2670
  } else if (issue2.code === "invalid_key") {
2068
- processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
2671
+ processError({ issues: issue2.issues }, [...path4, ...issue2.path]);
2069
2672
  } else if (issue2.code === "invalid_element") {
2070
- processError({ issues: issue2.issues }, [...path3, ...issue2.path]);
2673
+ processError({ issues: issue2.issues }, [...path4, ...issue2.path]);
2071
2674
  } else {
2072
- const fullpath = [...path3, ...issue2.path];
2675
+ const fullpath = [...path4, ...issue2.path];
2073
2676
  if (fullpath.length === 0) {
2074
2677
  result.errors.push(mapper(issue2));
2075
2678
  continue;
@@ -2101,8 +2704,8 @@ function treeifyError(error51, mapper = (issue2) => issue2.message) {
2101
2704
  }
2102
2705
  function toDotPath(_path) {
2103
2706
  const segs = [];
2104
- const path3 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
2105
- for (const seg of path3) {
2707
+ const path4 = _path.map((seg) => typeof seg === "object" ? seg.key : seg);
2708
+ for (const seg of path4) {
2106
2709
  if (typeof seg === "number")
2107
2710
  segs.push(`[${seg}]`);
2108
2711
  else if (typeof seg === "symbol")
@@ -14794,13 +15397,13 @@ function resolveRef(ref, ctx) {
14794
15397
  if (!ref.startsWith("#")) {
14795
15398
  throw new Error("External $ref is not supported, only local refs (#/...) are allowed");
14796
15399
  }
14797
- const path3 = ref.slice(1).split("/").filter(Boolean);
14798
- if (path3.length === 0) {
15400
+ const path4 = ref.slice(1).split("/").filter(Boolean);
15401
+ if (path4.length === 0) {
14799
15402
  return ctx.rootSchema;
14800
15403
  }
14801
15404
  const defsKey = ctx.version === "draft-2020-12" ? "$defs" : "definitions";
14802
- if (path3[0] === defsKey) {
14803
- const key = path3[1];
15405
+ if (path4[0] === defsKey) {
15406
+ const key = path4[1];
14804
15407
  if (!key || !ctx.defs[key]) {
14805
15408
  throw new Error(`Reference not found: ${ref}`);
14806
15409
  }
@@ -15340,6 +15943,45 @@ var jobListDataSchema = external_exports.object({
15340
15943
  limit: external_exports.number().int().positive(),
15341
15944
  origin: external_exports.string()
15342
15945
  });
15946
+ var recordingDataSchema = external_exports.object({
15947
+ recordingId: external_exports.string(),
15948
+ title: external_exports.string().nullable().optional(),
15949
+ summaryTitle: external_exports.string().nullable().optional(),
15950
+ status: recordingStatusSchema,
15951
+ durationMs: external_exports.number().int().nonnegative().nullable().optional(),
15952
+ sizeBytes: external_exports.number().int().nonnegative().nullable().optional(),
15953
+ contentType: external_exports.string().optional(),
15954
+ activeTranscriptId: external_exports.string().nullable().optional(),
15955
+ createdAt: external_exports.number().int(),
15956
+ updatedAt: external_exports.number().int(),
15957
+ origin: external_exports.string()
15958
+ });
15959
+ var recordingListDataSchema = external_exports.object({
15960
+ items: external_exports.array(recordingDataSchema),
15961
+ limit: external_exports.number().int().positive(),
15962
+ nextCursor: external_exports.string().nullable().optional(),
15963
+ totalCount: external_exports.number().int().nonnegative().optional(),
15964
+ origin: external_exports.string()
15965
+ });
15966
+ var dashboardStatsDataSchema = external_exports.object({
15967
+ origin: external_exports.string(),
15968
+ recordings: external_exports.object({
15969
+ total: external_exports.number().int().nonnegative(),
15970
+ ready: external_exports.number().int().nonnegative(),
15971
+ uploading: external_exports.number().int().nonnegative(),
15972
+ failed: external_exports.number().int().nonnegative(),
15973
+ aborted: external_exports.number().int().nonnegative(),
15974
+ totalDurationMs: external_exports.number().int().nonnegative(),
15975
+ totalSizeBytes: external_exports.number().int().nonnegative()
15976
+ }),
15977
+ jobs: external_exports.object({
15978
+ active: external_exports.number().int().nonnegative(),
15979
+ queued: external_exports.number().int().nonnegative(),
15980
+ running: external_exports.number().int().nonnegative(),
15981
+ succeeded: external_exports.number().int().nonnegative(),
15982
+ failed: external_exports.number().int().nonnegative()
15983
+ })
15984
+ });
15343
15985
  var transcriptSegmentSchema = external_exports.object({
15344
15986
  startMs: external_exports.number().nonnegative(),
15345
15987
  endMs: external_exports.number().nonnegative(),
@@ -15845,7 +16487,11 @@ function isRecord(value) {
15845
16487
  }
15846
16488
 
15847
16489
  // src/api.ts
15848
- import { promises as fs3 } from "fs";
16490
+ import { createWriteStream, promises as fs3 } from "fs";
16491
+ import os3 from "os";
16492
+ import path3 from "path";
16493
+ import { Readable } from "stream";
16494
+ import { pipeline } from "stream/promises";
15849
16495
 
15850
16496
  // src/files.ts
15851
16497
  import { promises as fs2 } from "fs";
@@ -16130,6 +16776,60 @@ var RecappiApiClient = class {
16130
16776
  origin: this.auth.origin
16131
16777
  });
16132
16778
  }
16779
+ async listRecordings(opts) {
16780
+ const params = new URLSearchParams({ limit: String(opts.limit) });
16781
+ if (opts.cursor) params.set("cursor", opts.cursor);
16782
+ if (opts.search) params.set("search", opts.search);
16783
+ const parsed = await this.getJson(`/api/recordings?${params}`);
16784
+ const items = Array.isArray(parsed.items) ? parsed.items.filter(isRecord2).map((row) => mapRecording(row, this.auth.origin)) : [];
16785
+ return recordingListDataSchema.parse({
16786
+ items,
16787
+ limit: opts.limit,
16788
+ ...typeof parsed.nextCursor === "string" || parsed.nextCursor === null ? { nextCursor: parsed.nextCursor } : {},
16789
+ ...typeof parsed.totalCount === "number" ? { totalCount: parsed.totalCount } : {},
16790
+ origin: this.auth.origin
16791
+ });
16792
+ }
16793
+ async getRecording(recordingId) {
16794
+ const parsed = await this.getJson(
16795
+ `/api/recordings/${encodeURIComponent(recordingId)}`
16796
+ );
16797
+ return mapRecording(parsed, this.auth.origin);
16798
+ }
16799
+ async downloadRecordingAudio(recordingId, opts = {}) {
16800
+ const response = await this.request(
16801
+ "GET",
16802
+ `/api/recordings/${encodeURIComponent(recordingId)}/audio`
16803
+ );
16804
+ if (!response.body) {
16805
+ throw cliError("cloud.invalid_response", "Recording audio response was empty.");
16806
+ }
16807
+ const contentType = normalizeContentType(response.headers.get("content-type"));
16808
+ const contentLength = numberHeader(response.headers.get("content-length"));
16809
+ const dir = opts.directory ?? await fs3.mkdtemp(path3.join(os3.tmpdir(), "recappi-cli-audio-"));
16810
+ if (opts.directory) await fs3.mkdir(dir, { recursive: true });
16811
+ const filePath = path3.join(dir, recordingAudioFileName(recordingId, opts.title, contentType));
16812
+ try {
16813
+ await pipeline(
16814
+ Readable.fromWeb(response.body),
16815
+ createWriteStream(filePath)
16816
+ );
16817
+ } catch (error51) {
16818
+ await fs3.rm(filePath, { force: true }).catch(() => void 0);
16819
+ throw error51;
16820
+ }
16821
+ return {
16822
+ recordingId,
16823
+ localPath: filePath,
16824
+ contentType,
16825
+ ...contentLength !== void 0 ? { contentLength } : {},
16826
+ origin: this.auth.origin
16827
+ };
16828
+ }
16829
+ async dashboardStats() {
16830
+ const parsed = await this.getJson("/api/dashboard/stats");
16831
+ return mapDashboardStats(parsed, this.auth.origin);
16832
+ }
16133
16833
  async uploadPathBatch(opts) {
16134
16834
  const files = await collectAudioFiles(opts.inputPath);
16135
16835
  if (files.length === 0) {
@@ -16310,12 +17010,12 @@ var RecappiApiClient = class {
16310
17010
  ...typeof parsed.language === "string" || parsed.language === null ? { language: parsed.language } : {}
16311
17011
  };
16312
17012
  }
16313
- async getJson(path3) {
16314
- const response = await this.request("GET", path3);
17013
+ async getJson(path4) {
17014
+ const response = await this.request("GET", path4);
16315
17015
  return await parseJson(response);
16316
17016
  }
16317
- async postJson(path3, body) {
16318
- const response = await this.request("POST", path3, JSON.stringify(body), {
17017
+ async postJson(path4, body) {
17018
+ const response = await this.request("POST", path4, JSON.stringify(body), {
16319
17019
  headers: { "content-type": "application/json" }
16320
17020
  });
16321
17021
  return await parseJson(response);
@@ -16358,6 +17058,53 @@ async function responseMessage(response) {
16358
17058
  return response.statusText;
16359
17059
  }
16360
17060
  }
17061
+ function normalizeContentType(value) {
17062
+ return value?.split(";")[0]?.trim().toLowerCase() || "audio/wav";
17063
+ }
17064
+ function numberHeader(value) {
17065
+ if (!value) return void 0;
17066
+ const parsed = Number(value);
17067
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
17068
+ }
17069
+ function audioExtensionForContentType(contentType) {
17070
+ switch (contentType) {
17071
+ case "audio/mpeg":
17072
+ case "audio/mp3":
17073
+ return "mp3";
17074
+ case "audio/aiff":
17075
+ case "audio/x-aiff":
17076
+ return "aiff";
17077
+ case "audio/aac":
17078
+ case "audio/mp4":
17079
+ case "audio/m4a":
17080
+ case "audio/x-m4a":
17081
+ return "m4a";
17082
+ case "audio/ogg":
17083
+ return "ogg";
17084
+ case "audio/flac":
17085
+ case "audio/x-flac":
17086
+ return "flac";
17087
+ case "audio/webm":
17088
+ return "webm";
17089
+ case "audio/wav":
17090
+ case "audio/x-wav":
17091
+ default:
17092
+ return "wav";
17093
+ }
17094
+ }
17095
+ function recordingAudioFileName(recordingId, title, contentType) {
17096
+ const idStem = truncateFileStem(safeFileStem(recordingId), 48);
17097
+ const titleStem = title ? truncateFileStem(safeFileStem(title), 80) : "";
17098
+ const stem = titleStem ? `${titleStem}-${idStem}` : idStem;
17099
+ return `${stem}.${audioExtensionForContentType(contentType)}`;
17100
+ }
17101
+ function truncateFileStem(value, maxLength) {
17102
+ return [...value].slice(0, maxLength).join("");
17103
+ }
17104
+ function safeFileStem(value) {
17105
+ const safe = value.normalize("NFKC").replace(/[^\p{L}\p{N}._-]+/gu, "-").replace(/^-+|-+$/g, "");
17106
+ return safe || "recording";
17107
+ }
16361
17108
  function isRecord2(value) {
16362
17109
  return typeof value === "object" && value !== null && !Array.isArray(value);
16363
17110
  }
@@ -16510,9 +17257,59 @@ function mapJobListItem(row) {
16510
17257
  }
16511
17258
  };
16512
17259
  }
17260
+ function mapRecording(row, origin) {
17261
+ const recordingId = stringValue(row.id) ?? stringValue(row.recordingId);
17262
+ const status = stringValue(row.status);
17263
+ const createdAt = numberValue(row.createdAt);
17264
+ const updatedAt = numberValue(row.updatedAt);
17265
+ if (!recordingId) {
17266
+ throw cliError("cloud.invalid_response", "Recording response was missing id.");
17267
+ }
17268
+ if (!status) {
17269
+ throw cliError("cloud.invalid_response", "Recording response was missing status.");
17270
+ }
17271
+ if (createdAt === void 0 || updatedAt === void 0) {
17272
+ throw cliError("cloud.invalid_response", "Recording response was missing timestamps.");
17273
+ }
17274
+ return recordingDataSchema.parse({
17275
+ recordingId,
17276
+ ...typeof row.title === "string" || row.title === null ? { title: row.title } : {},
17277
+ ...typeof row.summaryTitle === "string" || row.summaryTitle === null ? { summaryTitle: row.summaryTitle } : {},
17278
+ status,
17279
+ ...typeof row.durationMs === "number" || row.durationMs === null ? { durationMs: row.durationMs } : {},
17280
+ ...typeof row.sizeBytes === "number" || row.sizeBytes === null ? { sizeBytes: row.sizeBytes } : {},
17281
+ ...typeof row.contentType === "string" ? { contentType: row.contentType } : {},
17282
+ ...typeof row.activeTranscriptId === "string" || row.activeTranscriptId === null ? { activeTranscriptId: row.activeTranscriptId } : {},
17283
+ createdAt,
17284
+ updatedAt,
17285
+ origin
17286
+ });
17287
+ }
17288
+ function mapDashboardStats(row, origin) {
17289
+ return dashboardStatsDataSchema.parse({
17290
+ origin,
17291
+ recordings: mapCountObject(row.recordings, [
17292
+ "total",
17293
+ "ready",
17294
+ "uploading",
17295
+ "failed",
17296
+ "aborted",
17297
+ "totalDurationMs",
17298
+ "totalSizeBytes"
17299
+ ]),
17300
+ jobs: mapCountObject(row.jobs, ["active", "queued", "running", "succeeded", "failed"])
17301
+ });
17302
+ }
17303
+ function mapCountObject(value, keys) {
17304
+ const source = isRecord2(value) ? value : {};
17305
+ return Object.fromEntries(keys.map((key) => [key, numberValue(source[key]) ?? 0]));
17306
+ }
16513
17307
  function stringValue(value) {
16514
17308
  return typeof value === "string" ? value : void 0;
16515
17309
  }
17310
+ function numberValue(value) {
17311
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
17312
+ }
16516
17313
  function parseSummaryStatus(value) {
16517
17314
  const allowed = /* @__PURE__ */ new Set([
16518
17315
  "pending",
@@ -16545,6 +17342,62 @@ function decodeJsonRecord(value) {
16545
17342
  }
16546
17343
  }
16547
17344
 
17345
+ // src/audio.ts
17346
+ import { spawn } from "child_process";
17347
+ function createRecordingAudioRuntime(client, deps = {}) {
17348
+ return {
17349
+ downloadRecordingAudio: async (recordingId, opts) => (await client.downloadRecordingAudio(recordingId, opts)).localPath,
17350
+ openPath: (localPath) => openPath(localPath, deps),
17351
+ revealInFinder: (localPath) => revealInFinder(localPath, deps)
17352
+ };
17353
+ }
17354
+ function openPath(localPath, deps = {}) {
17355
+ return runMacOpen([localPath], deps);
17356
+ }
17357
+ function revealInFinder(localPath, deps = {}) {
17358
+ return runMacOpen(["-R", localPath], deps);
17359
+ }
17360
+ function runMacOpen(args, deps) {
17361
+ if ((deps.platform ?? process.platform) !== "darwin") {
17362
+ return Promise.reject(
17363
+ cliError(
17364
+ "usage.invalid_argument",
17365
+ "Recording audio file actions are supported on macOS only.",
17366
+ {
17367
+ hint: "Download the audio and open the printed local path manually on this platform."
17368
+ }
17369
+ )
17370
+ );
17371
+ }
17372
+ const spawnProcess = deps.spawnProcess ?? spawn;
17373
+ return new Promise((resolve, reject) => {
17374
+ let settled = false;
17375
+ const finish = (error51) => {
17376
+ if (settled) return;
17377
+ settled = true;
17378
+ if (error51) reject(error51);
17379
+ else resolve();
17380
+ };
17381
+ try {
17382
+ const child = spawnProcess("open", args, { stdio: "ignore" });
17383
+ child.once(
17384
+ "error",
17385
+ (error51) => finish(error51 instanceof Error ? error51 : new Error(String(error51)))
17386
+ );
17387
+ child.once("close", (code) => {
17388
+ if (code === 0) finish();
17389
+ else {
17390
+ finish(
17391
+ cliError("internal.unexpected", `open failed with exit code ${code ?? "unknown"}.`)
17392
+ );
17393
+ }
17394
+ });
17395
+ } catch (error51) {
17396
+ finish(error51 instanceof Error ? error51 : new Error(String(error51)));
17397
+ }
17398
+ });
17399
+ }
17400
+
16548
17401
  // src/render.ts
16549
17402
  function createHumanProgressState(interactive) {
16550
17403
  return {
@@ -16672,6 +17525,56 @@ function renderHumanSuccess(command, data, opts) {
16672
17525
  renderTranscriptHuman(data, opts);
16673
17526
  return;
16674
17527
  }
17528
+ if (command === "recordings list" && isRecord3(data) && Array.isArray(data.items)) {
17529
+ opts.stdout("Recordings:\n");
17530
+ for (const item of data.items) {
17531
+ if (!isRecord3(item)) continue;
17532
+ opts.stdout(` ${recordingLabel(item)}
17533
+ `);
17534
+ }
17535
+ if (data.items.length === 0) opts.stdout(" No recordings found.\n");
17536
+ if (typeof data.nextCursor === "string") {
17537
+ opts.stdout(`
17538
+ Next cursor: ${data.nextCursor}
17539
+ `);
17540
+ }
17541
+ return;
17542
+ }
17543
+ if (command === "recordings get" && isRecord3(data)) {
17544
+ opts.stdout(`${recordingTitle(data)}
17545
+ `);
17546
+ opts.stdout(` recordingId: ${String(data.recordingId)}
17547
+ `);
17548
+ if (typeof data.status === "string") opts.stdout(` status: ${data.status}
17549
+ `);
17550
+ if (typeof data.durationMs === "number")
17551
+ opts.stdout(` duration: ${formatDurationMs(data.durationMs)}
17552
+ `);
17553
+ if (typeof data.sizeBytes === "number") opts.stdout(` size: ${formatBytes(data.sizeBytes)}
17554
+ `);
17555
+ if (typeof data.activeTranscriptId === "string") {
17556
+ opts.stdout(` activeTranscriptId: ${data.activeTranscriptId}
17557
+ `);
17558
+ opts.stdout(`
17559
+ Next:
17560
+ recappi transcript get ${data.activeTranscriptId}
17561
+ `);
17562
+ }
17563
+ return;
17564
+ }
17565
+ if (command === "dashboard stats" && isRecord3(data)) {
17566
+ const recordings = isRecord3(data.recordings) ? data.recordings : {};
17567
+ const jobs = isRecord3(data.jobs) ? data.jobs : {};
17568
+ opts.stdout(
17569
+ `Recordings: ${numberText(recordings.total)} total, ${numberText(recordings.ready)} ready
17570
+ `
17571
+ );
17572
+ opts.stdout(
17573
+ `Jobs: ${numberText(jobs.active)} active (${numberText(jobs.queued)} queued, ${numberText(jobs.running)} running)
17574
+ `
17575
+ );
17576
+ return;
17577
+ }
16675
17578
  if (command === "upload" && isUploadBatch(data)) {
16676
17579
  if (data.successes.length > 0) {
16677
17580
  opts.stdout(data.successes.length === 1 ? "Upload complete\n" : "Uploads complete\n");
@@ -16852,6 +17755,37 @@ Summary:
16852
17755
  }
16853
17756
  }
16854
17757
  }
17758
+ function recordingLabel(item) {
17759
+ const id = typeof item.recordingId === "string" ? item.recordingId : "unknown";
17760
+ const status = typeof item.status === "string" ? item.status : "unknown";
17761
+ const duration3 = typeof item.durationMs === "number" ? ` \xB7 ${formatDurationMs(item.durationMs)}` : "";
17762
+ return `${recordingTitle(item)} (${status}, ${id}${duration3})`;
17763
+ }
17764
+ function recordingTitle(item) {
17765
+ for (const key of ["title", "summaryTitle"]) {
17766
+ const value = item[key];
17767
+ if (typeof value === "string" && value.trim()) return value.trim();
17768
+ }
17769
+ return "Untitled recording";
17770
+ }
17771
+ function numberText(value) {
17772
+ return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString("en-US") : "0";
17773
+ }
17774
+ function formatDurationMs(ms) {
17775
+ return formatClock(ms / 1e3);
17776
+ }
17777
+ function formatBytes(bytes) {
17778
+ if (bytes < 1024) return `${bytes} B`;
17779
+ const units = ["KB", "MB", "GB", "TB"];
17780
+ let value = bytes / 1024;
17781
+ let unit = units[0];
17782
+ for (const next of units.slice(1)) {
17783
+ if (value < 1024) break;
17784
+ value /= 1024;
17785
+ unit = next;
17786
+ }
17787
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
17788
+ }
16855
17789
  function formatClock(seconds) {
16856
17790
  const total = Math.max(0, Math.floor(seconds));
16857
17791
  const hours = Math.floor(total / 3600);
@@ -16942,7 +17876,10 @@ var COMMAND_DATA_SCHEMAS = {
16942
17876
  "auth import-macos": authImportDataSchema,
16943
17877
  "auth status": authStatusDataSchema,
16944
17878
  doctor: doctorDataSchema,
17879
+ "dashboard stats": dashboardStatsDataSchema,
16945
17880
  upload: uploadBatchDataSchema,
17881
+ "recordings get": recordingDataSchema,
17882
+ "recordings list": recordingListDataSchema,
16946
17883
  "jobs list": jobListDataSchema,
16947
17884
  "jobs wait": jobDataSchema,
16948
17885
  "transcript get": transcriptDataSchema
@@ -16977,11 +17914,11 @@ function buildSchemaDocument(program) {
16977
17914
  event: toJsonSchema(operationEventSchema)
16978
17915
  };
16979
17916
  }
16980
- function walkCommands(command, path3, out) {
17917
+ function walkCommands(command, path4, out) {
16981
17918
  for (const sub of subcommandsOf(command)) {
16982
17919
  const name = sub.name();
16983
17920
  if (name === "help") continue;
16984
- const fullPath = [...path3, name];
17921
+ const fullPath = [...path4, name];
16985
17922
  const children = subcommandsOf(sub).filter((child) => child.name() !== "help");
16986
17923
  if (children.length === 0) {
16987
17924
  out.push(leafCommandDoc(sub, fullPath.join(" ")));
@@ -17041,6 +17978,7 @@ function readCliVersion() {
17041
17978
  var CLI_VERSION = readCliVersion();
17042
17979
 
17043
17980
  // src/cli.ts
17981
+ var DASHBOARD_RECORDINGS_PAGE_SIZE = 50;
17044
17982
  async function runCli(deps = {}) {
17045
17983
  const argv = deps.argv ?? process.argv.slice(2);
17046
17984
  const stdout = deps.stdout ?? ((text) => process.stdout.write(text));
@@ -17084,7 +18022,10 @@ async function runCli(deps = {}) {
17084
18022
  const runDashboard2 = deps.runDashboard ?? (await Promise.resolve().then(() => (init_tui(), tui_exports))).runDashboard;
17085
18023
  await runDashboard2({
17086
18024
  fetchJobs: () => client.listJobs({ status: "active", limit: 20 }),
18025
+ fetchRecordings: ({ cursor, limit = DASHBOARD_RECORDINGS_PAGE_SIZE } = {}) => client.listRecordings({ limit, cursor }),
18026
+ fetchDashboardStats: () => client.dashboardStats(),
17087
18027
  fetchTranscript: (transcriptId) => client.getTranscript(transcriptId),
18028
+ recordingAudio: createRecordingAudioRuntime(client),
17088
18029
  initialView: parsed.initialView
17089
18030
  });
17090
18031
  return 0;
@@ -17110,7 +18051,7 @@ async function runCli(deps = {}) {
17110
18051
  return 0;
17111
18052
  }
17112
18053
  if (parsed.kind === "auth-logout") {
17113
- const cleared = await clearAuthConfig(deps.homeDir ?? os3.homedir());
18054
+ const cleared = await clearAuthConfig(deps.homeDir ?? os4.homedir());
17114
18055
  renderSuccess("auth logout", { loggedIn: false, origin: auth.origin, cleared }, render2);
17115
18056
  return 0;
17116
18057
  }
@@ -17121,7 +18062,7 @@ async function runCli(deps = {}) {
17121
18062
  hint: keychain.hint ?? "Run recappi auth login instead."
17122
18063
  });
17123
18064
  }
17124
- await saveAuthConfig(deps.homeDir ?? os3.homedir(), {
18065
+ await saveAuthConfig(deps.homeDir ?? os4.homedir(), {
17125
18066
  origin: auth.origin,
17126
18067
  token: keychain.token
17127
18068
  });
@@ -17181,6 +18122,25 @@ async function runCli(deps = {}) {
17181
18122
  renderSuccess("jobs list", data, render2);
17182
18123
  return 0;
17183
18124
  }
18125
+ if (parsed.kind === "recordings-list") {
18126
+ const data = await client.listRecordings({
18127
+ limit: parsed.limit,
18128
+ cursor: parsed.cursor,
18129
+ search: parsed.search
18130
+ });
18131
+ renderSuccess("recordings list", data, render2);
18132
+ return 0;
18133
+ }
18134
+ if (parsed.kind === "recordings-get") {
18135
+ const data = await client.getRecording(parsed.recordingId);
18136
+ renderSuccess("recordings get", data, render2);
18137
+ return 0;
18138
+ }
18139
+ if (parsed.kind === "dashboard-stats") {
18140
+ const data = await client.dashboardStats();
18141
+ renderSuccess("dashboard stats", data, render2);
18142
+ return 0;
18143
+ }
17184
18144
  if (parsed.kind === "transcript-get") {
17185
18145
  const data = await client.getTranscript(parsed.transcriptId);
17186
18146
  renderSuccess("transcript get", data, render2);
@@ -17402,6 +18362,44 @@ Agent mode:
17402
18362
  document: buildSchemaDocument(program)
17403
18363
  });
17404
18364
  });
18365
+ const dashboard = program.command("dashboard").description("Dashboard data commands");
18366
+ addCommonOptions(dashboard);
18367
+ const dashboardStats = dashboard.command("stats").description("Fetch dashboard counters");
18368
+ addCommonOptions(dashboardStats);
18369
+ dashboardStats.action((_options, command) => {
18370
+ onSelect({
18371
+ kind: "dashboard-stats",
18372
+ options: collectGlobalOptions(command),
18373
+ commandName: "dashboard stats"
18374
+ });
18375
+ });
18376
+ const recordings = program.command("recordings").description("Recording commands");
18377
+ addCommonOptions(recordings);
18378
+ const recordingsList = recordings.command("list").description("List recent recordings").option("--limit <n>", "number of recordings to show", parseLimitOption("--limit", 1, 100), 20).option("--cursor <cursor>", "pagination cursor", parseStringOption("--cursor")).option("--search <query>", "search recordings and transcripts", parseStringOption("--search"));
18379
+ addCommonOptions(recordingsList);
18380
+ recordingsList.action((_options, command) => {
18381
+ const opts = command.opts();
18382
+ onSelect({
18383
+ kind: "recordings-list",
18384
+ options: collectGlobalOptions(command),
18385
+ commandName: "recordings list",
18386
+ limit: opts.limit ?? 20,
18387
+ ...typeof opts.cursor === "string" ? { cursor: opts.cursor } : {},
18388
+ ...typeof opts.search === "string" ? { search: opts.search } : {}
18389
+ });
18390
+ });
18391
+ const recordingsGet = recordings.command("get <recordingId>").description("Fetch a recording by recording id");
18392
+ addCommonOptions(recordingsGet);
18393
+ recordingsGet.action(
18394
+ (recordingId, _options, command) => {
18395
+ onSelect({
18396
+ kind: "recordings-get",
18397
+ options: collectGlobalOptions(command),
18398
+ commandName: "recordings get",
18399
+ recordingId
18400
+ });
18401
+ }
18402
+ );
17405
18403
  const transcript = program.command("transcript").description("Transcript commands");
17406
18404
  addCommonOptions(transcript);
17407
18405
  const transcriptGet = transcript.command("get <transcriptId>").description("Fetch a transcript by transcript id");
@@ -17516,7 +18514,9 @@ var VALUE_OPTIONS = /* @__PURE__ */ new Set([
17516
18514
  "--provider",
17517
18515
  "--prompt",
17518
18516
  "--status",
17519
- "--limit"
18517
+ "--limit",
18518
+ "--cursor",
18519
+ "--search"
17520
18520
  ]);
17521
18521
  function hasCommandToken(argv) {
17522
18522
  return commandTokens(argv).length > 0;