@readwise/cli 0.3.0 → 0.5.0

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,9 @@
1
1
  import type { ToolDef, SchemaProperty } from "../config.js";
2
- import { resolveProperty, resolveRef } from "../commands.js";
2
+ import { resolveProperty } from "../commands.js";
3
3
  import { callTool } from "../mcp.js";
4
4
  import { ensureValidToken } from "../auth.js";
5
5
  import { LOGO } from "./logo.js";
6
- import { style, paint, screenSize, fitWidth, ansiSlice, stripAnsi, parseKey, type KeyEvent } from "./term.js";
6
+ import { style, paint, screenSize, fitWidth, ansiSlice, parseKey, type KeyEvent } from "./term.js";
7
7
  import { VERSION } from "../version.js";
8
8
 
9
9
  // --- Types ---
@@ -51,10 +51,12 @@ interface AppState {
51
51
  formEditFieldIdx: number;
52
52
  formEditing: boolean;
53
53
  formInputBuf: string;
54
+ formInputCursorPos: number;
54
55
  formEnumCursor: number;
55
56
  formEnumSelected: Set<number>;
56
57
  formValues: Record<string, string>;
57
58
  formShowRequired: boolean;
59
+ formShowOptional: boolean;
58
60
  formStack: FormStackEntry[];
59
61
  // Date picker
60
62
  dateParts: number[]; // [year, month, day] or [year, month, day, hour, minute]
@@ -184,6 +186,42 @@ function defaultFormCursor(fields: FormField[], filtered: number[], values: Reco
184
186
  return firstBlank >= 0 ? firstBlank : executeIndex(filtered);
185
187
  }
186
188
 
189
+ type FieldKind = "arrayObj" | "date" | "arrayEnum" | "enum" | "bool" | "arrayText" | "text";
190
+
191
+ function classifyField(prop: SchemaProperty): FieldKind {
192
+ if (isArrayOfObjects(prop)) return "arrayObj";
193
+ if (dateFieldFormat(prop)) return "date";
194
+ const eVals = prop.enum || prop.items?.enum;
195
+ if (prop.type === "boolean") return "bool";
196
+ if (eVals && prop.type === "array") return "arrayEnum";
197
+ if (eVals) return "enum";
198
+ if (prop.type === "array") return "arrayText";
199
+ return "text";
200
+ }
201
+
202
+ function fieldTypeBadge(prop: SchemaProperty): string {
203
+ const badges: Record<FieldKind, string> = {
204
+ arrayObj: "form", date: "date", bool: "yes/no", arrayEnum: "multi",
205
+ enum: "select", arrayText: "list", text: "text",
206
+ };
207
+ const badge = badges[classifyField(prop)];
208
+ if (badge !== "text") return badge;
209
+ if (prop.type === "integer" || prop.type === "number") return "number";
210
+ return "text";
211
+ }
212
+
213
+ function footerForFieldKind(kind: FieldKind): string {
214
+ switch (kind) {
215
+ case "arrayObj": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc back";
216
+ case "date": return "\u2190\u2192 part \u00B7 \u2191\u2193 adjust \u00B7 t today \u00B7 enter confirm \u00B7 esc cancel";
217
+ case "arrayEnum": return "space toggle \u00B7 enter confirm \u00B7 esc cancel";
218
+ case "arrayText": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc confirm";
219
+ case "enum":
220
+ case "bool": return "\u2191\u2193 navigate \u00B7 enter confirm \u00B7 esc cancel";
221
+ case "text": return "enter confirm \u00B7 esc cancel";
222
+ }
223
+ }
224
+
187
225
  function formFieldValueDisplay(value: string, maxWidth: number): string {
188
226
  if (!value) return style.dim("–");
189
227
  // JSON array display (for array-of-objects)
@@ -253,8 +291,9 @@ function popFormStack(state: AppState): AppState {
253
291
  formFilteredIndices: parentFiltered,
254
292
  formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, newParentValues),
255
293
  formScrollTop: 0,
256
- formShowRequired: false,
294
+ formShowRequired: false, formShowOptional: false,
257
295
  formInputBuf: "",
296
+ formInputCursorPos: 0,
258
297
  };
259
298
  }
260
299
 
@@ -296,6 +335,37 @@ function wrapText(text: string, width: number): string[] {
296
335
  return lines.length > 0 ? lines : [""];
297
336
  }
298
337
 
338
+ // --- Word boundary helpers ---
339
+
340
+ function prevWordBoundary(buf: string, pos: number): number {
341
+ if (pos <= 0) return 0;
342
+ let i = pos - 1;
343
+ // Skip whitespace
344
+ while (i > 0 && /\s/.test(buf[i]!)) i--;
345
+ // Skip word chars or non-word non-space chars
346
+ if (i >= 0 && /\w/.test(buf[i]!)) {
347
+ while (i > 0 && /\w/.test(buf[i - 1]!)) i--;
348
+ } else {
349
+ while (i > 0 && !/\w/.test(buf[i - 1]!) && !/\s/.test(buf[i - 1]!)) i--;
350
+ }
351
+ return i;
352
+ }
353
+
354
+ function nextWordBoundary(buf: string, pos: number): number {
355
+ const len = buf.length;
356
+ if (pos >= len) return len;
357
+ let i = pos;
358
+ // Skip current word chars or non-word non-space chars
359
+ if (/\w/.test(buf[i]!)) {
360
+ while (i < len && /\w/.test(buf[i]!)) i++;
361
+ } else if (!/\s/.test(buf[i]!)) {
362
+ while (i < len && !/\w/.test(buf[i]!) && !/\s/.test(buf[i]!)) i++;
363
+ }
364
+ // Skip whitespace
365
+ while (i < len && /\s/.test(buf[i]!)) i++;
366
+ return i;
367
+ }
368
+
299
369
  // --- Date helpers ---
300
370
 
301
371
  type DateFormat = "date" | "date-time";
