@readwise/cli 0.3.1 → 0.5.1

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,9 +1,10 @@
1
+ import { exec } from "node:child_process";
1
2
  import type { ToolDef, SchemaProperty } from "../config.js";
2
- import { resolveProperty, resolveRef } from "../commands.js";
3
+ import { resolveProperty } from "../commands.js";
3
4
  import { callTool } from "../mcp.js";
4
5
  import { ensureValidToken } from "../auth.js";
5
6
  import { LOGO } from "./logo.js";
6
- import { style, paint, screenSize, fitWidth, ansiSlice, stripAnsi, parseKey, type KeyEvent } from "./term.js";
7
+ import { style, paint, screenSize, fitWidth, ansiSlice, parseKey, type KeyEvent } from "./term.js";
7
8
  import { VERSION } from "../version.js";
8
9
 
9
10
  // --- Types ---
@@ -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[];
@@ -51,10 +64,12 @@ interface AppState {
51
64
  formEditFieldIdx: number;
52
65
  formEditing: boolean;
53
66
  formInputBuf: string;
67
+ formInputCursorPos: number;
54
68
  formEnumCursor: number;
55
69
  formEnumSelected: Set<number>;
56
70
  formValues: Record<string, string>;
57
71
  formShowRequired: boolean;
72
+ formShowOptional: boolean;
58
73
  formStack: FormStackEntry[];
59
74
  // Date picker
60
75
  dateParts: number[]; // [year, month, day] or [year, month, day, hour, minute]
@@ -64,6 +79,9 @@ interface AppState {
64
79
  error: string;
65
80
  resultScroll: number;
66
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
67
85
  // Spinner
68
86
  spinnerFrame: number;
69
87
  }
@@ -184,6 +202,42 @@ function defaultFormCursor(fields: FormField[], filtered: number[], values: Reco
184
202
  return firstBlank >= 0 ? firstBlank : executeIndex(filtered);
185
203
  }
186
204
 
205
+ type FieldKind = "arrayObj" | "date" | "arrayEnum" | "enum" | "bool" | "arrayText" | "text";
206
+
207
+ function classifyField(prop: SchemaProperty): FieldKind {
208
+ if (isArrayOfObjects(prop)) return "arrayObj";
209
+ if (dateFieldFormat(prop)) return "date";
210
+ const eVals = prop.enum || prop.items?.enum;
211
+ if (prop.type === "boolean") return "bool";
212
+ if (eVals && prop.type === "array") return "arrayEnum";
213
+ if (eVals) return "enum";
214
+ if (prop.type === "array") return "arrayText";
215
+ return "text";
216
+ }
217
+
218
+ function fieldTypeBadge(prop: SchemaProperty): string {
219
+ const badges: Record<FieldKind, string> = {
220
+ arrayObj: "form", date: "date", bool: "yes/no", arrayEnum: "multi",
221
+ enum: "select", arrayText: "list", text: "text",
222
+ };
223
+ const badge = badges[classifyField(prop)];
224
+ if (badge !== "text") return badge;
225
+ if (prop.type === "integer" || prop.type === "number") return "number";
226
+ return "text";
227
+ }
228
+
229
+ function footerForFieldKind(kind: FieldKind): string {
230
+ switch (kind) {
231
+ case "arrayObj": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc back";
232
+ case "date": return "\u2190\u2192 part \u00B7 \u2191\u2193 adjust \u00B7 t today \u00B7 enter confirm \u00B7 esc cancel";
233
+ case "arrayEnum": return "space toggle \u00B7 enter confirm \u00B7 esc cancel";
234
+ case "arrayText": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc confirm";
235
+ case "enum":
236
+ case "bool": return "\u2191\u2193 navigate \u00B7 enter confirm \u00B7 esc cancel";
237
+ case "text": return "enter confirm \u00B7 esc cancel";
238
+ }
239
+ }
240
+
187
241
  function formFieldValueDisplay(value: string, maxWidth: number): string {
188
242
  if (!value) return style.dim("–");
189
243
  // JSON array display (for array-of-objects)
@@ -253,8 +307,9 @@ function popFormStack(state: AppState): AppState {
253
307
  formFilteredIndices: parentFiltered,
254
308
  formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, newParentValues),
255
309
  formScrollTop: 0,
256
- formShowRequired: false,
310
+ formShowRequired: false, formShowOptional: false,
257
311
  formInputBuf: "",
312
+ formInputCursorPos: 0,
258
313
  };
259
314
  }
260
315
 
@@ -296,6 +351,219 @@ function wrapText(text: string, width: number): string[] {
296
351
  return lines.length > 0 ? lines : [""];
297
352
  }
298
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
+
536
+ // --- Word boundary helpers ---
537
+
538
+ function prevWordBoundary(buf: string, pos: number): number {
539
+ if (pos <= 0) return 0;
540
+ let i = pos - 1;
541
+ // Skip whitespace
542
+ while (i > 0 && /\s/.test(buf[i]!)) i--;
543
+ // Skip word chars or non-word non-space chars
544
+ if (i >= 0 && /\w/.test(buf[i]!)) {
545
+ while (i > 0 && /\w/.test(buf[i - 1]!)) i--;
546
+ } else {
547
+ while (i > 0 && !/\w/.test(buf[i - 1]!) && !/\s/.test(buf[i - 1]!)) i--;
548
+ }
549
+ return i;
550
+ }
551
+
552
+ function nextWordBoundary(buf: string, pos: number): number {
553
+ const len = buf.length;
554
+ if (pos >= len) return len;
555
+ let i = pos;
556
+ // Skip current word chars or non-word non-space chars
557
+ if (/\w/.test(buf[i]!)) {
558
+ while (i < len && /\w/.test(buf[i]!)) i++;
559
+ } else if (!/\s/.test(buf[i]!)) {
560
+ while (i < len && !/\w/.test(buf[i]!) && !/\s/.test(buf[i]!)) i++;
561
+ }
562
+ // Skip whitespace
563
+ while (i < len && /\s/.test(buf[i]!)) i++;
564
+ return i;
565
+ }
566
+
299
567
  // --- Date helpers ---
300
568
 
301
569
  type DateFormat = "date" | "date-time";
