@readwise/cli 0.5.0 → 0.5.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.
package/src/tui/app.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { exec } from "node:child_process";
1
2
  import type { ToolDef, SchemaProperty } from "../config.js";
2
3
  import { resolveProperty } from "../commands.js";
3
4
  import { callTool } from "../mcp.js";
@@ -29,6 +30,18 @@ function isArrayOfObjects(prop: SchemaProperty): boolean {
29
30
  return prop.type === "array" && !!prop.items?.properties;
30
31
  }
31
32
 
33
+ type CardKind = "document" | "highlight";
34
+
35
+ interface CardItem {
36
+ kind: CardKind;
37
+ title: string;
38
+ summary: string;
39
+ note: string; // highlight note (only for highlights)
40
+ meta: string; // "Source · Author · 12 min"
41
+ url: string; // for opening on enter
42
+ raw: Record<string, unknown>; // full object for fallback
43
+ }
44
+
32
45
  interface AppState {
33
46
  view: View;
34
47
  tools: ToolDef[];
@@ -66,6 +79,9 @@ interface AppState {
66
79
  error: string;
67
80
  resultScroll: number;
68
81
  resultScrollX: number;
82
+ resultCards: CardItem[]; // parsed card items for card view
83
+ resultCursor: number; // selected card index
84
+ resultCardScroll: number; // scroll offset for cards
69
85
  // Spinner
70
86
  spinnerFrame: number;
71
87
  }
@@ -335,6 +351,188 @@ function wrapText(text: string, width: number): string[] {
335
351
  return lines.length > 0 ? lines : [""];
336
352
  }
337
353
 
354
+ // --- Card parsing helpers ---
355
+
356
+ function extractCards(data: unknown): CardItem[] | null {
357
+ let items: unknown[];
358
+ if (Array.isArray(data)) {
359
+ items = data;
360
+ } else if (typeof data === "object" && data !== null) {
361
+ // Look for a "results" array or similar
362
+ const obj = data as Record<string, unknown>;
363
+ const arrayKey = Object.keys(obj).find((k) => Array.isArray(obj[k]) && (obj[k] as unknown[]).length > 0);
364
+ if (arrayKey) {
365
+ items = obj[arrayKey] as unknown[];
366
+ } else {
367
+ return null;
368
+ }
369
+ } else {
370
+ return null;
371
+ }
372
+
373
+ if (items.length === 0) return null;
374
+ // Only works for arrays of objects
375
+ if (typeof items[0] !== "object" || items[0] === null || Array.isArray(items[0])) return null;
376
+
377
+ const cards = items.map((item) => {
378
+ const obj = item as Record<string, unknown>;
379
+ const kind = isHighlightObj(obj) ? "highlight" : "document";
380
+ if (kind === "highlight") {
381
+ const attrs = (typeof obj.attributes === "object" && obj.attributes !== null)
382
+ ? obj.attributes as Record<string, unknown> : null;
383
+ return {
384
+ kind,
385
+ title: str(attrs?.document_title || obj.title || obj.readable_title || ""),
386
+ summary: str(attrs?.highlight_plaintext || obj.text || obj.summary || obj.content || ""),
387
+ note: str(attrs?.highlight_note || obj.note || obj.notes || ""),
388
+ meta: extractHighlightMeta(obj),
389
+ url: obj.id ? `https://readwise.io/open/${obj.id}` : extractCardUrl(obj),
390
+ raw: obj,
391
+ };
392
+ }
393
+ return {
394
+ kind,
395
+ title: extractDocTitle(obj),
396
+ summary: extractDocSummary(obj),
397
+ note: "",
398
+ meta: extractDocMeta(obj),
399
+ url: extractCardUrl(obj),
400
+ raw: obj,
401
+ };
402
+ }) as CardItem[];
403
+
404
+ // Skip card view if most items have no meaningful content (e.g. just URLs)
405
+ const hasContent = cards.filter((c) => c.summary || c.note || (c.kind === "document" && c.title !== "Untitled" && !c.raw.url?.toString().includes(c.title)));
406
+ if (hasContent.length < cards.length / 2) return null;
407
+
408
+ return cards;
409
+ }
410
+
411
+ function str(val: unknown): string {
412
+ if (val === null || val === undefined) return "";
413
+ return String(val);
414
+ }
415
+
416
+ function isHighlightObj(obj: Record<string, unknown>): boolean {
417
+ // Reader docs with category "highlight"
418
+ if (obj.category === "highlight") return true;
419
+ // Readwise search highlights: nested attributes with highlight_plaintext
420
+ const attrs = obj.attributes;
421
+ if (typeof attrs === "object" && attrs !== null && "highlight_plaintext" in (attrs as Record<string, unknown>)) return true;
422
+ // Readwise highlights: have text + highlight-specific fields
423
+ if (typeof obj.text === "string" &&
424
+ ("highlighted_at" in obj || "color" in obj || "book_id" in obj || "location_type" in obj)) {
425
+ return true;
426
+ }
427
+ // Has text + note fields (common highlight shape even without highlighted_at)
428
+ if (typeof obj.text === "string" && "note" in obj) return true;
429
+ return false;
430
+ }
431
+
432
+ // --- Document card helpers ---
433
+
434
+ function extractDocTitle(obj: Record<string, unknown>): string {
435
+ for (const key of ["title", "readable_title", "name"]) {
436
+ const val = obj[key];
437
+ if (val && typeof val === "string" && !String(val).startsWith("http")) return val as string;
438
+ }
439
+ // Last resort: show domain from URL
440
+ const url = str(obj.url || obj.source_url);
441
+ if (url) {
442
+ try { return new URL(url).hostname.replace(/^www\./, ""); } catch { /* */ }
443
+ }
444
+ return "Untitled";
445
+ }
446
+
447
+ function extractDocSummary(obj: Record<string, unknown>): string {
448
+ for (const key of ["summary", "description", "note", "notes", "content"]) {
449
+ const val = obj[key];
450
+ if (val && typeof val === "string") return val;
451
+ }
452
+ return "";
453
+ }
454
+
455
+ function extractDocMeta(obj: Record<string, unknown>): string {
456
+ const parts: string[] = [];
457
+
458
+ const siteName = str(obj.site_name);
459
+ if (siteName) parts.push(siteName);
460
+
461
+ const author = str(obj.author);
462
+ if (author && author !== siteName) parts.push(author);
463
+
464
+ const category = str(obj.category);
465
+ if (category) parts.push(category);
466
+
467
+ const wordCount = Number(obj.word_count);
468
+ if (wordCount > 0) {
469
+ const mins = Math.ceil(wordCount / 250);
470
+ parts.push(`${mins} min`);
471
+ }
472
+
473
+ const progress = Number(obj.reading_progress);
474
+ if (progress > 0 && progress < 1) {
475
+ parts.push(`${Math.round(progress * 100)}% read`);
476
+ } else if (progress >= 1) {
477
+ parts.push("finished");
478
+ }
479
+
480
+ const date = str(obj.created_at || obj.saved_at || obj.published_date);
481
+ if (date) {
482
+ const d = new Date(date);
483
+ if (!isNaN(d.getTime())) {
484
+ parts.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }));
485
+ }
486
+ }
487
+
488
+ return parts.join(" · ");
489
+ }
490
+
491
+ // --- Highlight card helpers ---
492
+
493
+ function extractHighlightMeta(obj: Record<string, unknown>): string {
494
+ const attrs = (typeof obj.attributes === "object" && obj.attributes !== null)
495
+ ? obj.attributes as Record<string, unknown> : null;
496
+ const parts: string[] = [];
497
+
498
+ // Source book/article author
499
+ const author = str(attrs?.document_author || obj.author || obj.book_author);
500
+ if (author && !author.startsWith("http")) parts.push(author);
501
+
502
+ // Category
503
+ const category = str(attrs?.document_category || obj.category);
504
+ if (category) parts.push(category);
505
+
506
+ const color = str(obj.color);
507
+ if (color) parts.push(color);
508
+
509
+ // Tags (from attributes or top-level)
510
+ const tags = attrs?.highlight_tags || obj.tags;
511
+ if (Array.isArray(tags) && tags.length > 0) {
512
+ const tagNames = tags.map((t: unknown) =>
513
+ typeof t === "object" && t !== null ? str((t as Record<string, unknown>).name) : str(t)
514
+ ).filter(Boolean);
515
+ if (tagNames.length > 0) parts.push(tagNames.join(", "));
516
+ }
517
+
518
+ const date = str(obj.highlighted_at || obj.created_at);
519
+ if (date) {
520
+ const d = new Date(date);
521
+ if (!isNaN(d.getTime())) {
522
+ parts.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }));
523
+ }
524
+ }
525
+
526
+ return parts.join(" · ");
527
+ }
528
+
529
+ function extractCardUrl(obj: Record<string, unknown>): string {
530
+ for (const key of ["url", "source_url", "reader_url", "readwise_url"]) {
531
+ if (obj[key] && typeof obj[key] === "string") return obj[key] as string;
532
+ }
533
+ return "";
534
+ }
535
+
338
536
  // --- Word boundary helpers ---