@@ -449,7 +519,7 @@ function renderCommandList(state: AppState): string[] {
449
519
  if (i === Math.floor(LOGO.length / 2) - 1) {
450
520
  content.push(` ${logoLine} ${style.boldYellow("Readwise")} ${style.dim("v" + VERSION)}`);
451
521
  } else if (i === Math.floor(LOGO.length / 2)) {
452
- content.push(` ${logoLine} ${style.dim("Command-line interface")}`);
522
+ content.push(` ${logoLine} ${style.dim("Built for AI agents · This TUI is just for fun/learning")}`);
453
523
  } else {
454
524
  content.push(` ${logoLine}`);
455
525
  }
@@ -510,8 +580,8 @@ function renderCommandList(state: AppState): string[] {
510
580
  }
511
581
 
512
582
  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");
583
+ ? style.yellow("Press again to quit")
584
+ : style.dim("type to search · ↑↓ navigate · enter select · esc/ctrl+c quit");
515
585
 
516
586
  return renderLayout({
517
587
  breadcrumb: style.boldYellow("Readwise"),
@@ -525,16 +595,223 @@ function renderForm(state: AppState): string[] {
525
595
  const tool = state.selectedTool!;
526
596
  const fields = state.fields;
527
597
  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
598
 
534
- if (state.formEditing && state.formEditFieldIdx >= 0) {
535
- return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
599
+ // Sub-forms (nested array-of-objects) keep existing form UI
600
+ if (state.formStack.length > 0) {
601
+ const stackParts = state.formStack.map((e) => e.parentFieldName);
602
+ const title = toolTitle + " › " + stackParts.join(" › ");
603
+ if (state.formEditing && state.formEditFieldIdx >= 0) {
604
+ return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
605
+ }
606
+ return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
536
607
  }
537
- return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
608
+
609
+ // Top-level: command builder
610
+ return renderCommandBuilder(state, toolTitle, fields, contentHeight, innerWidth);
611
+ }
612
+
613
+ function renderCommandBuilder(
614
+ state: AppState, title: string, fields: FormField[],
615
+ contentHeight: number, innerWidth: number,
616
+ ): string[] {
617
+ const tool = state.selectedTool!;
618
+ const cmdName = tool.name.replace(/_/g, "-");
619
+ const content: string[] = [];
620
+ const editField = state.formEditing && state.formEditFieldIdx >= 0
621
+ ? fields[state.formEditFieldIdx]!
622
+ : null;
623
+
624
+ // Header
625
+ content.push("");
626
+ content.push(" " + style.bold(title));
627
+ if (tool.description) {
628
+ const wrapped = wrapText(tool.description, innerWidth - 4);
629
+ for (const line of wrapped) {
630
+ content.push(" " + style.dim(line));
631
+ }
632
+ }
633
+ content.push("");
634
+
635
+ // Classify editing field type once for both content and footer
636
+ const editKind = editField ? classifyField(editField.prop) : null;
637
+ const isTextLikeEdit = editKind === "text";
638
+
639
+ // Build command lines
640
+ const argLines: string[] = [];
641
+ for (const field of fields) {
642
+ const flagName = field.name.replace(/_/g, "-");
643
+ if (field === editField) {
644
+ if (isTextLikeEdit) {
645
+ // Inline cursor for text fields
646
+ const buf = state.formInputBuf;
647
+ const before = buf.slice(0, state.formInputCursorPos);
648
+ const cursorChar = state.formInputCursorPos < buf.length ? buf[state.formInputCursorPos]! : " ";
649
+ const after = state.formInputCursorPos < buf.length ? buf.slice(state.formInputCursorPos + 1) : "";
650
+ argLines.push(" --" + flagName + "=" + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
651
+ } else if (isArrayOfObjects(field.prop)) {
652
+ // Array-of-objects: show item count
653
+ const existing = state.formValues[field.name] || "[]";
654
+ let items: unknown[] = [];
655
+ try { items = JSON.parse(existing); } catch { /* */ }
656
+ const label = items.length > 0 ? `[${items.length} item${items.length > 1 ? "s" : ""}]` : "[...]";
657
+ argLines.push(" --" + flagName + "=" + style.yellow(label));
658
+ } else {
659
+ // Non-text editors (enum, bool, date): show pending
660
+ argLines.push(" --" + flagName + "=" + style.inverse(" "));
661
+ }
662
+ } else {
663
+ const val = state.formValues[field.name];
664
+ if (val) {
665
+ const needsQuotes = val.includes(" ") || val.includes(",");
666
+ const displayVal = needsQuotes ? '"' + val + '"' : val;
667
+ argLines.push(" --" + flagName + "=" + style.cyan(displayVal));
668
+ }
669
+ }
670
+ }
671
+
672
+ // Render command with line continuations
673
+ const cmdPrefix = " " + style.dim("$") + " " + style.dim("readwise") + " " + cmdName;
674
+ if (argLines.length === 0) {
675
+ content.push(cmdPrefix);
676
+ } else {
677
+ content.push(cmdPrefix + " \\");
678
+ for (let i = 0; i < argLines.length; i++) {
679
+ const isLast = i === argLines.length - 1;
680
+ content.push(argLines[i]! + (isLast ? "" : " \\"));
681
+ }
682
+ }
683
+
684
+ content.push("");
685
+
686
+ // Context area: field description, editor, or ready state
687
+ if (editField) {
688
+ // Field description
689
+ if (editField.prop.description) {
690
+ content.push(" " + style.dim(editField.prop.description));
691
+ }
692
+ if (editField.prop.examples?.length) {
693
+ const exStr = editField.prop.examples.map((e: unknown) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
694
+ content.push(" " + style.dim("e.g. ") + style.cyan(truncateVisible(exStr, innerWidth - 10)));
695
+ }
696
+ if (editField.prop.default != null) {
697
+ content.push(" " + style.dim("default: " + editField.prop.default));
698
+ }
699
+
700
+ const eVals = editField.prop.enum || editField.prop.items?.enum;
701
+
702
+ if (editKind === "bool") {
703
+ content.push("");
704
+ const choices = ["true", "false"];
705
+ for (let ci = 0; ci < choices.length; ci++) {
706
+ const sel = ci === state.formEnumCursor;
707
+ content.push(sel ? " " + style.cyan(style.bold("\u203A " + choices[ci]!)) : " " + choices[ci]!);
708
+ }
709
+ } else if (editKind === "enum" && eVals) {
710
+ content.push("");
711
+ for (let ci = 0; ci < eVals.length; ci++) {
712
+ const sel = ci === state.formEnumCursor;
713
+ content.push(sel ? " " + style.cyan(style.bold("\u203A " + eVals[ci]!)) : " " + eVals[ci]!);
714
+ }
715
+ } else if (editKind === "arrayEnum" && eVals) {
716
+ content.push("");
717
+ for (let ci = 0; ci < eVals.length; ci++) {
718
+ const sel = ci === state.formEnumCursor;
719
+ const checked = state.formEnumSelected.has(ci);
720
+ const check = checked ? style.cyan("[x]") : style.dim("[ ]");
721
+ content.push((sel ? " \u203A " : " ") + check + " " + eVals[ci]!);
722
+ }
723
+ } else if (editKind === "date") {
724
+ const dateFmt = dateFieldFormat(editField.prop)!;
725
+ content.push("");
726
+ content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
727
+ } else if (editKind === "arrayText") {
728
+ const existing = state.formValues[editField.name] || "";
729
+ const items = existing ? existing.split(",").map((s: string) => s.trim()).filter(Boolean) : [];
730
+ content.push("");
731
+ for (let i = 0; i < items.length; i++) {
732
+ const isCursor = i === state.formEnumCursor;
733
+ content.push((isCursor ? " \u276F " : " ") + style.cyan(items[i]!));
734
+ }
735
+ const onInput = state.formEnumCursor === items.length;
736
+ content.push((onInput ? " \u276F " : " ") + style.cyan(state.formInputBuf) + (onInput ? style.inverse(" ") : ""));
737
+ } else if (editKind === "arrayObj") {
738
+ const existing = state.formValues[editField.name] || "[]";
739
+ let items: unknown[] = [];
740
+ try { items = JSON.parse(existing); } catch { /* */ }
741
+ content.push("");
742
+ for (let i = 0; i < items.length; i++) {
743
+ const item = items[i] as Record<string, unknown>;
744
+ const summary = Object.entries(item)
745
+ .filter(([, v]) => v != null && v !== "")
746
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
747
+ .join(", ");
748
+ const isCursor = i === state.formEnumCursor;
749
+ content.push((isCursor ? " \u276F " : " ") + truncateVisible(summary || "(empty)", innerWidth - 6));
750
+ }
751
+ if (items.length > 0) content.push("");
752
+ const addCursor = state.formEnumCursor === items.length;
753
+ content.push(addCursor
754
+ ? " " + style.inverse(style.green(" + Add new item "))
755
+ : " " + style.dim("+") + " Add new item");
756
+ } else if (isTextLikeEdit) {
757
+ // Text field: cursor is already inline in the command string
758
+ // Just show a hint if empty
759
+ if (!state.formInputBuf) {
760
+ content.push("");
761
+ content.push(" " + style.dim("Type a value and press enter"));
762
+ }
763
+ }
764
+ } else {
765
+ // Ready state / optional picker
766
+ const missing = missingRequiredFields(fields, state.formValues);
767
+ if (missing.length > 0) {
768
+ content.push(" " + style.red("Missing: " + missing.map((f) => f.name).join(", ")));
769
+ } else {
770
+ content.push(" " + style.dim("Press enter to run"));
771
+ }
772
+
773
+ // Always show optional params
774
+ const optionalFields = fields
775
+ .map((f, i) => ({ field: f, idx: i }))
776
+ .filter(({ field }) => !field.required);
777
+ if (optionalFields.length > 0) {
778
+ content.push("");
779
+ content.push(" " + style.dim("Optional parameters (tab to add)"));
780
+ content.push("");
781
+ const maxFlagWidth = Math.max(...optionalFields.map(({ field }) => field.name.length), 0) + 2;
782
+ for (let i = 0; i < optionalFields.length; i++) {
783
+ const { field } = optionalFields[i]!;
784
+ const flagName = field.name.replace(/_/g, "-");
785
+ const hasValue = !!state.formValues[field.name]?.trim();
786
+ const sel = state.formShowOptional && i === state.formListCursor;
787
+ const prefix = sel ? " \u276F " : " ";
788
+ const paddedName = flagName.padEnd(maxFlagWidth);
789
+ const desc = field.prop.description
790
+ ? style.dim(truncateVisible(field.prop.description, innerWidth - maxFlagWidth - 8))
791
+ : "";
792
+ if (sel) {
793
+ content.push(style.boldYellow(prefix + paddedName) + " " + desc);
794
+ } else if (hasValue) {
795
+ content.push(prefix + style.green(paddedName) + " " + desc);
796
+ } else {
797
+ content.push(prefix + style.dim(paddedName) + " " + desc);
798
+ }
799
+ }
800
+ }
801
+ }
802
+
803
+ // Footer
804
+ const footer = editKind
805
+ ? style.dim(footerForFieldKind(editKind))
806
+ : state.formShowOptional
807
+ ? style.dim("\u2191\u2193 select \u00B7 enter add \u00B7 esc done")
808
+ : style.dim("enter run \u00B7 tab add option \u00B7 esc back");
809
+
810
+ return renderLayout({
811
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" \u203A ") + style.bold(title),
812
+ content,
813
+ footer,
814
+ });
538
815
  }
539
816
 
540
817
  function renderFormPaletteMode(
@@ -558,28 +835,51 @@ function renderFormPaletteMode(
558
835
  content.push(" " + style.dim(line));
559
836
  }
560
837
  }
838
+
839
+ // Progress indicator for required fields
840
+ const requiredFields = fields.filter((f) => f.required);
841
+ if (requiredFields.length > 0) {
842
+ const filledRequired = requiredFields.filter((f) => {
843
+ const val = state.formValues[f.name]?.trim();
844
+ if (!val) return false;
845
+ if (isArrayOfObjects(f.prop)) {
846
+ try { return JSON.parse(val).length > 0; } catch { return false; }
847
+ }
848
+ return true;
849
+ });
850
+ const allFilled = filledRequired.length === requiredFields.length;
851
+ const progressText = `${filledRequired.length} of ${requiredFields.length} required`;
852
+ content.push(" " + (allFilled ? style.green("✓ " + progressText) : style.dim(progressText)));
853
+ }
854
+
561
855
  content.push("");
562
856
 
563
- // Search input
857
+ // Search input (only show when there's a search query or many fields)
564
858
  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("");
859
+ if (queryText || fields.length > 6) {
860
+ const before = queryText.slice(0, state.formSearchCursorPos);
861
+ const cursorChar = state.formSearchCursorPos < queryText.length
862
+ ? queryText[state.formSearchCursorPos]!
863
+ : " ";
864
+ const after = state.formSearchCursorPos < queryText.length
865
+ ? queryText.slice(state.formSearchCursorPos + 1)
866
+ : "";
867
+ content.push(" " + style.dim("/") + " " + before + style.inverse(cursorChar) + after);
868
+ content.push("");
869
+ } else {
870
+ content.push("");
871
+ }
574
872
 
575
- // Compute maxLabelWidth
873
+ // Compute maxLabelWidth (include " *" for required fields)
576
874
  const maxLabelWidth = Math.max(
577
875
  ...fields.map((f) => f.name.length + (f.required ? 2 : 0)),
578
876
  6,
579
877
  ) + 1;
580
878
 
581
- // Value display width budget: innerWidth - prefix(3) - label - gap(2)
582
- const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2);
879
+ // Badge width: " text" = ~7 chars max
880
+ const badgeWidth = 8;
881
+ // Value display width budget: innerWidth - cursor(3) - label - gap(2) - badge
882
+ const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2 - badgeWidth);
583
883
 
584
884
  const headerUsed = content.length;
585
885
  // Reserve space for: blank + Execute + blank + description (up to 4 lines)
@@ -588,29 +888,69 @@ function renderFormPaletteMode(
588
888
  const filtered = state.formFilteredIndices;
589
889
  const hasOnlyExecute = filtered.length === 1 && filtered[0] === -1;
590
890
 
891
+ // Split fields into required and optional
892
+ const requiredIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && fields[idx]!.required);
893
+ const optionalIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && !fields[idx]!.required);
894
+ const hasOptional = optionalIdxs.length > 0;
895
+ const showingOptional = state.formShowOptional || state.formSearchQuery;
896
+ // Count optional fields that have been filled
897
+ const filledOptionalCount = optionalIdxs.filter((idx) => !!state.formValues[fields[idx]!.name]?.trim()).length;
898
+
899
+ const renderField = (fieldIdx: number) => {
900
+ const field = fields[fieldIdx]!;
901
+ const val = state.formValues[field.name] || "";
902
+ const isFilled = !!val.trim();
903
+ const listPos = filtered.indexOf(fieldIdx);
904
+ const selected = listPos === state.formListCursor;
905
+
906
+ // Value display
907
+ const valStr = formFieldValueDisplay(val, valueAvail);
908
+
909
+ // Type badge
910
+ const badge = style.dim(fieldTypeBadge(field.prop));
911
+
912
+ const cursor = selected ? " ❯ " : " ";
913
+ if (selected) {
914
+ const label = field.name + (field.required ? " *" : "");
915
+ content.push(style.boldYellow(cursor) + style.boldYellow(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
916
+ } else if (isFilled) {
917
+ const label = field.name + (field.required ? " *" : "");
918
+ content.push(cursor + style.green(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
919
+ } else {
920
+ // Unfilled: show required * in red
921
+ const namePart = field.name;
922
+ const starPart = field.required ? " *" : "";
923
+ const plainLabel = namePart + starPart;
924
+ const padAmount = Math.max(0, maxLabelWidth - plainLabel.length);
925
+ const displayLabel = field.required ? namePart + style.red(" *") + " ".repeat(padAmount) : plainLabel.padEnd(maxLabelWidth);
926
+ content.push(cursor + displayLabel + " " + style.dim("–") + " " + badge);
927
+ }
928
+ };
929
+
591
930
  if (hasOnlyExecute && state.formSearchQuery) {
592
931
  content.push(" " + style.dim("No matching parameters"));
593
932
  content.push("");
594
933
  } 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);
934
+ // Required fields (always visible)
935
+ for (const fieldIdx of requiredIdxs) {
936
+ renderField(fieldIdx);
937
+ }
938
+
939
+ // Optional fields separator
940
+ if (hasOptional) {
941
+ if (showingOptional) {
942
+ if (requiredIdxs.length > 0) content.push("");
943
+ content.push(" " + style.dim("── optional ──"));
944
+ const visibleOptional = optionalIdxs.slice(0, listHeight - requiredIdxs.length - 2);
945
+ for (const fieldIdx of visibleOptional) {
946
+ renderField(fieldIdx);
947
+ }
612
948
  } else {
613
- content.push(prefix + paddedName + " " + valStr);
949
+ content.push("");
950
+ const optLabel = filledOptionalCount > 0
951
+ ? `── ${optionalIdxs.length} optional (${filledOptionalCount} set) · 'o' to show ──`
952
+ : `── ${optionalIdxs.length} optional · 'o' to show ──`;
953
+ content.push(" " + style.dim(optLabel));
614
954
  }
615
955
  }
616
956
  }
@@ -639,23 +979,34 @@ function renderFormPaletteMode(
639
979
  }
640
980
  }
641
981
 
642
- // Description of highlighted field
982
+ // Description of highlighted field or Execute hint
643
983
  const highlightedIdx = filtered[state.formListCursor];
644
984
  if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
645
- const desc = fields[highlightedIdx]!.prop.description;
646
- if (desc) {
985
+ const prop = fields[highlightedIdx]!.prop;
986
+ if (prop.description) {
647
987
  content.push("");
648
- const wrapped = wrapText(desc, innerWidth - 4);
988
+ const wrapped = wrapText(prop.description, innerWidth - 4);
649
989
  for (const line of wrapped) {
650
990
  content.push(" " + style.dim(line));
651
991
  }
652
992
  }
993
+ if (prop.examples?.length) {
994
+ const exStr = prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
995
+ content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
996
+ }
997
+ } else if (highlightedIdx === -1) {
998
+ content.push("");
999
+ content.push(" " + style.dim("Press enter to run"));
653
1000
  }
654
1001
 
1002
+ // Footer hints
1003
+ const hasUnfilledRequired = requiredFields.some((f) => !state.formValues[f.name]?.trim());
1004
+ const tabHint = hasUnfilledRequired ? " · tab next required" : "";
1005
+ const optionalHint = hasOptional ? " · o optional" : "";
655
1006
  return renderLayout({
656
1007
  breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
657
1008
  content,
658
- footer: style.dim("type to filter ↑↓ navigate enter edit/run esc back"),
1009
+ footer: style.dim("↑↓ navigate · enter edit" + tabHint + optionalHint + " · esc back"),
659
1010
  });
660
1011
  }
661
1012
 
@@ -668,11 +1019,24 @@ function renderFormEditMode(
668
1019
 
669
1020
  content.push("");
670
1021
  content.push(" " + style.bold(title));
1022
+
1023
+ // Show tool description for context
1024
+ const toolDesc = state.formStack.length > 0
1025
+ ? state.formStack[state.formStack.length - 1]!.parentFields
1026
+ .find((f) => f.name === state.formStack[state.formStack.length - 1]!.parentFieldName)
1027
+ ?.prop.items?.description
1028
+ : state.selectedTool!.description;
1029
+ if (toolDesc) {
1030
+ const wrapped = wrapText(toolDesc, innerWidth - 4);
1031
+ for (const line of wrapped) {
1032
+ content.push(" " + style.dim(line));
1033
+ }
1034
+ }
671
1035
  content.push("");
672
1036
 
673
- // Field name
1037
+ // Field label
674
1038
  const nameLabel = field.name + (field.required ? " *" : "");
675
- content.push(" " + style.boldYellow("❯ " + nameLabel));
1039
+ content.push(" " + style.bold(nameLabel));
676
1040
 
677
1041
  // Field description
678
1042
  if (field.prop.description) {
@@ -681,17 +1045,17 @@ function renderFormEditMode(
681
1045
  content.push(" " + style.dim(line));
682
1046
  }
683
1047
  }
1048
+ if (field.prop.examples?.length) {
1049
+ const exStr = field.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
1050
+ content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
1051
+ }
684
1052
  content.push("");
685
1053
 
686
1054
  // Editor area
1055
+ const kind = classifyField(field.prop);
687
1056
  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
1057
 
694
- if (isArrayObj) {
1058
+ if (kind === "arrayObj") {
695
1059
  // Array-of-objects editor: show existing items + "Add new item"
696
1060
  const existing = state.formValues[field.name] || "[]";
697
1061
  let items: unknown[] = [];
@@ -715,9 +1079,10 @@ function renderFormEditMode(
715
1079
  } else {
716
1080
  content.push(" " + style.dim("+") + " Add new item");
717
1081
  }
718
- } else if (dateFmt) {
1082
+ } else if (kind === "date") {
1083
+ const dateFmt = dateFieldFormat(field.prop)!;
719
1084
  content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
720
- } else if (isArrayEnum && eVals) {
1085
+ } else if (kind === "arrayEnum" && eVals) {
721
1086
  // Multi-select picker
722
1087
  for (let ci = 0; ci < eVals.length; ci++) {
723
1088
  const isCursor = ci === state.formEnumCursor;
@@ -727,7 +1092,7 @@ function renderFormEditMode(
727
1092
  const label = marker + check + " " + eVals[ci]!;
728
1093
  content.push(isCursor ? style.bold(label) : label);
729
1094
  }
730
- } else if (isArrayText) {
1095
+ } else if (kind === "arrayText") {
731
1096
  // Tag-style list editor: navigable items + text input at bottom
732
1097
  const existing = state.formValues[field.name] || "";
733
1098
  const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
@@ -750,45 +1115,76 @@ function renderFormEditMode(
750
1115
  content.push(" " + style.dim("enter ") + style.dim("edit item"));
751
1116
  content.push(" " + style.dim("bksp ") + style.dim("remove item"));
752
1117
  }
753
- } else if (eVals || isBool) {
754
- const choices = isBool ? ["true", "false"] : eVals!;
1118
+ } else if (kind === "enum" || kind === "bool") {
1119
+ const choices = kind === "bool" ? ["true", "false"] : eVals!;
755
1120
  for (let ci = 0; ci < choices.length; ci++) {
756
1121
  const sel = ci === state.formEnumCursor;
757
1122
  const choiceLine = (sel ? " › " : " ") + choices[ci]!;
758
1123
  content.push(sel ? style.cyan(style.bold(choiceLine)) : choiceLine);
759
1124
  }
760
1125
  } 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]!));
1126
+ // Text editor with cursor position
1127
+ const prefix0 = " ";
1128
+ if (!state.formInputBuf) {
1129
+ // Show placeholder text when input is empty
1130
+ let placeholder = "type a value";
1131
+ if (field.prop.examples?.length) {
1132
+ placeholder = String(field.prop.examples[0]);
1133
+ } else if (field.prop.description) {
1134
+ placeholder = field.prop.description.toLowerCase().replace(/[.!]$/, "");
1135
+ } else if (field.prop.type === "integer" || field.prop.type === "number") {
1136
+ placeholder = "enter a number";
1137
+ }
1138
+ content.push(prefix0 + style.inverse(" ") + style.dim(" " + placeholder + "…"));
1139
+ } else {
1140
+ const lines = state.formInputBuf.split("\n");
1141
+ // Find cursor line and column from flat position
1142
+ let cursorLine = 0;
1143
+ let cursorCol = state.formInputCursorPos;
1144
+ for (let li = 0; li < lines.length; li++) {
1145
+ if (cursorCol <= lines[li]!.length) {
1146
+ cursorLine = li;
1147
+ break;
1148
+ }
1149
+ cursorCol -= lines[li]!.length + 1;
1150
+ }
1151
+ for (let li = 0; li < lines.length; li++) {
1152
+ const prefix = li === 0 ? prefix0 : " ";
1153
+ const lineText = lines[li]!;
1154
+ if (li === cursorLine) {
1155
+ const before = lineText.slice(0, cursorCol);
1156
+ const cursorChar = cursorCol < lineText.length ? lineText[cursorCol]! : " ";
1157
+ const after = cursorCol < lineText.length ? lineText.slice(cursorCol + 1) : "";
1158
+ content.push(prefix + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
1159
+ } else {
1160
+ content.push(prefix + style.cyan(lineText));
1161
+ }
769
1162
  }
770
1163
  }
771
1164
  }
772
1165
 
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");
1166
+ // Show remaining required fields hint (for text editors)
1167
+ if (kind === "text") {
1168
+ const requiredFields = fields.filter((f) => f.required);
1169
+ const filledCount = requiredFields.filter((f) => {
1170
+ if (f.name === field.name) return !!state.formInputBuf.trim(); // current field
1171
+ return !!state.formValues[f.name]?.trim();
1172
+ }).length;
1173
+ const remaining = requiredFields.length - filledCount;
1174
+ content.push("");
1175
+ if (remaining <= 0) {
1176
+ content.push(" " + style.dim("Then press enter to confirm → Execute"));
1177
+ } else if (remaining === 1 && !state.formInputBuf.trim()) {
1178
+ content.push(" " + style.dim("Type a value, then press enter"));
1179
+ } else {
1180
+ content.push(" " + style.dim(`${remaining} required field${remaining > 1 ? "s" : ""} remaining`));
1181
+ }
786
1182
  }
787
1183
 
788
1184
  return renderLayout({
789
1185
  breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
790
1186
  content,
791
- footer,
1187
+ footer: style.dim(footerForFieldKind(kind)),
792
1188
  });
793
1189
  }
794
1190
 
@@ -860,7 +1256,7 @@ function renderResults(state: AppState): string[] {
860
1256
  content,
861
1257
  footer: state.quitConfirm
862
1258
  ? style.yellow("Press q again to quit")
863
- : style.dim("enter/esc back q quit"),
1259
+ : style.dim("enter/esc back · q quit"),
864
1260
  });