@@ -449,7 +717,7 @@ function renderCommandList(state: AppState): string[] {
449
717
  if (i === Math.floor(LOGO.length / 2) - 1) {
450
718
  content.push(` ${logoLine} ${style.boldYellow("Readwise")} ${style.dim("v" + VERSION)}`);
451
719
  } else if (i === Math.floor(LOGO.length / 2)) {
452
- content.push(` ${logoLine} ${style.dim("Command-line interface")}`);
720
+ content.push(` ${logoLine} ${style.dim("Built for AI agents · This TUI is just for fun/learning")}`);
453
721
  } else {
454
722
  content.push(` ${logoLine}`);
455
723
  }
@@ -510,8 +778,8 @@ function renderCommandList(state: AppState): string[] {
510
778
  }
511
779
 
512
780
  const footer = state.quitConfirm
513
- ? style.yellow("Press q or esc again to quit")
514
- : style.dim("type to search ↑↓ navigate enter select esc clear/quit");
781
+ ? style.yellow("Press again to quit")
782
+ : style.dim("type to search · ↑↓ navigate · enter select · esc/ctrl+c quit");
515
783
 
516
784
  return renderLayout({
517
785
  breadcrumb: style.boldYellow("Readwise"),
@@ -525,16 +793,223 @@ function renderForm(state: AppState): string[] {
525
793
  const tool = state.selectedTool!;
526
794
  const fields = state.fields;
527
795
  const toolTitle = humanLabel(tool.name, toolPrefix(tool));
528
- // Build title: tool name + any stack breadcrumb
529
- const stackParts = state.formStack.map((e) => e.parentFieldName);
530
- const title = stackParts.length > 0
531
- ? toolTitle + " › " + stackParts.join(" › ")
532
- : toolTitle;
533
796
 
534
- if (state.formEditing && state.formEditFieldIdx >= 0) {
535
- return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
797
+ // Sub-forms (nested array-of-objects) keep existing form UI
798
+ if (state.formStack.length > 0) {
799
+ const stackParts = state.formStack.map((e) => e.parentFieldName);
800
+ const title = toolTitle + " › " + stackParts.join(" › ");
801
+ if (state.formEditing && state.formEditFieldIdx >= 0) {
802
+ return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
803
+ }
804
+ return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
536
805
  }
537
- return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
806
+
807
+ // Top-level: command builder
808
+ return renderCommandBuilder(state, toolTitle, fields, contentHeight, innerWidth);
809
+ }
810
+
811
+ function renderCommandBuilder(
812
+ state: AppState, title: string, fields: FormField[],
813
+ contentHeight: number, innerWidth: number,
814
+ ): string[] {
815
+ const tool = state.selectedTool!;
816
+ const cmdName = tool.name.replace(/_/g, "-");
817
+ const content: string[] = [];
818
+ const editField = state.formEditing && state.formEditFieldIdx >= 0
819
+ ? fields[state.formEditFieldIdx]!
820
+ : null;
821
+
822
+ // Header
823
+ content.push("");
824
+ content.push(" " + style.bold(title));
825
+ if (tool.description) {
826
+ const wrapped = wrapText(tool.description, innerWidth - 4);
827
+ for (const line of wrapped) {
828
+ content.push(" " + style.dim(line));
829
+ }
830
+ }
831
+ content.push("");
832
+
833
+ // Classify editing field type once for both content and footer
834
+ const editKind = editField ? classifyField(editField.prop) : null;
835
+ const isTextLikeEdit = editKind === "text";
836
+
837
+ // Build command lines
838
+ const argLines: string[] = [];
839
+ for (const field of fields) {
840
+ const flagName = field.name.replace(/_/g, "-");
841
+ if (field === editField) {
842
+ if (isTextLikeEdit) {
843
+ // Inline cursor for text fields
844
+ const buf = state.formInputBuf;
845
+ const before = buf.slice(0, state.formInputCursorPos);
846
+ const cursorChar = state.formInputCursorPos < buf.length ? buf[state.formInputCursorPos]! : " ";
847
+ const after = state.formInputCursorPos < buf.length ? buf.slice(state.formInputCursorPos + 1) : "";
848
+ argLines.push(" --" + flagName + "=" + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
849
+ } else if (isArrayOfObjects(field.prop)) {
850
+ // Array-of-objects: show item count
851
+ const existing = state.formValues[field.name] || "[]";
852
+ let items: unknown[] = [];
853
+ try { items = JSON.parse(existing); } catch { /* */ }
854
+ const label = items.length > 0 ? `[${items.length} item${items.length > 1 ? "s" : ""}]` : "[...]";
855
+ argLines.push(" --" + flagName + "=" + style.yellow(label));
856
+ } else {
857
+ // Non-text editors (enum, bool, date): show pending
858
+ argLines.push(" --" + flagName + "=" + style.inverse(" "));
859
+ }
860
+ } else {
861
+ const val = state.formValues[field.name];
862
+ if (val) {
863
+ const needsQuotes = val.includes(" ") || val.includes(",");
864
+ const displayVal = needsQuotes ? '"' + val + '"' : val;
865
+ argLines.push(" --" + flagName + "=" + style.cyan(displayVal));
866
+ }
867
+ }
868
+ }
869
+
870
+ // Render command with line continuations
871
+ const cmdPrefix = " " + style.dim("$") + " " + style.dim("readwise") + " " + cmdName;
872
+ if (argLines.length === 0) {
873
+ content.push(cmdPrefix);
874
+ } else {
875
+ content.push(cmdPrefix + " \\");
876
+ for (let i = 0; i < argLines.length; i++) {
877
+ const isLast = i === argLines.length - 1;
878
+ content.push(argLines[i]! + (isLast ? "" : " \\"));
879
+ }
880
+ }
881
+
882
+ content.push("");
883
+
884
+ // Context area: field description, editor, or ready state
885
+ if (editField) {
886
+ // Field description
887
+ if (editField.prop.description) {
888
+ content.push(" " + style.dim(editField.prop.description));
889
+ }
890
+ if (editField.prop.examples?.length) {
891
+ const exStr = editField.prop.examples.map((e: unknown) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
892
+ content.push(" " + style.dim("e.g. ") + style.cyan(truncateVisible(exStr, innerWidth - 10)));
893
+ }
894
+ if (editField.prop.default != null) {
895
+ content.push(" " + style.dim("default: " + editField.prop.default));
896
+ }
897
+
898
+ const eVals = editField.prop.enum || editField.prop.items?.enum;
899
+
900
+ if (editKind === "bool") {
901
+ content.push("");
902
+ const choices = ["true", "false"];
903
+ for (let ci = 0; ci < choices.length; ci++) {
904
+ const sel = ci === state.formEnumCursor;
905
+ content.push(sel ? " " + style.cyan(style.bold("\u203A " + choices[ci]!)) : " " + choices[ci]!);
906
+ }
907
+ } else if (editKind === "enum" && eVals) {
908
+ content.push("");
909
+ for (let ci = 0; ci < eVals.length; ci++) {
910
+ const sel = ci === state.formEnumCursor;
911
+ content.push(sel ? " " + style.cyan(style.bold("\u203A " + eVals[ci]!)) : " " + eVals[ci]!);
912
+ }
913
+ } else if (editKind === "arrayEnum" && eVals) {
914
+ content.push("");
915
+ for (let ci = 0; ci < eVals.length; ci++) {
916
+ const sel = ci === state.formEnumCursor;
917
+ const checked = state.formEnumSelected.has(ci);
918
+ const check = checked ? style.cyan("[x]") : style.dim("[ ]");
919
+ content.push((sel ? " \u203A " : " ") + check + " " + eVals[ci]!);
920
+ }
921
+ } else if (editKind === "date") {
922
+ const dateFmt = dateFieldFormat(editField.prop)!;
923
+ content.push("");
924
+ content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
925
+ } else if (editKind === "arrayText") {
926
+ const existing = state.formValues[editField.name] || "";
927
+ const items = existing ? existing.split(",").map((s: string) => s.trim()).filter(Boolean) : [];
928
+ content.push("");
929
+ for (let i = 0; i < items.length; i++) {
930
+ const isCursor = i === state.formEnumCursor;
931
+ content.push((isCursor ? " \u276F " : " ") + style.cyan(items[i]!));
932
+ }
933
+ const onInput = state.formEnumCursor === items.length;
934
+ content.push((onInput ? " \u276F " : " ") + style.cyan(state.formInputBuf) + (onInput ? style.inverse(" ") : ""));
935
+ } else if (editKind === "arrayObj") {
936
+ const existing = state.formValues[editField.name] || "[]";
937
+ let items: unknown[] = [];
938
+ try { items = JSON.parse(existing); } catch { /* */ }
939
+ content.push("");
940
+ for (let i = 0; i < items.length; i++) {
941
+ const item = items[i] as Record<string, unknown>;
942
+ const summary = Object.entries(item)
943
+ .filter(([, v]) => v != null && v !== "")
944
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
945
+ .join(", ");
946
+ const isCursor = i === state.formEnumCursor;
947
+ content.push((isCursor ? " \u276F " : " ") + truncateVisible(summary || "(empty)", innerWidth - 6));
948
+ }
949
+ if (items.length > 0) content.push("");
950
+ const addCursor = state.formEnumCursor === items.length;
951
+ content.push(addCursor
952
+ ? " " + style.inverse(style.green(" + Add new item "))
953
+ : " " + style.dim("+") + " Add new item");
954
+ } else if (isTextLikeEdit) {
955
+ // Text field: cursor is already inline in the command string
956
+ // Just show a hint if empty
957
+ if (!state.formInputBuf) {
958
+ content.push("");
959
+ content.push(" " + style.dim("Type a value and press enter"));
960
+ }
961
+ }
962
+ } else {
963
+ // Ready state / optional picker
964
+ const missing = missingRequiredFields(fields, state.formValues);
965
+ if (missing.length > 0) {
966
+ content.push(" " + style.red("Missing: " + missing.map((f) => f.name).join(", ")));
967
+ } else {
968
+ content.push(" " + style.dim("Press enter to run"));
969
+ }
970
+
971
+ // Always show optional params
972
+ const optionalFields = fields
973
+ .map((f, i) => ({ field: f, idx: i }))
974
+ .filter(({ field }) => !field.required);
975
+ if (optionalFields.length > 0) {
976
+ content.push("");
977
+ content.push(" " + style.dim("Optional parameters (tab to add)"));
978
+ content.push("");
979
+ const maxFlagWidth = Math.max(...optionalFields.map(({ field }) => field.name.length), 0) + 2;
980
+ for (let i = 0; i < optionalFields.length; i++) {
981
+ const { field } = optionalFields[i]!;
982
+ const flagName = field.name.replace(/_/g, "-");
983
+ const hasValue = !!state.formValues[field.name]?.trim();
984
+ const sel = state.formShowOptional && i === state.formListCursor;
985
+ const prefix = sel ? " \u276F " : " ";
986
+ const paddedName = flagName.padEnd(maxFlagWidth);
987
+ const desc = field.prop.description
988
+ ? style.dim(truncateVisible(field.prop.description, innerWidth - maxFlagWidth - 8))
989
+ : "";
990
+ if (sel) {
991
+ content.push(style.boldYellow(prefix + paddedName) + " " + desc);
992
+ } else if (hasValue) {
993
+ content.push(prefix + style.green(paddedName) + " " + desc);
994
+ } else {
995
+ content.push(prefix + style.dim(paddedName) + " " + desc);
996
+ }
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // Footer
1002
+ const footer = editKind
1003
+ ? style.dim(footerForFieldKind(editKind))
1004
+ : state.formShowOptional
1005
+ ? style.dim("\u2191\u2193 select \u00B7 enter add \u00B7 esc done")
1006
+ : style.dim("enter run \u00B7 tab add option \u00B7 esc back");
1007
+
1008
+ return renderLayout({
1009
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" \u203A ") + style.bold(title),
1010
+ content,
1011
+ footer,
1012
+ });
538
1013
  }
539
1014
 
540
1015
  function renderFormPaletteMode(
@@ -558,28 +1033,51 @@ function renderFormPaletteMode(
558
1033
  content.push(" " + style.dim(line));
559
1034
  }
560
1035
  }
1036
+
1037
+ // Progress indicator for required fields
1038
+ const requiredFields = fields.filter((f) => f.required);
1039
+ if (requiredFields.length > 0) {
1040
+ const filledRequired = requiredFields.filter((f) => {
1041
+ const val = state.formValues[f.name]?.trim();
1042
+ if (!val) return false;
1043
+ if (isArrayOfObjects(f.prop)) {
1044
+ try { return JSON.parse(val).length > 0; } catch { return false; }
1045
+ }
1046
+ return true;
1047
+ });
1048
+ const allFilled = filledRequired.length === requiredFields.length;
1049
+ const progressText = `${filledRequired.length} of ${requiredFields.length} required`;
1050
+ content.push(" " + (allFilled ? style.green("✓ " + progressText) : style.dim(progressText)));
1051
+ }
1052
+
561
1053
  content.push("");
562
1054
 
563
- // Search input
1055
+ // Search input (only show when there's a search query or many fields)
564
1056
  const queryText = state.formSearchQuery;
565
- const before = queryText.slice(0, state.formSearchCursorPos);
566
- const cursorChar = state.formSearchCursorPos < queryText.length
567
- ? queryText[state.formSearchCursorPos]!
568
- : " ";
569
- const after = state.formSearchCursorPos < queryText.length
570
- ? queryText.slice(state.formSearchCursorPos + 1)
571
- : "";
572
- content.push(" " + style.yellow("❯") + " " + before + style.inverse(cursorChar) + after);
573
- content.push("");
1057
+ if (queryText || fields.length > 6) {
1058
+ const before = queryText.slice(0, state.formSearchCursorPos);
1059
+ const cursorChar = state.formSearchCursorPos < queryText.length
1060
+ ? queryText[state.formSearchCursorPos]!
1061
+ : " ";
1062
+ const after = state.formSearchCursorPos < queryText.length
1063
+ ? queryText.slice(state.formSearchCursorPos + 1)
1064
+ : "";
1065
+ content.push(" " + style.dim("/") + " " + before + style.inverse(cursorChar) + after);
1066
+ content.push("");
1067
+ } else {
1068
+ content.push("");
1069
+ }
574
1070
 
575
- // Compute maxLabelWidth
1071
+ // Compute maxLabelWidth (include " *" for required fields)
576
1072
  const maxLabelWidth = Math.max(
577
1073
  ...fields.map((f) => f.name.length + (f.required ? 2 : 0)),
578
1074
  6,
579
1075
  ) + 1;
580
1076
 
581
- // Value display width budget: innerWidth - prefix(3) - label - gap(2)
582
- const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2);
1077
+ // Badge width: " text" = ~7 chars max
1078
+ const badgeWidth = 8;
1079
+ // Value display width budget: innerWidth - cursor(3) - label - gap(2) - badge
1080
+ const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2 - badgeWidth);
583
1081
 
584
1082
  const headerUsed = content.length;
585
1083
  // Reserve space for: blank + Execute + blank + description (up to 4 lines)
@@ -588,29 +1086,69 @@ function renderFormPaletteMode(
588
1086
  const filtered = state.formFilteredIndices;
589
1087
  const hasOnlyExecute = filtered.length === 1 && filtered[0] === -1;
590
1088
 
1089
+ // Split fields into required and optional
1090
+ const requiredIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && fields[idx]!.required);
1091
+ const optionalIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && !fields[idx]!.required);
1092
+ const hasOptional = optionalIdxs.length > 0;
1093
+ const showingOptional = state.formShowOptional || state.formSearchQuery;
1094
+ // Count optional fields that have been filled
1095
+ const filledOptionalCount = optionalIdxs.filter((idx) => !!state.formValues[fields[idx]!.name]?.trim()).length;
1096
+
1097
+ const renderField = (fieldIdx: number) => {
1098
+ const field = fields[fieldIdx]!;
1099
+ const val = state.formValues[field.name] || "";
1100
+ const isFilled = !!val.trim();
1101
+ const listPos = filtered.indexOf(fieldIdx);
1102
+ const selected = listPos === state.formListCursor;
1103
+
1104
+ // Value display
1105
+ const valStr = formFieldValueDisplay(val, valueAvail);
1106
+
1107
+ // Type badge
1108
+ const badge = style.dim(fieldTypeBadge(field.prop));
1109
+
1110
+ const cursor = selected ? " ❯ " : " ";
1111
+ if (selected) {
1112
+ const label = field.name + (field.required ? " *" : "");
1113
+ content.push(style.boldYellow(cursor) + style.boldYellow(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
1114
+ } else if (isFilled) {
1115
+ const label = field.name + (field.required ? " *" : "");
1116
+ content.push(cursor + style.green(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
1117
+ } else {
1118
+ // Unfilled: show required * in red
1119
+ const namePart = field.name;
1120
+ const starPart = field.required ? " *" : "";
1121
+ const plainLabel = namePart + starPart;
1122
+ const padAmount = Math.max(0, maxLabelWidth - plainLabel.length);
1123
+ const displayLabel = field.required ? namePart + style.red(" *") + " ".repeat(padAmount) : plainLabel.padEnd(maxLabelWidth);
1124
+ content.push(cursor + displayLabel + " " + style.dim("–") + " " + badge);
1125
+ }
1126
+ };
1127
+
591
1128
  if (hasOnlyExecute && state.formSearchQuery) {
592
1129
  content.push(" " + style.dim("No matching parameters"));
593
1130
  content.push("");
594
1131
  } else {
595
- // Scrolling: items before the Execute sentinel
596
- const paramItems = filtered.filter((idx) => idx !== -1);
597
- const visStart = state.formScrollTop;
598
- const visEnd = Math.min(paramItems.length, visStart + listHeight);
599
- const visible = paramItems.slice(visStart, visEnd);
600
-
601
- for (const fieldIdx of visible) {
602
- const field = fields[fieldIdx]!;
603
- const nameLabel = field.name + (field.required ? " *" : "");
604
- const paddedName = nameLabel.padEnd(maxLabelWidth);
605
- const val = state.formValues[field.name] || "";
606
- const valStr = formFieldValueDisplay(val, valueAvail);
607
- const listPos = filtered.indexOf(fieldIdx);
608
- const selected = listPos === state.formListCursor;
609
- const prefix = selected ? " ❯ " : " ";
610
- if (selected) {
611
- content.push(style.boldYellow(prefix + paddedName) + " " + valStr);
1132
+ // Required fields (always visible)
1133
+ for (const fieldIdx of requiredIdxs) {
1134
+ renderField(fieldIdx);
1135
+ }
1136
+
1137
+ // Optional fields separator
1138
+ if (hasOptional) {
1139
+ if (showingOptional) {
1140
+ if (requiredIdxs.length > 0) content.push("");
1141
+ content.push(" " + style.dim("── optional ──"));
1142
+ const visibleOptional = optionalIdxs.slice(0, listHeight - requiredIdxs.length - 2);
1143
+ for (const fieldIdx of visibleOptional) {
1144
+ renderField(fieldIdx);
1145
+ }
612
1146
  } else {
613
- content.push(prefix + paddedName + " " + valStr);
1147
+ content.push("");
1148
+ const optLabel = filledOptionalCount > 0
1149
+ ? `── ${optionalIdxs.length} optional (${filledOptionalCount} set) · 'o' to show ──`
1150
+ : `── ${optionalIdxs.length} optional · 'o' to show ──`;
1151
+ content.push(" " + style.dim(optLabel));
614
1152
  }
615
1153
  }
616
1154
  }
@@ -639,23 +1177,34 @@ function renderFormPaletteMode(
639
1177
  }
640
1178
  }
641
1179
 
642
- // Description of highlighted field
1180
+ // Description of highlighted field or Execute hint
643
1181
  const highlightedIdx = filtered[state.formListCursor];
644
1182
  if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
645
- const desc = fields[highlightedIdx]!.prop.description;
646
- if (desc) {
1183
+ const prop = fields[highlightedIdx]!.prop;
1184
+ if (prop.description) {
647
1185
  content.push("");
648
- const wrapped = wrapText(desc, innerWidth - 4);
1186
+ const wrapped = wrapText(prop.description, innerWidth - 4);
649
1187
  for (const line of wrapped) {
650
1188
  content.push(" " + style.dim(line));
651
1189
  }
652
1190
  }
1191
+ if (prop.examples?.length) {
1192
+ const exStr = prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
1193
+ content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
1194
+ }
1195
+ } else if (highlightedIdx === -1) {
1196
+ content.push("");
1197
+ content.push(" " + style.dim("Press enter to run"));
653
1198
  }
654
1199
 
1200
+ // Footer hints
1201
+ const hasUnfilledRequired = requiredFields.some((f) => !state.formValues[f.name]?.trim());
1202
+ const tabHint = hasUnfilledRequired ? " · tab next required" : "";
1203
+ const optionalHint = hasOptional ? " · o optional" : "";
655
1204
  return renderLayout({
656
1205
  breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
657
1206
  content,
658
- footer: style.dim("type to filter ↑↓ navigate enter edit/run esc back"),
1207
+ footer: style.dim("↑↓ navigate · enter edit" + tabHint + optionalHint + " · esc back"),
659
1208
  });
660
1209
  }
661
1210
 
@@ -668,11 +1217,24 @@ function renderFormEditMode(
668
1217
 
669
1218
  content.push("");
670
1219
  content.push(" " + style.bold(title));
1220
+
1221
+ // Show tool description for context
1222
+ const toolDesc = state.formStack.length > 0
1223
+ ? state.formStack[state.formStack.length - 1]!.parentFields
1224
+ .find((f) => f.name === state.formStack[state.formStack.length - 1]!.parentFieldName)
1225
+ ?.prop.items?.description
1226
+ : state.selectedTool!.description;
1227
+ if (toolDesc) {
1228
+ const wrapped = wrapText(toolDesc, innerWidth - 4);
1229
+ for (const line of wrapped) {
1230
+ content.push(" " + style.dim(line));
1231
+ }
1232
+ }
671
1233
  content.push("");
672
1234
 
673
- // Field name
1235
+ // Field label
674
1236
  const nameLabel = field.name + (field.required ? " *" : "");
675
- content.push(" " + style.boldYellow("❯ " + nameLabel));
1237
+ content.push(" " + style.bold(nameLabel));
676
1238
 
677
1239
  // Field description
678
1240
  if (field.prop.description) {
@@ -681,17 +1243,17 @@ function renderFormEditMode(
681
1243
  content.push(" " + style.dim(line));
682
1244
  }
683
1245
  }
1246
+ if (field.prop.examples?.length) {
1247
+ const exStr = field.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
1248
+ content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
1249
+ }
684
1250
  content.push("");
685
1251
 
686
1252
  // Editor area
1253
+ const kind = classifyField(field.prop);
687
1254
  const eVals = field.prop.enum || field.prop.items?.enum;
688
- const isArrayObj = isArrayOfObjects(field.prop);
689
- const isArrayEnum = !isArrayObj && field.prop.type === "array" && !!field.prop.items?.enum;
690
- const isArrayText = !isArrayObj && field.prop.type === "array" && !field.prop.items?.enum;
691
- const isBool = field.prop.type === "boolean";
692
- const dateFmt = dateFieldFormat(field.prop);
693
1255
 
694
- if (isArrayObj) {
1256
+ if (kind === "arrayObj") {
695
1257
  // Array-of-objects editor: show existing items + "Add new item"
696
1258
  const existing = state.formValues[field.name] || "[]";
697
1259
  let items: unknown[] = [];
@@ -715,9 +1277,10 @@ function renderFormEditMode(
715
1277
  } else {
716
1278
  content.push(" " + style.dim("+") + " Add new item");
717
1279
  }
718
- } else if (dateFmt) {
1280
+ } else if (kind === "date") {
1281
+ const dateFmt = dateFieldFormat(field.prop)!;
719
1282
  content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
720
- } else if (isArrayEnum && eVals) {
1283
+ } else if (kind === "arrayEnum" && eVals) {
721
1284
  // Multi-select picker
722
1285
  for (let ci = 0; ci < eVals.length; ci++) {
723
1286
  const isCursor = ci === state.formEnumCursor;
@@ -727,7 +1290,7 @@ function renderFormEditMode(
727
1290
  const label = marker + check + " " + eVals[ci]!;
728
1291
  content.push(isCursor ? style.bold(label) : label);
729
1292
  }
730
- } else if (isArrayText) {
1293
+ } else if (kind === "arrayText") {
731
1294
  // Tag-style list editor: navigable items + text input at bottom
732
1295
  const existing = state.formValues[field.name] || "";
733
1296
  const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
@@ -750,45 +1313,76 @@ function renderFormEditMode(
750
1313
  content.push(" " + style.dim("enter ") + style.dim("edit item"));
751
1314
  content.push(" " + style.dim("bksp ") + style.dim("remove item"));
752
1315
  }
753
- } else if (eVals || isBool) {
754
- const choices = isBool ? ["true", "false"] : eVals!;
1316
+ } else if (kind === "enum" || kind === "bool") {
1317
+ const choices = kind === "bool" ? ["true", "false"] : eVals!;
755
1318
  for (let ci = 0; ci < choices.length; ci++) {
756
1319
  const sel = ci === state.formEnumCursor;
757
1320
  const choiceLine = (sel ? " › " : " ") + choices[ci]!;
758
1321
  content.push(sel ? style.cyan(style.bold(choiceLine)) : choiceLine);
759
1322
  }
760
1323
  } else {
761
- // Text editor
762
- const lines = state.formInputBuf.split("\n");
763
- for (let li = 0; li < lines.length; li++) {
764
- const prefix = li === 0 ? " " + style.yellow("❯") + " " : " ";
765
- if (li === lines.length - 1) {
766
- content.push(prefix + style.cyan(lines[li]!) + style.inverse(" "));
767
- } else {
768
- content.push(prefix + style.cyan(lines[li]!));
1324
+ // Text editor with cursor position
1325
+ const prefix0 = " ";
1326
+ if (!state.formInputBuf) {
1327
+ // Show placeholder text when input is empty
1328
+ let placeholder = "type a value";
1329
+ if (field.prop.examples?.length) {
1330
+ placeholder = String(field.prop.examples[0]);
1331
+ } else if (field.prop.description) {
1332
+ placeholder = field.prop.description.toLowerCase().replace(/[.!]$/, "");
1333
+ } else if (field.prop.type === "integer" || field.prop.type === "number") {
1334
+ placeholder = "enter a number";
1335
+ }
1336
+ content.push(prefix0 + style.inverse(" ") + style.dim(" " + placeholder + "…"));
1337
+ } else {
1338
+ const lines = state.formInputBuf.split("\n");
1339
+ // Find cursor line and column from flat position
1340
+ let cursorLine = 0;
1341
+ let cursorCol = state.formInputCursorPos;
1342
+ for (let li = 0; li < lines.length; li++) {
1343
+ if (cursorCol <= lines[li]!.length) {
1344
+ cursorLine = li;
1345
+ break;
1346
+ }
1347
+ cursorCol -= lines[li]!.length + 1;
1348
+ }
1349
+ for (let li = 0; li < lines.length; li++) {
1350
+ const prefix = li === 0 ? prefix0 : " ";
1351
+ const lineText = lines[li]!;
1352
+ if (li === cursorLine) {
1353
+ const before = lineText.slice(0, cursorCol);
1354
+ const cursorChar = cursorCol < lineText.length ? lineText[cursorCol]! : " ";
1355
+ const after = cursorCol < lineText.length ? lineText.slice(cursorCol + 1) : "";
1356
+ content.push(prefix + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
1357
+ } else {
1358
+ content.push(prefix + style.cyan(lineText));
1359
+ }
769
1360
  }
770
1361
  }
771
1362
  }
772
1363
 
773
- let footer: string;
774
- if (isArrayObj) {
775
- footer = style.dim("↑↓ navigate enter add/select backspace delete esc back");
776
- } else if (dateFmt) {
777
- footer = style.dim("←→ part ↑↓ adjust t today enter confirm esc cancel");
778
- } else if (isArrayEnum) {
779
- footer = style.dim("space toggle enter select esc confirm");
780
- } else if (isArrayText) {
781
- footer = style.dim("↑↓ navigate enter add/edit backspace delete esc confirm");
782
- } else if (eVals || isBool) {
783
- footer = style.dim("↑↓ navigate enter confirm esc cancel");
784
- } else {
785
- footer = style.dim("enter confirm shift+enter newline esc cancel");
1364
+ // Show remaining required fields hint (for text editors)
1365
+ if (kind === "text") {
1366
+ const requiredFields = fields.filter((f) => f.required);
1367
+ const filledCount = requiredFields.filter((f) => {
1368
+ if (f.name === field.name) return !!state.formInputBuf.trim(); // current field
1369
+ return !!state.formValues[f.name]?.trim();
1370
+ }).length;
1371
+ const remaining = requiredFields.length - filledCount;
1372
+ content.push("");
1373
+ if (remaining <= 0) {
1374
+ content.push(" " + style.dim("Then press enter to confirm → Execute"));
1375
+ } else if (remaining === 1 && !state.formInputBuf.trim()) {
1376
+ content.push(" " + style.dim("Type a value, then press enter"));
1377
+ } else {
1378
+ content.push(" " + style.dim(`${remaining} required field${remaining > 1 ? "s" : ""} remaining`));
1379
+ }
786
1380
  }
787
1381
 
788
1382
  return renderLayout({
789
1383
  breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
790
1384
  content,
791
- footer,
1385
+ footer: style.dim(footerForFieldKind(kind)),
792
1386
  });
793
1387
  }
794
1388
 
@@ -822,6 +1416,122 @@ const SUCCESS_ICON = [
822
1416
  " ╚═════╝ ╚═╝ ╚═╝",
823
1417
  ];
824
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
+
825
1535
  function renderResults(state: AppState): string[] {
826
1536
  const { contentHeight, innerWidth } = getBoxDimensions();
827
1537
  const tool = state.selectedTool;
@@ -830,6 +1540,11 @@ function renderResults(state: AppState): string[] {
830
1540
  const isEmptyList = !isError && state.result === EMPTY_LIST_SENTINEL;
831
1541
  const isEmpty = !isError && !isEmptyList && !state.result.trim();
832
1542
 
1543
+ // Card view for list results
1544
+ if (!isError && !isEmptyList && !isEmpty && state.resultCards.length > 0) {
1545
+ return renderCardView(state);
1546
+ }
1547
+
833
1548
  // No results screen for empty lists
834
1549
  if (isEmptyList) {
835
1550
  const ghost = [
@@ -860,7 +1575,7 @@ function renderResults(state: AppState): string[] {
860
1575
  content,
861
1576
  footer: state.quitConfirm
862
1577
  ? style.yellow("Press q again to quit")
863
- : style.dim("enter/esc back q quit"),
1578
+ : style.dim("enter/esc back · q quit"),
864
1579
  });
865
1580
  }
866
1581
 
@@ -881,7 +1596,7 @@ function renderResults(state: AppState): string[] {
881
1596
  content,
882
1597
  footer: state.quitConfirm
883
1598
  ? style.yellow("Press q again to quit")
884
- : style.dim("enter/esc back q quit"),
1599
+ : style.dim("enter/esc back · q quit"),
885
1600
  });
886
1601
  }
887
1602
 
@@ -913,7 +1628,7 @@ function renderResults(state: AppState): string[] {
913
1628
  content,
914
1629
  footer: state.quitConfirm
915
1630
  ? style.yellow("Press q again to quit")
916
- : style.dim(scrollHint + "↑↓←→ scroll esc back q quit"),
1631
+ : style.dim(scrollHint + "↑↓←→ scroll · esc back · q quit"),
917
1632
  });
918
1633
  }
919
1634
 
@@ -928,7 +1643,7 @@ function renderState(state: AppState): string[] {
928
1643
 
929
1644
  // --- Input handling ---
930
1645
 
931
- function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" {
1646
+ function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" | "openUrl" {
932
1647
  switch (state.view) {
933
1648
  case "commands": return handleCommandListInput(state, key);
934
1649
  case "form": return handleFormInput(state, key);
@@ -945,10 +1660,8 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
945
1660
  const logoUsed = LOGO.length + 3;
946
1661
  const listHeight = Math.max(1, contentHeight - logoUsed);
947
1662
 
948
- if (key.ctrl && key.name === "c") return "exit";
949
-
950
- // Escape: clear query if non-empty, otherwise quit confirm
951
- if (key.name === "escape") {
1663
+ // Escape / ctrl+c / q: clear query if non-empty, otherwise quit confirm
1664
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
952
1665
  if (state.searchQuery) {
953
1666
  const filtered = filterCommands(state.tools, "");
954
1667
  const sel = selectableIndices(filtered);
@@ -1034,11 +1747,7 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
1034
1747
 
1035
1748
  const formValues: Record<string, string> = {};
1036
1749
  for (const f of fields) {
1037
- if (f.prop.default != null) {
1038
- formValues[f.name] = String(f.prop.default);
1039
- } else {
1040
- formValues[f.name] = "";
1041
- }
1750
+ formValues[f.name] = "";
1042
1751
  }
1043
1752
 
1044
1753
  if (fields.length === 0) {
@@ -1057,33 +1766,44 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
1057
1766
  formEditFieldIdx: -1,
1058
1767
  formEditing: false,
1059
1768
  formInputBuf: "",
1769
+ formInputCursorPos: 0,
1060
1770
  formEnumCursor: 0,
1061
1771
  formEnumSelected: new Set(),
1062
- formShowRequired: false,
1772
+ formShowRequired: false, formShowOptional: false,
1063
1773
  formStack: [],
1064
1774
  };
1065
1775
  }
1066
1776
 
1067
- return {
1777
+ const filteredIndices = filterFormFields(fields, "");
1778
+ const firstBlankRequired = fields.findIndex((f) => f.required && !formValues[f.name]?.trim());
1779
+
1780
+ const baseState: AppState = {
1068
1781
  ...s,
1069
- view: "form",
1782
+ view: "form" as View,
1070
1783
  selectedTool: tool,
1071
1784
  fields,
1072
1785
  nameColWidth,
1073
1786
  formValues,
1074
1787
  formSearchQuery: "",
1075
1788
  formSearchCursorPos: 0,
1076
- formFilteredIndices: filterFormFields(fields, ""),
1077
- formListCursor: defaultFormCursor(fields, filterFormFields(fields, ""), formValues),
1789
+ formFilteredIndices: filteredIndices,
1790
+ formListCursor: defaultFormCursor(fields, filteredIndices, formValues),
1078
1791
  formScrollTop: 0,
1079
1792
  formEditFieldIdx: -1,
1080
1793
  formEditing: false,
1081
1794
  formInputBuf: "",
1795
+ formInputCursorPos: 0,
1082
1796
  formEnumCursor: 0,
1083
1797
  formEnumSelected: new Set(),
1084
- formShowRequired: false,
1798
+ formShowRequired: false, formShowOptional: false,
1085
1799
  formStack: [],
1086
1800
  };
1801
+
1802
+ // Auto-open first required field
1803
+ if (firstBlankRequired >= 0) {
1804
+ return startEditingField(baseState, firstBlankRequired);
1805
+ }
1806
+ return baseState;
1087
1807
  }
1088
1808
  }
1089
1809
  return s;
@@ -1100,20 +1820,169 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
1100
1820
  return s;
1101
1821
  }
1102
1822
 
1103
- // Printable characters: insert into search query
1104
- if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
1105
- const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + key.raw + s.searchQuery.slice(s.searchCursorPos);
1106
- const filtered = filterCommands(s.tools, newQuery);
1107
- const sel = selectableIndices(filtered);
1108
- return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + 1, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1823
+ // Printable characters or paste: insert into search query
1824
+ if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
1825
+ const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
1826
+ if (text) {
1827
+ const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + text + s.searchQuery.slice(s.searchCursorPos);
1828
+ const filtered = filterCommands(s.tools, newQuery);
1829
+ const sel = selectableIndices(filtered);
1830
+ return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + text.length, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1831
+ }
1109
1832
  }
1110
1833
 
1111
1834
  return s;
1112
1835
  }
1113
1836
 
1837
+ function startEditingField(state: AppState, fieldIdx: number): AppState {
1838
+ const field = state.fields[fieldIdx]!;
1839
+ if (isArrayOfObjects(field.prop)) {
1840
+ const existing = state.formValues[field.name] || "[]";
1841
+ let items: unknown[] = [];
1842
+ try { items = JSON.parse(existing); } catch { /* */ }
1843
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: items.length };
1844
+ }
1845
+ const dateFmt = dateFieldFormat(field.prop);
1846
+ if (dateFmt) {
1847
+ const existing = state.formValues[field.name] || "";
1848
+ const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
1849
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, dateParts: parts, datePartCursor: 0 };
1850
+ }
1851
+ const enumValues = field.prop.enum || field.prop.items?.enum;
1852
+ const isBool = field.prop.type === "boolean";
1853
+ const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
1854
+ if (isArrayEnum && enumValues) {
1855
+ const curVal = state.formValues[field.name] || "";
1856
+ const selected = new Set<number>();
1857
+ if (curVal) {
1858
+ const parts = curVal.split(",").map((s) => s.trim());
1859
+ for (const p of parts) {
1860
+ const idx = enumValues.indexOf(p);
1861
+ if (idx >= 0) selected.add(idx);
1862
+ }
1863
+ }
1864
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: 0, formEnumSelected: selected };
1865
+ }
1866
+ if (enumValues || isBool) {
1867
+ const choices = isBool ? ["true", "false"] : enumValues!;
1868
+ const curVal = state.formValues[field.name] || "";
1869
+ const idx = choices.indexOf(curVal);
1870
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: idx >= 0 ? idx : 0 };
1871
+ }
1872
+ if (field.prop.type === "array" && !field.prop.items?.enum) {
1873
+ const existing = state.formValues[field.name] || "";
1874
+ const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
1875
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: itemCount };
1876
+ }
1877
+ const editBuf = state.formValues[field.name] || "";
1878
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: editBuf, formInputCursorPos: editBuf.length };
1879
+ }
1880
+
1114
1881
  function handleFormInput(state: AppState, key: KeyEvent): AppState | "submit" {
1115
- if (state.formEditing) return handleFormEditInput(state, key);
1116
- return handleFormPaletteInput(state, key);
1882
+ // Sub-forms use existing palette/edit handlers
1883
+ if (state.formStack.length > 0) {
1884
+ if (state.formEditing) return handleFormEditInput(state, key);
1885
+ return handleFormPaletteInput(state, key);
1886
+ }
1887
+
1888
+ // Command builder: editing a field
1889
+ if (state.formEditing) {
1890
+ const result = handleFormEditInput(state, key);
1891
+ if (result === "submit") return result;
1892
+ // Auto-advance: if editing just ended via confirm (not cancel), jump to next blank required field
1893
+ if (!result.formEditing && state.formEditing) {
1894
+ const wasCancel = key.name === "escape";
1895
+ if (!wasCancel) {
1896
+ const nextBlank = result.fields.findIndex((f) => f.required && !result.formValues[f.name]?.trim());
1897
+ if (nextBlank >= 0) {
1898
+ return startEditingField(result, nextBlank);
1899
+ }
1900
+ }
1901
+ }
1902
+ return result;
1903
+ }
1904
+
1905
+ // Command builder: optional picker
1906
+ if (state.formShowOptional) {
1907
+ return handleOptionalPickerInput(state, key);
1908
+ }
1909
+
1910
+ // Command builder: ready state
1911
+ return handleCommandBuilderReadyInput(state, key);
1912
+ }
1913
+
1914
+ function commandListReset(tools: ToolDef[]): Partial<AppState> {
1915
+ const filteredItems = buildCommandList(tools);
1916
+ const sel = selectableIndices(filteredItems);
1917
+ return {
1918
+ view: "commands" as View, selectedTool: null,
1919
+ searchQuery: "", searchCursorPos: 0,
1920
+ filteredItems, listCursor: sel[0] ?? 0, listScrollTop: 0,
1921
+ };
1922
+ }
1923
+
1924
+ function handleCommandBuilderReadyInput(state: AppState, key: KeyEvent): AppState | "submit" {
1925
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1926
+ return { ...state, ...commandListReset(state.tools) };
1927
+ }
1928
+
1929
+ if (key.name === "return") {
1930
+ if (missingRequiredFields(state.fields, state.formValues).length === 0) {
1931
+ return "submit";
1932
+ }
1933
+ // Jump to first missing required field
1934
+ const nextBlank = state.fields.findIndex((f) => f.required && !state.formValues[f.name]?.trim());
1935
+ if (nextBlank >= 0) {
1936
+ return startEditingField(state, nextBlank);
1937
+ }
1938
+ return state;
1939
+ }
1940
+
1941
+ if (key.name === "tab") {
1942
+ const hasOptional = state.fields.some((f) => !f.required);
1943
+ if (hasOptional) {
1944
+ return { ...state, formShowOptional: true, formListCursor: 0 };
1945
+ }
1946
+ return state;
1947
+ }
1948
+
1949
+ // Backspace: re-edit last set field
1950
+ if (key.name === "backspace") {
1951
+ for (let i = state.fields.length - 1; i >= 0; i--) {
1952
+ if (state.formValues[state.fields[i]!.name]?.trim()) {
1953
+ return startEditingField(state, i);
1954
+ }
1955
+ }
1956
+ return state;
1957
+ }
1958
+
1959
+ return state;
1960
+ }
1961
+
1962
+ function handleOptionalPickerInput(state: AppState, key: KeyEvent): AppState | "submit" {
1963
+ const optionalFields = state.fields
1964
+ .map((f, i) => ({ field: f, idx: i }))
1965
+ .filter(({ field }) => !field.required);
1966
+
1967
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1968
+ return { ...state, formShowOptional: false };
1969
+ }
1970
+
1971
+ if (key.name === "up") {
1972
+ return { ...state, formListCursor: state.formListCursor > 0 ? state.formListCursor - 1 : optionalFields.length - 1 };
1973
+ }
1974
+ if (key.name === "down") {
1975
+ return { ...state, formListCursor: state.formListCursor < optionalFields.length - 1 ? state.formListCursor + 1 : 0 };
1976
+ }
1977
+
1978
+ if (key.name === "return") {
1979
+ const selected = optionalFields[state.formListCursor];
1980
+ if (selected) {
1981
+ return startEditingField({ ...state, formShowOptional: false }, selected.idx);
1982
+ }
1983
+ }
1984
+
1985
+ return state;
1117
1986
  }
1118
1987
 
1119
1988
  function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "submit" {
@@ -1155,13 +2024,62 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1155
2024
  formFilteredIndices: parentFiltered,
1156
2025
  formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, entry.parentValues),
1157
2026
  formScrollTop: 0,
1158
- formShowRequired: false,
2027
+ formShowRequired: false, formShowOptional: false,
1159
2028
  formInputBuf: "",
2029
+ formInputCursorPos: 0,
1160
2030
  };
1161
2031
  }
1162
- const resetFiltered = buildCommandList(state.tools);
1163
- const resetSel = selectableIndices(resetFiltered);
1164
- return { ...state, view: "commands", selectedTool: null, searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
2032
+ return { ...state, ...commandListReset(state.tools) };
2033
+ }
2034
+
2035
+ // Tab: jump to next unfilled required field
2036
+ if (key.name === "tab") {
2037
+ const unfilledRequired = filtered
2038
+ .map((idx, listPos) => ({ idx, listPos }))
2039
+ .filter(({ idx }) => {
2040
+ if (idx < 0 || idx >= fields.length) return false;
2041
+ const f = fields[idx]!;
2042
+ if (!f.required) return false;
2043
+ const val = state.formValues[f.name]?.trim();
2044
+ if (!val) return true;
2045
+ if (isArrayOfObjects(f.prop)) {
2046
+ try { return JSON.parse(val).length === 0; } catch { return true; }
2047
+ }
2048
+ return false;
2049
+ });
2050
+ if (unfilledRequired.length > 0) {
2051
+ // Find the next one after current cursor, wrapping around
2052
+ const after = unfilledRequired.find((u) => u.listPos > formListCursor);
2053
+ const target = after || unfilledRequired[0]!;
2054
+ let scroll = state.formScrollTop;
2055
+ const paramItems = filtered.filter((idx) => idx !== -1);
2056
+ if (target.idx >= 0) {
2057
+ const posInParams = paramItems.indexOf(target.idx);
2058
+ if (posInParams < scroll) scroll = posInParams;
2059
+ if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
2060
+ }
2061
+ return { ...state, formListCursor: target.listPos, formScrollTop: scroll };
2062
+ }
2063
+ // No unfilled required fields — jump to Execute
2064
+ const execPos = filtered.indexOf(-1);
2065
+ if (execPos >= 0) return { ...state, formListCursor: execPos };
2066
+ return state;
2067
+ }
2068
+
2069
+ // 'o' key: toggle optional fields visibility (when not searching)
2070
+ if (key.raw === "o" && !key.ctrl && !formSearchQuery) {
2071
+ const optionalExists = filtered.some((idx) => idx >= 0 && idx < fields.length && !fields[idx]!.required);
2072
+ if (optionalExists) {
2073
+ const newShow = !state.formShowOptional;
2074
+ if (!newShow) {
2075
+ const curIdx = filtered[formListCursor];
2076
+ if (curIdx !== undefined && curIdx >= 0 && curIdx < fields.length && !fields[curIdx]!.required) {
2077
+ const execPos = filtered.indexOf(-1);
2078
+ return { ...state, formShowOptional: false, formListCursor: execPos >= 0 ? execPos : 0 };
2079
+ }
2080
+ }
2081
+ return { ...state, formShowOptional: newShow };
2082
+ }
1165
2083
  }
1166
2084
 
1167
2085
  // Arrow left/right: move text cursor within search input
@@ -1172,29 +2090,44 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1172
2090
  return { ...state, formSearchCursorPos: Math.min(formSearchQuery.length, state.formSearchCursorPos + 1) };
1173
2091
  }
1174
2092
 
1175
- // Arrow up/down: navigate filtered list (cycling)
2093
+ // Helper: check if a position in filtered is navigable (skip collapsed optional fields)
2094
+ const isNavigable = (listPos: number) => {
2095
+ const idx = filtered[listPos];
2096
+ if (idx === undefined) return false;
2097
+ if (idx === -1) return true; // Execute always navigable
2098
+ if (!state.formShowOptional && !state.formSearchQuery && idx >= 0 && idx < fields.length && !fields[idx]!.required) return false;
2099
+ return true;
2100
+ };
2101
+
2102
+ // Arrow up/down: navigate filtered list (cycling, skipping hidden items)
1176
2103
  if (key.name === "up") {
1177
- const next = formListCursor > 0 ? formListCursor - 1 : filtered.length - 1;
2104
+ let next = formListCursor;
2105
+ for (let i = 0; i < filtered.length; i++) {
2106
+ next = next > 0 ? next - 1 : filtered.length - 1;
2107
+ if (isNavigable(next)) break;
2108
+ }
1178
2109
  let scroll = state.formScrollTop;
1179
2110
  const itemIdx = filtered[next]!;
1180
2111
  if (itemIdx !== -1) {
1181
2112
  const paramItems = filtered.filter((idx) => idx !== -1);
1182
2113
  const posInParams = paramItems.indexOf(itemIdx);
1183
2114
  if (posInParams < scroll) scroll = posInParams;
1184
- // Wrap to bottom: reset scroll to show end of list
1185
2115
  if (next > formListCursor) scroll = Math.max(0, paramItems.length - listHeight);
1186
2116
  }
1187
2117
  return { ...state, formListCursor: next, formScrollTop: scroll };
1188
2118
  }
1189
2119
  if (key.name === "down") {
1190
- const next = formListCursor < filtered.length - 1 ? formListCursor + 1 : 0;
2120
+ let next = formListCursor;
2121
+ for (let i = 0; i < filtered.length; i++) {
2122
+ next = next < filtered.length - 1 ? next + 1 : 0;
2123
+ if (isNavigable(next)) break;
2124
+ }
1191
2125
  let scroll = state.formScrollTop;
1192
2126
  const itemIdx = filtered[next]!;
1193
2127
  if (itemIdx !== -1) {
1194
2128
  const paramItems = filtered.filter((idx) => idx !== -1);
1195
2129
  const posInParams = paramItems.indexOf(itemIdx);
1196
2130
  if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
1197
- // Wrap to top: reset scroll
1198
2131
  if (next < formListCursor) scroll = 0;
1199
2132
  } else if (next < formListCursor) {
1200
2133
  scroll = 0;
@@ -1217,47 +2150,7 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1217
2150
  return { ...state, formShowRequired: true };
1218
2151
  }
1219
2152
  if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
1220
- const field = fields[highlightedIdx]!;
1221
- // Array-of-objects: enter edit mode with cursor on "Add new item"
1222
- if (isArrayOfObjects(field.prop)) {
1223
- const existing = state.formValues[field.name] || "[]";
1224
- let items: unknown[] = [];
1225
- try { items = JSON.parse(existing); } catch { /* */ }
1226
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: items.length };
1227
- }
1228
- const dateFmt = dateFieldFormat(field.prop);
1229
- const enumValues = field.prop.enum || field.prop.items?.enum;
1230
- const isBool = field.prop.type === "boolean";
1231
- if (dateFmt) {
1232
- const existing = state.formValues[field.name] || "";
1233
- const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
1234
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, dateParts: parts, datePartCursor: 0 };
1235
- }
1236
- const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
1237
- if (isArrayEnum && enumValues) {
1238
- const curVal = state.formValues[field.name] || "";
1239
- const selected = new Set<number>();
1240
- if (curVal) {
1241
- const parts = curVal.split(",").map((s) => s.trim());
1242
- for (const p of parts) {
1243
- const idx = enumValues.indexOf(p);
1244
- if (idx >= 0) selected.add(idx);
1245
- }
1246
- }
1247
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: 0, formEnumSelected: selected };
1248
- }
1249
- if (enumValues || isBool) {
1250
- const choices = isBool ? ["true", "false"] : enumValues!;
1251
- const curVal = state.formValues[field.name] || "";
1252
- const idx = choices.indexOf(curVal);
1253
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: idx >= 0 ? idx : 0 };
1254
- }
1255
- if (field.prop.type === "array" && !field.prop.items?.enum) {
1256
- const existing = state.formValues[field.name] || "";
1257
- const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
1258
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formInputBuf: "", formEnumCursor: itemCount };
1259
- }
1260
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formInputBuf: state.formValues[field.name] || "" };
2153
+ return startEditingField(state, highlightedIdx);
1261
2154
  }
