@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/dist/tui/app.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { exec } from "node:child_process";
1
2
  import { resolveProperty } from "../commands.js";
2
3
  import { callTool } from "../mcp.js";
3
4
  import { ensureValidToken } from "../auth.js";
@@ -125,6 +126,45 @@ function defaultFormCursor(fields, filtered, values) {
125
126
  const firstBlank = filtered.findIndex((idx) => idx >= 0 && missing.has(fields[idx].name));
126
127
  return firstBlank >= 0 ? firstBlank : executeIndex(filtered);
127
128
  }
129
+ function classifyField(prop) {
130
+ if (isArrayOfObjects(prop))
131
+ return "arrayObj";
132
+ if (dateFieldFormat(prop))
133
+ return "date";
134
+ const eVals = prop.enum || prop.items?.enum;
135
+ if (prop.type === "boolean")
136
+ return "bool";
137
+ if (eVals && prop.type === "array")
138
+ return "arrayEnum";
139
+ if (eVals)
140
+ return "enum";
141
+ if (prop.type === "array")
142
+ return "arrayText";
143
+ return "text";
144
+ }
145
+ function fieldTypeBadge(prop) {
146
+ const badges = {
147
+ arrayObj: "form", date: "date", bool: "yes/no", arrayEnum: "multi",
148
+ enum: "select", arrayText: "list", text: "text",
149
+ };
150
+ const badge = badges[classifyField(prop)];
151
+ if (badge !== "text")
152
+ return badge;
153
+ if (prop.type === "integer" || prop.type === "number")
154
+ return "number";
155
+ return "text";
156
+ }
157
+ function footerForFieldKind(kind) {
158
+ switch (kind) {
159
+ case "arrayObj": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc back";
160
+ case "date": return "\u2190\u2192 part \u00B7 \u2191\u2193 adjust \u00B7 t today \u00B7 enter confirm \u00B7 esc cancel";
161
+ case "arrayEnum": return "space toggle \u00B7 enter confirm \u00B7 esc cancel";
162
+ case "arrayText": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc confirm";
163
+ case "enum":
164
+ case "bool": return "\u2191\u2193 navigate \u00B7 enter confirm \u00B7 esc cancel";
165
+ case "text": return "enter confirm \u00B7 esc cancel";
166
+ }
167
+ }
128
168
  function formFieldValueDisplay(value, maxWidth) {
129
169
  if (!value)
130
170
  return style.dim("–");
@@ -205,8 +245,9 @@ function popFormStack(state) {
205
245
  formFilteredIndices: parentFiltered,
206
246
  formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, newParentValues),
207
247
  formScrollTop: 0,
208
- formShowRequired: false,
248
+ formShowRequired: false, formShowOptional: false,
209
249
  formInputBuf: "",
250
+ formInputCursorPos: 0,
210
251
  };
211
252
  }
212
253
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -248,6 +289,220 @@ function wrapText(text, width) {
248
289
  lines.push(current);
249
290
  return lines.length > 0 ? lines : [""];
250
291
  }