865
1261
  }
866
1262
 
@@ -881,7 +1277,7 @@ function renderResults(state: AppState): string[] {
881
1277
  content,
882
1278
  footer: state.quitConfirm
883
1279
  ? style.yellow("Press q again to quit")
884
- : style.dim("enter/esc back q quit"),
1280
+ : style.dim("enter/esc back · q quit"),
885
1281
  });
886
1282
  }
887
1283
 
@@ -913,7 +1309,7 @@ function renderResults(state: AppState): string[] {
913
1309
  content,
914
1310
  footer: state.quitConfirm
915
1311
  ? style.yellow("Press q again to quit")
916
- : style.dim(scrollHint + "↑↓←→ scroll esc back q quit"),
1312
+ : style.dim(scrollHint + "↑↓←→ scroll · esc back · q quit"),
917
1313
  });
918
1314
  }
919
1315
 
@@ -945,10 +1341,8 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
945
1341
  const logoUsed = LOGO.length + 3;
946
1342
  const listHeight = Math.max(1, contentHeight - logoUsed);
947
1343
 
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") {
1344
+ // Escape / ctrl+c / q: clear query if non-empty, otherwise quit confirm
1345
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
952
1346
  if (state.searchQuery) {
953
1347
  const filtered = filterCommands(state.tools, "");
954
1348
  const sel = selectableIndices(filtered);
@@ -1034,11 +1428,7 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
1034
1428
 
1035
1429
  const formValues: Record<string, string> = {};
1036
1430
  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
- }
1431
+ formValues[f.name] = "";
1042
1432
  }