1262
2155
  return state;
1263
2156
  }
@@ -1272,11 +2165,14 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1272
2165
  return state;
1273
2166
  }
1274
2167
 
1275
- // Printable characters: insert into search query
1276
- if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
1277
- const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + key.raw + formSearchQuery.slice(state.formSearchCursorPos);
1278
- const newFiltered = filterFormFields(fields, newQuery);
1279
- return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + 1, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
2168
+ // Printable characters or paste: insert into search query
2169
+ if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
2170
+ const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
2171
+ if (text) {
2172
+ const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + text + formSearchQuery.slice(state.formSearchCursorPos);
2173
+ const newFiltered = filterFormFields(fields, newQuery);
2174
+ return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + text.length, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
2175
+ }
1280
2176
  }
1281
2177
 
1282
2178
  return state;
@@ -1291,7 +2187,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1291
2187
 
1292
2188
  const resetPalette = (updatedValues?: Record<string, string>) => {
1293
2189
  const f = filterFormFields(fields, "");
1294
- return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false };
2190
+ return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false, formShowOptional: false };
1295
2191
  };
1296
2192
 
1297
2193
  // Escape: cancel edit (for multi-select and tag editor, escape confirms since items are saved live)
@@ -1304,7 +2200,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1304
2200
  const newValues = { ...formValues, [field.name]: val };
