@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/README.md +24 -9
- package/dist/index.js +4 -1
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +246 -0
- package/dist/tui/app.js +384 -10
- package/package.json +1 -1
- package/src/index.ts +5 -1
- package/src/skills.ts +260 -0
- package/src/tui/app.ts +424 -12
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
|
|
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
|
|
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 === "
|
|
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
|
-
|
|
2408
|
-
emptyList = isEmptyListResult(
|
|
2409
|
-
formatted = formatJsonPretty(
|
|
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
|
-
|
|
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));
|