1043
1433
 
1044
1434
  if (fields.length === 0) {
@@ -1057,33 +1447,44 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
1057
1447
  formEditFieldIdx: -1,
1058
1448
  formEditing: false,
1059
1449
  formInputBuf: "",
1450
+ formInputCursorPos: 0,
1060
1451
  formEnumCursor: 0,
1061
1452
  formEnumSelected: new Set(),
1062
- formShowRequired: false,
1453
+ formShowRequired: false, formShowOptional: false,
1063
1454
  formStack: [],
1064
1455
  };
1065
1456
  }
1066
1457
 
1067
- return {
1458
+ const filteredIndices = filterFormFields(fields, "");
1459
+ const firstBlankRequired = fields.findIndex((f) => f.required && !formValues[f.name]?.trim());
1460
+
1461
+ const baseState: AppState = {
1068
1462
  ...s,
1069
- view: "form",
1463
+ view: "form" as View,
1070
1464
  selectedTool: tool,
1071
1465
  fields,
1072
1466
  nameColWidth,
1073
1467
  formValues,
1074
1468
  formSearchQuery: "",
1075
1469
  formSearchCursorPos: 0,
1076
- formFilteredIndices: filterFormFields(fields, ""),
1077
- formListCursor: defaultFormCursor(fields, filterFormFields(fields, ""), formValues),
1470
+ formFilteredIndices: filteredIndices,
1471
+ formListCursor: defaultFormCursor(fields, filteredIndices, formValues),
1078
1472
  formScrollTop: 0,
1079
1473
  formEditFieldIdx: -1,
1080
1474
  formEditing: false,
1081
1475
  formInputBuf: "",
1476
+ formInputCursorPos: 0,
1082
1477
  formEnumCursor: 0,
1083
1478
  formEnumSelected: new Set(),
1084
- formShowRequired: false,
1479
+ formShowRequired: false, formShowOptional: false,
1085
1480
  formStack: [],
1086
1481
  };
1482
+
1483
+ // Auto-open first required field
1484
+ if (firstBlankRequired >= 0) {
1485
+ return startEditingField(baseState, firstBlankRequired);
1486
+ }
1487
+ return baseState;
1087
1488
  }