1305
2201
  return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette(newValues) };
1306
2202
  }
1307
- return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", ...resetPalette() };
2203
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", formInputCursorPos: 0, ...resetPalette() };
1308
2204
  }
1309
2205
 
1310
2206
  if (key.ctrl && key.name === "c") return "submit";
@@ -1376,10 +2272,11 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1376
2272
  formFilteredIndices: subFiltered,
1377
2273
  formListCursor: defaultFormCursor(subFields, subFiltered, subValues),
1378
2274
  formScrollTop: 0,
1379
- formShowRequired: false,
2275
+ formShowRequired: false, formShowOptional: false,
1380
2276
  formEnumCursor: 0,
1381
2277
  formEnumSelected: new Set(),
1382
2278
  formInputBuf: "",
2279
+ formInputCursorPos: 0,
1383
2280
  };
1384
2281
  }
1385
2282
  if (key.name === "backspace" && formEnumCursor < items.length) {
@@ -1491,7 +2388,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1491
2388
  const newItems = [...items];
1492
2389
  newItems.splice(formEnumCursor, 1);
1493
2390
  const newValues = { ...formValues, [field.name]: newItems.join(", ") };
1494
- return { ...state, formValues: newValues, formInputBuf: editVal, formEnumCursor: newItems.length };
2391
+ return { ...state, formValues: newValues, formInputBuf: editVal, formInputCursorPos: editVal.length, formEnumCursor: newItems.length };
1495
2392
  }