339
537
 
340
538
  function prevWordBoundary(buf: string, pos: number): number {
@@ -1218,6 +1416,122 @@ const SUCCESS_ICON = [
1218
1416
  " ╚═════╝ ╚═╝ ╚═╝",
1219
1417
  ];
1220
1418
 
1419
+ function cardLine(text: string, innerW: number, borderFn: (s: string) => string): string {
1420
+ return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
1421
+ }
1422
+
1423
+ function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth: number): string[] {
1424
+ const borderColor = selected ? style.cyan : style.dim;
1425
+ const innerW = Math.max(1, cardWidth - 4);
1426
+ const lines: string[] = [];
1427
+
1428
+ lines.push(" " + borderColor("╭" + "─".repeat(cardWidth - 2) + "╮"));
1429
+
1430
+ if (card.kind === "highlight") {
1431
+ // Highlight card: quote-style passage, optional note, meta
1432
+ const quotePrefix = "\u201c ";
1433
+ const quoteSuffix = "\u201d";
1434
+ const passage = card.summary || "\u2026";
1435
+ const maxQuoteW = innerW - quotePrefix.length;
1436
+ const wrapped = wrapText(passage, maxQuoteW);
1437
+ // Cap at 6 lines
1438
+ const showLines = wrapped.slice(0, 6);
1439
+ if (wrapped.length > 6) {
1440
+ const last = showLines[5]!;
1441
+ showLines[5] = truncateVisible(last, maxQuoteW - 1) + "…";
1442
+ }
1443
+ for (let i = 0; i < showLines.length; i++) {
1444
+ let lineText: string;
1445
+ if (i === 0) {
1446
+ lineText = quotePrefix + showLines[i]!;
1447
+ if (showLines.length === 1) lineText += quoteSuffix;
1448
+ } else if (i === showLines.length - 1) {
1449
+ lineText = " " + showLines[i]! + quoteSuffix;
1450
+ } else {
1451
+ lineText = " " + showLines[i]!;
1452
+ }
1453
+ const styled = selected ? style.cyan(lineText) : lineText;
1454
+ lines.push(cardLine(styled, innerW, borderColor));
1455
+ }
1456
+
1457
+ // Note (if present)
1458
+ if (card.note) {
1459
+ const noteText = "✏ " + truncateVisible(card.note, innerW - 2);
1460
+ lines.push(cardLine(style.yellow(noteText), innerW, borderColor));
1461
+ }
1462
+
1463
+ // Meta line
1464
+ if (card.meta) {
1465
+ lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
1466
+ }
1467
+ } else {
1468
+ // Document card: title, summary, meta
1469
+ const titleText = truncateVisible(card.title || "Untitled", innerW);
1470
+ const titleStyled = selected ? style.bold(style.cyan(titleText)) : style.bold(titleText);
1471
+ lines.push(cardLine(titleStyled, innerW, borderColor));
1472
+
1473
+ if (card.summary) {
1474
+ const summaryText = truncateVisible(card.summary, innerW);
1475
+ lines.push(cardLine(style.dim(summaryText), innerW, borderColor));
1476
+ }
1477
+
1478
+ if (card.meta) {
1479
+ lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
1480
+ }
1481
+ }
1482
+
1483
+ lines.push(" " + borderColor("╰" + "─".repeat(cardWidth - 2) + "╯"));
1484
+ return lines;
1485
+ }
1486
+
1487
+ function renderCardView(state: AppState): string[] {
1488
+ const { contentHeight, innerWidth } = getBoxDimensions();
1489
+ const tool = state.selectedTool;
1490
+ const title = tool ? humanLabel(tool.name, toolPrefix(tool)) : "";
1491
+ const cards = state.resultCards;
1492
+ const cardWidth = Math.min(innerWidth - 4, 72);
1493
+
1494
+ const content: string[] = [];
1495
+
1496
+ // Header with count
1497
+ const countInfo = style.dim(` (${state.resultCursor + 1} of ${cards.length})`);
1498
+ content.push(" " + style.bold("Results") + countInfo);
1499
+ content.push("");
1500
+
1501
+ // Build all card lines
1502
+ const allLines: { line: string; cardIdx: number }[] = [];
1503
+ for (let ci = 0; ci < cards.length; ci++) {
1504
+ const cardContentLines = buildCardLines(cards[ci]!, ci, ci === state.resultCursor, cardWidth);
1505
+ for (const line of cardContentLines) {
1506
+ allLines.push({ line, cardIdx: ci });
1507
+ }
1508
+ if (ci < cards.length - 1) {
1509
+ allLines.push({ line: "", cardIdx: ci });
1510
+ }
1511
+ }
1512
+
1513
+ // Scroll so selected card is visible
1514
+ const availableHeight = Math.max(1, contentHeight - content.length);
1515
+ const scroll = state.resultCardScroll;
1516
+ const visible = allLines.slice(scroll, scroll + availableHeight);
1517
+ for (const entry of visible) {
1518
+ content.push(entry.line);
1519
+ }
1520
+
1521
+ const hasUrl = cards[state.resultCursor]?.url;
1522
+ const footerParts = ["↑↓ navigate"];
1523
+ if (hasUrl) footerParts.push("enter open");
1524
+ footerParts.push("esc back", "q quit");
1525
+
1526
+ return renderLayout({
1527
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › results"),
1528
+ content,
1529
+ footer: state.quitConfirm
1530
+ ? style.yellow("Press q again to quit")
1531
+ : style.dim(footerParts.join(" · ")),
1532
+ });
1533
+ }
1534
+
1221
1535
  function renderResults(state: AppState): string[] {
1222
1536
  const { contentHeight, innerWidth } = getBoxDimensions();
1223
1537
  const tool = state.selectedTool;
@@ -1226,6 +1540,11 @@ function renderResults(state: AppState): string[] {
1226
1540
  const isEmptyList = !isError && state.result === EMPTY_LIST_SENTINEL;
1227
1541
  const isEmpty = !isError && !isEmptyList && !state.result.trim();
1228
1542
 
1543
+ // Card view for list results
1544
+ if (!isError && !isEmptyList && !isEmpty && state.resultCards.length > 0) {
1545
+ return renderCardView(state);
1546
+ }
1547
+
1229
1548
  // No results screen for empty lists
1230
1549
  if (isEmptyList) {
1231
1550
  const ghost = [
@@ -1324,7 +1643,7 @@ function renderState(state: AppState): string[] {
1324
1643
 
1325
1644
  // --- Input handling ---
1326
1645
 
1327
- function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" {
1646
+ function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" | "openUrl" {
1328
1647
  switch (state.view) {
1329
1648
  case "commands": return handleCommandListInput(state, key);
1330
1649
  case "form": return handleFormInput(state, key);
@@ -2198,10 +2517,40 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
2198
2517
  return state;
2199
2518
  }
2200
2519
 
2201
- function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
2520
+ function cardLineCount(card: CardItem, cardWidth: number): number {
2521
+ const innerW = Math.max(1, cardWidth - 4);
2522
+ if (card.kind === "highlight") {
2523
+ const quoteW = innerW - 2; // account for quote prefix
2524
+ const passage = card.summary || "\u2026";
2525
+ const wrapped = wrapText(passage, quoteW);
2526
+ const textLines = Math.min(wrapped.length, 6);
2527
+ return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
2528
+ }
2529
+ return 2 + 1 + (card.summary ? 1 : 0) + (card.meta ? 1 : 0); // top + title + summary? + meta? + bottom
2530
+ }
2531
+
2532
+ function computeCardScroll(cards: CardItem[], cursor: number, currentScroll: number, availableHeight: number): number {
2533
+ const { innerWidth } = getBoxDimensions();
2534
+ const cardWidth = Math.min(innerWidth - 4, 72);
2535
+ let lineStart = 0;
2536
+ for (let ci = 0; ci < cards.length; ci++) {
2537
+ const card = cards[ci]!;
2538
+ const height = cardLineCount(card, cardWidth);
2539
+ const spacing = ci < cards.length - 1 ? 1 : 0;
2540
+ if (ci === cursor) {
2541
+ const lineEnd = lineStart + height + spacing;
2542
+ if (lineStart < currentScroll) return lineStart;
2543
+ if (lineEnd > currentScroll + availableHeight) return Math.max(0, lineEnd - availableHeight);
2544
+ return currentScroll;
2545
+ }
2546
+ lineStart += height + spacing;
2547
+ }
2548
+ return currentScroll;
2549
+ }
2550
+
2551
+ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" | "openUrl" {
2202
2552
  const { contentHeight } = getBoxDimensions();
2203
- const contentLines = (state.error || state.result).split("\n");
2204
- const visibleCount = Math.max(1, contentHeight - 3);
2553
+ const inCardMode = state.resultCards.length > 0 && !state.error;
2205
2554
 
2206
2555
  if (key.ctrl && key.name === "c") {
2207
2556
  if (state.quitConfirm) return "exit";
@@ -2216,7 +2565,7 @@ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
2216
2565
  // Any other key cancels quit confirm
2217
2566
  const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
2218
2567
 
2219
- const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0 };
2568
+ const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0, resultCards: [] as CardItem[], resultCursor: 0, resultCardScroll: 0 };
2220
2569
 
2221
2570
  const goBack = (): AppState => {
2222
2571
  const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
@@ -2234,10 +2583,58 @@ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
2234
2583
  return { ...s, ...commandListReset(s.tools), ...resultsClear };
2235
2584
  };
2236
2585
 
2237
- if (key.name === "return" || key.name === "escape") {
2586
+ if (key.name === "escape") {
2238
2587
  return goBack();
2239
2588
  }
2240
2589
 
2590
+ if (inCardMode) {
2591
+ const cards = s.resultCards;
2592
+ // 2 lines used by header + blank
2593
+ const availableHeight = Math.max(1, contentHeight - 2);
2594
+
2595
+ if (key.name === "return") {
2596
+ const card = cards[s.resultCursor];
2597
+ if (card?.url) return "openUrl";
2598
+ return goBack();
2599
+ }
2600
+
2601
+ if (key.name === "up") {
2602
+ if (s.resultCursor > 0) {
2603
+ const newCursor = s.resultCursor - 1;
2604
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2605
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2606
+ }
2607
+ return s;
2608
+ }
2609
+ if (key.name === "down") {
2610
+ if (s.resultCursor < cards.length - 1) {
2611
+ const newCursor = s.resultCursor + 1;
2612
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2613
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2614
+ }
2615
+ return s;
2616
+ }
2617
+ if (key.name === "pageup") {
2618
+ const newCursor = Math.max(0, s.resultCursor - 5);
2619
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2620
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2621
+ }
2622
+ if (key.name === "pagedown") {
2623
+ const newCursor = Math.min(cards.length - 1, s.resultCursor + 5);
2624
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2625
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2626
+ }
2627
+
2628
+ return s;
2629
+ }
2630
+
2631
+ // Text mode fallback
2632
+ const contentLines = (state.error || state.result).split("\n");
2633
+ const visibleCount = Math.max(1, contentHeight - 3);
2634
+
2635
+ if (key.name === "return") {
2636
+ return goBack();
2637
+ }
2241
2638
  if (key.name === "up") {
2242
2639
  return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
2243
2640
  }
@@ -2391,7 +2788,7 @@ async function executeTool(state: AppState): Promise<AppState> {
2391
2788
 
2392
2789
  if (res.isError) {
2393
2790
  const errMsg = res.content.map((c) => c.text || "").filter(Boolean).join("\n");
2394
- return { ...state, view: "results", error: errMsg || "Unknown error", result: "", resultScroll: 0, resultScrollX: 0 };
2791
+ return { ...state, view: "results", error: errMsg || "Unknown error", result: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
2395
2792
  }
2396
2793
 
2397
2794
  const text = res.content.filter((c) => c.type === "text" && c.text).map((c) => c.text!).join("\n");
@@ -2399,21 +2796,24 @@ async function executeTool(state: AppState): Promise<AppState> {
2399
2796
  const structured = (res as Record<string, unknown>).structuredContent;
2400
2797
  let formatted: string;
2401
2798
  let emptyList = false;
2799
+ let parsedData: unknown = undefined;
2402
2800
  if (!text && structured !== undefined) {
2801
+ parsedData = structured;
2403
2802
  emptyList = isEmptyListResult(structured);
2404
2803
  formatted = formatJsonPretty(structured);
2405
2804
  } else {
2406
2805
  try {
2407
- const parsed = JSON.parse(text);
2408
- emptyList = isEmptyListResult(parsed);
2409
- formatted = formatJsonPretty(parsed);
2806
+ parsedData = JSON.parse(text);
2807
+ emptyList = isEmptyListResult(parsedData);
2808
+ formatted = formatJsonPretty(parsedData);
2410
2809
  } catch {
2411
2810
  formatted = text;
2412
2811
  }
2413
2812
  }
2414
- return { ...state, view: "results", result: emptyList ? EMPTY_LIST_SENTINEL : formatted, error: "", resultScroll: 0, resultScrollX: 0 };
2813
+ const cards = parsedData !== undefined ? extractCards(parsedData) || [] : [];
2814
+ return { ...state, view: "results", result: emptyList ? EMPTY_LIST_SENTINEL : formatted, error: "", resultScroll: 0, resultScrollX: 0, resultCards: cards, resultCursor: 0, resultCardScroll: 0 };
2415
2815
  } catch (err) {
2416
- return { ...state, view: "results", error: (err as Error).message, result: "", resultScroll: 0, resultScrollX: 0 };
2816
+ return { ...state, view: "results", error: (err as Error).message, result: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
2417
2817
  }
2418
2818
  }
2419
2819
 
@@ -2455,6 +2855,9 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
2455
2855
  error: "",
2456
2856
  resultScroll: 0,
2457
2857
  resultScrollX: 0,
2858
+ resultCards: [],
2859
+ resultCursor: 0,
2860
+ resultCardScroll: 0,
2458
2861
  spinnerFrame: 0,
2459
2862
  };
2460
2863
 
@@ -2514,6 +2917,15 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
2514
2917
  return;
2515
2918
  }
2516
2919
 
2920
+ if (result === "openUrl") {
2921
+ const card = state.resultCards[state.resultCursor];
2922
+ if (card?.url) {
2923
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2924
+ exec(`${cmd} ${JSON.stringify(card.url)}`);
2925
+ }
2926
+ return;
2927
+ }
2928
+
2517
2929
  if (result === "submit") {
2518
2930
  state = { ...state, view: "loading", spinnerFrame: 0 };
2519
2931
  paint(renderState(state));