1088
1489
  }
1089
1490
  return s;
@@ -1100,20 +1501,169 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
1100
1501
  return s;
1101
1502
  }
1102
1503
 
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 };
1504
+ // Printable characters or paste: insert into search query
1505
+ if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
1506
+ const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
1507
+ if (text) {
1508
+ const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + text + s.searchQuery.slice(s.searchCursorPos);
1509
+ const filtered = filterCommands(s.tools, newQuery);
1510
+ const sel = selectableIndices(filtered);
1511
+ return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + text.length, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1512
+ }
1109
1513
  }
1110
1514
 
1111
1515
  return s;
1112
1516
  }
1113
1517
 
1518
+ function startEditingField(state: AppState, fieldIdx: number): AppState {
1519
+ const field = state.fields[fieldIdx]!;
1520
+ if (isArrayOfObjects(field.prop)) {
1521
+ const existing = state.formValues[field.name] || "[]";
1522
+ let items: unknown[] = [];
1523
+ try { items = JSON.parse(existing); } catch { /* */ }
1524
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: items.length };
1525
+ }
1526
+ const dateFmt = dateFieldFormat(field.prop);
1527
+ if (dateFmt) {
1528
+ const existing = state.formValues[field.name] || "";
1529
+ const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
1530
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, dateParts: parts, datePartCursor: 0 };
1531
+ }
1532
+ const enumValues = field.prop.enum || field.prop.items?.enum;
1533
+ const isBool = field.prop.type === "boolean";
1534
+ const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
1535
+ if (isArrayEnum && enumValues) {
1536
+ const curVal = state.formValues[field.name] || "";
1537
+ const selected = new Set<number>();
1538
+ if (curVal) {
1539
+ const parts = curVal.split(",").map((s) => s.trim());
1540
+ for (const p of parts) {
1541
+ const idx = enumValues.indexOf(p);
1542
+ if (idx >= 0) selected.add(idx);
1543
+ }
1544
+ }
1545
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: 0, formEnumSelected: selected };
1546
+ }
1547
+ if (enumValues || isBool) {
1548
+ const choices = isBool ? ["true", "false"] : enumValues!;
1549
+ const curVal = state.formValues[field.name] || "";
1550
+ const idx = choices.indexOf(curVal);
1551
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: idx >= 0 ? idx : 0 };
1552
+ }
1553
+ if (field.prop.type === "array" && !field.prop.items?.enum) {
1554
+ const existing = state.formValues[field.name] || "";
1555
+ const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
1556
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: itemCount };
1557
+ }
1558
+ const editBuf = state.formValues[field.name] || "";
1559
+ return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: editBuf, formInputCursorPos: editBuf.length };
1560
+ }
1561
+
1114
1562
  function handleFormInput(state: AppState, key: KeyEvent): AppState | "submit" {
1115
- if (state.formEditing) return handleFormEditInput(state, key);
1116
- return handleFormPaletteInput(state, key);
1563
+ // Sub-forms use existing palette/edit handlers
1564
+ if (state.formStack.length > 0) {
1565
+ if (state.formEditing) return handleFormEditInput(state, key);
1566
+ return handleFormPaletteInput(state, key);
1567
+ }
1568
+
1569
+ // Command builder: editing a field
1570
+ if (state.formEditing) {
1571
+ const result = handleFormEditInput(state, key);
1572
+ if (result === "submit") return result;
1573
+ // Auto-advance: if editing just ended via confirm (not cancel), jump to next blank required field
1574
+ if (!result.formEditing && state.formEditing) {
1575
+ const wasCancel = key.name === "escape";
1576
+ if (!wasCancel) {
1577
+ const nextBlank = result.fields.findIndex((f) => f.required && !result.formValues[f.name]?.trim());
1578
+ if (nextBlank >= 0) {
1579
+ return startEditingField(result, nextBlank);
1580
+ }
1581
+ }
1582
+ }
1583
+ return result;
1584
+ }
1585
+
1586
+ // Command builder: optional picker
1587
+ if (state.formShowOptional) {
1588
+ return handleOptionalPickerInput(state, key);
1589
+ }
1590
+
1591
+ // Command builder: ready state
1592
+ return handleCommandBuilderReadyInput(state, key);
1593
+ }
1594
+
1595
+ function commandListReset(tools: ToolDef[]): Partial<AppState> {
1596
+ const filteredItems = buildCommandList(tools);
1597
+ const sel = selectableIndices(filteredItems);
1598
+ return {
1599
+ view: "commands" as View, selectedTool: null,
1600
+ searchQuery: "", searchCursorPos: 0,
1601
+ filteredItems, listCursor: sel[0] ?? 0, listScrollTop: 0,
1602
+ };
1603
+ }
1604
+
1605
+ function handleCommandBuilderReadyInput(state: AppState, key: KeyEvent): AppState | "submit" {
1606
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1607
+ return { ...state, ...commandListReset(state.tools) };
1608
+ }
1609
+
1610
+ if (key.name === "return") {
1611
+ if (missingRequiredFields(state.fields, state.formValues).length === 0) {
1612
+ return "submit";
1613
+ }
1614
+ // Jump to first missing required field
1615
+ const nextBlank = state.fields.findIndex((f) => f.required && !state.formValues[f.name]?.trim());
1616
+ if (nextBlank >= 0) {
1617
+ return startEditingField(state, nextBlank);
1618
+ }
1619
+ return state;
1620
+ }
1621
+
1622
+ if (key.name === "tab") {
1623
+ const hasOptional = state.fields.some((f) => !f.required);
1624
+ if (hasOptional) {
1625
+ return { ...state, formShowOptional: true, formListCursor: 0 };
1626
+ }
1627
+ return state;
1628
+ }
1629
+
1630
+ // Backspace: re-edit last set field
1631
+ if (key.name === "backspace") {
1632
+ for (let i = state.fields.length - 1; i >= 0; i--) {
1633
+ if (state.formValues[state.fields[i]!.name]?.trim()) {
1634
+ return startEditingField(state, i);
1635
+ }
1636
+ }
1637
+ return state;
1638
+ }
1639
+
1640
+ return state;
1641
+ }
1642
+
1643
+ function handleOptionalPickerInput(state: AppState, key: KeyEvent): AppState | "submit" {
1644
+ const optionalFields = state.fields
1645
+ .map((f, i) => ({ field: f, idx: i }))
1646
+ .filter(({ field }) => !field.required);
1647
+
1648
+ if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1649
+ return { ...state, formShowOptional: false };
1650
+ }
1651
+
1652
+ if (key.name === "up") {
1653
+ return { ...state, formListCursor: state.formListCursor > 0 ? state.formListCursor - 1 : optionalFields.length - 1 };
1654
+ }
1655
+ if (key.name === "down") {
1656
+ return { ...state, formListCursor: state.formListCursor < optionalFields.length - 1 ? state.formListCursor + 1 : 0 };
1657
+ }
1658
+
1659
+ if (key.name === "return") {
1660
+ const selected = optionalFields[state.formListCursor];
1661
+ if (selected) {
1662
+ return startEditingField({ ...state, formShowOptional: false }, selected.idx);
1663
+ }
1664
+ }
1665
+
1666
+ return state;
1117
1667
  }