1496
2393
  if (key.name === "backspace") {
1497
2394
  // Delete item
@@ -1505,11 +2402,16 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1505
2402
  }
1506
2403
 
1507
2404
  // Cursor on text input
2405
+ if (key.name === "paste") {
2406
+ // Paste: strip newlines for tag input
2407
+ const text = key.raw.replace(/\n/g, "");
2408
+ return { ...state, formInputBuf: formInputBuf + text, formInputCursorPos: formInputBuf.length + text.length };
2409
+ }
1508
2410
  if (key.name === "return") {
1509
2411
  if (formInputBuf.trim()) {
1510
2412
  items.push(formInputBuf.trim());
1511
2413
  const newValues = { ...formValues, [field.name]: items.join(", ") };
1512
- return { ...state, formValues: newValues, formInputBuf: "", formEnumCursor: items.length };
2414
+ return { ...state, formValues: newValues, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: items.length };
1513
2415
  }
1514
2416
  // Empty input: confirm and close
1515
2417
  const newValues = { ...formValues, [field.name]: items.join(", ") };
@@ -1517,41 +2419,143 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1517
2419
  }
1518
2420
  if (key.name === "backspace") {
1519
2421
  if (formInputBuf) {
1520
- return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
2422
+ return { ...state, formInputBuf: formInputBuf.slice(0, -1), formInputCursorPos: formInputBuf.length - 1 };
1521
2423
  }
1522
2424
  return state;
1523
2425
  }