292
+ // --- Card parsing helpers ---
293
+ function extractCards(data) {
294
+ let items;
295
+ if (Array.isArray(data)) {
296
+ items = data;
297
+ }
298
+ else if (typeof data === "object" && data !== null) {
299
+ // Look for a "results" array or similar
300
+ const obj = data;
301
+ const arrayKey = Object.keys(obj).find((k) => Array.isArray(obj[k]) && obj[k].length > 0);
302
+ if (arrayKey) {
303
+ items = obj[arrayKey];
304
+ }
305
+ else {
306
+ return null;
307
+ }
308
+ }
309
+ else {
310
+ return null;
311
+ }
312
+ if (items.length === 0)
313
+ return null;
314
+ // Only works for arrays of objects
315
+ if (typeof items[0] !== "object" || items[0] === null || Array.isArray(items[0]))
316
+ return null;
317
+ const cards = items.map((item) => {
318
+ const obj = item;
319
+ const kind = isHighlightObj(obj) ? "highlight" : "document";
320
+ if (kind === "highlight") {
321
+ const attrs = (typeof obj.attributes === "object" && obj.attributes !== null)
322
+ ? obj.attributes : null;
323
+ return {
324
+ kind,
325
+ title: str(attrs?.document_title || obj.title || obj.readable_title || ""),
326
+ summary: str(attrs?.highlight_plaintext || obj.text || obj.summary || obj.content || ""),
327
+ note: str(attrs?.highlight_note || obj.note || obj.notes || ""),
328
+ meta: extractHighlightMeta(obj),
329
+ url: obj.id ? `https://readwise.io/open/${obj.id}` : extractCardUrl(obj),
330
+ raw: obj,
331
+ };
332
+ }
333
+ return {
334
+ kind,
335
+ title: extractDocTitle(obj),
336
+ summary: extractDocSummary(obj),
337
+ note: "",
338
+ meta: extractDocMeta(obj),
339
+ url: extractCardUrl(obj),
340
+ raw: obj,
341
+ };
342
+ });
343
+ // Skip card view if most items have no meaningful content (e.g. just URLs)
344
+ const hasContent = cards.filter((c) => c.summary || c.note || (c.kind === "document" && c.title !== "Untitled" && !c.raw.url?.toString().includes(c.title)));
345
+ if (hasContent.length < cards.length / 2)
346
+ return null;
347
+ return cards;
348
+ }
349
+ function str(val) {
350
+ if (val === null || val === undefined)
351
+ return "";
352
+ return String(val);
353
+ }
354
+ function isHighlightObj(obj) {
355
+ // Reader docs with category "highlight"
356
+ if (obj.category === "highlight")
357
+ return true;
358
+ // Readwise search highlights: nested attributes with highlight_plaintext
359
+ const attrs = obj.attributes;
360
+ if (typeof attrs === "object" && attrs !== null && "highlight_plaintext" in attrs)
361
+ return true;
362
+ // Readwise highlights: have text + highlight-specific fields
363
+ if (typeof obj.text === "string" &&
364
+ ("highlighted_at" in obj || "color" in obj || "book_id" in obj || "location_type" in obj)) {
365
+ return true;
366
+ }
367
+ // Has text + note fields (common highlight shape even without highlighted_at)
368
+ if (typeof obj.text === "string" && "note" in obj)
369
+ return true;
370
+ return false;
371
+ }
372
+ // --- Document card helpers ---
373
+ function extractDocTitle(obj) {
374
+ for (const key of ["title", "readable_title", "name"]) {
375
+ const val = obj[key];
376
+ if (val && typeof val === "string" && !String(val).startsWith("http"))
377
+ return val;
378
+ }
379
+ // Last resort: show domain from URL
380
+ const url = str(obj.url || obj.source_url);
381
+ if (url) {
382
+ try {
383
+ return new URL(url).hostname.replace(/^www\./, "");
384
+ }
385
+ catch { /* */ }
386
+ }
387
+ return "Untitled";
388
+ }
389
+ function extractDocSummary(obj) {
390
+ for (const key of ["summary", "description", "note", "notes", "content"]) {
391
+ const val = obj[key];
392
+ if (val && typeof val === "string")
393
+ return val;
394
+ }
395
+ return "";
396
+ }
397
+ function extractDocMeta(obj) {
398
+ const parts = [];
399
+ const siteName = str(obj.site_name);
400
+ if (siteName)
401
+ parts.push(siteName);
402
+ const author = str(obj.author);
403
+ if (author && author !== siteName)
404
+ parts.push(author);
405
+ const category = str(obj.category);
406
+ if (category)
407
+ parts.push(category);
408
+ const wordCount = Number(obj.word_count);
409
+ if (wordCount > 0) {
410
+ const mins = Math.ceil(wordCount / 250);
411
+ parts.push(`${mins} min`);
412
+ }
413
+ const progress = Number(obj.reading_progress);
414
+ if (progress > 0 && progress < 1) {
415
+ parts.push(`${Math.round(progress * 100)}% read`);
416
+ }
417
+ else if (progress >= 1) {
418
+ parts.push("finished");
419
+ }
420
+ const date = str(obj.created_at || obj.saved_at || obj.published_date);
421
+ if (date) {
422
+ const d = new Date(date);
423
+ if (!isNaN(d.getTime())) {
424
+ parts.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }));
425
+ }
426
+ }
427
+ return parts.join(" · ");
428
+ }
429
+ // --- Highlight card helpers ---
430
+ function extractHighlightMeta(obj) {
431
+ const attrs = (typeof obj.attributes === "object" && obj.attributes !== null)
432
+ ? obj.attributes : null;
433
+ const parts = [];
434
+ // Source book/article author
435
+ const author = str(attrs?.document_author || obj.author || obj.book_author);
436
+ if (author && !author.startsWith("http"))
437
+ parts.push(author);
438
+ // Category
439
+ const category = str(attrs?.document_category || obj.category);
440
+ if (category)
441
+ parts.push(category);
442
+ const color = str(obj.color);
443
+ if (color)
444
+ parts.push(color);
445
+ // Tags (from attributes or top-level)
446
+ const tags = attrs?.highlight_tags || obj.tags;
447
+ if (Array.isArray(tags) && tags.length > 0) {
448
+ const tagNames = tags.map((t) => typeof t === "object" && t !== null ? str(t.name) : str(t)).filter(Boolean);
449
+ if (tagNames.length > 0)
450
+ parts.push(tagNames.join(", "));
451
+ }
452
+ const date = str(obj.highlighted_at || obj.created_at);
453
+ if (date) {
454
+ const d = new Date(date);
455
+ if (!isNaN(d.getTime())) {
456
+ parts.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }));
457
+ }
458
+ }
459
+ return parts.join(" · ");
460
+ }
461
+ function extractCardUrl(obj) {
462
+ for (const key of ["url", "source_url", "reader_url", "readwise_url"]) {
463
+ if (obj[key] && typeof obj[key] === "string")
464
+ return obj[key];
465
+ }
466
+ return "";
467
+ }
468
+ // --- Word boundary helpers ---
469
+ function prevWordBoundary(buf, pos) {
470
+ if (pos <= 0)
471
+ return 0;
472
+ let i = pos - 1;
473
+ // Skip whitespace
474
+ while (i > 0 && /\s/.test(buf[i]))
475
+ i--;
476
+ // Skip word chars or non-word non-space chars
477
+ if (i >= 0 && /\w/.test(buf[i])) {
478
+ while (i > 0 && /\w/.test(buf[i - 1]))
479
+ i--;
480
+ }
481
+ else {
482
+ while (i > 0 && !/\w/.test(buf[i - 1]) && !/\s/.test(buf[i - 1]))
483
+ i--;
484
+ }
485
+ return i;
486
+ }
487
+ function nextWordBoundary(buf, pos) {
488
+ const len = buf.length;
489
+ if (pos >= len)
490
+ return len;
491
+ let i = pos;
492
+ // Skip current word chars or non-word non-space chars
493
+ if (/\w/.test(buf[i])) {
494
+ while (i < len && /\w/.test(buf[i]))
495
+ i++;
496
+ }
497
+ else if (!/\s/.test(buf[i])) {
498
+ while (i < len && !/\w/.test(buf[i]) && !/\s/.test(buf[i]))
499
+ i++;
500
+ }
501
+ // Skip whitespace
502
+ while (i < len && /\s/.test(buf[i]))
503
+ i++;
504
+ return i;
505
+ }
251
506
  function dateFieldFormat(prop) {
252
507
  if (prop.format === "date")
253
508
  return "date";
@@ -385,7 +640,7 @@ function renderCommandList(state) {
385
640
  content.push(` ${logoLine} ${style.boldYellow("Readwise")} ${style.dim("v" + VERSION)}`);
386
641
  }
387
642
  else if (i === Math.floor(LOGO.length / 2)) {
388
- content.push(` ${logoLine} ${style.dim("Command-line interface")}`);
643
+ content.push(` ${logoLine} ${style.dim("Built for AI agents · This TUI is just for fun/learning")}`);
389
644
  }
390
645
  else {
391
646
  content.push(` ${logoLine}`);
@@ -445,8 +700,8 @@ function renderCommandList(state) {
445
700
  }
446
701
  }
447
702
  const footer = state.quitConfirm
448
- ? style.yellow("Press q or esc again to quit")
449
- : style.dim("type to search ↑↓ navigate enter select esc clear/quit");
703
+ ? style.yellow("Press again to quit")
704
+ : style.dim("type to search · ↑↓ navigate · enter select · esc/ctrl+c quit");
450
705
  return renderLayout({
451
706
  breadcrumb: style.boldYellow("Readwise"),
452
707
  content,
@@ -458,15 +713,227 @@ function renderForm(state) {
458
713
  const tool = state.selectedTool;
459
714
  const fields = state.fields;
460
715
  const toolTitle = humanLabel(tool.name, toolPrefix(tool));
461
- // Build title: tool name + any stack breadcrumb
462
- const stackParts = state.formStack.map((e) => e.parentFieldName);
463
- const title = stackParts.length > 0
464
- ? toolTitle + " › " + stackParts.join(" › ")
465
- : toolTitle;
466
- if (state.formEditing && state.formEditFieldIdx >= 0) {
467
- return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
716
+ // Sub-forms (nested array-of-objects) keep existing form UI
717
+ if (state.formStack.length > 0) {
718
+ const stackParts = state.formStack.map((e) => e.parentFieldName);
719
+ const title = toolTitle + " › " + stackParts.join(" › ");
720
+ if (state.formEditing && state.formEditFieldIdx >= 0) {
721
+ return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
722
+ }
723
+ return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
468
724
  }
469
- return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
725
+ // Top-level: command builder
726
+ return renderCommandBuilder(state, toolTitle, fields, contentHeight, innerWidth);
727
+ }
728
+ function renderCommandBuilder(state, title, fields, contentHeight, innerWidth) {
729
+ const tool = state.selectedTool;
730
+ const cmdName = tool.name.replace(/_/g, "-");
731
+ const content = [];
732
+ const editField = state.formEditing && state.formEditFieldIdx >= 0
733
+ ? fields[state.formEditFieldIdx]
734
+ : null;
735
+ // Header
736
+ content.push("");
737
+ content.push(" " + style.bold(title));
738
+ if (tool.description) {
739
+ const wrapped = wrapText(tool.description, innerWidth - 4);
740
+ for (const line of wrapped) {
741
+ content.push(" " + style.dim(line));
742
+ }
743
+ }
744
+ content.push("");
745
+ // Classify editing field type once for both content and footer
746
+ const editKind = editField ? classifyField(editField.prop) : null;
747
+ const isTextLikeEdit = editKind === "text";
748
+ // Build command lines
749
+ const argLines = [];
750
+ for (const field of fields) {
751
+ const flagName = field.name.replace(/_/g, "-");
752
+ if (field === editField) {
753
+ if (isTextLikeEdit) {
754
+ // Inline cursor for text fields
755
+ const buf = state.formInputBuf;
756
+ const before = buf.slice(0, state.formInputCursorPos);
757
+ const cursorChar = state.formInputCursorPos < buf.length ? buf[state.formInputCursorPos] : " ";
758
+ const after = state.formInputCursorPos < buf.length ? buf.slice(state.formInputCursorPos + 1) : "";
759
+ argLines.push(" --" + flagName + "=" + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
760
+ }
761
+ else if (isArrayOfObjects(field.prop)) {
762
+ // Array-of-objects: show item count
763
+ const existing = state.formValues[field.name] || "[]";
764
+ let items = [];
765
+ try {
766
+ items = JSON.parse(existing);
767
+ }
768
+ catch { /* */ }
769
+ const label = items.length > 0 ? `[${items.length} item${items.length > 1 ? "s" : ""}]` : "[...]";
770
+ argLines.push(" --" + flagName + "=" + style.yellow(label));
771
+ }
772
+ else {
773
+ // Non-text editors (enum, bool, date): show pending
774
+ argLines.push(" --" + flagName + "=" + style.inverse(" "));
775
+ }
776
+ }
777
+ else {
778
+ const val = state.formValues[field.name];
779
+ if (val) {
780
+ const needsQuotes = val.includes(" ") || val.includes(",");
781
+ const displayVal = needsQuotes ? '"' + val + '"' : val;
782
+ argLines.push(" --" + flagName + "=" + style.cyan(displayVal));
783
+ }
784
+ }
785
+ }
786
+ // Render command with line continuations
787
+ const cmdPrefix = " " + style.dim("$") + " " + style.dim("readwise") + " " + cmdName;
788
+ if (argLines.length === 0) {
789
+ content.push(cmdPrefix);
790
+ }
791
+ else {
792
+ content.push(cmdPrefix + " \\");
793
+ for (let i = 0; i < argLines.length; i++) {
794
+ const isLast = i === argLines.length - 1;
795
+ content.push(argLines[i] + (isLast ? "" : " \\"));
796
+ }
797
+ }
798
+ content.push("");
799
+ // Context area: field description, editor, or ready state
800
+ if (editField) {
801
+ // Field description
802
+ if (editField.prop.description) {
803
+ content.push(" " + style.dim(editField.prop.description));
804
+ }
805
+ if (editField.prop.examples?.length) {
806
+ const exStr = editField.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
807
+ content.push(" " + style.dim("e.g. ") + style.cyan(truncateVisible(exStr, innerWidth - 10)));
808
+ }
809
+ if (editField.prop.default != null) {
810
+ content.push(" " + style.dim("default: " + editField.prop.default));
811
+ }
812
+ const eVals = editField.prop.enum || editField.prop.items?.enum;
813
+ if (editKind === "bool") {
814
+ content.push("");
815
+ const choices = ["true", "false"];
816
+ for (let ci = 0; ci < choices.length; ci++) {
817
+ const sel = ci === state.formEnumCursor;
818
+ content.push(sel ? " " + style.cyan(style.bold("\u203A " + choices[ci])) : " " + choices[ci]);
819
+ }
820
+ }
821
+ else if (editKind === "enum" && eVals) {
822
+ content.push("");
823
+ for (let ci = 0; ci < eVals.length; ci++) {
824
+ const sel = ci === state.formEnumCursor;
825
+ content.push(sel ? " " + style.cyan(style.bold("\u203A " + eVals[ci])) : " " + eVals[ci]);
826
+ }
827
+ }
828
+ else if (editKind === "arrayEnum" && eVals) {
829
+ content.push("");
830
+ for (let ci = 0; ci < eVals.length; ci++) {
831
+ const sel = ci === state.formEnumCursor;
832
+ const checked = state.formEnumSelected.has(ci);
833
+ const check = checked ? style.cyan("[x]") : style.dim("[ ]");
834
+ content.push((sel ? " \u203A " : " ") + check + " " + eVals[ci]);
835
+ }
836
+ }
837
+ else if (editKind === "date") {
838
+ const dateFmt = dateFieldFormat(editField.prop);
839
+ content.push("");
840
+ content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
841
+ }
842
+ else if (editKind === "arrayText") {
843
+ const existing = state.formValues[editField.name] || "";
844
+ const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
845
+ content.push("");
846
+ for (let i = 0; i < items.length; i++) {
847
+ const isCursor = i === state.formEnumCursor;
848
+ content.push((isCursor ? " \u276F " : " ") + style.cyan(items[i]));
849
+ }
850
+ const onInput = state.formEnumCursor === items.length;
851
+ content.push((onInput ? " \u276F " : " ") + style.cyan(state.formInputBuf) + (onInput ? style.inverse(" ") : ""));
852
+ }
853
+ else if (editKind === "arrayObj") {
854
+ const existing = state.formValues[editField.name] || "[]";
855
+ let items = [];
856
+ try {
857
+ items = JSON.parse(existing);
858
+ }
859
+ catch { /* */ }
860
+ content.push("");
861
+ for (let i = 0; i < items.length; i++) {
862
+ const item = items[i];
863
+ const summary = Object.entries(item)
864
+ .filter(([, v]) => v != null && v !== "")
865
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
866
+ .join(", ");
867
+ const isCursor = i === state.formEnumCursor;
868
+ content.push((isCursor ? " \u276F " : " ") + truncateVisible(summary || "(empty)", innerWidth - 6));
869
+ }
870
+ if (items.length > 0)
871
+ content.push("");
872
+ const addCursor = state.formEnumCursor === items.length;
873
+ content.push(addCursor
874
+ ? " " + style.inverse(style.green(" + Add new item "))
875
+ : " " + style.dim("+") + " Add new item");
876
+ }
877
+ else if (isTextLikeEdit) {
878
+ // Text field: cursor is already inline in the command string
879
+ // Just show a hint if empty
880
+ if (!state.formInputBuf) {
881
+ content.push("");
882
+ content.push(" " + style.dim("Type a value and press enter"));
883
+ }
884
+ }
885
+ }
886
+ else {
887
+ // Ready state / optional picker
888
+ const missing = missingRequiredFields(fields, state.formValues);
889
+ if (missing.length > 0) {
890
+ content.push(" " + style.red("Missing: " + missing.map((f) => f.name).join(", ")));
891
+ }
892
+ else {
893
+ content.push(" " + style.dim("Press enter to run"));
894
+ }
895
+ // Always show optional params
896
+ const optionalFields = fields
897
+ .map((f, i) => ({ field: f, idx: i }))
898
+ .filter(({ field }) => !field.required);
899
+ if (optionalFields.length > 0) {
900
+ content.push("");
901
+ content.push(" " + style.dim("Optional parameters (tab to add)"));
902
+ content.push("");
903
+ const maxFlagWidth = Math.max(...optionalFields.map(({ field }) => field.name.length), 0) + 2;
904
+ for (let i = 0; i < optionalFields.length; i++) {
905
+ const { field } = optionalFields[i];
906
+ const flagName = field.name.replace(/_/g, "-");
907
+ const hasValue = !!state.formValues[field.name]?.trim();
908
+ const sel = state.formShowOptional && i === state.formListCursor;
909
+ const prefix = sel ? " \u276F " : " ";
910
+ const paddedName = flagName.padEnd(maxFlagWidth);
911
+ const desc = field.prop.description
912
+ ? style.dim(truncateVisible(field.prop.description, innerWidth - maxFlagWidth - 8))
913
+ : "";
914
+ if (sel) {
915
+ content.push(style.boldYellow(prefix + paddedName) + " " + desc);
916
+ }
917
+ else if (hasValue) {
918
+ content.push(prefix + style.green(paddedName) + " " + desc);
919
+ }
920
+ else {
921
+ content.push(prefix + style.dim(paddedName) + " " + desc);
922
+ }
923
+ }
924
+ }
925
+ }
926
+ // Footer
927
+ const footer = editKind
928
+ ? style.dim(footerForFieldKind(editKind))
929
+ : state.formShowOptional
930
+ ? style.dim("\u2191\u2193 select \u00B7 enter add \u00B7 esc done")
931
+ : style.dim("enter run \u00B7 tab add option \u00B7 esc back");
932
+ return renderLayout({
933
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" \u203A ") + style.bold(title),
934
+ content,
935
+ footer,
936
+ });
470
937
  }
471
938
  function renderFormPaletteMode(state, title, fields, contentHeight, innerWidth) {
472
939
  const content = [];
@@ -485,51 +952,117 @@ function renderFormPaletteMode(state, title, fields, contentHeight, innerWidth)
485
952
  content.push(" " + style.dim(line));
486
953
  }
487
954
  }
955
+ // Progress indicator for required fields
956
+ const requiredFields = fields.filter((f) => f.required);
957
+ if (requiredFields.length > 0) {
958
+ const filledRequired = requiredFields.filter((f) => {
959
+ const val = state.formValues[f.name]?.trim();
960
+ if (!val)
961
+ return false;
962
+ if (isArrayOfObjects(f.prop)) {
963
+ try {
964
+ return JSON.parse(val).length > 0;
965
+ }
966
+ catch {
967
+ return false;
968
+ }
969
+ }
970
+ return true;
971
+ });
972
+ const allFilled = filledRequired.length === requiredFields.length;
973
+ const progressText = `${filledRequired.length} of ${requiredFields.length} required`;
974
+ content.push(" " + (allFilled ? style.green("✓ " + progressText) : style.dim(progressText)));
975
+ }
488
976
  content.push("");
489
- // Search input
977
+ // Search input (only show when there's a search query or many fields)
490
978
  const queryText = state.formSearchQuery;
491
- const before = queryText.slice(0, state.formSearchCursorPos);
492
- const cursorChar = state.formSearchCursorPos < queryText.length
493
- ? queryText[state.formSearchCursorPos]
494
- : " ";
495
- const after = state.formSearchCursorPos < queryText.length
496
- ? queryText.slice(state.formSearchCursorPos + 1)
497
- : "";
498
- content.push(" " + style.yellow("❯") + " " + before + style.inverse(cursorChar) + after);
499
- content.push("");
500
- // Compute maxLabelWidth
979
+ if (queryText || fields.length > 6) {
980
+ const before = queryText.slice(0, state.formSearchCursorPos);
981
+ const cursorChar = state.formSearchCursorPos < queryText.length
982
+ ? queryText[state.formSearchCursorPos]
983
+ : " ";
984
+ const after = state.formSearchCursorPos < queryText.length
985
+ ? queryText.slice(state.formSearchCursorPos + 1)
986
+ : "";
987
+ content.push(" " + style.dim("/") + " " + before + style.inverse(cursorChar) + after);
988
+ content.push("");
989
+ }
990
+ else {
991
+ content.push("");
992
+ }
993
+ // Compute maxLabelWidth (include " *" for required fields)
501
994
  const maxLabelWidth = Math.max(...fields.map((f) => f.name.length + (f.required ? 2 : 0)), 6) + 1;
502
- // Value display width budget: innerWidth - prefix(3) - label - gap(2)
503
- const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2);
995
+ // Badge width: " text" = ~7 chars max
996
+ const badgeWidth = 8;
997
+ // Value display width budget: innerWidth - cursor(3) - label - gap(2) - badge
998
+ const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2 - badgeWidth);
504
999
  const headerUsed = content.length;
505
1000
  // Reserve space for: blank + Execute + blank + description (up to 4 lines)
506
1001
  const listHeight = Math.max(1, contentHeight - headerUsed - 8);
507
1002
  const filtered = state.formFilteredIndices;
508
1003
  const hasOnlyExecute = filtered.length === 1 && filtered[0] === -1;
1004
+ // Split fields into required and optional
1005
+ const requiredIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && fields[idx].required);
1006
+ const optionalIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && !fields[idx].required);
1007
+ const hasOptional = optionalIdxs.length > 0;
1008
+ const showingOptional = state.formShowOptional || state.formSearchQuery;
1009
+ // Count optional fields that have been filled
1010
+ const filledOptionalCount = optionalIdxs.filter((idx) => !!state.formValues[fields[idx].name]?.trim()).length;
1011
+ const renderField = (fieldIdx) => {
1012
+ const field = fields[fieldIdx];
1013
+ const val = state.formValues[field.name] || "";
1014
+ const isFilled = !!val.trim();
1015
+ const listPos = filtered.indexOf(fieldIdx);
1016
+ const selected = listPos === state.formListCursor;
1017
+ // Value display
1018
+ const valStr = formFieldValueDisplay(val, valueAvail);
1019
+ // Type badge
1020
+ const badge = style.dim(fieldTypeBadge(field.prop));
1021
+ const cursor = selected ? " ❯ " : " ";
1022
+ if (selected) {
1023
+ const label = field.name + (field.required ? " *" : "");
1024
+ content.push(style.boldYellow(cursor) + style.boldYellow(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
1025
+ }
1026
+ else if (isFilled) {
1027
+ const label = field.name + (field.required ? " *" : "");
1028
+ content.push(cursor + style.green(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
1029
+ }
1030
+ else {
1031
+ // Unfilled: show required * in red
1032
+ const namePart = field.name;
1033
+ const starPart = field.required ? " *" : "";
1034
+ const plainLabel = namePart + starPart;
1035
+ const padAmount = Math.max(0, maxLabelWidth - plainLabel.length);
1036
+ const displayLabel = field.required ? namePart + style.red(" *") + " ".repeat(padAmount) : plainLabel.padEnd(maxLabelWidth);
1037
+ content.push(cursor + displayLabel + " " + style.dim("–") + " " + badge);
1038
+ }
1039
+ };
509
1040
  if (hasOnlyExecute && state.formSearchQuery) {
510
1041
  content.push(" " + style.dim("No matching parameters"));
511
1042
  content.push("");
512
1043
  }
513
1044
  else {
514
- // Scrolling: items before the Execute sentinel
515
- const paramItems = filtered.filter((idx) => idx !== -1);
516
- const visStart = state.formScrollTop;
517
- const visEnd = Math.min(paramItems.length, visStart + listHeight);
518
- const visible = paramItems.slice(visStart, visEnd);
519
- for (const fieldIdx of visible) {
520
- const field = fields[fieldIdx];
521
- const nameLabel = field.name + (field.required ? " *" : "");
522
- const paddedName = nameLabel.padEnd(maxLabelWidth);
523
- const val = state.formValues[field.name] || "";
524
- const valStr = formFieldValueDisplay(val, valueAvail);
525
- const listPos = filtered.indexOf(fieldIdx);
526
- const selected = listPos === state.formListCursor;
527
- const prefix = selected ? " ❯ " : " ";
528
- if (selected) {
529
- content.push(style.boldYellow(prefix + paddedName) + " " + valStr);
1045
+ // Required fields (always visible)
1046
+ for (const fieldIdx of requiredIdxs) {
1047
+ renderField(fieldIdx);
1048
+ }
1049
+ // Optional fields separator
1050
+ if (hasOptional) {
1051
+ if (showingOptional) {
1052
+ if (requiredIdxs.length > 0)
1053
+ content.push("");
1054
+ content.push(" " + style.dim("── optional ──"));
1055
+ const visibleOptional = optionalIdxs.slice(0, listHeight - requiredIdxs.length - 2);
1056
+ for (const fieldIdx of visibleOptional) {
1057
+ renderField(fieldIdx);
1058
+ }
530
1059
  }
531
1060
  else {
532
- content.push(prefix + paddedName + " " + valStr);
1061
+ content.push("");
1062
+ const optLabel = filledOptionalCount > 0
1063
+ ? `── ${optionalIdxs.length} optional (${filledOptionalCount} set) · 'o' to show ──`
1064
+ : `── ${optionalIdxs.length} optional · 'o' to show ──`;
1065
+ content.push(" " + style.dim(optLabel));
533
1066
  }
534
1067
  }
535
1068
  }
@@ -556,22 +1089,34 @@ function renderFormPaletteMode(state, title, fields, contentHeight, innerWidth)
556
1089
  content.push(" " + style.red("Required: " + names));
557
1090
  }
558
1091
  }
559
- // Description of highlighted field
1092
+ // Description of highlighted field or Execute hint
560
1093
  const highlightedIdx = filtered[state.formListCursor];
561
1094
  if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
562
- const desc = fields[highlightedIdx].prop.description;
563
- if (desc) {
1095
+ const prop = fields[highlightedIdx].prop;
1096
+ if (prop.description) {
564
1097
  content.push("");
565
- const wrapped = wrapText(desc, innerWidth - 4);
1098
+ const wrapped = wrapText(prop.description, innerWidth - 4);
566
1099
  for (const line of wrapped) {
567
1100
  content.push(" " + style.dim(line));
568
1101
  }
569
1102
  }
1103
+ if (prop.examples?.length) {
1104
+ const exStr = prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
1105
+ content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
1106
+ }
1107
+ }
1108
+ else if (highlightedIdx === -1) {
1109
+ content.push("");
1110
+ content.push(" " + style.dim("Press enter to run"));
570
1111
  }
1112
+ // Footer hints
1113
+ const hasUnfilledRequired = requiredFields.some((f) => !state.formValues[f.name]?.trim());
1114
+ const tabHint = hasUnfilledRequired ? " · tab next required" : "";
1115
+ const optionalHint = hasOptional ? " · o optional" : "";
571
1116
  return renderLayout({
572
1117
  breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
573
1118
  content,
574
- footer: style.dim("type to filter ↑↓ navigate enter edit/run esc back"),
1119
+ footer: style.dim("↑↓ navigate · enter edit" + tabHint + optionalHint + " · esc back"),
575
1120
  });
576
1121
  }
577
1122
  function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
@@ -579,10 +1124,22 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
579
1124
  const content = [];
580
1125
  content.push("");
581
1126
  content.push(" " + style.bold(title));
1127
+ // Show tool description for context
1128
+ const toolDesc = state.formStack.length > 0
1129
+ ? state.formStack[state.formStack.length - 1].parentFields
1130
+ .find((f) => f.name === state.formStack[state.formStack.length - 1].parentFieldName)
1131
+ ?.prop.items?.description
1132
+ : state.selectedTool.description;
1133
+ if (toolDesc) {
1134
+ const wrapped = wrapText(toolDesc, innerWidth - 4);
1135
+ for (const line of wrapped) {
1136
+ content.push(" " + style.dim(line));
1137
+ }
1138
+ }
582
1139
  content.push("");
583
- // Field name
1140
+ // Field label
584
1141
  const nameLabel = field.name + (field.required ? " *" : "");
585
- content.push(" " + style.boldYellow("❯ " + nameLabel));
1142
+ content.push(" " + style.bold(nameLabel));
586
1143
  // Field description
587
1144
  if (field.prop.description) {
588
1145
  const wrapped = wrapText(field.prop.description, innerWidth - 4);
@@ -590,15 +1147,15 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
590
1147
  content.push(" " + style.dim(line));
591
1148
  }
592
1149
  }
1150
+ if (field.prop.examples?.length) {
1151
+ const exStr = field.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
1152
+ content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
1153
+ }
593
1154
  content.push("");
594
1155
  // Editor area
1156
+ const kind = classifyField(field.prop);
595
1157
  const eVals = field.prop.enum || field.prop.items?.enum;
596
- const isArrayObj = isArrayOfObjects(field.prop);
597
- const isArrayEnum = !isArrayObj && field.prop.type === "array" && !!field.prop.items?.enum;
598
- const isArrayText = !isArrayObj && field.prop.type === "array" && !field.prop.items?.enum;
599
- const isBool = field.prop.type === "boolean";
600
- const dateFmt = dateFieldFormat(field.prop);
601
- if (isArrayObj) {
1158
+ if (kind === "arrayObj") {
602
1159
  // Array-of-objects editor: show existing items + "Add new item"
603
1160
  const existing = state.formValues[field.name] || "[]";
604
1161
  let items = [];
@@ -628,10 +1185,11 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
628
1185
  content.push(" " + style.dim("+") + " Add new item");
629
1186
  }
630
1187
  }
631
- else if (dateFmt) {
1188
+ else if (kind === "date") {
1189
+ const dateFmt = dateFieldFormat(field.prop);
632
1190
  content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
633
1191
  }
634
- else if (isArrayEnum && eVals) {
1192
+ else if (kind === "arrayEnum" && eVals) {
635
1193
  // Multi-select picker
636
1194
  for (let ci = 0; ci < eVals.length; ci++) {
637
1195
  const isCursor = ci === state.formEnumCursor;
@@ -642,7 +1200,7 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
642
1200
  content.push(isCursor ? style.bold(label) : label);
643
1201
  }
644
1202
  }
645
- else if (isArrayText) {
1203
+ else if (kind === "arrayText") {
646
1204
  // Tag-style list editor: navigable items + text input at bottom
647
1205
  const existing = state.formValues[field.name] || "";
648
1206
  const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
@@ -668,8 +1226,8 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
668
1226
  content.push(" " + style.dim("bksp ") + style.dim("remove item"));
669
1227
  }
670
1228
  }
671
- else if (eVals || isBool) {
672
- const choices = isBool ? ["true", "false"] : eVals;
1229
+ else if (kind === "enum" || kind === "bool") {
1230
+ const choices = kind === "bool" ? ["true", "false"] : eVals;
673
1231
  for (let ci = 0; ci < choices.length; ci++) {
674
1232
  const sel = ci === state.formEnumCursor;
675
1233
  const choiceLine = (sel ? " › " : " ") + choices[ci];
@@ -677,41 +1235,73 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
677
1235
  }
678
1236
  }
679
1237
  else {
680
- // Text editor
681
- const lines = state.formInputBuf.split("\n");
682
- for (let li = 0; li < lines.length; li++) {
683
- const prefix = li === 0 ? " " + style.yellow("❯") + " " : " ";
684
- if (li === lines.length - 1) {
685
- content.push(prefix + style.cyan(lines[li]) + style.inverse(" "));
1238
+ // Text editor with cursor position
1239
+ const prefix0 = " ";
1240
+ if (!state.formInputBuf) {
1241
+ // Show placeholder text when input is empty
1242
+ let placeholder = "type a value";
1243
+ if (field.prop.examples?.length) {
1244
+ placeholder = String(field.prop.examples[0]);
686
1245
  }
687
- else {
688
- content.push(prefix + style.cyan(lines[li]));
1246
+ else if (field.prop.description) {
1247
+ placeholder = field.prop.description.toLowerCase().replace(/[.!]$/, "");
1248
+ }
1249
+ else if (field.prop.type === "integer" || field.prop.type === "number") {
1250
+ placeholder = "enter a number";
1251
+ }
1252
+ content.push(prefix0 + style.inverse(" ") + style.dim(" " + placeholder + "…"));
1253
+ }
1254
+ else {
1255
+ const lines = state.formInputBuf.split("\n");
1256
+ // Find cursor line and column from flat position
1257
+ let cursorLine = 0;
1258
+ let cursorCol = state.formInputCursorPos;
1259
+ for (let li = 0; li < lines.length; li++) {
1260
+ if (cursorCol <= lines[li].length) {
1261
+ cursorLine = li;
1262
+ break;
1263
+ }
1264
+ cursorCol -= lines[li].length + 1;
1265
+ }
1266
+ for (let li = 0; li < lines.length; li++) {
1267
+ const prefix = li === 0 ? prefix0 : " ";
1268
+ const lineText = lines[li];
1269
+ if (li === cursorLine) {
1270
+ const before = lineText.slice(0, cursorCol);
1271
+ const cursorChar = cursorCol < lineText.length ? lineText[cursorCol] : " ";
1272
+ const after = cursorCol < lineText.length ? lineText.slice(cursorCol + 1) : "";
1273
+ content.push(prefix + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
1274
+ }
1275
+ else {
1276
+ content.push(prefix + style.cyan(lineText));
1277
+ }
689
1278
  }
690
1279
  }
691
1280
  }
692
- let footer;
693
- if (isArrayObj) {
694
- footer = style.dim("↑↓ navigate enter add/select backspace delete esc back");
695
- }
696
- else if (dateFmt) {
697
- footer = style.dim("←→ part ↑↓ adjust t today enter confirm esc cancel");
698
- }
699
- else if (isArrayEnum) {
700
- footer = style.dim("space toggle enter select esc confirm");
701
- }
702
- else if (isArrayText) {
703
- footer = style.dim("↑↓ navigate enter add/edit backspace delete esc confirm");
704
- }
705
- else if (eVals || isBool) {
706
- footer = style.dim("↑↓ navigate enter confirm esc cancel");
707
- }
708
- else {
709
- footer = style.dim("enter confirm shift+enter newline esc cancel");
1281
+ // Show remaining required fields hint (for text editors)
1282
+ if (kind === "text") {
1283
+ const requiredFields = fields.filter((f) => f.required);
1284
+ const filledCount = requiredFields.filter((f) => {
1285
+ if (f.name === field.name)
1286
+ return !!state.formInputBuf.trim(); // current field
1287
+ return !!state.formValues[f.name]?.trim();
1288
+ }).length;
1289
+ const remaining = requiredFields.length - filledCount;
1290
+ content.push("");
1291
+ if (remaining <= 0) {
1292
+ content.push(" " + style.dim("Then press enter to confirm → Execute"));
1293
+ }
1294
+ else if (remaining === 1 && !state.formInputBuf.trim()) {
1295
+ content.push(" " + style.dim("Type a value, then press enter"));
1296
+ }
1297
+ else {
1298
+ content.push(" " + style.dim(`${remaining} required field${remaining > 1 ? "s" : ""} remaining`));
1299
+ }
710
1300
  }
711
1301
  return renderLayout({
712
1302
  breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
713
1303
  content,
714
- footer,
1304
+ footer: style.dim(footerForFieldKind(kind)),
715
1305
  });
716
1306
  }
717
1307
  function renderLoading(state) {
@@ -740,6 +1330,111 @@ const SUCCESS_ICON = [
740
1330
  "╚██████╔╝██║ ██╗",
741
1331
  " ╚═════╝ ╚═╝ ╚═╝",
742
1332
  ];
1333
+ function cardLine(text, innerW, borderFn) {
1334
+ return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
1335
+ }
1336
+ function buildCardLines(card, ci, selected, cardWidth) {
1337
+ const borderColor = selected ? style.cyan : style.dim;
1338
+ const innerW = Math.max(1, cardWidth - 4);
1339
+ const lines = [];
1340
+ lines.push(" " + borderColor("╭" + "─".repeat(cardWidth - 2) + "╮"));
1341
+ if (card.kind === "highlight") {
1342
+ // Highlight card: quote-style passage, optional note, meta
1343
+ const quotePrefix = "\u201c ";
1344
+ const quoteSuffix = "\u201d";
1345
+ const passage = card.summary || "\u2026";
1346
+ const maxQuoteW = innerW - quotePrefix.length;
1347
+ const wrapped = wrapText(passage, maxQuoteW);
1348
+ // Cap at 6 lines
1349
+ const showLines = wrapped.slice(0, 6);
1350
+ if (wrapped.length > 6) {
1351
+ const last = showLines[5];
1352
+ showLines[5] = truncateVisible(last, maxQuoteW - 1) + "…";
1353
+ }
1354
+ for (let i = 0; i < showLines.length; i++) {
1355
+ let lineText;
1356
+ if (i === 0) {
1357
+ lineText = quotePrefix + showLines[i];
1358
+ if (showLines.length === 1)
1359
+ lineText += quoteSuffix;
1360
+ }
1361
+ else if (i === showLines.length - 1) {
1362
+ lineText = " " + showLines[i] + quoteSuffix;
1363
+ }
1364
+ else {
1365
+ lineText = " " + showLines[i];
1366
+ }
1367
+ const styled = selected ? style.cyan(lineText) : lineText;
1368
+ lines.push(cardLine(styled, innerW, borderColor));
1369
+ }
1370
+ // Note (if present)
1371
+ if (card.note) {
1372
+ const noteText = "✏ " + truncateVisible(card.note, innerW - 2);
1373
+ lines.push(cardLine(style.yellow(noteText), innerW, borderColor));
1374
+ }
1375
+ // Meta line
1376
+ if (card.meta) {
1377
+ lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
1378
+ }
1379
+ }
1380
+ else {
1381
+ // Document card: title, summary, meta
1382
+ const titleText = truncateVisible(card.title || "Untitled", innerW);
1383
+ const titleStyled = selected ? style.bold(style.cyan(titleText)) : style.bold(titleText);
1384
+ lines.push(cardLine(titleStyled, innerW, borderColor));
1385
+ if (card.summary) {
1386
+ const summaryText = truncateVisible(card.summary, innerW);
1387
+ lines.push(cardLine(style.dim(summaryText), innerW, borderColor));
1388
+ }
1389
+ if (card.meta) {
1390
+ lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
1391
+ }
1392
+ }
1393
+ lines.push(" " + borderColor("╰" + "─".repeat(cardWidth - 2) + "╯"));
1394
+ return lines;
1395
+ }
1396
+ function renderCardView(state) {
1397
+ const { contentHeight, innerWidth } = getBoxDimensions();
1398
+ const tool = state.selectedTool;
1399
+ const title = tool ? humanLabel(tool.name, toolPrefix(tool)) : "";
1400
+ const cards = state.resultCards;
1401
+ const cardWidth = Math.min(innerWidth - 4, 72);
1402
+ const content = [];
1403
+ // Header with count
1404
+ const countInfo = style.dim(` (${state.resultCursor + 1} of ${cards.length})`);
1405
+ content.push(" " + style.bold("Results") + countInfo);
1406
+ content.push("");
1407
+ // Build all card lines
1408
+ const allLines = [];
1409
+ for (let ci = 0; ci < cards.length; ci++) {
1410
+ const cardContentLines = buildCardLines(cards[ci], ci, ci === state.resultCursor, cardWidth);
1411
+ for (const line of cardContentLines) {
1412
+ allLines.push({ line, cardIdx: ci });
1413
+ }
1414
+ if (ci < cards.length - 1) {
1415
+ allLines.push({ line: "", cardIdx: ci });
1416
+ }
1417
+ }
1418
+ // Scroll so selected card is visible
1419
+ const availableHeight = Math.max(1, contentHeight - content.length);
1420
+ const scroll = state.resultCardScroll;
1421
+ const visible = allLines.slice(scroll, scroll + availableHeight);
1422
+ for (const entry of visible) {
1423
+ content.push(entry.line);
1424
+ }
1425
+ const hasUrl = cards[state.resultCursor]?.url;
1426
+ const footerParts = ["↑↓ navigate"];
1427
+ if (hasUrl)
1428
+ footerParts.push("enter open");
1429
+ footerParts.push("esc back", "q quit");
1430
+ return renderLayout({
1431
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › results"),
1432
+ content,
1433
+ footer: state.quitConfirm
1434
+ ? style.yellow("Press q again to quit")
1435
+ : style.dim(footerParts.join(" · ")),
1436
+ });
1437
+ }
743
1438
  function renderResults(state) {
744
1439
  const { contentHeight, innerWidth } = getBoxDimensions();
745
1440
  const tool = state.selectedTool;
@@ -747,6 +1442,10 @@ function renderResults(state) {
747
1442
  const isError = !!state.error;
748
1443
  const isEmptyList = !isError && state.result === EMPTY_LIST_SENTINEL;
749
1444
  const isEmpty = !isError && !isEmptyList && !state.result.trim();
1445
+ // Card view for list results
1446
+ if (!isError && !isEmptyList && !isEmpty && state.resultCards.length > 0) {
1447
+ return renderCardView(state);
1448
+ }
750
1449
  // No results screen for empty lists
751
1450
  if (isEmptyList) {
752
1451
  const ghost = [
@@ -777,7 +1476,7 @@ function renderResults(state) {
777
1476
  content,
778
1477
  footer: state.quitConfirm
779
1478
  ? style.yellow("Press q again to quit")
780
- : style.dim("enter/esc back q quit"),
1479
+ : style.dim("enter/esc back · q quit"),
781
1480
  });
782
1481
  }
783
1482
  // Success screen for empty results
@@ -797,7 +1496,7 @@ function renderResults(state) {
797
1496
  content,
798
1497
  footer: state.quitConfirm
799
1498
  ? style.yellow("Press q again to quit")
800
- : style.dim("enter/esc back q quit"),
1499
+ : style.dim("enter/esc back · q quit"),
801
1500
  });
802
1501
  }
803
1502
  const rawContent = state.error || state.result;
@@ -825,7 +1524,7 @@ function renderResults(state) {
825
1524
  content,
826
1525
  footer: state.quitConfirm
827
1526
  ? style.yellow("Press q again to quit")
828
- : style.dim(scrollHint + "↑↓←→ scroll esc back q quit"),
1527
+ : style.dim(scrollHint + "↑↓←→ scroll · esc back · q quit"),
829
1528
  });
830
1529
  }
831
1530
  function renderState(state) {
@@ -852,10 +1551,8 @@ function handleCommandListInput(state, key) {
852
1551
  // search input uses: LOGO.length + 1 (blank) + 1 (search line) + 1 (blank)
853
1552
  const logoUsed = LOGO.length + 3;
854
1553
  const listHeight = Math.max(1, contentHeight - logoUsed);
855
- if (key.ctrl && key.name === "c")
856
- return "exit";
857
- // Escape: clear query if non-empty, otherwise quit confirm
858
- if (key.name === "escape") {
1554
+ // Escape / ctrl+c / q: clear query if non-empty, otherwise quit confirm
1555
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
859
1556
  if (state.searchQuery) {
860
1557
  const filtered = filterCommands(state.tools, "");
861
1558
  const sel = selectableIndices(filtered);
@@ -937,12 +1634,7 @@ function handleCommandListInput(state, key) {
937
1634
  const nameColWidth = Math.max(...fields.map((f) => f.name.length + (f.required ? 2 : 0)), 6) + 1;
938
1635
  const formValues = {};
939
1636
  for (const f of fields) {
940
- if (f.prop.default != null) {
941
- formValues[f.name] = String(f.prop.default);
942
- }
943
- else {
944
- formValues[f.name] = "";
945
- }
1637
+ formValues[f.name] = "";
946
1638
  }
947
1639
  if (fields.length === 0) {
948
1640
  return {
@@ -960,13 +1652,16 @@ function handleCommandListInput(state, key) {
960
1652
  formEditFieldIdx: -1,
961
1653
  formEditing: false,
962
1654
  formInputBuf: "",
1655
+ formInputCursorPos: 0,
963
1656
  formEnumCursor: 0,
964
1657
  formEnumSelected: new Set(),
965
- formShowRequired: false,
1658
+ formShowRequired: false, formShowOptional: false,
966
1659
  formStack: [],
967
1660
  };
968
1661
  }
969
- return {
1662
+ const filteredIndices = filterFormFields(fields, "");
1663
+ const firstBlankRequired = fields.findIndex((f) => f.required && !formValues[f.name]?.trim());
1664
+ const baseState = {
970
1665
  ...s,
971
1666
  view: "form",
972
1667
  selectedTool: tool,
@@ -975,17 +1670,23 @@ function handleCommandListInput(state, key) {
975
1670
  formValues,
976
1671
  formSearchQuery: "",
977
1672
  formSearchCursorPos: 0,
978
- formFilteredIndices: filterFormFields(fields, ""),
979
- formListCursor: defaultFormCursor(fields, filterFormFields(fields, ""), formValues),
1673
+ formFilteredIndices: filteredIndices,
1674
+ formListCursor: defaultFormCursor(fields, filteredIndices, formValues),
980
1675
  formScrollTop: 0,
981
1676
  formEditFieldIdx: -1,
982
1677
  formEditing: false,
983
1678
  formInputBuf: "",
1679
+ formInputCursorPos: 0,
984
1680
  formEnumCursor: 0,
985
1681
  formEnumSelected: new Set(),
986
- formShowRequired: false,
1682
+ formShowRequired: false, formShowOptional: false,
987
1683
  formStack: [],
988
1684
  };
1685
+ // Auto-open first required field
1686
+ if (firstBlankRequired >= 0) {
1687
+ return startEditingField(baseState, firstBlankRequired);
1688
+ }
1689
+ return baseState;
989
1690
  }
990
1691
  }
991
1692
  return s;
@@ -1000,19 +1701,158 @@ function handleCommandListInput(state, key) {
1000
1701
  }
1001
1702
  return s;
1002
1703
  }
1003
- // Printable characters: insert into search query
1004
- if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
1005
- const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + key.raw + s.searchQuery.slice(s.searchCursorPos);
1006
- const filtered = filterCommands(s.tools, newQuery);
1007
- const sel = selectableIndices(filtered);
1008
- return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + 1, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1704
+ // Printable characters or paste: insert into search query
1705
+ if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
1706
+ const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
1707
+ if (text) {
1708
+ const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + text + s.searchQuery.slice(s.searchCursorPos);
1709
+ const filtered = filterCommands(s.tools, newQuery);
1710
+ const sel = selectableIndices(filtered);
1711
+ return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + text.length, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1712
+ }
1009
1713
  }
1010
1714
  return s;
1011
1715
  }
1716
+ function startEditingField(state, fieldIdx) {
1717
+ const field = state.fields[fieldIdx];
1718
+ if (isArrayOfObjects(field.prop)) {
1719
+ const existing = state.formValues[field.name] || "[]";
1720
+ let items = [];
1721
+ try {
1722
+ items = JSON.parse(existing);
1723
+ }
1724
+ catch { /* */ }
1725
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: items.length };
1726
+ }
1727
+ const dateFmt = dateFieldFormat(field.prop);
1728
+ if (dateFmt) {
1729
+ const existing = state.formValues[field.name] || "";
1730
+ const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
1731
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, dateParts: parts, datePartCursor: 0 };
1732
+ }
1733
+ const enumValues = field.prop.enum || field.prop.items?.enum;
1734
+ const isBool = field.prop.type === "boolean";
1735
+ const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
1736
+ if (isArrayEnum && enumValues) {
1737
+ const curVal = state.formValues[field.name] || "";
1738
+ const selected = new Set();
1739
+ if (curVal) {
1740
+ const parts = curVal.split(",").map((s) => s.trim());
1741
+ for (const p of parts) {
1742
+ const idx = enumValues.indexOf(p);
1743
+ if (idx >= 0)
1744
+ selected.add(idx);
1745
+ }
1746
+ }
1747
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: 0, formEnumSelected: selected };
1748
+ }
1749
+ if (enumValues || isBool) {
1750
+ const choices = isBool ? ["true", "false"] : enumValues;
1751
+ const curVal = state.formValues[field.name] || "";
1752
+ const idx = choices.indexOf(curVal);
1753
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: idx >= 0 ? idx : 0 };
1754
+ }
1755
+ if (field.prop.type === "array" && !field.prop.items?.enum) {
1756
+ const existing = state.formValues[field.name] || "";
1757
+ const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
1758
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: itemCount };
1759
+ }
1760
+ const editBuf = state.formValues[field.name] || "";
1761
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: editBuf, formInputCursorPos: editBuf.length };
1762
+ }
1012
1763
  function handleFormInput(state, key) {
1013
- if (state.formEditing)
1014
- return handleFormEditInput(state, key);
1015
- return handleFormPaletteInput(state, key);
1764
+ // Sub-forms use existing palette/edit handlers
1765
+ if (state.formStack.length > 0) {
1766
+ if (state.formEditing)
1767
+ return handleFormEditInput(state, key);
1768
+ return handleFormPaletteInput(state, key);
1769
+ }
1770
+ // Command builder: editing a field
1771
+ if (state.formEditing) {
1772
+ const result = handleFormEditInput(state, key);
1773
+ if (result === "submit")
1774
+ return result;
1775
+ // Auto-advance: if editing just ended via confirm (not cancel), jump to next blank required field
1776
+ if (!result.formEditing && state.formEditing) {
1777
+ const wasCancel = key.name === "escape";
1778
+ if (!wasCancel) {
1779
+ const nextBlank = result.fields.findIndex((f) => f.required && !result.formValues[f.name]?.trim());
1780
+ if (nextBlank >= 0) {
1781
+ return startEditingField(result, nextBlank);
1782
+ }
1783
+ }
1784
+ }
1785
+ return result;
1786
+ }
1787
+ // Command builder: optional picker
1788
+ if (state.formShowOptional) {
1789
+ return handleOptionalPickerInput(state, key);
1790
+ }
1791
+ // Command builder: ready state
1792
+ return handleCommandBuilderReadyInput(state, key);
1793
+ }
1794
+ function commandListReset(tools) {
1795
+ const filteredItems = buildCommandList(tools);
1796
+ const sel = selectableIndices(filteredItems);
1797
+ return {
1798
+ view: "commands", selectedTool: null,
1799
+ searchQuery: "", searchCursorPos: 0,
1800
+ filteredItems, listCursor: sel[0] ?? 0, listScrollTop: 0,
1801
+ };
1802
+ }
1803
+ function handleCommandBuilderReadyInput(state, key) {
1804
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1805
+ return { ...state, ...commandListReset(state.tools) };
1806
+ }
1807
+ if (key.name === "return") {
1808
+ if (missingRequiredFields(state.fields, state.formValues).length === 0) {
1809
+ return "submit";
1810
+ }
1811
+ // Jump to first missing required field
1812
+ const nextBlank = state.fields.findIndex((f) => f.required && !state.formValues[f.name]?.trim());
1813
+ if (nextBlank >= 0) {
1814
+ return startEditingField(state, nextBlank);
1815
+ }
1816
+ return state;
1817
+ }
1818
+ if (key.name === "tab") {
1819
+ const hasOptional = state.fields.some((f) => !f.required);
1820
+ if (hasOptional) {
1821
+ return { ...state, formShowOptional: true, formListCursor: 0 };
1822
+ }
1823
+ return state;
1824
+ }
1825
+ // Backspace: re-edit last set field
1826
+ if (key.name === "backspace") {
1827
+ for (let i = state.fields.length - 1; i >= 0; i--) {
1828
+ if (state.formValues[state.fields[i].name]?.trim()) {
1829
+ return startEditingField(state, i);
1830
+ }
1831
+ }
1832
+ return state;
1833
+ }
1834
+ return state;
1835
+ }
1836
+ function handleOptionalPickerInput(state, key) {
1837
+ const optionalFields = state.fields
1838
+ .map((f, i) => ({ field: f, idx: i }))
1839
+ .filter(({ field }) => !field.required);
1840
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1841
+ return { ...state, formShowOptional: false };
1842
+ }
1843
+ if (key.name === "up") {
1844
+ return { ...state, formListCursor: state.formListCursor > 0 ? state.formListCursor - 1 : optionalFields.length - 1 };
1845
+ }
1846
+ if (key.name === "down") {
1847
+ return { ...state, formListCursor: state.formListCursor < optionalFields.length - 1 ? state.formListCursor + 1 : 0 };
1848
+ }
1849
+ if (key.name === "return") {
1850
+ const selected = optionalFields[state.formListCursor];
1851
+ if (selected) {
1852
+ return startEditingField({ ...state, formShowOptional: false }, selected.idx);
1853
+ }
1854
+ }
1855
+ return state;
1016
1856
  }
1017
1857
  function handleFormPaletteInput(state, key) {
1018
1858
  const { fields, formFilteredIndices: filtered, formListCursor, formSearchQuery } = state;
@@ -1055,13 +1895,71 @@ function handleFormPaletteInput(state, key) {
1055
1895
  formFilteredIndices: parentFiltered,
1056
1896
  formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, entry.parentValues),
1057
1897
  formScrollTop: 0,
1058
- formShowRequired: false,
1898
+ formShowRequired: false, formShowOptional: false,
1059
1899
  formInputBuf: "",
1900
+ formInputCursorPos: 0,
1060
1901
  };
1061
1902
  }
1062
- const resetFiltered = buildCommandList(state.tools);
1063
- const resetSel = selectableIndices(resetFiltered);
1064
- return { ...state, view: "commands", selectedTool: null, searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1903
+ return { ...state, ...commandListReset(state.tools) };
1904
+ }
1905
+ // Tab: jump to next unfilled required field
1906
+ if (key.name === "tab") {
1907
+ const unfilledRequired = filtered
1908
+ .map((idx, listPos) => ({ idx, listPos }))
1909
+ .filter(({ idx }) => {
1910
+ if (idx < 0 || idx >= fields.length)
1911
+ return false;
1912
+ const f = fields[idx];
1913
+ if (!f.required)
1914
+ return false;
1915
+ const val = state.formValues[f.name]?.trim();
1916
+ if (!val)
1917
+ return true;
1918
+ if (isArrayOfObjects(f.prop)) {
1919
+ try {
1920
+ return JSON.parse(val).length === 0;
1921
+ }
1922
+ catch {
1923
+ return true;
1924
+ }
1925
+ }
1926
+ return false;
1927
+ });
1928
+ if (unfilledRequired.length > 0) {
1929
+ // Find the next one after current cursor, wrapping around
1930
+ const after = unfilledRequired.find((u) => u.listPos > formListCursor);
1931
+ const target = after || unfilledRequired[0];
1932
+ let scroll = state.formScrollTop;
1933
+ const paramItems = filtered.filter((idx) => idx !== -1);
1934
+ if (target.idx >= 0) {
1935
+ const posInParams = paramItems.indexOf(target.idx);
1936
+ if (posInParams < scroll)
1937
+ scroll = posInParams;
1938
+ if (posInParams >= scroll + listHeight)
1939
+ scroll = posInParams - listHeight + 1;
1940
+ }
1941
+ return { ...state, formListCursor: target.listPos, formScrollTop: scroll };
1942
+ }
1943
+ // No unfilled required fields — jump to Execute
1944
+ const execPos = filtered.indexOf(-1);
1945
+ if (execPos >= 0)
1946
+ return { ...state, formListCursor: execPos };
1947
+ return state;
1948
+ }
1949
+ // 'o' key: toggle optional fields visibility (when not searching)
1950
+ if (key.raw === "o" && !key.ctrl && !formSearchQuery) {
1951
+ const optionalExists = filtered.some((idx) => idx >= 0 && idx < fields.length && !fields[idx].required);
1952
+ if (optionalExists) {
1953
+ const newShow = !state.formShowOptional;
1954
+ if (!newShow) {
1955
+ const curIdx = filtered[formListCursor];
1956
+ if (curIdx !== undefined && curIdx >= 0 && curIdx < fields.length && !fields[curIdx].required) {
1957
+ const execPos = filtered.indexOf(-1);
1958
+ return { ...state, formShowOptional: false, formListCursor: execPos >= 0 ? execPos : 0 };
1959
+ }
1960
+ }
1961
+ return { ...state, formShowOptional: newShow };
1962
+ }
1065
1963
  }
1066
1964
  // Arrow left/right: move text cursor within search input
1067
1965
  if (key.name === "left") {
@@ -1070,9 +1968,25 @@ function handleFormPaletteInput(state, key) {
1070
1968
  if (key.name === "right") {
1071
1969
  return { ...state, formSearchCursorPos: Math.min(formSearchQuery.length, state.formSearchCursorPos + 1) };
1072
1970
  }
1073
- // Arrow up/down: navigate filtered list (cycling)
1971
+ // Helper: check if a position in filtered is navigable (skip collapsed optional fields)
1972
+ const isNavigable = (listPos) => {
1973
+ const idx = filtered[listPos];
1974
+ if (idx === undefined)
1975
+ return false;
1976
+ if (idx === -1)
1977
+ return true; // Execute always navigable
1978
+ if (!state.formShowOptional && !state.formSearchQuery && idx >= 0 && idx < fields.length && !fields[idx].required)
1979
+ return false;
1980
+ return true;
1981
+ };
1982
+ // Arrow up/down: navigate filtered list (cycling, skipping hidden items)
1074
1983
  if (key.name === "up") {
1075
- const next = formListCursor > 0 ? formListCursor - 1 : filtered.length - 1;
1984
+ let next = formListCursor;
1985
+ for (let i = 0; i < filtered.length; i++) {
1986
+ next = next > 0 ? next - 1 : filtered.length - 1;
1987
+ if (isNavigable(next))
1988
+ break;
1989
+ }
1076
1990
  let scroll = state.formScrollTop;
1077
1991
  const itemIdx = filtered[next];
1078
1992
  if (itemIdx !== -1) {
@@ -1080,14 +1994,18 @@ function handleFormPaletteInput(state, key) {
1080
1994
  const posInParams = paramItems.indexOf(itemIdx);
1081
1995
  if (posInParams < scroll)
1082
1996
  scroll = posInParams;
1083
- // Wrap to bottom: reset scroll to show end of list
1084
1997
  if (next > formListCursor)
1085
1998
  scroll = Math.max(0, paramItems.length - listHeight);
1086
1999
  }
1087
2000
  return { ...state, formListCursor: next, formScrollTop: scroll };
1088
2001
  }
1089
2002
  if (key.name === "down") {
1090
- const next = formListCursor < filtered.length - 1 ? formListCursor + 1 : 0;
2003
+ let next = formListCursor;
2004
+ for (let i = 0; i < filtered.length; i++) {
2005
+ next = next < filtered.length - 1 ? next + 1 : 0;
2006
+ if (isNavigable(next))
2007
+ break;
2008
+ }
1091
2009
  let scroll = state.formScrollTop;
1092
2010
  const itemIdx = filtered[next];
1093
2011
  if (itemIdx !== -1) {
@@ -1095,7 +2013,6 @@ function handleFormPaletteInput(state, key) {
1095
2013
  const posInParams = paramItems.indexOf(itemIdx);
1096
2014
  if (posInParams >= scroll + listHeight)
1097
2015
  scroll = posInParams - listHeight + 1;
1098
- // Wrap to top: reset scroll
1099
2016
  if (next < formListCursor)
1100
2017
  scroll = 0;
1101
2018
  }
@@ -1119,51 +2036,7 @@ function handleFormPaletteInput(state, key) {
1119
2036
  return { ...state, formShowRequired: true };
1120
2037
  }
1121
2038
  if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
1122
- const field = fields[highlightedIdx];
1123
- // Array-of-objects: enter edit mode with cursor on "Add new item"
1124
- if (isArrayOfObjects(field.prop)) {
1125
- const existing = state.formValues[field.name] || "[]";
1126
- let items = [];
1127
- try {
1128
- items = JSON.parse(existing);
1129
- }
1130
- catch { /* */ }
1131
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: items.length };
1132
- }
1133
- const dateFmt = dateFieldFormat(field.prop);
1134
- const enumValues = field.prop.enum || field.prop.items?.enum;
1135
- const isBool = field.prop.type === "boolean";
1136
- if (dateFmt) {
1137
- const existing = state.formValues[field.name] || "";
1138
- const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
1139
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, dateParts: parts, datePartCursor: 0 };
1140
- }
1141
- const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
1142
- if (isArrayEnum && enumValues) {
1143
- const curVal = state.formValues[field.name] || "";
1144
- const selected = new Set();
1145
- if (curVal) {
1146
- const parts = curVal.split(",").map((s) => s.trim());
1147
- for (const p of parts) {
1148
- const idx = enumValues.indexOf(p);
1149
- if (idx >= 0)
1150
- selected.add(idx);
1151
- }
1152
- }
1153
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: 0, formEnumSelected: selected };
1154
- }
1155
- if (enumValues || isBool) {
1156
- const choices = isBool ? ["true", "false"] : enumValues;
1157
- const curVal = state.formValues[field.name] || "";
1158
- const idx = choices.indexOf(curVal);
1159
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: idx >= 0 ? idx : 0 };
1160
- }
1161
- if (field.prop.type === "array" && !field.prop.items?.enum) {
1162
- const existing = state.formValues[field.name] || "";
1163
- const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
1164
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formInputBuf: "", formEnumCursor: itemCount };
1165
- }
1166
- return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formInputBuf: state.formValues[field.name] || "" };
2039
+ return startEditingField(state, highlightedIdx);
1167
2040
  }