1118
1668
 
1119
1669
  function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "submit" {
@@ -1155,13 +1705,62 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1155
1705
  formFilteredIndices: parentFiltered,
1156
1706
  formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, entry.parentValues),
1157
1707
  formScrollTop: 0,
1158
- formShowRequired: false,
1708
+ formShowRequired: false, formShowOptional: false,
1159
1709
  formInputBuf: "",
1710
+ formInputCursorPos: 0,
1160
1711
  };
1161
1712
  }
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 };
1713
+ return { ...state, ...commandListReset(state.tools) };
1714
+ }
1715
+
1716
+ // Tab: jump to next unfilled required field
1717
+ if (key.name === "tab") {
1718
+ const unfilledRequired = filtered
1719
+ .map((idx, listPos) => ({ idx, listPos }))
1720
+ .filter(({ idx }) => {
1721
+ if (idx < 0 || idx >= fields.length) return false;
1722
+ const f = fields[idx]!;
1723
+ if (!f.required) return false;
1724
+ const val = state.formValues[f.name]?.trim();
1725
+ if (!val) return true;
1726
+ if (isArrayOfObjects(f.prop)) {
1727
+ try { return JSON.parse(val).length === 0; } catch { return true; }
1728
+ }
1729
+ return false;
1730
+ });
1731
+ if (unfilledRequired.length > 0) {
1732
+ // Find the next one after current cursor, wrapping around
1733
+ const after = unfilledRequired.find((u) => u.listPos > formListCursor);
1734
+ const target = after || unfilledRequired[0]!;
1735
+ let scroll = state.formScrollTop;
1736
+ const paramItems = filtered.filter((idx) => idx !== -1);
1737
+ if (target.idx >= 0) {
1738
+ const posInParams = paramItems.indexOf(target.idx);
1739
+ if (posInParams < scroll) scroll = posInParams;
1740
+ if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
1741
+ }
1742
+ return { ...state, formListCursor: target.listPos, formScrollTop: scroll };
1743
+ }
1744
+ // No unfilled required fields — jump to Execute
1745
+ const execPos = filtered.indexOf(-1);
1746
+ if (execPos >= 0) return { ...state, formListCursor: execPos };
1747
+ return state;
1748
+ }
1749
+
1750
+ // 'o' key: toggle optional fields visibility (when not searching)
1751
+ if (key.raw === "o" && !key.ctrl && !formSearchQuery) {
1752
+ const optionalExists = filtered.some((idx) => idx >= 0 && idx < fields.length && !fields[idx]!.required);
1753
+ if (optionalExists) {
1754
+ const newShow = !state.formShowOptional;
1755
+ if (!newShow) {
1756
+ const curIdx = filtered[formListCursor];
1757
+ if (curIdx !== undefined && curIdx >= 0 && curIdx < fields.length && !fields[curIdx]!.required) {
1758
+ const execPos = filtered.indexOf(-1);
1759
+ return { ...state, formShowOptional: false, formListCursor: execPos >= 0 ? execPos : 0 };
1760
+ }
1761
+ }
1762
+ return { ...state, formShowOptional: newShow };
1763
+ }
1165
1764
  }