1524
2426
  if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1525
- return { ...state, formInputBuf: formInputBuf + key.raw };
2427
+ const clean = key.raw.replace(/[\x00-\x1f\x7f]/g, ""); // strip control chars for tags
2428
+ if (clean) return { ...state, formInputBuf: formInputBuf + clean, formInputCursorPos: formInputBuf.length + clean.length };
1526
2429
  }
1527
2430
  return state;
1528
2431
  }
1529
2432
 
1530
2433
  // Text editing mode
1531
- if (key.name === "return" && key.shift) {
1532
- // Shift+Enter: insert newline
1533
- return { ...state, formInputBuf: formInputBuf + "\n" };
2434
+ const pos = state.formInputCursorPos;
2435
+ if (key.name === "paste") {
2436
+ const newBuf = formInputBuf.slice(0, pos) + key.raw + formInputBuf.slice(pos);
2437
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + key.raw.length };
2438
+ }
2439
+ // Insert newline: Ctrl+J (\n), Shift+Enter, or Alt+Enter
2440
+ if (key.raw === "\n" || (key.name === "return" && key.shift)) {
2441
+ const newBuf = formInputBuf.slice(0, pos) + "\n" + formInputBuf.slice(pos);
2442
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + 1 };
1534
2443
  }
1535
2444
  if (key.name === "return") {
1536
- // Enter: confirm
2445
+ // Enter (\r): confirm value
1537
2446
  const newValues = { ...formValues, [field.name]: formInputBuf };
1538
- return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
2447
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputCursorPos: 0, formValues: newValues, ...resetPalette(newValues) };
2448
+ }
2449
+ if (key.name === "left") {
2450
+ return { ...state, formInputCursorPos: Math.max(0, pos - 1) };
2451
+ }
2452
+ if (key.name === "right") {
2453
+ return { ...state, formInputCursorPos: Math.min(formInputBuf.length, pos + 1) };
2454
+ }
2455
+ if (key.name === "wordLeft") {
2456
+ return { ...state, formInputCursorPos: prevWordBoundary(formInputBuf, pos) };
2457
+ }
2458
+ if (key.name === "wordRight") {
2459
+ return { ...state, formInputCursorPos: nextWordBoundary(formInputBuf, pos) };
2460
+ }
2461
+ if (key.name === "wordBackspace") {
2462
+ const boundary = prevWordBoundary(formInputBuf, pos);
2463
+ const newBuf = formInputBuf.slice(0, boundary) + formInputBuf.slice(pos);
2464
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: boundary };
2465
+ }
2466
+ if (key.name === "up") {
2467
+ // Move cursor up one line
2468
+ const lines = formInputBuf.split("\n");
2469
+ let rem = pos;
2470
+ let line = 0;
2471
+ for (; line < lines.length; line++) {
2472
+ if (rem <= lines[line]!.length) break;
2473
+ rem -= lines[line]!.length + 1;
2474
+ }
2475
+ if (line > 0) {
2476
+ const col = Math.min(rem, lines[line - 1]!.length);
2477
+ let newPos = 0;
2478
+ for (let i = 0; i < line - 1; i++) newPos += lines[i]!.length + 1;
2479
+ newPos += col;
2480
+ return { ...state, formInputCursorPos: newPos };
2481
+ }
2482
+ return state;
2483
+ }
2484
+ if (key.name === "down") {
2485
+ // Move cursor down one line
2486
+ const lines = formInputBuf.split("\n");
2487
+ let rem = pos;
2488
+ let line = 0;
2489
+ for (; line < lines.length; line++) {
2490
+ if (rem <= lines[line]!.length) break;
2491
+ rem -= lines[line]!.length + 1;
2492
+ }
2493
+ if (line < lines.length - 1) {
2494
+ const col = Math.min(rem, lines[line + 1]!.length);
2495
+ let newPos = 0;
2496
+ for (let i = 0; i <= line; i++) newPos += lines[i]!.length + 1;
2497
+ newPos += col;
2498
+ return { ...state, formInputCursorPos: newPos };
2499
+ }
2500
+ return state;
1539
2501
  }