1168
2041
  return state;
1169
2042
  }
@@ -1176,11 +2049,14 @@ function handleFormPaletteInput(state, key) {
1176
2049
  }
1177
2050
  return state;
1178
2051
  }
1179
- // Printable characters: insert into search query
1180
- if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
1181
- const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + key.raw + formSearchQuery.slice(state.formSearchCursorPos);
1182
- const newFiltered = filterFormFields(fields, newQuery);
1183
- return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + 1, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
2052
+ // Printable characters or paste: insert into search query
2053
+ if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
2054
+ const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
2055
+ if (text) {
2056
+ const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + text + formSearchQuery.slice(state.formSearchCursorPos);
2057
+ const newFiltered = filterFormFields(fields, newQuery);
2058
+ return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + text.length, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
2059
+ }
1184
2060
  }
1185
2061
  return state;
1186
2062
  }
@@ -1192,7 +2068,7 @@ function handleFormEditInput(state, key) {
1192
2068
  const isBool = field.prop.type === "boolean";
1193
2069
  const resetPalette = (updatedValues) => {
1194
2070
  const f = filterFormFields(fields, "");
1195
- return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false };
2071
+ return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false, formShowOptional: false };
1196
2072
  };
1197
2073
  // Escape: cancel edit (for multi-select and tag editor, escape confirms since items are saved live)