1166
1765
 
1167
1766
  // Arrow left/right: move text cursor within search input
@@ -1172,29 +1771,44 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1172
1771
  return { ...state, formSearchCursorPos: Math.min(formSearchQuery.length, state.formSearchCursorPos + 1) };
1173
1772
  }
1174
1773
 
1175
- // Arrow up/down: navigate filtered list (cycling)
1774
+ // Helper: check if a position in filtered is navigable (skip collapsed optional fields)
1775
+ const isNavigable = (listPos: number) => {
1776
+ const idx = filtered[listPos];
1777
+ if (idx === undefined) return false;
1778
+ if (idx === -1) return true; // Execute always navigable
1779
+ if (!state.formShowOptional && !state.formSearchQuery && idx >= 0 && idx < fields.length && !fields[idx]!.required) return false;
1780
+ return true;
1781
+ };
1782
+
1783
+ // Arrow up/down: navigate filtered list (cycling, skipping hidden items)
1176
1784
  if (key.name === "up") {
1177
- const next = formListCursor > 0 ? formListCursor - 1 : filtered.length - 1;
1785
+ let next = formListCursor;
1786
+ for (let i = 0; i < filtered.length; i++) {
1787
+ next = next > 0 ? next - 1 : filtered.length - 1;
1788
+ if (isNavigable(next)) break;
1789
+ }
1178
1790
  let scroll = state.formScrollTop;
1179
1791
  const itemIdx = filtered[next]!;
1180
1792
  if (itemIdx !== -1) {
1181
1793
  const paramItems = filtered.filter((idx) => idx !== -1);
1182
1794
  const posInParams = paramItems.indexOf(itemIdx);
1183
1795
  if (posInParams < scroll) scroll = posInParams;
1184
- // Wrap to bottom: reset scroll to show end of list
1185
1796
  if (next > formListCursor) scroll = Math.max(0, paramItems.length - listHeight);
1186
1797
  }
1187
1798
  return { ...state, formListCursor: next, formScrollTop: scroll };
1188
1799
  }
1189
1800
  if (key.name === "down") {
1190
- const next = formListCursor < filtered.length - 1 ? formListCursor + 1 : 0;
1801
+ let next = formListCursor;
1802
+ for (let i = 0; i < filtered.length; i++) {
1803
+ next = next < filtered.length - 1 ? next + 1 : 0;
1804
+ if (isNavigable(next)) break;
1805
+ }
1191
1806
  let scroll = state.formScrollTop;
1192
1807
  const itemIdx = filtered[next]!;
1193
1808
  if (itemIdx !== -1) {
1194
1809
  const paramItems = filtered.filter((idx) => idx !== -1);
1195
1810
  const posInParams = paramItems.indexOf(itemIdx);
1196
1811
  if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
1197
- // Wrap to top: reset scroll
1198
1812
  if (next < formListCursor) scroll = 0;
1199
1813
  } else if (next < formListCursor) {
1200
1814
  scroll = 0;
@@ -1217,47 +1831,7 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1217
1831
  return { ...state, formShowRequired: true };
1218
1832
  }
1219
1833
  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] || "" };
1834
+ return startEditingField(state, highlightedIdx);
1261
1835
  }
1262
1836
  return state;
1263
1837
  }
@@ -1272,11 +1846,14 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
1272
1846
  return state;
1273
1847
  }
1274
1848
 
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 };
1849
+ // Printable characters or paste: insert into search query
1850
+ if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
1851
+ const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
1852
+ if (text) {
1853
+ const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + text + formSearchQuery.slice(state.formSearchCursorPos);
1854
+ const newFiltered = filterFormFields(fields, newQuery);
1855
+ return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + text.length, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
1856
+ }
1280
1857
  }
1281
1858
 
1282
1859
  return state;
@@ -1291,7 +1868,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1291
1868
 
1292
1869
  const resetPalette = (updatedValues?: Record<string, string>) => {
1293
1870
  const f = filterFormFields(fields, "");
1294
- return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false };
1871
+ return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false, formShowOptional: false };
1295
1872
  };
1296
1873
 
1297
1874
  // Escape: cancel edit (for multi-select and tag editor, escape confirms since items are saved live)
@@ -1304,7 +1881,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1304
1881
  const newValues = { ...formValues, [field.name]: val };
1305
1882
  return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette(newValues) };
1306
1883
  }
1307
- return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", ...resetPalette() };
1884
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", formInputCursorPos: 0, ...resetPalette() };
1308
1885
  }
1309
1886
 
1310
1887
  if (key.ctrl && key.name === "c") return "submit";
@@ -1376,10 +1953,11 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1376
1953
  formFilteredIndices: subFiltered,
1377
1954
  formListCursor: defaultFormCursor(subFields, subFiltered, subValues),
1378
1955
  formScrollTop: 0,
1379
- formShowRequired: false,
1956
+ formShowRequired: false, formShowOptional: false,
1380
1957
  formEnumCursor: 0,
1381
1958
  formEnumSelected: new Set(),
1382
1959
  formInputBuf: "",
1960
+ formInputCursorPos: 0,
1383
1961
  };
1384
1962
  }
1385
1963
  if (key.name === "backspace" && formEnumCursor < items.length) {
@@ -1491,7 +2069,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1491
2069
  const newItems = [...items];
1492
2070
  newItems.splice(formEnumCursor, 1);
1493
2071
  const newValues = { ...formValues, [field.name]: newItems.join(", ") };
1494
- return { ...state, formValues: newValues, formInputBuf: editVal, formEnumCursor: newItems.length };
2072
+ return { ...state, formValues: newValues, formInputBuf: editVal, formInputCursorPos: editVal.length, formEnumCursor: newItems.length };
1495
2073
  }
1496
2074
  if (key.name === "backspace") {
1497
2075
  // Delete item
@@ -1505,11 +2083,16 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1505
2083
  }
1506
2084
 
1507
2085
  // Cursor on text input
2086
+ if (key.name === "paste") {
2087
+ // Paste: strip newlines for tag input
2088
+ const text = key.raw.replace(/\n/g, "");
2089
+ return { ...state, formInputBuf: formInputBuf + text, formInputCursorPos: formInputBuf.length + text.length };
2090
+ }
1508
2091
  if (key.name === "return") {
1509
2092
  if (formInputBuf.trim()) {
1510
2093
  items.push(formInputBuf.trim());
1511
2094
  const newValues = { ...formValues, [field.name]: items.join(", ") };
1512
- return { ...state, formValues: newValues, formInputBuf: "", formEnumCursor: items.length };
2095
+ return { ...state, formValues: newValues, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: items.length };
1513
2096
  }
1514
2097
  // Empty input: confirm and close
1515
2098
  const newValues = { ...formValues, [field.name]: items.join(", ") };
@@ -1517,31 +2100,100 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
1517
2100
  }