1540
2502
  if (key.name === "backspace") {
1541
- return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
2503
+ if (pos > 0) {
2504
+ const newBuf = formInputBuf.slice(0, pos - 1) + formInputBuf.slice(pos);
2505
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos - 1 };
2506
+ }
2507
+ return state;
1542
2508
  }
1543
2509
  if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1544
- return { ...state, formInputBuf: formInputBuf + key.raw };
2510
+ // Preserve newlines in pasted text (multi-line supported), strip other control chars
2511
+ const clean = key.raw.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, "");
2512
+ if (clean) {
2513
+ const newBuf = formInputBuf.slice(0, pos) + clean + formInputBuf.slice(pos);
2514
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + clean.length };
2515
+ }
1545
2516
  }
1546
2517
  return state;
1547
2518
  }
1548
2519
 
1549
- 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" {
1550
2552
  const { contentHeight } = getBoxDimensions();
1551
- const contentLines = (state.error || state.result).split("\n");
1552
- const visibleCount = Math.max(1, contentHeight - 3);
2553
+ const inCardMode = state.resultCards.length > 0 && !state.error;
1553
2554
 
1554
- if (key.ctrl && key.name === "c") return "exit";
2555
+ if (key.ctrl && key.name === "c") {
2556
+ if (state.quitConfirm) return "exit";
2557
+ return { ...state, quitConfirm: true };
2558
+ }
1555
2559
 