1198
2074
  if (key.name === "escape") {
@@ -1204,7 +2080,7 @@ function handleFormEditInput(state, key) {
1204
2080
  const newValues = { ...formValues, [field.name]: val };
1205
2081
  return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette(newValues) };
1206
2082
  }
1207
- return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", ...resetPalette() };
2083
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", formInputCursorPos: 0, ...resetPalette() };
1208
2084
  }
1209
2085
  if (key.ctrl && key.name === "c")
1210
2086
  return "submit";
@@ -1280,10 +2156,11 @@ function handleFormEditInput(state, key) {
1280
2156
  formFilteredIndices: subFiltered,
1281
2157
  formListCursor: defaultFormCursor(subFields, subFiltered, subValues),
1282
2158
  formScrollTop: 0,
1283
- formShowRequired: false,
2159
+ formShowRequired: false, formShowOptional: false,
1284
2160
  formEnumCursor: 0,
1285
2161
  formEnumSelected: new Set(),
1286
2162
  formInputBuf: "",
2163
+ formInputCursorPos: 0,
1287
2164
  };
1288
2165
  }
1289
2166
  if (key.name === "backspace" && formEnumCursor < items.length) {
@@ -1391,7 +2268,7 @@ function handleFormEditInput(state, key) {
1391
2268
  const newItems = [...items];
1392
2269
  newItems.splice(formEnumCursor, 1);
1393
2270
  const newValues = { ...formValues, [field.name]: newItems.join(", ") };
1394
- return { ...state, formValues: newValues, formInputBuf: editVal, formEnumCursor: newItems.length };
2271
+ return { ...state, formValues: newValues, formInputBuf: editVal, formInputCursorPos: editVal.length, formEnumCursor: newItems.length };
1395
2272
  }
1396
2273
  if (key.name === "backspace") {
1397
2274
  // Delete item
@@ -1404,11 +2281,16 @@ function handleFormEditInput(state, key) {
1404
2281
  return state;
1405
2282
  }
1406
2283
  // Cursor on text input
2284
+ if (key.name === "paste") {
2285
+ // Paste: strip newlines for tag input
2286
+ const text = key.raw.replace(/\n/g, "");
2287
+ return { ...state, formInputBuf: formInputBuf + text, formInputCursorPos: formInputBuf.length + text.length };
2288
+ }
1407
2289
  if (key.name === "return") {
1408
2290
  if (formInputBuf.trim()) {
1409
2291
  items.push(formInputBuf.trim());
1410
2292
  const newValues = { ...formValues, [field.name]: items.join(", ") };
1411
- return { ...state, formValues: newValues, formInputBuf: "", formEnumCursor: items.length };
2293
+ return { ...state, formValues: newValues, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: items.length };
1412
2294
  }
1413
2295
  // Empty input: confirm and close
1414
2296
  const newValues = { ...formValues, [field.name]: items.join(", ") };
@@ -1416,39 +2298,146 @@ function handleFormEditInput(state, key) {
1416
2298
  }
1417
2299
  if (key.name === "backspace") {
1418
2300
  if (formInputBuf) {
1419
- return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
2301
+ return { ...state, formInputBuf: formInputBuf.slice(0, -1), formInputCursorPos: formInputBuf.length - 1 };
1420
2302
  }
1421
2303
  return state;
1422
2304
  }
1423
2305
  if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1424
- return { ...state, formInputBuf: formInputBuf + key.raw };
2306
+ const clean = key.raw.replace(/[\x00-\x1f\x7f]/g, ""); // strip control chars for tags
2307
+ if (clean)
2308
+ return { ...state, formInputBuf: formInputBuf + clean, formInputCursorPos: formInputBuf.length + clean.length };
1425
2309
  }
1426
2310
  return state;
1427
2311
  }
1428
2312
  // Text editing mode
1429
- if (key.name === "return" && key.shift) {
1430
- // Shift+Enter: insert newline
1431
- return { ...state, formInputBuf: formInputBuf + "\n" };
2313
+ const pos = state.formInputCursorPos;
2314
+ if (key.name === "paste") {
2315
+ const newBuf = formInputBuf.slice(0, pos) + key.raw + formInputBuf.slice(pos);
2316
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + key.raw.length };
2317
+ }
2318
+ // Insert newline: Ctrl+J (\n), Shift+Enter, or Alt+Enter
2319
+ if (key.raw === "\n" || (key.name === "return" && key.shift)) {
2320
+ const newBuf = formInputBuf.slice(0, pos) + "\n" + formInputBuf.slice(pos);
2321
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + 1 };
1432
2322
  }