1518
2101
  if (key.name === "backspace") {
1519
2102
  if (formInputBuf) {
1520
- return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
2103
+ return { ...state, formInputBuf: formInputBuf.slice(0, -1), formInputCursorPos: formInputBuf.length - 1 };
1521
2104
  }
1522
2105
  return state;
1523
2106
  }
1524
2107
  if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1525
- return { ...state, formInputBuf: formInputBuf + key.raw };
2108
+ const clean = key.raw.replace(/[\x00-\x1f\x7f]/g, ""); // strip control chars for tags
2109
+ if (clean) return { ...state, formInputBuf: formInputBuf + clean, formInputCursorPos: formInputBuf.length + clean.length };
1526
2110
  }
1527
2111
  return state;
1528
2112
  }
1529
2113
 
1530
2114
  // Text editing mode
1531
- if (key.name === "return" && key.shift) {
1532
- // Shift+Enter: insert newline
1533
- return { ...state, formInputBuf: formInputBuf + "\n" };
2115
+ const pos = state.formInputCursorPos;
2116
+ if (key.name === "paste") {
2117
+ const newBuf = formInputBuf.slice(0, pos) + key.raw + formInputBuf.slice(pos);
2118
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + key.raw.length };
2119
+ }
2120
+ // Insert newline: Ctrl+J (\n), Shift+Enter, or Alt+Enter
2121
+ if (key.raw === "\n" || (key.name === "return" && key.shift)) {
2122
+ const newBuf = formInputBuf.slice(0, pos) + "\n" + formInputBuf.slice(pos);
2123
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + 1 };
1534
2124
  }
1535
2125
  if (key.name === "return") {
1536
- // Enter: confirm
2126
+ // Enter (\r): confirm value
1537
2127
  const newValues = { ...formValues, [field.name]: formInputBuf };
1538
- return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
2128
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputCursorPos: 0, formValues: newValues, ...resetPalette(newValues) };
2129
+ }
2130
+ if (key.name === "left") {
2131
+ return { ...state, formInputCursorPos: Math.max(0, pos - 1) };
2132
+ }
2133
+ if (key.name === "right") {
2134
+ return { ...state, formInputCursorPos: Math.min(formInputBuf.length, pos + 1) };
2135
+ }
2136
+ if (key.name === "wordLeft") {
2137
+ return { ...state, formInputCursorPos: prevWordBoundary(formInputBuf, pos) };
2138
+ }
2139
+ if (key.name === "wordRight") {
2140
+ return { ...state, formInputCursorPos: nextWordBoundary(formInputBuf, pos) };
2141
+ }
2142
+ if (key.name === "wordBackspace") {
2143
+ const boundary = prevWordBoundary(formInputBuf, pos);
2144
+ const newBuf = formInputBuf.slice(0, boundary) + formInputBuf.slice(pos);
2145
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: boundary };
2146
+ }
2147
+ if (key.name === "up") {
2148
+ // Move cursor up one line
2149
+ const lines = formInputBuf.split("\n");
2150
+ let rem = pos;
2151
+ let line = 0;
2152
+ for (; line < lines.length; line++) {
2153
+ if (rem <= lines[line]!.length) break;
2154
+ rem -= lines[line]!.length + 1;
2155
+ }
2156
+ if (line > 0) {
2157
+ const col = Math.min(rem, lines[line - 1]!.length);
2158
+ let newPos = 0;
2159
+ for (let i = 0; i < line - 1; i++) newPos += lines[i]!.length + 1;
2160
+ newPos += col;
2161
+ return { ...state, formInputCursorPos: newPos };
2162
+ }
2163
+ return state;
2164
+ }
2165
+ if (key.name === "down") {
2166
+ // Move cursor down one line
2167
+ const lines = formInputBuf.split("\n");
2168
+ let rem = pos;
2169
+ let line = 0;
2170
+ for (; line < lines.length; line++) {
2171
+ if (rem <= lines[line]!.length) break;
2172
+ rem -= lines[line]!.length + 1;
2173
+ }
2174
+ if (line < lines.length - 1) {
2175
+ const col = Math.min(rem, lines[line + 1]!.length);
2176
+ let newPos = 0;
2177
+ for (let i = 0; i <= line; i++) newPos += lines[i]!.length + 1;
2178
+ newPos += col;
2179
+ return { ...state, formInputCursorPos: newPos };
2180
+ }
2181
+ return state;
1539
2182
  }
1540
2183
  if (key.name === "backspace") {
1541
- return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
2184
+ if (pos > 0) {
2185
+ const newBuf = formInputBuf.slice(0, pos - 1) + formInputBuf.slice(pos);
2186
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos - 1 };
2187
+ }
2188
+ return state;
1542
2189
  }
1543
2190
  if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1544
- return { ...state, formInputBuf: formInputBuf + key.raw };
2191
+ // Preserve newlines in pasted text (multi-line supported), strip other control chars
2192
+ const clean = key.raw.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, "");
2193
+ if (clean) {
2194
+ const newBuf = formInputBuf.slice(0, pos) + clean + formInputBuf.slice(pos);
2195
+ return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + clean.length };
2196
+ }
1545
2197
  }
1546
2198
  return state;
1547
2199
  }
@@ -1551,7 +2203,10 @@ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
1551
2203
  const contentLines = (state.error || state.result).split("\n");
1552
2204
  const visibleCount = Math.max(1, contentHeight - 3);
1553
2205
 
1554
- if (key.ctrl && key.name === "c") return "exit";
2206
+ if (key.ctrl && key.name === "c") {
2207
+ if (state.quitConfirm) return "exit";
2208
+ return { ...state, quitConfirm: true };
2209
+ }
1555
2210
 
1556
2211
  if (key.name === "q" && !key.ctrl) {
1557
2212
  if (state.quitConfirm) return "exit";
@@ -1561,59 +2216,28 @@ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
1561
2216
  // Any other key cancels quit confirm
1562
2217
  const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
1563
2218
 
2219
+ const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0 };
2220
+
1564
2221
  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;
2222
+ const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
2223
+ const hasParams = !isEmpty && s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1569
2224
  if (hasParams) {
2225
+ const f = filterFormFields(s.fields, "");
1570
2226
  return {
1571
- ...s, view: "form" as View, result: "", error: "", resultScroll: 0, resultScrollX: 0,
2227
+ ...s, view: "form" as View, ...resultsClear,
1572
2228
  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,
2229
+ formFilteredIndices: f,
2230
+ formListCursor: defaultFormCursor(s.fields, f, s.formValues), formScrollTop: 0,
2231
+ formEditing: false, formEditFieldIdx: -1, formShowRequired: false, formShowOptional: false,
1576
2232
  };
1577
2233
  }
1578
- return { ...s, view: "commands" as View, selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
2234
+ return { ...s, ...commandListReset(s.tools), ...resultsClear };
1579
2235
  };
1580
2236
 
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
- }
2237
+ if (key.name === "return" || key.name === "escape") {
1591
2238
  return goBack();
1592
2239
  }
1593
2240
 
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
- };
1613
- }
1614
- return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1615
- }
1616
-
1617
2241
  if (key.name === "up") {
1618
2242
  return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
1619
2243
  }
@@ -1819,10 +2443,11 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
1819
2443
  formEditFieldIdx: -1,
1820
2444
  formEditing: false,
1821
2445
  formInputBuf: "",
2446
+ formInputCursorPos: 0,
1822
2447
  formEnumCursor: 0,
1823
2448
  formEnumSelected: new Set(),
1824
2449
  formValues: {},
1825
- formShowRequired: false,
2450
+ formShowRequired: false, formShowOptional: false,
1826
2451
  formStack: [],
1827
2452
  dateParts: [],
1828
2453
  datePartCursor: 0,