1556
2560
  if (key.name === "q" && !key.ctrl) {
1557
2561
  if (state.quitConfirm) return "exit";
@@ -1561,59 +2565,76 @@ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
1561
2565
  // Any other key cancels quit confirm
1562
2566
  const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
1563
2567
 
2568
+ const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0, resultCards: [] as CardItem[], resultCursor: 0, resultCardScroll: 0 };
2569
+
1564
2570
  const goBack = (): AppState => {
1565
- const resetFiltered = buildCommandList(s.tools);
1566
- const resetSel = selectableIndices(resetFiltered);
1567
- const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1568
- const hasParams = s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
2571
+ const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
2572
+ const hasParams = !isEmpty && s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1569
2573
  if (hasParams) {
2574
+ const f = filterFormFields(s.fields, "");
1570
2575
  return {
1571
- ...s, view: "form" as View, result: "", error: "", resultScroll: 0, resultScrollX: 0,
2576
+ ...s, view: "form" as View, ...resultsClear,
1572
2577
  formSearchQuery: "", formSearchCursorPos: 0,
1573
- formFilteredIndices: filterFormFields(s.fields, ""),
1574
- formListCursor: defaultFormCursor(s.fields, filterFormFields(s.fields, ""), s.formValues), formScrollTop: 0,
1575
- formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
2578
+ formFilteredIndices: f,
2579
+ formListCursor: defaultFormCursor(s.fields, f, s.formValues), formScrollTop: 0,
2580
+ formEditing: false, formEditFieldIdx: -1, formShowRequired: false, formShowOptional: false,
1576
2581
  };
1577
2582
  }
1578
- return { ...s, view: "commands" as View, selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
2583
+ return { ...s, ...commandListReset(s.tools), ...resultsClear };
1579
2584
  };
1580
2585
 
1581
- // Enter on success/empty-list/error screens → go back
1582
- if (key.name === "return") {
1583
- const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
1584
- if (isEmpty) {
1585
- // Success screen → back to main menu
1586
- const resetFiltered = buildCommandList(s.tools);
1587
- const resetSel = selectableIndices(resetFiltered);
1588
- const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1589
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1590
- }
2586
+ if (key.name === "escape") {
1591
2587
  return goBack();
1592
2588
  }
1593
2589
 
1594
- if (key.name === "escape") {
1595
- const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
1596
- const resetFiltered = buildCommandList(s.tools);
1597
- const resetSel = selectableIndices(resetFiltered);
1598
- const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1599
- if (isEmpty) {
1600
- // Success screen → back to main menu
1601
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1602
- }
1603
- // Data or error → back to form if it has params, otherwise main menu
1604
- const hasParams = s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1605
- if (hasParams) {
1606
- return {
1607
- ...s, view: "form", result: "", error: "", resultScroll: 0, resultScrollX: 0,
1608
- formSearchQuery: "", formSearchCursorPos: 0,
1609
- formFilteredIndices: filterFormFields(s.fields, ""),
1610
- formListCursor: defaultFormCursor(s.fields, filterFormFields(s.fields, ""), s.formValues), formScrollTop: 0,
1611
- formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
1612
- };
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 };
1613
2626
  }
1614
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
2627
+
2628
+ return s;
1615
2629
  }
1616
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
+ }
1617
2638
  if (key.name === "up") {
1618
2639
  return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
1619
2640
  }
@@ -1767,7 +2788,7 @@ async function executeTool(state: AppState): Promise<AppState> {
1767
2788
 
1768
2789
  if (res.isError) {
1769
2790
  const errMsg = res.content.map((c) => c.text || "").filter(Boolean).join("\n");
1770
- 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 };
1771
2792
  }
1772
2793
 
1773
2794
  const text = res.content.filter((c) => c.type === "text" && c.text).map((c) => c.text!).join("\n");
@@ -1775,21 +2796,24 @@ async function executeTool(state: AppState): Promise<AppState> {
1775
2796
  const structured = (res as Record<string, unknown>).structuredContent;
1776
2797
  let formatted: string;
1777
2798
  let emptyList = false;
2799
+ let parsedData: unknown = undefined;
1778
2800
  if (!text && structured !== undefined) {
2801
+ parsedData = structured;
1779
2802
  emptyList = isEmptyListResult(structured);
1780
2803
  formatted = formatJsonPretty(structured);
1781
2804
  } else {
1782
2805
  try {
1783
- const parsed = JSON.parse(text);
1784
- emptyList = isEmptyListResult(parsed);
1785
- formatted = formatJsonPretty(parsed);
2806
+ parsedData = JSON.parse(text);
2807
+ emptyList = isEmptyListResult(parsedData);
2808
+ formatted = formatJsonPretty(parsedData);
1786
2809
  } catch {
1787
2810
  formatted = text;
1788
2811
  }
1789
2812
  }
1790
- 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 };
1791
2815
  } catch (err) {
1792
- 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 };
1793
2817
  }
1794
2818
  }
1795
2819
 
@@ -1819,10 +2843,11 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
1819
2843
  formEditFieldIdx: -1,
1820
2844
  formEditing: false,
1821
2845
  formInputBuf: "",
2846
+ formInputCursorPos: 0,
1822
2847
  formEnumCursor: 0,
1823
2848
  formEnumSelected: new Set(),
1824
2849
  formValues: {},
1825
- formShowRequired: false,
2850
+ formShowRequired: false, formShowOptional: false,
1826
2851
  formStack: [],
1827
2852
  dateParts: [],
1828
2853
  datePartCursor: 0,
@@ -1830,6 +2855,9 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
1830
2855
  error: "",
1831
2856
  resultScroll: 0,
1832
2857
  resultScrollX: 0,
2858
+ resultCards: [],
2859
+ resultCursor: 0,
2860
+ resultCardScroll: 0,
1833
2861
  spinnerFrame: 0,
1834
2862
  };
1835
2863
 
@@ -1889,6 +2917,15 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
1889
2917
  return;
1890
2918
  }
1891
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
+
1892
2929
  if (result === "submit") {
1893
2930
  state = { ...state, view: "loading", spinnerFrame: 0 };
1894
2931
  paint(renderState(state));