1433
2323
  if (key.name === "return") {
1434
- // Enter: confirm
2324
+ // Enter (\r): confirm value
1435
2325
  const newValues = { ...formValues, [field.name]: formInputBuf };
1436
- return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
2326
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputCursorPos: 0, formValues: newValues, ...resetPalette(newValues) };
2327
+ }
2328
+ if (key.name === "left") {
2329
+ return { ...state, formInputCursorPos: Math.max(0, pos - 1) };
2330
+ }
2331
+ if (key.name === "right") {
2332
+ return { ...state, formInputCursorPos: Math.min(formInputBuf.length, pos + 1) };
2333
+ }
2334
+ if (key.name === "wordLeft") {
2335
+ return { ...state, formInputCursorPos: prevWordBoundary(formInputBuf, pos) };
2336
+ }
2337
+ if (key.name === "wordRight") {
2338
+ return { ...state, formInputCursorPos: nextWordBoundary(formInputBuf, pos) };
2339
+ }
2340
+ if (key.name === "wordBackspace") {
2341
+ const boundary = prevWordBoundary(formInputBuf, pos);
2342
+ const newBuf = formInputBuf.slice(0, boundary) + formInputBuf.slice(pos);
2343
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: boundary };
2344
+ }
2345
+ if (key.name === "up") {
2346
+ // Move cursor up one line
2347
+ const lines = formInputBuf.split("\n");
2348
+ let rem = pos;
2349
+ let line = 0;
2350
+ for (; line < lines.length; line++) {
2351
+ if (rem <= lines[line].length)
2352
+ break;
2353
+ rem -= lines[line].length + 1;
2354
+ }
2355
+ if (line > 0) {
2356
+ const col = Math.min(rem, lines[line - 1].length);
2357
+ let newPos = 0;
2358
+ for (let i = 0; i < line - 1; i++)
2359
+ newPos += lines[i].length + 1;
2360
+ newPos += col;
2361
+ return { ...state, formInputCursorPos: newPos };
2362
+ }
2363
+ return state;
2364
+ }
2365
+ if (key.name === "down") {
2366
+ // Move cursor down one line
2367
+ const lines = formInputBuf.split("\n");
2368
+ let rem = pos;
2369
+ let line = 0;
2370
+ for (; line < lines.length; line++) {
2371
+ if (rem <= lines[line].length)
2372
+ break;
2373
+ rem -= lines[line].length + 1;
2374
+ }
2375
+ if (line < lines.length - 1) {
2376
+ const col = Math.min(rem, lines[line + 1].length);
2377
+ let newPos = 0;
2378
+ for (let i = 0; i <= line; i++)
2379
+ newPos += lines[i].length + 1;
2380
+ newPos += col;
2381
+ return { ...state, formInputCursorPos: newPos };
2382
+ }
2383
+ return state;
1437
2384
  }
1438
2385
  if (key.name === "backspace") {
1439
- return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
2386
+ if (pos > 0) {
2387
+ const newBuf = formInputBuf.slice(0, pos - 1) + formInputBuf.slice(pos);
2388
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos - 1 };
2389
+ }
2390
+ return state;
1440
2391
  }
1441
2392
  if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1442
- return { ...state, formInputBuf: formInputBuf + key.raw };
2393
+ // Preserve newlines in pasted text (multi-line supported), strip other control chars
2394
+ const clean = key.raw.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, "");
2395
+ if (clean) {
2396
+ const newBuf = formInputBuf.slice(0, pos) + clean + formInputBuf.slice(pos);
2397
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + clean.length };
2398
+ }
1443
2399
  }
1444
2400
  return state;
1445
2401
  }
2402
+ function cardLineCount(card, cardWidth) {
2403
+ const innerW = Math.max(1, cardWidth - 4);
2404
+ if (card.kind === "highlight") {
2405
+ const quoteW = innerW - 2; // account for quote prefix
2406
+ const passage = card.summary || "\u2026";
2407
+ const wrapped = wrapText(passage, quoteW);
2408
+ const textLines = Math.min(wrapped.length, 6);
2409
+ return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
2410
+ }
2411
+ return 2 + 1 + (card.summary ? 1 : 0) + (card.meta ? 1 : 0); // top + title + summary? + meta? + bottom
2412
+ }
2413
+ function computeCardScroll(cards, cursor, currentScroll, availableHeight) {
2414
+ const { innerWidth } = getBoxDimensions();
2415
+ const cardWidth = Math.min(innerWidth - 4, 72);
2416
+ let lineStart = 0;
2417
+ for (let ci = 0; ci < cards.length; ci++) {
2418
+ const card = cards[ci];
2419
+ const height = cardLineCount(card, cardWidth);
2420
+ const spacing = ci < cards.length - 1 ? 1 : 0;
2421
+ if (ci === cursor) {
2422
+ const lineEnd = lineStart + height + spacing;
2423
+ if (lineStart < currentScroll)
2424
+ return lineStart;
2425
+ if (lineEnd > currentScroll + availableHeight)
2426
+ return Math.max(0, lineEnd - availableHeight);
2427
+ return currentScroll;
2428
+ }
2429
+ lineStart += height + spacing;
2430
+ }
2431
+ return currentScroll;
2432
+ }
1446
2433
  function handleResultsInput(state, key) {
1447
2434
  const { contentHeight } = getBoxDimensions();
1448
- const contentLines = (state.error || state.result).split("\n");
1449
- const visibleCount = Math.max(1, contentHeight - 3);
1450
- if (key.ctrl && key.name === "c")
1451
- return "exit";
2435
+ const inCardMode = state.resultCards.length > 0 && !state.error;
2436
+ if (key.ctrl && key.name === "c") {
2437
+ if (state.quitConfirm)
2438
+ return "exit";
2439
+ return { ...state, quitConfirm: true };
2440
+ }
1452
2441
  if (key.name === "q" && !key.ctrl) {
1453
2442
  if (state.quitConfirm)
1454
2443
  return "exit";
@@ -1456,55 +2445,68 @@ function handleResultsInput(state, key) {
1456
2445
  }
1457
2446
  // Any other key cancels quit confirm
1458
2447
  const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
2448
+ const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
1459
2449
  const goBack = () => {
1460
- const resetFiltered = buildCommandList(s.tools);
1461
- const resetSel = selectableIndices(resetFiltered);
1462
- const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1463
- const hasParams = s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
2450
+ const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
2451
+ const hasParams = !isEmpty && s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1464
2452
  if (hasParams) {
2453
+ const f = filterFormFields(s.fields, "");
1465
2454
  return {
1466
- ...s, view: "form", result: "", error: "", resultScroll: 0, resultScrollX: 0,
2455
+ ...s, view: "form", ...resultsClear,
1467
2456
  formSearchQuery: "", formSearchCursorPos: 0,
1468
- formFilteredIndices: filterFormFields(s.fields, ""),
1469
- formListCursor: defaultFormCursor(s.fields, filterFormFields(s.fields, ""), s.formValues), formScrollTop: 0,
1470
- formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
2457
+ formFilteredIndices: f,
2458
+ formListCursor: defaultFormCursor(s.fields, f, s.formValues), formScrollTop: 0,
2459
+ formEditing: false, formEditFieldIdx: -1, formShowRequired: false, formShowOptional: false,
1471
2460
  };
1472
2461
  }
1473
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
2462
+ return { ...s, ...commandListReset(s.tools), ...resultsClear };
1474
2463
  };
1475
- // Enter on success/empty-list/error screens → go back
1476
- if (key.name === "return") {
1477
- const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
1478
- if (isEmpty) {
1479
- // Success screen → back to main menu
1480
- const resetFiltered = buildCommandList(s.tools);
1481
- const resetSel = selectableIndices(resetFiltered);
1482
- const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1483
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1484
- }
2464
+ if (key.name === "escape") {
1485
2465
  return goBack();
1486
2466
  }
1487
- if (key.name === "escape") {
1488
- const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
1489
- const resetFiltered = buildCommandList(s.tools);
1490
- const resetSel = selectableIndices(resetFiltered);
1491
- const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1492
- if (isEmpty) {
1493
- // Success screen → back to main menu
1494
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1495
- }
1496
- // Data or error → back to form if it has params, otherwise main menu
1497
- const hasParams = s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1498
- if (hasParams) {
1499
- return {
1500
- ...s, view: "form", result: "", error: "", resultScroll: 0, resultScrollX: 0,
1501
- formSearchQuery: "", formSearchCursorPos: 0,
1502
- formFilteredIndices: filterFormFields(s.fields, ""),
1503
- formListCursor: defaultFormCursor(s.fields, filterFormFields(s.fields, ""), s.formValues), formScrollTop: 0,
1504
- formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
1505
- };
2467
+ if (inCardMode) {
2468
+ const cards = s.resultCards;
2469
+ // 2 lines used by header + blank
2470
+ const availableHeight = Math.max(1, contentHeight - 2);
2471
+ if (key.name === "return") {
2472
+ const card = cards[s.resultCursor];
2473
+ if (card?.url)
2474
+ return "openUrl";
2475
+ return goBack();
2476
+ }
2477
+ if (key.name === "up") {
2478
+ if (s.resultCursor > 0) {
2479
+ const newCursor = s.resultCursor - 1;
2480
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2481
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2482
+ }
2483
+ return s;
2484
+ }
2485
+ if (key.name === "down") {
2486
+ if (s.resultCursor < cards.length - 1) {
2487
+ const newCursor = s.resultCursor + 1;
2488
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2489
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2490
+ }
2491
+ return s;
1506
2492
  }
1507
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
2493
+ if (key.name === "pageup") {
2494
+ const newCursor = Math.max(0, s.resultCursor - 5);
2495
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2496
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2497
+ }
2498
+ if (key.name === "pagedown") {
2499
+ const newCursor = Math.min(cards.length - 1, s.resultCursor + 5);
2500
+ const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
2501
+ return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
2502
+ }
2503
+ return s;
2504
+ }
2505
+ // Text mode fallback
2506
+ const contentLines = (state.error || state.result).split("\n");
2507
+ const visibleCount = Math.max(1, contentHeight - 3);
2508
+ if (key.name === "return") {
2509
+ return goBack();
1508
2510
  }
1509
2511
  if (key.name === "up") {
1510
2512
  return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
@@ -1668,31 +2670,34 @@ async function executeTool(state) {
1668
2670
  const res = await callTool(token, authType, tool.name, args);
1669
2671
  if (res.isError) {
1670
2672
  const errMsg = res.content.map((c) => c.text || "").filter(Boolean).join("\n");
1671
- return { ...state, view: "results", error: errMsg || "Unknown error", result: "", resultScroll: 0, resultScrollX: 0 };
2673
+ return { ...state, view: "results", error: errMsg || "Unknown error", result: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
1672
2674
  }
1673
2675
  const text = res.content.filter((c) => c.type === "text" && c.text).map((c) => c.text).join("\n");
1674
2676
  // Check structuredContent if text content is empty
1675
2677
  const structured = res.structuredContent;
1676
2678
  let formatted;
1677
2679
  let emptyList = false;
2680
+ let parsedData = undefined;
1678
2681
  if (!text && structured !== undefined) {
2682
+ parsedData = structured;
1679
2683
  emptyList = isEmptyListResult(structured);
1680
2684
  formatted = formatJsonPretty(structured);
1681
2685
  }
1682
2686
  else {
1683
2687
  try {
1684
- const parsed = JSON.parse(text);
1685
- emptyList = isEmptyListResult(parsed);
1686
- formatted = formatJsonPretty(parsed);
2688
+ parsedData = JSON.parse(text);
2689
+ emptyList = isEmptyListResult(parsedData);
2690
+ formatted = formatJsonPretty(parsedData);
1687
2691
  }
1688
2692
  catch {
1689
2693
  formatted = text;
1690
2694
  }
1691
2695
  }
1692
- return { ...state, view: "results", result: emptyList ? EMPTY_LIST_SENTINEL : formatted, error: "", resultScroll: 0, resultScrollX: 0 };
2696
+ const cards = parsedData !== undefined ? extractCards(parsedData) || [] : [];
2697
+ return { ...state, view: "results", result: emptyList ? EMPTY_LIST_SENTINEL : formatted, error: "", resultScroll: 0, resultScrollX: 0, resultCards: cards, resultCursor: 0, resultCardScroll: 0 };
1693
2698
  }
1694
2699
  catch (err) {
1695
- return { ...state, view: "results", error: err.message, result: "", resultScroll: 0, resultScrollX: 0 };
2700
+ return { ...state, view: "results", error: err.message, result: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
1696
2701
  }
1697
2702
  }
1698
2703
  // --- Main loop ---
@@ -1719,10 +2724,11 @@ export async function runApp(tools) {
1719
2724
  formEditFieldIdx: -1,
1720
2725
  formEditing: false,
1721
2726
  formInputBuf: "",
2727
+ formInputCursorPos: 0,
1722
2728
  formEnumCursor: 0,
1723
2729
  formEnumSelected: new Set(),
1724
2730
  formValues: {},
1725
- formShowRequired: false,
2731
+ formShowRequired: false, formShowOptional: false,
1726
2732
  formStack: [],
1727
2733
  dateParts: [],
1728
2734
  datePartCursor: 0,
@@ -1730,6 +2736,9 @@ export async function runApp(tools) {
1730
2736
  error: "",
1731
2737
  resultScroll: 0,
1732
2738
  resultScrollX: 0,
2739
+ resultCards: [],
2740
+ resultCursor: 0,
2741
+ resultCardScroll: 0,
1733
2742
  spinnerFrame: 0,
1734
2743
  };
1735
2744
  paint(renderState(state));
@@ -1781,6 +2790,14 @@ export async function runApp(tools) {
1781
2790
  cleanup();
1782
2791
  return;
1783
2792
  }
2793
+ if (result === "openUrl") {
2794
+ const card = state.resultCards[state.resultCursor];
2795
+ if (card?.url) {
2796
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
2797
+ exec(`${cmd} ${JSON.stringify(card.url)}`);
2798
+ }
2799
+ return;
2800
+ }
1784
2801
  if (result === "submit") {
1785
2802
  state = { ...state, view: "loading", spinnerFrame: 0 };
1786
2803
  paint(renderState(state));