@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/README.md +12 -11
- package/dist/config.d.ts +1 -0
- package/dist/index.js +11 -54
- package/dist/tui/app.js +875 -232
- package/dist/tui/term.js +45 -6
- package/package.json +3 -3
- package/src/config.ts +1 -0
- package/src/index.ts +10 -3
- package/src/tui/app.ts +849 -224
- package/src/tui/term.ts +41 -6
package/dist/tui/app.js
CHANGED
|
@@ -125,6 +125,45 @@ function defaultFormCursor(fields, filtered, values) {
|
|
|
125
125
|
const firstBlank = filtered.findIndex((idx) => idx >= 0 && missing.has(fields[idx].name));
|
|
126
126
|
return firstBlank >= 0 ? firstBlank : executeIndex(filtered);
|
|
127
127
|
}
|
|
128
|
+
function classifyField(prop) {
|
|
129
|
+
if (isArrayOfObjects(prop))
|
|
130
|
+
return "arrayObj";
|
|
131
|
+
if (dateFieldFormat(prop))
|
|
132
|
+
return "date";
|
|
133
|
+
const eVals = prop.enum || prop.items?.enum;
|
|
134
|
+
if (prop.type === "boolean")
|
|
135
|
+
return "bool";
|
|
136
|
+
if (eVals && prop.type === "array")
|
|
137
|
+
return "arrayEnum";
|
|
138
|
+
if (eVals)
|
|
139
|
+
return "enum";
|
|
140
|
+
if (prop.type === "array")
|
|
141
|
+
return "arrayText";
|
|
142
|
+
return "text";
|
|
143
|
+
}
|
|
144
|
+
function fieldTypeBadge(prop) {
|
|
145
|
+
const badges = {
|
|
146
|
+
arrayObj: "form", date: "date", bool: "yes/no", arrayEnum: "multi",
|
|
147
|
+
enum: "select", arrayText: "list", text: "text",
|
|
148
|
+
};
|
|
149
|
+
const badge = badges[classifyField(prop)];
|
|
150
|
+
if (badge !== "text")
|
|
151
|
+
return badge;
|
|
152
|
+
if (prop.type === "integer" || prop.type === "number")
|
|
153
|
+
return "number";
|
|
154
|
+
return "text";
|
|
155
|
+
}
|
|
156
|
+
function footerForFieldKind(kind) {
|
|
157
|
+
switch (kind) {
|
|
158
|
+
case "arrayObj": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc back";
|
|
159
|
+
case "date": return "\u2190\u2192 part \u00B7 \u2191\u2193 adjust \u00B7 t today \u00B7 enter confirm \u00B7 esc cancel";
|
|
160
|
+
case "arrayEnum": return "space toggle \u00B7 enter confirm \u00B7 esc cancel";
|
|
161
|
+
case "arrayText": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc confirm";
|
|
162
|
+
case "enum":
|
|
163
|
+
case "bool": return "\u2191\u2193 navigate \u00B7 enter confirm \u00B7 esc cancel";
|
|
164
|
+
case "text": return "enter confirm \u00B7 esc cancel";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
128
167
|
function formFieldValueDisplay(value, maxWidth) {
|
|
129
168
|
if (!value)
|
|
130
169
|
return style.dim("–");
|
|
@@ -205,8 +244,9 @@ function popFormStack(state) {
|
|
|
205
244
|
formFilteredIndices: parentFiltered,
|
|
206
245
|
formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, newParentValues),
|
|
207
246
|
formScrollTop: 0,
|
|
208
|
-
formShowRequired: false,
|
|
247
|
+
formShowRequired: false, formShowOptional: false,
|
|
209
248
|
formInputBuf: "",
|
|
249
|
+
formInputCursorPos: 0,
|
|
210
250
|
};
|
|
211
251
|
}
|
|
212
252
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -248,6 +288,44 @@ function wrapText(text, width) {
|
|
|
248
288
|
lines.push(current);
|
|
249
289
|
return lines.length > 0 ? lines : [""];
|
|
250
290
|
}
|
|
291
|
+
// --- Word boundary helpers ---
|
|
292
|
+
function prevWordBoundary(buf, pos) {
|
|
293
|
+
if (pos <= 0)
|
|
294
|
+
return 0;
|
|
295
|
+
let i = pos - 1;
|
|
296
|
+
// Skip whitespace
|
|
297
|
+
while (i > 0 && /\s/.test(buf[i]))
|
|
298
|
+
i--;
|
|
299
|
+
// Skip word chars or non-word non-space chars
|
|
300
|
+
if (i >= 0 && /\w/.test(buf[i])) {
|
|
301
|
+
while (i > 0 && /\w/.test(buf[i - 1]))
|
|
302
|
+
i--;
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
while (i > 0 && !/\w/.test(buf[i - 1]) && !/\s/.test(buf[i - 1]))
|
|
306
|
+
i--;
|
|
307
|
+
}
|
|
308
|
+
return i;
|
|
309
|
+
}
|
|
310
|
+
function nextWordBoundary(buf, pos) {
|
|
311
|
+
const len = buf.length;
|
|
312
|
+
if (pos >= len)
|
|
313
|
+
return len;
|
|
314
|
+
let i = pos;
|
|
315
|
+
// Skip current word chars or non-word non-space chars
|
|
316
|
+
if (/\w/.test(buf[i])) {
|
|
317
|
+
while (i < len && /\w/.test(buf[i]))
|
|
318
|
+
i++;
|
|
319
|
+
}
|
|
320
|
+
else if (!/\s/.test(buf[i])) {
|
|
321
|
+
while (i < len && !/\w/.test(buf[i]) && !/\s/.test(buf[i]))
|
|
322
|
+
i++;
|
|
323
|
+
}
|
|
324
|
+
// Skip whitespace
|
|
325
|
+
while (i < len && /\s/.test(buf[i]))
|
|
326
|
+
i++;
|
|
327
|
+
return i;
|
|
328
|
+
}
|
|
251
329
|
function dateFieldFormat(prop) {
|
|
252
330
|
if (prop.format === "date")
|
|
253
331
|
return "date";
|
|
@@ -385,7 +463,7 @@ function renderCommandList(state) {
|
|
|
385
463
|
content.push(` ${logoLine} ${style.boldYellow("Readwise")} ${style.dim("v" + VERSION)}`);
|
|
386
464
|
}
|
|
387
465
|
else if (i === Math.floor(LOGO.length / 2)) {
|
|
388
|
-
content.push(` ${logoLine} ${style.dim("
|
|
466
|
+
content.push(` ${logoLine} ${style.dim("Built for AI agents · This TUI is just for fun/learning")}`);
|
|
389
467
|
}
|
|
390
468
|
else {
|
|
391
469
|
content.push(` ${logoLine}`);
|
|
@@ -445,8 +523,8 @@ function renderCommandList(state) {
|
|
|
445
523
|
}
|
|
446
524
|
}
|
|
447
525
|
const footer = state.quitConfirm
|
|
448
|
-
? style.yellow("Press
|
|
449
|
-
: style.dim("type to search
|
|
526
|
+
? style.yellow("Press again to quit")
|
|
527
|
+
: style.dim("type to search · ↑↓ navigate · enter select · esc/ctrl+c quit");
|
|
450
528
|
return renderLayout({
|
|
451
529
|
breadcrumb: style.boldYellow("Readwise"),
|
|
452
530
|
content,
|
|
@@ -458,15 +536,227 @@ function renderForm(state) {
|
|
|
458
536
|
const tool = state.selectedTool;
|
|
459
537
|
const fields = state.fields;
|
|
460
538
|
const toolTitle = humanLabel(tool.name, toolPrefix(tool));
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
539
|
+
// Sub-forms (nested array-of-objects) keep existing form UI
|
|
540
|
+
if (state.formStack.length > 0) {
|
|
541
|
+
const stackParts = state.formStack.map((e) => e.parentFieldName);
|
|
542
|
+
const title = toolTitle + " › " + stackParts.join(" › ");
|
|
543
|
+
if (state.formEditing && state.formEditFieldIdx >= 0) {
|
|
544
|
+
return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
|
|
545
|
+
}
|
|
546
|
+
return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
|
|
547
|
+
}
|
|
548
|
+
// Top-level: command builder
|
|
549
|
+
return renderCommandBuilder(state, toolTitle, fields, contentHeight, innerWidth);
|
|
550
|
+
}
|
|
551
|
+
function renderCommandBuilder(state, title, fields, contentHeight, innerWidth) {
|
|
552
|
+
const tool = state.selectedTool;
|
|
553
|
+
const cmdName = tool.name.replace(/_/g, "-");
|
|
554
|
+
const content = [];
|
|
555
|
+
const editField = state.formEditing && state.formEditFieldIdx >= 0
|
|
556
|
+
? fields[state.formEditFieldIdx]
|
|
557
|
+
: null;
|
|
558
|
+
// Header
|
|
559
|
+
content.push("");
|
|
560
|
+
content.push(" " + style.bold(title));
|
|
561
|
+
if (tool.description) {
|
|
562
|
+
const wrapped = wrapText(tool.description, innerWidth - 4);
|
|
563
|
+
for (const line of wrapped) {
|
|
564
|
+
content.push(" " + style.dim(line));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
content.push("");
|
|
568
|
+
// Classify editing field type once for both content and footer
|
|
569
|
+
const editKind = editField ? classifyField(editField.prop) : null;
|
|
570
|
+
const isTextLikeEdit = editKind === "text";
|
|
571
|
+
// Build command lines
|
|
572
|
+
const argLines = [];
|
|
573
|
+
for (const field of fields) {
|
|
574
|
+
const flagName = field.name.replace(/_/g, "-");
|
|
575
|
+
if (field === editField) {
|
|
576
|
+
if (isTextLikeEdit) {
|
|
577
|
+
// Inline cursor for text fields
|
|
578
|
+
const buf = state.formInputBuf;
|
|
579
|
+
const before = buf.slice(0, state.formInputCursorPos);
|
|
580
|
+
const cursorChar = state.formInputCursorPos < buf.length ? buf[state.formInputCursorPos] : " ";
|
|
581
|
+
const after = state.formInputCursorPos < buf.length ? buf.slice(state.formInputCursorPos + 1) : "";
|
|
582
|
+
argLines.push(" --" + flagName + "=" + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
|
|
583
|
+
}
|
|
584
|
+
else if (isArrayOfObjects(field.prop)) {
|
|
585
|
+
// Array-of-objects: show item count
|
|
586
|
+
const existing = state.formValues[field.name] || "[]";
|
|
587
|
+
let items = [];
|
|
588
|
+
try {
|
|
589
|
+
items = JSON.parse(existing);
|
|
590
|
+
}
|
|
591
|
+
catch { /* */ }
|
|
592
|
+
const label = items.length > 0 ? `[${items.length} item${items.length > 1 ? "s" : ""}]` : "[...]";
|
|
593
|
+
argLines.push(" --" + flagName + "=" + style.yellow(label));
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
// Non-text editors (enum, bool, date): show pending
|
|
597
|
+
argLines.push(" --" + flagName + "=" + style.inverse(" "));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
const val = state.formValues[field.name];
|
|
602
|
+
if (val) {
|
|
603
|
+
const needsQuotes = val.includes(" ") || val.includes(",");
|
|
604
|
+
const displayVal = needsQuotes ? '"' + val + '"' : val;
|
|
605
|
+
argLines.push(" --" + flagName + "=" + style.cyan(displayVal));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
// Render command with line continuations
|
|
610
|
+
const cmdPrefix = " " + style.dim("$") + " " + style.dim("readwise") + " " + cmdName;
|
|
611
|
+
if (argLines.length === 0) {
|
|
612
|
+
content.push(cmdPrefix);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
content.push(cmdPrefix + " \\");
|
|
616
|
+
for (let i = 0; i < argLines.length; i++) {
|
|
617
|
+
const isLast = i === argLines.length - 1;
|
|
618
|
+
content.push(argLines[i] + (isLast ? "" : " \\"));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
content.push("");
|
|
622
|
+
// Context area: field description, editor, or ready state
|
|
623
|
+
if (editField) {
|
|
624
|
+
// Field description
|
|
625
|
+
if (editField.prop.description) {
|
|
626
|
+
content.push(" " + style.dim(editField.prop.description));
|
|
627
|
+
}
|
|
628
|
+
if (editField.prop.examples?.length) {
|
|
629
|
+
const exStr = editField.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
|
|
630
|
+
content.push(" " + style.dim("e.g. ") + style.cyan(truncateVisible(exStr, innerWidth - 10)));
|
|
631
|
+
}
|
|
632
|
+
if (editField.prop.default != null) {
|
|
633
|
+
content.push(" " + style.dim("default: " + editField.prop.default));
|
|
634
|
+
}
|
|
635
|
+
const eVals = editField.prop.enum || editField.prop.items?.enum;
|
|
636
|
+
if (editKind === "bool") {
|
|
637
|
+
content.push("");
|
|
638
|
+
const choices = ["true", "false"];
|
|
639
|
+
for (let ci = 0; ci < choices.length; ci++) {
|
|
640
|
+
const sel = ci === state.formEnumCursor;
|
|
641
|
+
content.push(sel ? " " + style.cyan(style.bold("\u203A " + choices[ci])) : " " + choices[ci]);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
else if (editKind === "enum" && eVals) {
|
|
645
|
+
content.push("");
|
|
646
|
+
for (let ci = 0; ci < eVals.length; ci++) {
|
|
647
|
+
const sel = ci === state.formEnumCursor;
|
|
648
|
+
content.push(sel ? " " + style.cyan(style.bold("\u203A " + eVals[ci])) : " " + eVals[ci]);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
else if (editKind === "arrayEnum" && eVals) {
|
|
652
|
+
content.push("");
|
|
653
|
+
for (let ci = 0; ci < eVals.length; ci++) {
|
|
654
|
+
const sel = ci === state.formEnumCursor;
|
|
655
|
+
const checked = state.formEnumSelected.has(ci);
|
|
656
|
+
const check = checked ? style.cyan("[x]") : style.dim("[ ]");
|
|
657
|
+
content.push((sel ? " \u203A " : " ") + check + " " + eVals[ci]);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
else if (editKind === "date") {
|
|
661
|
+
const dateFmt = dateFieldFormat(editField.prop);
|
|
662
|
+
content.push("");
|
|
663
|
+
content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
|
|
664
|
+
}
|
|
665
|
+
else if (editKind === "arrayText") {
|
|
666
|
+
const existing = state.formValues[editField.name] || "";
|
|
667
|
+
const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
668
|
+
content.push("");
|
|
669
|
+
for (let i = 0; i < items.length; i++) {
|
|
670
|
+
const isCursor = i === state.formEnumCursor;
|
|
671
|
+
content.push((isCursor ? " \u276F " : " ") + style.cyan(items[i]));
|
|
672
|
+
}
|
|
673
|
+
const onInput = state.formEnumCursor === items.length;
|
|
674
|
+
content.push((onInput ? " \u276F " : " ") + style.cyan(state.formInputBuf) + (onInput ? style.inverse(" ") : ""));
|
|
675
|
+
}
|
|
676
|
+
else if (editKind === "arrayObj") {
|
|
677
|
+
const existing = state.formValues[editField.name] || "[]";
|
|
678
|
+
let items = [];
|
|
679
|
+
try {
|
|
680
|
+
items = JSON.parse(existing);
|
|
681
|
+
}
|
|
682
|
+
catch { /* */ }
|
|
683
|
+
content.push("");
|
|
684
|
+
for (let i = 0; i < items.length; i++) {
|
|
685
|
+
const item = items[i];
|
|
686
|
+
const summary = Object.entries(item)
|
|
687
|
+
.filter(([, v]) => v != null && v !== "")
|
|
688
|
+
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
689
|
+
.join(", ");
|
|
690
|
+
const isCursor = i === state.formEnumCursor;
|
|
691
|
+
content.push((isCursor ? " \u276F " : " ") + truncateVisible(summary || "(empty)", innerWidth - 6));
|
|
692
|
+
}
|
|
693
|
+
if (items.length > 0)
|
|
694
|
+
content.push("");
|
|
695
|
+
const addCursor = state.formEnumCursor === items.length;
|
|
696
|
+
content.push(addCursor
|
|
697
|
+
? " " + style.inverse(style.green(" + Add new item "))
|
|
698
|
+
: " " + style.dim("+") + " Add new item");
|
|
699
|
+
}
|
|
700
|
+
else if (isTextLikeEdit) {
|
|
701
|
+
// Text field: cursor is already inline in the command string
|
|
702
|
+
// Just show a hint if empty
|
|
703
|
+
if (!state.formInputBuf) {
|
|
704
|
+
content.push("");
|
|
705
|
+
content.push(" " + style.dim("Type a value and press enter"));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
// Ready state / optional picker
|
|
711
|
+
const missing = missingRequiredFields(fields, state.formValues);
|
|
712
|
+
if (missing.length > 0) {
|
|
713
|
+
content.push(" " + style.red("Missing: " + missing.map((f) => f.name).join(", ")));
|
|
714
|
+
}
|
|
715
|
+
else {
|
|
716
|
+
content.push(" " + style.dim("Press enter to run"));
|
|
717
|
+
}
|
|
718
|
+
// Always show optional params
|
|
719
|
+
const optionalFields = fields
|
|
720
|
+
.map((f, i) => ({ field: f, idx: i }))
|
|
721
|
+
.filter(({ field }) => !field.required);
|
|
722
|
+
if (optionalFields.length > 0) {
|
|
723
|
+
content.push("");
|
|
724
|
+
content.push(" " + style.dim("Optional parameters (tab to add)"));
|
|
725
|
+
content.push("");
|
|
726
|
+
const maxFlagWidth = Math.max(...optionalFields.map(({ field }) => field.name.length), 0) + 2;
|
|
727
|
+
for (let i = 0; i < optionalFields.length; i++) {
|
|
728
|
+
const { field } = optionalFields[i];
|
|
729
|
+
const flagName = field.name.replace(/_/g, "-");
|
|
730
|
+
const hasValue = !!state.formValues[field.name]?.trim();
|
|
731
|
+
const sel = state.formShowOptional && i === state.formListCursor;
|
|
732
|
+
const prefix = sel ? " \u276F " : " ";
|
|
733
|
+
const paddedName = flagName.padEnd(maxFlagWidth);
|
|
734
|
+
const desc = field.prop.description
|
|
735
|
+
? style.dim(truncateVisible(field.prop.description, innerWidth - maxFlagWidth - 8))
|
|
736
|
+
: "";
|
|
737
|
+
if (sel) {
|
|
738
|
+
content.push(style.boldYellow(prefix + paddedName) + " " + desc);
|
|
739
|
+
}
|
|
740
|
+
else if (hasValue) {
|
|
741
|
+
content.push(prefix + style.green(paddedName) + " " + desc);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
content.push(prefix + style.dim(paddedName) + " " + desc);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Footer
|
|
750
|
+
const footer = editKind
|
|
751
|
+
? style.dim(footerForFieldKind(editKind))
|
|
752
|
+
: state.formShowOptional
|
|
753
|
+
? style.dim("\u2191\u2193 select \u00B7 enter add \u00B7 esc done")
|
|
754
|
+
: style.dim("enter run \u00B7 tab add option \u00B7 esc back");
|
|
755
|
+
return renderLayout({
|
|
756
|
+
breadcrumb: style.boldYellow("Readwise") + style.dim(" \u203A ") + style.bold(title),
|
|
757
|
+
content,
|
|
758
|
+
footer,
|
|
759
|
+
});
|
|
470
760
|
}
|
|
471
761
|
function renderFormPaletteMode(state, title, fields, contentHeight, innerWidth) {
|
|
472
762
|
const content = [];
|
|
@@ -485,51 +775,117 @@ function renderFormPaletteMode(state, title, fields, contentHeight, innerWidth)
|
|
|
485
775
|
content.push(" " + style.dim(line));
|
|
486
776
|
}
|
|
487
777
|
}
|
|
778
|
+
// Progress indicator for required fields
|
|
779
|
+
const requiredFields = fields.filter((f) => f.required);
|
|
780
|
+
if (requiredFields.length > 0) {
|
|
781
|
+
const filledRequired = requiredFields.filter((f) => {
|
|
782
|
+
const val = state.formValues[f.name]?.trim();
|
|
783
|
+
if (!val)
|
|
784
|
+
return false;
|
|
785
|
+
if (isArrayOfObjects(f.prop)) {
|
|
786
|
+
try {
|
|
787
|
+
return JSON.parse(val).length > 0;
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return true;
|
|
794
|
+
});
|
|
795
|
+
const allFilled = filledRequired.length === requiredFields.length;
|
|
796
|
+
const progressText = `${filledRequired.length} of ${requiredFields.length} required`;
|
|
797
|
+
content.push(" " + (allFilled ? style.green("✓ " + progressText) : style.dim(progressText)));
|
|
798
|
+
}
|
|
488
799
|
content.push("");
|
|
489
|
-
// Search input
|
|
800
|
+
// Search input (only show when there's a search query or many fields)
|
|
490
801
|
const queryText = state.formSearchQuery;
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
802
|
+
if (queryText || fields.length > 6) {
|
|
803
|
+
const before = queryText.slice(0, state.formSearchCursorPos);
|
|
804
|
+
const cursorChar = state.formSearchCursorPos < queryText.length
|
|
805
|
+
? queryText[state.formSearchCursorPos]
|
|
806
|
+
: " ";
|
|
807
|
+
const after = state.formSearchCursorPos < queryText.length
|
|
808
|
+
? queryText.slice(state.formSearchCursorPos + 1)
|
|
809
|
+
: "";
|
|
810
|
+
content.push(" " + style.dim("/") + " " + before + style.inverse(cursorChar) + after);
|
|
811
|
+
content.push("");
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
content.push("");
|
|
815
|
+
}
|
|
816
|
+
// Compute maxLabelWidth (include " *" for required fields)
|
|
501
817
|
const maxLabelWidth = Math.max(...fields.map((f) => f.name.length + (f.required ? 2 : 0)), 6) + 1;
|
|
502
|
-
//
|
|
503
|
-
const
|
|
818
|
+
// Badge width: " text" = ~7 chars max
|
|
819
|
+
const badgeWidth = 8;
|
|
820
|
+
// Value display width budget: innerWidth - cursor(3) - label - gap(2) - badge
|
|
821
|
+
const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2 - badgeWidth);
|
|
504
822
|
const headerUsed = content.length;
|
|
505
823
|
// Reserve space for: blank + Execute + blank + description (up to 4 lines)
|
|
506
824
|
const listHeight = Math.max(1, contentHeight - headerUsed - 8);
|
|
507
825
|
const filtered = state.formFilteredIndices;
|
|
508
826
|
const hasOnlyExecute = filtered.length === 1 && filtered[0] === -1;
|
|
827
|
+
// Split fields into required and optional
|
|
828
|
+
const requiredIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && fields[idx].required);
|
|
829
|
+
const optionalIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && !fields[idx].required);
|
|
830
|
+
const hasOptional = optionalIdxs.length > 0;
|
|
831
|
+
const showingOptional = state.formShowOptional || state.formSearchQuery;
|
|
832
|
+
// Count optional fields that have been filled
|
|
833
|
+
const filledOptionalCount = optionalIdxs.filter((idx) => !!state.formValues[fields[idx].name]?.trim()).length;
|
|
834
|
+
const renderField = (fieldIdx) => {
|
|
835
|
+
const field = fields[fieldIdx];
|
|
836
|
+
const val = state.formValues[field.name] || "";
|
|
837
|
+
const isFilled = !!val.trim();
|
|
838
|
+
const listPos = filtered.indexOf(fieldIdx);
|
|
839
|
+
const selected = listPos === state.formListCursor;
|
|
840
|
+
// Value display
|
|
841
|
+
const valStr = formFieldValueDisplay(val, valueAvail);
|
|
842
|
+
// Type badge
|
|
843
|
+
const badge = style.dim(fieldTypeBadge(field.prop));
|
|
844
|
+
const cursor = selected ? " ❯ " : " ";
|
|
845
|
+
if (selected) {
|
|
846
|
+
const label = field.name + (field.required ? " *" : "");
|
|
847
|
+
content.push(style.boldYellow(cursor) + style.boldYellow(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
|
|
848
|
+
}
|
|
849
|
+
else if (isFilled) {
|
|
850
|
+
const label = field.name + (field.required ? " *" : "");
|
|
851
|
+
content.push(cursor + style.green(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
// Unfilled: show required * in red
|
|
855
|
+
const namePart = field.name;
|
|
856
|
+
const starPart = field.required ? " *" : "";
|
|
857
|
+
const plainLabel = namePart + starPart;
|
|
858
|
+
const padAmount = Math.max(0, maxLabelWidth - plainLabel.length);
|
|
859
|
+
const displayLabel = field.required ? namePart + style.red(" *") + " ".repeat(padAmount) : plainLabel.padEnd(maxLabelWidth);
|
|
860
|
+
content.push(cursor + displayLabel + " " + style.dim("–") + " " + badge);
|
|
861
|
+
}
|
|
862
|
+
};
|
|
509
863
|
if (hasOnlyExecute && state.formSearchQuery) {
|
|
510
864
|
content.push(" " + style.dim("No matching parameters"));
|
|
511
865
|
content.push("");
|
|
512
866
|
}
|
|
513
867
|
else {
|
|
514
|
-
//
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (selected) {
|
|
529
|
-
content.push(style.boldYellow(prefix + paddedName) + " " + valStr);
|
|
868
|
+
// Required fields (always visible)
|
|
869
|
+
for (const fieldIdx of requiredIdxs) {
|
|
870
|
+
renderField(fieldIdx);
|
|
871
|
+
}
|
|
872
|
+
// Optional fields separator
|
|
873
|
+
if (hasOptional) {
|
|
874
|
+
if (showingOptional) {
|
|
875
|
+
if (requiredIdxs.length > 0)
|
|
876
|
+
content.push("");
|
|
877
|
+
content.push(" " + style.dim("── optional ──"));
|
|
878
|
+
const visibleOptional = optionalIdxs.slice(0, listHeight - requiredIdxs.length - 2);
|
|
879
|
+
for (const fieldIdx of visibleOptional) {
|
|
880
|
+
renderField(fieldIdx);
|
|
881
|
+
}
|
|
530
882
|
}
|
|
531
883
|
else {
|
|
532
|
-
content.push(
|
|
884
|
+
content.push("");
|
|
885
|
+
const optLabel = filledOptionalCount > 0
|
|
886
|
+
? `── ${optionalIdxs.length} optional (${filledOptionalCount} set) · 'o' to show ──`
|
|
887
|
+
: `── ${optionalIdxs.length} optional · 'o' to show ──`;
|
|
888
|
+
content.push(" " + style.dim(optLabel));
|
|
533
889
|
}
|
|
534
890
|
}
|
|
535
891
|
}
|
|
@@ -556,22 +912,34 @@ function renderFormPaletteMode(state, title, fields, contentHeight, innerWidth)
|
|
|
556
912
|
content.push(" " + style.red("Required: " + names));
|
|
557
913
|
}
|
|
558
914
|
}
|
|
559
|
-
// Description of highlighted field
|
|
915
|
+
// Description of highlighted field or Execute hint
|
|
560
916
|
const highlightedIdx = filtered[state.formListCursor];
|
|
561
917
|
if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
|
|
562
|
-
const
|
|
563
|
-
if (
|
|
918
|
+
const prop = fields[highlightedIdx].prop;
|
|
919
|
+
if (prop.description) {
|
|
564
920
|
content.push("");
|
|
565
|
-
const wrapped = wrapText(
|
|
921
|
+
const wrapped = wrapText(prop.description, innerWidth - 4);
|
|
566
922
|
for (const line of wrapped) {
|
|
567
923
|
content.push(" " + style.dim(line));
|
|
568
924
|
}
|
|
569
925
|
}
|
|
926
|
+
if (prop.examples?.length) {
|
|
927
|
+
const exStr = prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
|
|
928
|
+
content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
|
|
929
|
+
}
|
|
570
930
|
}
|
|
931
|
+
else if (highlightedIdx === -1) {
|
|
932
|
+
content.push("");
|
|
933
|
+
content.push(" " + style.dim("Press enter to run"));
|
|
934
|
+
}
|
|
935
|
+
// Footer hints
|
|
936
|
+
const hasUnfilledRequired = requiredFields.some((f) => !state.formValues[f.name]?.trim());
|
|
937
|
+
const tabHint = hasUnfilledRequired ? " · tab next required" : "";
|
|
938
|
+
const optionalHint = hasOptional ? " · o optional" : "";
|
|
571
939
|
return renderLayout({
|
|
572
940
|
breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
|
|
573
941
|
content,
|
|
574
|
-
footer: style.dim("
|
|
942
|
+
footer: style.dim("↑↓ navigate · enter edit" + tabHint + optionalHint + " · esc back"),
|
|
575
943
|
});
|
|
576
944
|
}
|
|
577
945
|
function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
@@ -579,10 +947,22 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
|
579
947
|
const content = [];
|
|
580
948
|
content.push("");
|
|
581
949
|
content.push(" " + style.bold(title));
|
|
950
|
+
// Show tool description for context
|
|
951
|
+
const toolDesc = state.formStack.length > 0
|
|
952
|
+
? state.formStack[state.formStack.length - 1].parentFields
|
|
953
|
+
.find((f) => f.name === state.formStack[state.formStack.length - 1].parentFieldName)
|
|
954
|
+
?.prop.items?.description
|
|
955
|
+
: state.selectedTool.description;
|
|
956
|
+
if (toolDesc) {
|
|
957
|
+
const wrapped = wrapText(toolDesc, innerWidth - 4);
|
|
958
|
+
for (const line of wrapped) {
|
|
959
|
+
content.push(" " + style.dim(line));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
582
962
|
content.push("");
|
|
583
|
-
// Field
|
|
963
|
+
// Field label
|
|
584
964
|
const nameLabel = field.name + (field.required ? " *" : "");
|
|
585
|
-
content.push("
|
|
965
|
+
content.push(" " + style.bold(nameLabel));
|
|
586
966
|
// Field description
|
|
587
967
|
if (field.prop.description) {
|
|
588
968
|
const wrapped = wrapText(field.prop.description, innerWidth - 4);
|
|
@@ -590,15 +970,15 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
|
590
970
|
content.push(" " + style.dim(line));
|
|
591
971
|
}
|
|
592
972
|
}
|
|
973
|
+
if (field.prop.examples?.length) {
|
|
974
|
+
const exStr = field.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
|
|
975
|
+
content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
|
|
976
|
+
}
|
|
593
977
|
content.push("");
|
|
594
978
|
// Editor area
|
|
979
|
+
const kind = classifyField(field.prop);
|
|
595
980
|
const eVals = field.prop.enum || field.prop.items?.enum;
|
|
596
|
-
|
|
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) {
|
|
981
|
+
if (kind === "arrayObj") {
|
|
602
982
|
// Array-of-objects editor: show existing items + "Add new item"
|
|
603
983
|
const existing = state.formValues[field.name] || "[]";
|
|
604
984
|
let items = [];
|
|
@@ -628,10 +1008,11 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
|
628
1008
|
content.push(" " + style.dim("+") + " Add new item");
|
|
629
1009
|
}
|
|
630
1010
|
}
|
|
631
|
-
else if (
|
|
1011
|
+
else if (kind === "date") {
|
|
1012
|
+
const dateFmt = dateFieldFormat(field.prop);
|
|
632
1013
|
content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
|
|
633
1014
|
}
|
|
634
|
-
else if (
|
|
1015
|
+
else if (kind === "arrayEnum" && eVals) {
|
|
635
1016
|
// Multi-select picker
|
|
636
1017
|
for (let ci = 0; ci < eVals.length; ci++) {
|
|
637
1018
|
const isCursor = ci === state.formEnumCursor;
|
|
@@ -642,7 +1023,7 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
|
642
1023
|
content.push(isCursor ? style.bold(label) : label);
|
|
643
1024
|
}
|
|
644
1025
|
}
|
|
645
|
-
else if (
|
|
1026
|
+
else if (kind === "arrayText") {
|
|
646
1027
|
// Tag-style list editor: navigable items + text input at bottom
|
|
647
1028
|
const existing = state.formValues[field.name] || "";
|
|
648
1029
|
const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
@@ -668,8 +1049,8 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
|
668
1049
|
content.push(" " + style.dim("bksp ") + style.dim("remove item"));
|
|
669
1050
|
}
|
|
670
1051
|
}
|
|
671
|
-
else if (
|
|
672
|
-
const choices =
|
|
1052
|
+
else if (kind === "enum" || kind === "bool") {
|
|
1053
|
+
const choices = kind === "bool" ? ["true", "false"] : eVals;
|
|
673
1054
|
for (let ci = 0; ci < choices.length; ci++) {
|
|
674
1055
|
const sel = ci === state.formEnumCursor;
|
|
675
1056
|
const choiceLine = (sel ? " › " : " ") + choices[ci];
|
|
@@ -677,41 +1058,73 @@ function renderFormEditMode(state, title, fields, _contentHeight, innerWidth) {
|
|
|
677
1058
|
}
|
|
678
1059
|
}
|
|
679
1060
|
else {
|
|
680
|
-
// Text editor
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1061
|
+
// Text editor with cursor position
|
|
1062
|
+
const prefix0 = " ";
|
|
1063
|
+
if (!state.formInputBuf) {
|
|
1064
|
+
// Show placeholder text when input is empty
|
|
1065
|
+
let placeholder = "type a value";
|
|
1066
|
+
if (field.prop.examples?.length) {
|
|
1067
|
+
placeholder = String(field.prop.examples[0]);
|
|
686
1068
|
}
|
|
687
|
-
else {
|
|
688
|
-
|
|
1069
|
+
else if (field.prop.description) {
|
|
1070
|
+
placeholder = field.prop.description.toLowerCase().replace(/[.!]$/, "");
|
|
1071
|
+
}
|
|
1072
|
+
else if (field.prop.type === "integer" || field.prop.type === "number") {
|
|
1073
|
+
placeholder = "enter a number";
|
|
1074
|
+
}
|
|
1075
|
+
content.push(prefix0 + style.inverse(" ") + style.dim(" " + placeholder + "…"));
|
|
1076
|
+
}
|
|
1077
|
+
else {
|
|
1078
|
+
const lines = state.formInputBuf.split("\n");
|
|
1079
|
+
// Find cursor line and column from flat position
|
|
1080
|
+
let cursorLine = 0;
|
|
1081
|
+
let cursorCol = state.formInputCursorPos;
|
|
1082
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1083
|
+
if (cursorCol <= lines[li].length) {
|
|
1084
|
+
cursorLine = li;
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
cursorCol -= lines[li].length + 1;
|
|
1088
|
+
}
|
|
1089
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1090
|
+
const prefix = li === 0 ? prefix0 : " ";
|
|
1091
|
+
const lineText = lines[li];
|
|
1092
|
+
if (li === cursorLine) {
|
|
1093
|
+
const before = lineText.slice(0, cursorCol);
|
|
1094
|
+
const cursorChar = cursorCol < lineText.length ? lineText[cursorCol] : " ";
|
|
1095
|
+
const after = cursorCol < lineText.length ? lineText.slice(cursorCol + 1) : "";
|
|
1096
|
+
content.push(prefix + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
|
|
1097
|
+
}
|
|
1098
|
+
else {
|
|
1099
|
+
content.push(prefix + style.cyan(lineText));
|
|
1100
|
+
}
|
|
689
1101
|
}
|
|
690
1102
|
}
|
|
691
1103
|
}
|
|
692
|
-
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1104
|
+
// Show remaining required fields hint (for text editors)
|
|
1105
|
+
if (kind === "text") {
|
|
1106
|
+
const requiredFields = fields.filter((f) => f.required);
|
|
1107
|
+
const filledCount = requiredFields.filter((f) => {
|
|
1108
|
+
if (f.name === field.name)
|
|
1109
|
+
return !!state.formInputBuf.trim(); // current field
|
|
1110
|
+
return !!state.formValues[f.name]?.trim();
|
|
1111
|
+
}).length;
|
|
1112
|
+
const remaining = requiredFields.length - filledCount;
|
|
1113
|
+
content.push("");
|
|
1114
|
+
if (remaining <= 0) {
|
|
1115
|
+
content.push(" " + style.dim("Then press enter to confirm → Execute"));
|
|
1116
|
+
}
|
|
1117
|
+
else if (remaining === 1 && !state.formInputBuf.trim()) {
|
|
1118
|
+
content.push(" " + style.dim("Type a value, then press enter"));
|
|
1119
|
+
}
|
|
1120
|
+
else {
|
|
1121
|
+
content.push(" " + style.dim(`${remaining} required field${remaining > 1 ? "s" : ""} remaining`));
|
|
1122
|
+
}
|
|
710
1123
|
}
|
|
711
1124
|
return renderLayout({
|
|
712
1125
|
breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
|
|
713
1126
|
content,
|
|
714
|
-
footer,
|
|
1127
|
+
footer: style.dim(footerForFieldKind(kind)),
|
|
715
1128
|
});
|
|
716
1129
|
}
|
|
717
1130
|
function renderLoading(state) {
|
|
@@ -777,7 +1190,7 @@ function renderResults(state) {
|
|
|
777
1190
|
content,
|
|
778
1191
|
footer: state.quitConfirm
|
|
779
1192
|
? style.yellow("Press q again to quit")
|
|
780
|
-
: style.dim("enter/esc back
|
|
1193
|
+
: style.dim("enter/esc back · q quit"),
|
|
781
1194
|
});
|
|
782
1195
|
}
|
|
783
1196
|
// Success screen for empty results
|
|
@@ -797,7 +1210,7 @@ function renderResults(state) {
|
|
|
797
1210
|
content,
|
|
798
1211
|
footer: state.quitConfirm
|
|
799
1212
|
? style.yellow("Press q again to quit")
|
|
800
|
-
: style.dim("enter/esc back
|
|
1213
|
+
: style.dim("enter/esc back · q quit"),
|
|
801
1214
|
});
|
|
802
1215
|
}
|
|
803
1216
|
const rawContent = state.error || state.result;
|
|
@@ -825,7 +1238,7 @@ function renderResults(state) {
|
|
|
825
1238
|
content,
|
|
826
1239
|
footer: state.quitConfirm
|
|
827
1240
|
? style.yellow("Press q again to quit")
|
|
828
|
-
: style.dim(scrollHint + "↑↓←→ scroll
|
|
1241
|
+
: style.dim(scrollHint + "↑↓←→ scroll · esc back · q quit"),
|
|
829
1242
|
});
|
|
830
1243
|
}
|
|
831
1244
|
function renderState(state) {
|
|
@@ -852,10 +1265,8 @@ function handleCommandListInput(state, key) {
|
|
|
852
1265
|
// search input uses: LOGO.length + 1 (blank) + 1 (search line) + 1 (blank)
|
|
853
1266
|
const logoUsed = LOGO.length + 3;
|
|
854
1267
|
const listHeight = Math.max(1, contentHeight - logoUsed);
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
// Escape: clear query if non-empty, otherwise quit confirm
|
|
858
|
-
if (key.name === "escape") {
|
|
1268
|
+
// Escape / ctrl+c / q: clear query if non-empty, otherwise quit confirm
|
|
1269
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
859
1270
|
if (state.searchQuery) {
|
|
860
1271
|
const filtered = filterCommands(state.tools, "");
|
|
861
1272
|
const sel = selectableIndices(filtered);
|
|
@@ -937,12 +1348,7 @@ function handleCommandListInput(state, key) {
|
|
|
937
1348
|
const nameColWidth = Math.max(...fields.map((f) => f.name.length + (f.required ? 2 : 0)), 6) + 1;
|
|
938
1349
|
const formValues = {};
|
|
939
1350
|
for (const f of fields) {
|
|
940
|
-
|
|
941
|
-
formValues[f.name] = String(f.prop.default);
|
|
942
|
-
}
|
|
943
|
-
else {
|
|
944
|
-
formValues[f.name] = "";
|
|
945
|
-
}
|
|
1351
|
+
formValues[f.name] = "";
|
|
946
1352
|
}
|
|
947
1353
|
if (fields.length === 0) {
|
|
948
1354
|
return {
|
|
@@ -960,13 +1366,16 @@ function handleCommandListInput(state, key) {
|
|
|
960
1366
|
formEditFieldIdx: -1,
|
|
961
1367
|
formEditing: false,
|
|
962
1368
|
formInputBuf: "",
|
|
1369
|
+
formInputCursorPos: 0,
|
|
963
1370
|
formEnumCursor: 0,
|
|
964
1371
|
formEnumSelected: new Set(),
|
|
965
|
-
formShowRequired: false,
|
|
1372
|
+
formShowRequired: false, formShowOptional: false,
|
|
966
1373
|
formStack: [],
|
|
967
1374
|
};
|
|
968
1375
|
}
|
|
969
|
-
|
|
1376
|
+
const filteredIndices = filterFormFields(fields, "");
|
|
1377
|
+
const firstBlankRequired = fields.findIndex((f) => f.required && !formValues[f.name]?.trim());
|
|
1378
|
+
const baseState = {
|
|
970
1379
|
...s,
|
|
971
1380
|
view: "form",
|
|
972
1381
|
selectedTool: tool,
|
|
@@ -975,17 +1384,23 @@ function handleCommandListInput(state, key) {
|
|
|
975
1384
|
formValues,
|
|
976
1385
|
formSearchQuery: "",
|
|
977
1386
|
formSearchCursorPos: 0,
|
|
978
|
-
formFilteredIndices:
|
|
979
|
-
formListCursor: defaultFormCursor(fields,
|
|
1387
|
+
formFilteredIndices: filteredIndices,
|
|
1388
|
+
formListCursor: defaultFormCursor(fields, filteredIndices, formValues),
|
|
980
1389
|
formScrollTop: 0,
|
|
981
1390
|
formEditFieldIdx: -1,
|
|
982
1391
|
formEditing: false,
|
|
983
1392
|
formInputBuf: "",
|
|
1393
|
+
formInputCursorPos: 0,
|
|
984
1394
|
formEnumCursor: 0,
|
|
985
1395
|
formEnumSelected: new Set(),
|
|
986
|
-
formShowRequired: false,
|
|
1396
|
+
formShowRequired: false, formShowOptional: false,
|
|
987
1397
|
formStack: [],
|
|
988
1398
|
};
|
|
1399
|
+
// Auto-open first required field
|
|
1400
|
+
if (firstBlankRequired >= 0) {
|
|
1401
|
+
return startEditingField(baseState, firstBlankRequired);
|
|
1402
|
+
}
|
|
1403
|
+
return baseState;
|
|
989
1404
|
}
|
|
990
1405
|
}
|
|
991
1406
|
return s;
|
|
@@ -1000,19 +1415,158 @@ function handleCommandListInput(state, key) {
|
|
|
1000
1415
|
}
|
|
1001
1416
|
return s;
|
|
1002
1417
|
}
|
|
1003
|
-
// Printable characters: insert into search query
|
|
1004
|
-
if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
|
|
1005
|
-
const
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1418
|
+
// Printable characters or paste: insert into search query
|
|
1419
|
+
if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
|
|
1420
|
+
const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
|
|
1421
|
+
if (text) {
|
|
1422
|
+
const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + text + s.searchQuery.slice(s.searchCursorPos);
|
|
1423
|
+
const filtered = filterCommands(s.tools, newQuery);
|
|
1424
|
+
const sel = selectableIndices(filtered);
|
|
1425
|
+
return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + text.length, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
|
|
1426
|
+
}
|
|
1009
1427
|
}
|
|
1010
1428
|
return s;
|
|
1011
1429
|
}
|
|
1430
|
+
function startEditingField(state, fieldIdx) {
|
|
1431
|
+
const field = state.fields[fieldIdx];
|
|
1432
|
+
if (isArrayOfObjects(field.prop)) {
|
|
1433
|
+
const existing = state.formValues[field.name] || "[]";
|
|
1434
|
+
let items = [];
|
|
1435
|
+
try {
|
|
1436
|
+
items = JSON.parse(existing);
|
|
1437
|
+
}
|
|
1438
|
+
catch { /* */ }
|
|
1439
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: items.length };
|
|
1440
|
+
}
|
|
1441
|
+
const dateFmt = dateFieldFormat(field.prop);
|
|
1442
|
+
if (dateFmt) {
|
|
1443
|
+
const existing = state.formValues[field.name] || "";
|
|
1444
|
+
const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
|
|
1445
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, dateParts: parts, datePartCursor: 0 };
|
|
1446
|
+
}
|
|
1447
|
+
const enumValues = field.prop.enum || field.prop.items?.enum;
|
|
1448
|
+
const isBool = field.prop.type === "boolean";
|
|
1449
|
+
const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
|
|
1450
|
+
if (isArrayEnum && enumValues) {
|
|
1451
|
+
const curVal = state.formValues[field.name] || "";
|
|
1452
|
+
const selected = new Set();
|
|
1453
|
+
if (curVal) {
|
|
1454
|
+
const parts = curVal.split(",").map((s) => s.trim());
|
|
1455
|
+
for (const p of parts) {
|
|
1456
|
+
const idx = enumValues.indexOf(p);
|
|
1457
|
+
if (idx >= 0)
|
|
1458
|
+
selected.add(idx);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: 0, formEnumSelected: selected };
|
|
1462
|
+
}
|
|
1463
|
+
if (enumValues || isBool) {
|
|
1464
|
+
const choices = isBool ? ["true", "false"] : enumValues;
|
|
1465
|
+
const curVal = state.formValues[field.name] || "";
|
|
1466
|
+
const idx = choices.indexOf(curVal);
|
|
1467
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: idx >= 0 ? idx : 0 };
|
|
1468
|
+
}
|
|
1469
|
+
if (field.prop.type === "array" && !field.prop.items?.enum) {
|
|
1470
|
+
const existing = state.formValues[field.name] || "";
|
|
1471
|
+
const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
|
|
1472
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: itemCount };
|
|
1473
|
+
}
|
|
1474
|
+
const editBuf = state.formValues[field.name] || "";
|
|
1475
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: editBuf, formInputCursorPos: editBuf.length };
|
|
1476
|
+
}
|
|
1012
1477
|
function handleFormInput(state, key) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1478
|
+
// Sub-forms use existing palette/edit handlers
|
|
1479
|
+
if (state.formStack.length > 0) {
|
|
1480
|
+
if (state.formEditing)
|
|
1481
|
+
return handleFormEditInput(state, key);
|
|
1482
|
+
return handleFormPaletteInput(state, key);
|
|
1483
|
+
}
|
|
1484
|
+
// Command builder: editing a field
|
|
1485
|
+
if (state.formEditing) {
|
|
1486
|
+
const result = handleFormEditInput(state, key);
|
|
1487
|
+
if (result === "submit")
|
|
1488
|
+
return result;
|
|
1489
|
+
// Auto-advance: if editing just ended via confirm (not cancel), jump to next blank required field
|
|
1490
|
+
if (!result.formEditing && state.formEditing) {
|
|
1491
|
+
const wasCancel = key.name === "escape";
|
|
1492
|
+
if (!wasCancel) {
|
|
1493
|
+
const nextBlank = result.fields.findIndex((f) => f.required && !result.formValues[f.name]?.trim());
|
|
1494
|
+
if (nextBlank >= 0) {
|
|
1495
|
+
return startEditingField(result, nextBlank);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return result;
|
|
1500
|
+
}
|
|
1501
|
+
// Command builder: optional picker
|
|
1502
|
+
if (state.formShowOptional) {
|
|
1503
|
+
return handleOptionalPickerInput(state, key);
|
|
1504
|
+
}
|
|
1505
|
+
// Command builder: ready state
|
|
1506
|
+
return handleCommandBuilderReadyInput(state, key);
|
|
1507
|
+
}
|
|
1508
|
+
function commandListReset(tools) {
|
|
1509
|
+
const filteredItems = buildCommandList(tools);
|
|
1510
|
+
const sel = selectableIndices(filteredItems);
|
|
1511
|
+
return {
|
|
1512
|
+
view: "commands", selectedTool: null,
|
|
1513
|
+
searchQuery: "", searchCursorPos: 0,
|
|
1514
|
+
filteredItems, listCursor: sel[0] ?? 0, listScrollTop: 0,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
function handleCommandBuilderReadyInput(state, key) {
|
|
1518
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
1519
|
+
return { ...state, ...commandListReset(state.tools) };
|
|
1520
|
+
}
|
|
1521
|
+
if (key.name === "return") {
|
|
1522
|
+
if (missingRequiredFields(state.fields, state.formValues).length === 0) {
|
|
1523
|
+
return "submit";
|
|
1524
|
+
}
|
|
1525
|
+
// Jump to first missing required field
|
|
1526
|
+
const nextBlank = state.fields.findIndex((f) => f.required && !state.formValues[f.name]?.trim());
|
|
1527
|
+
if (nextBlank >= 0) {
|
|
1528
|
+
return startEditingField(state, nextBlank);
|
|
1529
|
+
}
|
|
1530
|
+
return state;
|
|
1531
|
+
}
|
|
1532
|
+
if (key.name === "tab") {
|
|
1533
|
+
const hasOptional = state.fields.some((f) => !f.required);
|
|
1534
|
+
if (hasOptional) {
|
|
1535
|
+
return { ...state, formShowOptional: true, formListCursor: 0 };
|
|
1536
|
+
}
|
|
1537
|
+
return state;
|
|
1538
|
+
}
|
|
1539
|
+
// Backspace: re-edit last set field
|
|
1540
|
+
if (key.name === "backspace") {
|
|
1541
|
+
for (let i = state.fields.length - 1; i >= 0; i--) {
|
|
1542
|
+
if (state.formValues[state.fields[i].name]?.trim()) {
|
|
1543
|
+
return startEditingField(state, i);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return state;
|
|
1547
|
+
}
|
|
1548
|
+
return state;
|
|
1549
|
+
}
|
|
1550
|
+
function handleOptionalPickerInput(state, key) {
|
|
1551
|
+
const optionalFields = state.fields
|
|
1552
|
+
.map((f, i) => ({ field: f, idx: i }))
|
|
1553
|
+
.filter(({ field }) => !field.required);
|
|
1554
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
1555
|
+
return { ...state, formShowOptional: false };
|
|
1556
|
+
}
|
|
1557
|
+
if (key.name === "up") {
|
|
1558
|
+
return { ...state, formListCursor: state.formListCursor > 0 ? state.formListCursor - 1 : optionalFields.length - 1 };
|
|
1559
|
+
}
|
|
1560
|
+
if (key.name === "down") {
|
|
1561
|
+
return { ...state, formListCursor: state.formListCursor < optionalFields.length - 1 ? state.formListCursor + 1 : 0 };
|
|
1562
|
+
}
|
|
1563
|
+
if (key.name === "return") {
|
|
1564
|
+
const selected = optionalFields[state.formListCursor];
|
|
1565
|
+
if (selected) {
|
|
1566
|
+
return startEditingField({ ...state, formShowOptional: false }, selected.idx);
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return state;
|
|
1016
1570
|
}
|
|
1017
1571
|
function handleFormPaletteInput(state, key) {
|
|
1018
1572
|
const { fields, formFilteredIndices: filtered, formListCursor, formSearchQuery } = state;
|
|
@@ -1055,13 +1609,71 @@ function handleFormPaletteInput(state, key) {
|
|
|
1055
1609
|
formFilteredIndices: parentFiltered,
|
|
1056
1610
|
formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, entry.parentValues),
|
|
1057
1611
|
formScrollTop: 0,
|
|
1058
|
-
formShowRequired: false,
|
|
1612
|
+
formShowRequired: false, formShowOptional: false,
|
|
1059
1613
|
formInputBuf: "",
|
|
1614
|
+
formInputCursorPos: 0,
|
|
1060
1615
|
};
|
|
1061
1616
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1617
|
+
return { ...state, ...commandListReset(state.tools) };
|
|
1618
|
+
}
|
|
1619
|
+
// Tab: jump to next unfilled required field
|
|
1620
|
+
if (key.name === "tab") {
|
|
1621
|
+
const unfilledRequired = filtered
|
|
1622
|
+
.map((idx, listPos) => ({ idx, listPos }))
|
|
1623
|
+
.filter(({ idx }) => {
|
|
1624
|
+
if (idx < 0 || idx >= fields.length)
|
|
1625
|
+
return false;
|
|
1626
|
+
const f = fields[idx];
|
|
1627
|
+
if (!f.required)
|
|
1628
|
+
return false;
|
|
1629
|
+
const val = state.formValues[f.name]?.trim();
|
|
1630
|
+
if (!val)
|
|
1631
|
+
return true;
|
|
1632
|
+
if (isArrayOfObjects(f.prop)) {
|
|
1633
|
+
try {
|
|
1634
|
+
return JSON.parse(val).length === 0;
|
|
1635
|
+
}
|
|
1636
|
+
catch {
|
|
1637
|
+
return true;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return false;
|
|
1641
|
+
});
|
|
1642
|
+
if (unfilledRequired.length > 0) {
|
|
1643
|
+
// Find the next one after current cursor, wrapping around
|
|
1644
|
+
const after = unfilledRequired.find((u) => u.listPos > formListCursor);
|
|
1645
|
+
const target = after || unfilledRequired[0];
|
|
1646
|
+
let scroll = state.formScrollTop;
|
|
1647
|
+
const paramItems = filtered.filter((idx) => idx !== -1);
|
|
1648
|
+
if (target.idx >= 0) {
|
|
1649
|
+
const posInParams = paramItems.indexOf(target.idx);
|
|
1650
|
+
if (posInParams < scroll)
|
|
1651
|
+
scroll = posInParams;
|
|
1652
|
+
if (posInParams >= scroll + listHeight)
|
|
1653
|
+
scroll = posInParams - listHeight + 1;
|
|
1654
|
+
}
|
|
1655
|
+
return { ...state, formListCursor: target.listPos, formScrollTop: scroll };
|
|
1656
|
+
}
|
|
1657
|
+
// No unfilled required fields — jump to Execute
|
|
1658
|
+
const execPos = filtered.indexOf(-1);
|
|
1659
|
+
if (execPos >= 0)
|
|
1660
|
+
return { ...state, formListCursor: execPos };
|
|
1661
|
+
return state;
|
|
1662
|
+
}
|
|
1663
|
+
// 'o' key: toggle optional fields visibility (when not searching)
|
|
1664
|
+
if (key.raw === "o" && !key.ctrl && !formSearchQuery) {
|
|
1665
|
+
const optionalExists = filtered.some((idx) => idx >= 0 && idx < fields.length && !fields[idx].required);
|
|
1666
|
+
if (optionalExists) {
|
|
1667
|
+
const newShow = !state.formShowOptional;
|
|
1668
|
+
if (!newShow) {
|
|
1669
|
+
const curIdx = filtered[formListCursor];
|
|
1670
|
+
if (curIdx !== undefined && curIdx >= 0 && curIdx < fields.length && !fields[curIdx].required) {
|
|
1671
|
+
const execPos = filtered.indexOf(-1);
|
|
1672
|
+
return { ...state, formShowOptional: false, formListCursor: execPos >= 0 ? execPos : 0 };
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
return { ...state, formShowOptional: newShow };
|
|
1676
|
+
}
|
|
1065
1677
|
}
|
|
1066
1678
|
// Arrow left/right: move text cursor within search input
|
|
1067
1679
|
if (key.name === "left") {
|
|
@@ -1070,9 +1682,25 @@ function handleFormPaletteInput(state, key) {
|
|
|
1070
1682
|
if (key.name === "right") {
|
|
1071
1683
|
return { ...state, formSearchCursorPos: Math.min(formSearchQuery.length, state.formSearchCursorPos + 1) };
|
|
1072
1684
|
}
|
|
1073
|
-
//
|
|
1685
|
+
// Helper: check if a position in filtered is navigable (skip collapsed optional fields)
|
|
1686
|
+
const isNavigable = (listPos) => {
|
|
1687
|
+
const idx = filtered[listPos];
|
|
1688
|
+
if (idx === undefined)
|
|
1689
|
+
return false;
|
|
1690
|
+
if (idx === -1)
|
|
1691
|
+
return true; // Execute always navigable
|
|
1692
|
+
if (!state.formShowOptional && !state.formSearchQuery && idx >= 0 && idx < fields.length && !fields[idx].required)
|
|
1693
|
+
return false;
|
|
1694
|
+
return true;
|
|
1695
|
+
};
|
|
1696
|
+
// Arrow up/down: navigate filtered list (cycling, skipping hidden items)
|
|
1074
1697
|
if (key.name === "up") {
|
|
1075
|
-
|
|
1698
|
+
let next = formListCursor;
|
|
1699
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
1700
|
+
next = next > 0 ? next - 1 : filtered.length - 1;
|
|
1701
|
+
if (isNavigable(next))
|
|
1702
|
+
break;
|
|
1703
|
+
}
|
|
1076
1704
|
let scroll = state.formScrollTop;
|
|
1077
1705
|
const itemIdx = filtered[next];
|
|
1078
1706
|
if (itemIdx !== -1) {
|
|
@@ -1080,14 +1708,18 @@ function handleFormPaletteInput(state, key) {
|
|
|
1080
1708
|
const posInParams = paramItems.indexOf(itemIdx);
|
|
1081
1709
|
if (posInParams < scroll)
|
|
1082
1710
|
scroll = posInParams;
|
|
1083
|
-
// Wrap to bottom: reset scroll to show end of list
|
|
1084
1711
|
if (next > formListCursor)
|
|
1085
1712
|
scroll = Math.max(0, paramItems.length - listHeight);
|
|
1086
1713
|
}
|
|
1087
1714
|
return { ...state, formListCursor: next, formScrollTop: scroll };
|
|
1088
1715
|
}
|
|
1089
1716
|
if (key.name === "down") {
|
|
1090
|
-
|
|
1717
|
+
let next = formListCursor;
|
|
1718
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
1719
|
+
next = next < filtered.length - 1 ? next + 1 : 0;
|
|
1720
|
+
if (isNavigable(next))
|
|
1721
|
+
break;
|
|
1722
|
+
}
|
|
1091
1723
|
let scroll = state.formScrollTop;
|
|
1092
1724
|
const itemIdx = filtered[next];
|
|
1093
1725
|
if (itemIdx !== -1) {
|
|
@@ -1095,7 +1727,6 @@ function handleFormPaletteInput(state, key) {
|
|
|
1095
1727
|
const posInParams = paramItems.indexOf(itemIdx);
|
|
1096
1728
|
if (posInParams >= scroll + listHeight)
|
|
1097
1729
|
scroll = posInParams - listHeight + 1;
|
|
1098
|
-
// Wrap to top: reset scroll
|
|
1099
1730
|
if (next < formListCursor)
|
|
1100
1731
|
scroll = 0;
|
|
1101
1732
|
}
|
|
@@ -1119,51 +1750,7 @@ function handleFormPaletteInput(state, key) {
|
|
|
1119
1750
|
return { ...state, formShowRequired: true };
|
|
1120
1751
|
}
|
|
1121
1752
|
if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
|
|
1122
|
-
|
|
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] || "" };
|
|
1753
|
+
return startEditingField(state, highlightedIdx);
|
|
1167
1754
|
}
|
|
1168
1755
|
return state;
|
|
1169
1756
|
}
|
|
@@ -1176,11 +1763,14 @@ function handleFormPaletteInput(state, key) {
|
|
|
1176
1763
|
}
|
|
1177
1764
|
return state;
|
|
1178
1765
|
}
|
|
1179
|
-
// Printable characters: insert into search query
|
|
1180
|
-
if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
|
|
1181
|
-
const
|
|
1182
|
-
|
|
1183
|
-
|
|
1766
|
+
// Printable characters or paste: insert into search query
|
|
1767
|
+
if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
|
|
1768
|
+
const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
|
|
1769
|
+
if (text) {
|
|
1770
|
+
const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + text + formSearchQuery.slice(state.formSearchCursorPos);
|
|
1771
|
+
const newFiltered = filterFormFields(fields, newQuery);
|
|
1772
|
+
return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + text.length, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
|
|
1773
|
+
}
|
|
1184
1774
|
}
|
|
1185
1775
|
return state;
|
|
1186
1776
|
}
|
|
@@ -1192,7 +1782,7 @@ function handleFormEditInput(state, key) {
|
|
|
1192
1782
|
const isBool = field.prop.type === "boolean";
|
|
1193
1783
|
const resetPalette = (updatedValues) => {
|
|
1194
1784
|
const f = filterFormFields(fields, "");
|
|
1195
|
-
return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false };
|
|
1785
|
+
return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false, formShowOptional: false };
|
|
1196
1786
|
};
|
|
1197
1787
|
// Escape: cancel edit (for multi-select and tag editor, escape confirms since items are saved live)
|
|
1198
1788
|
if (key.name === "escape") {
|
|
@@ -1204,7 +1794,7 @@ function handleFormEditInput(state, key) {
|
|
|
1204
1794
|
const newValues = { ...formValues, [field.name]: val };
|
|
1205
1795
|
return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette(newValues) };
|
|
1206
1796
|
}
|
|
1207
|
-
return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", ...resetPalette() };
|
|
1797
|
+
return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", formInputCursorPos: 0, ...resetPalette() };
|
|
1208
1798
|
}
|
|
1209
1799
|
if (key.ctrl && key.name === "c")
|
|
1210
1800
|
return "submit";
|
|
@@ -1280,10 +1870,11 @@ function handleFormEditInput(state, key) {
|
|
|
1280
1870
|
formFilteredIndices: subFiltered,
|
|
1281
1871
|
formListCursor: defaultFormCursor(subFields, subFiltered, subValues),
|
|
1282
1872
|
formScrollTop: 0,
|
|
1283
|
-
formShowRequired: false,
|
|
1873
|
+
formShowRequired: false, formShowOptional: false,
|
|
1284
1874
|
formEnumCursor: 0,
|
|
1285
1875
|
formEnumSelected: new Set(),
|
|
1286
1876
|
formInputBuf: "",
|
|
1877
|
+
formInputCursorPos: 0,
|
|
1287
1878
|
};
|
|
1288
1879
|
}
|
|
1289
1880
|
if (key.name === "backspace" && formEnumCursor < items.length) {
|
|
@@ -1391,7 +1982,7 @@ function handleFormEditInput(state, key) {
|
|
|
1391
1982
|
const newItems = [...items];
|
|
1392
1983
|
newItems.splice(formEnumCursor, 1);
|
|
1393
1984
|
const newValues = { ...formValues, [field.name]: newItems.join(", ") };
|
|
1394
|
-
return { ...state, formValues: newValues, formInputBuf: editVal, formEnumCursor: newItems.length };
|
|
1985
|
+
return { ...state, formValues: newValues, formInputBuf: editVal, formInputCursorPos: editVal.length, formEnumCursor: newItems.length };
|
|
1395
1986
|
}
|
|
1396
1987
|
if (key.name === "backspace") {
|
|
1397
1988
|
// Delete item
|
|
@@ -1404,11 +1995,16 @@ function handleFormEditInput(state, key) {
|
|
|
1404
1995
|
return state;
|
|
1405
1996
|
}
|
|
1406
1997
|
// Cursor on text input
|
|
1998
|
+
if (key.name === "paste") {
|
|
1999
|
+
// Paste: strip newlines for tag input
|
|
2000
|
+
const text = key.raw.replace(/\n/g, "");
|
|
2001
|
+
return { ...state, formInputBuf: formInputBuf + text, formInputCursorPos: formInputBuf.length + text.length };
|
|
2002
|
+
}
|
|
1407
2003
|
if (key.name === "return") {
|
|
1408
2004
|
if (formInputBuf.trim()) {
|
|
1409
2005
|
items.push(formInputBuf.trim());
|
|
1410
2006
|
const newValues = { ...formValues, [field.name]: items.join(", ") };
|
|
1411
|
-
return { ...state, formValues: newValues, formInputBuf: "", formEnumCursor: items.length };
|
|
2007
|
+
return { ...state, formValues: newValues, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: items.length };
|
|
1412
2008
|
}
|
|
1413
2009
|
// Empty input: confirm and close
|
|
1414
2010
|
const newValues = { ...formValues, [field.name]: items.join(", ") };
|
|
@@ -1416,30 +2012,104 @@ function handleFormEditInput(state, key) {
|
|
|
1416
2012
|
}
|
|
1417
2013
|
if (key.name === "backspace") {
|
|
1418
2014
|
if (formInputBuf) {
|
|
1419
|
-
return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
|
|
2015
|
+
return { ...state, formInputBuf: formInputBuf.slice(0, -1), formInputCursorPos: formInputBuf.length - 1 };
|
|
1420
2016
|
}
|
|
1421
2017
|
return state;
|
|
1422
2018
|
}
|
|
1423
2019
|
if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
|
|
1424
|
-
|
|
2020
|
+
const clean = key.raw.replace(/[\x00-\x1f\x7f]/g, ""); // strip control chars for tags
|
|
2021
|
+
if (clean)
|
|
2022
|
+
return { ...state, formInputBuf: formInputBuf + clean, formInputCursorPos: formInputBuf.length + clean.length };
|
|
1425
2023
|
}
|
|
1426
2024
|
return state;
|
|
1427
2025
|
}
|
|
1428
2026
|
// Text editing mode
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
2027
|
+
const pos = state.formInputCursorPos;
|
|
2028
|
+
if (key.name === "paste") {
|
|
2029
|
+
const newBuf = formInputBuf.slice(0, pos) + key.raw + formInputBuf.slice(pos);
|
|
2030
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + key.raw.length };
|
|
2031
|
+
}
|
|
2032
|
+
// Insert newline: Ctrl+J (\n), Shift+Enter, or Alt+Enter
|
|
2033
|
+
if (key.raw === "\n" || (key.name === "return" && key.shift)) {
|
|
2034
|
+
const newBuf = formInputBuf.slice(0, pos) + "\n" + formInputBuf.slice(pos);
|
|
2035
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + 1 };
|
|
1432
2036
|
}
|
|
1433
2037
|
if (key.name === "return") {
|
|
1434
|
-
// Enter: confirm
|
|
2038
|
+
// Enter (\r): confirm value
|
|
1435
2039
|
const newValues = { ...formValues, [field.name]: formInputBuf };
|
|
1436
|
-
return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
|
|
2040
|
+
return { ...state, formEditing: false, formEditFieldIdx: -1, formInputCursorPos: 0, formValues: newValues, ...resetPalette(newValues) };
|
|
2041
|
+
}
|
|
2042
|
+
if (key.name === "left") {
|
|
2043
|
+
return { ...state, formInputCursorPos: Math.max(0, pos - 1) };
|
|
2044
|
+
}
|
|
2045
|
+
if (key.name === "right") {
|
|
2046
|
+
return { ...state, formInputCursorPos: Math.min(formInputBuf.length, pos + 1) };
|
|
2047
|
+
}
|
|
2048
|
+
if (key.name === "wordLeft") {
|
|
2049
|
+
return { ...state, formInputCursorPos: prevWordBoundary(formInputBuf, pos) };
|
|
2050
|
+
}
|
|
2051
|
+
if (key.name === "wordRight") {
|
|
2052
|
+
return { ...state, formInputCursorPos: nextWordBoundary(formInputBuf, pos) };
|
|
2053
|
+
}
|
|
2054
|
+
if (key.name === "wordBackspace") {
|
|
2055
|
+
const boundary = prevWordBoundary(formInputBuf, pos);
|
|
2056
|
+
const newBuf = formInputBuf.slice(0, boundary) + formInputBuf.slice(pos);
|
|
2057
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: boundary };
|
|
2058
|
+
}
|
|
2059
|
+
if (key.name === "up") {
|
|
2060
|
+
// Move cursor up one line
|
|
2061
|
+
const lines = formInputBuf.split("\n");
|
|
2062
|
+
let rem = pos;
|
|
2063
|
+
let line = 0;
|
|
2064
|
+
for (; line < lines.length; line++) {
|
|
2065
|
+
if (rem <= lines[line].length)
|
|
2066
|
+
break;
|
|
2067
|
+
rem -= lines[line].length + 1;
|
|
2068
|
+
}
|
|
2069
|
+
if (line > 0) {
|
|
2070
|
+
const col = Math.min(rem, lines[line - 1].length);
|
|
2071
|
+
let newPos = 0;
|
|
2072
|
+
for (let i = 0; i < line - 1; i++)
|
|
2073
|
+
newPos += lines[i].length + 1;
|
|
2074
|
+
newPos += col;
|
|
2075
|
+
return { ...state, formInputCursorPos: newPos };
|
|
2076
|
+
}
|
|
2077
|
+
return state;
|
|
2078
|
+
}
|
|
2079
|
+
if (key.name === "down") {
|
|
2080
|
+
// Move cursor down one line
|
|
2081
|
+
const lines = formInputBuf.split("\n");
|
|
2082
|
+
let rem = pos;
|
|
2083
|
+
let line = 0;
|
|
2084
|
+
for (; line < lines.length; line++) {
|
|
2085
|
+
if (rem <= lines[line].length)
|
|
2086
|
+
break;
|
|
2087
|
+
rem -= lines[line].length + 1;
|
|
2088
|
+
}
|
|
2089
|
+
if (line < lines.length - 1) {
|
|
2090
|
+
const col = Math.min(rem, lines[line + 1].length);
|
|
2091
|
+
let newPos = 0;
|
|
2092
|
+
for (let i = 0; i <= line; i++)
|
|
2093
|
+
newPos += lines[i].length + 1;
|
|
2094
|
+
newPos += col;
|
|
2095
|
+
return { ...state, formInputCursorPos: newPos };
|
|
2096
|
+
}
|
|
2097
|
+
return state;
|
|
1437
2098
|
}
|
|
1438
2099
|
if (key.name === "backspace") {
|
|
1439
|
-
|
|
2100
|
+
if (pos > 0) {
|
|
2101
|
+
const newBuf = formInputBuf.slice(0, pos - 1) + formInputBuf.slice(pos);
|
|
2102
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos - 1 };
|
|
2103
|
+
}
|
|
2104
|
+
return state;
|
|
1440
2105
|
}
|
|
1441
2106
|
if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
|
|
1442
|
-
|
|
2107
|
+
// Preserve newlines in pasted text (multi-line supported), strip other control chars
|
|
2108
|
+
const clean = key.raw.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
|
2109
|
+
if (clean) {
|
|
2110
|
+
const newBuf = formInputBuf.slice(0, pos) + clean + formInputBuf.slice(pos);
|
|
2111
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + clean.length };
|
|
2112
|
+
}
|
|
1443
2113
|
}
|
|
1444
2114
|
return state;
|
|
1445
2115
|
}
|
|
@@ -1447,8 +2117,11 @@ function handleResultsInput(state, key) {
|
|
|
1447
2117
|
const { contentHeight } = getBoxDimensions();
|
|
1448
2118
|
const contentLines = (state.error || state.result).split("\n");
|
|
1449
2119
|
const visibleCount = Math.max(1, contentHeight - 3);
|
|
1450
|
-
if (key.ctrl && key.name === "c")
|
|
1451
|
-
|
|
2120
|
+
if (key.ctrl && key.name === "c") {
|
|
2121
|
+
if (state.quitConfirm)
|
|
2122
|
+
return "exit";
|
|
2123
|
+
return { ...state, quitConfirm: true };
|
|
2124
|
+
}
|
|
1452
2125
|
if (key.name === "q" && !key.ctrl) {
|
|
1453
2126
|
if (state.quitConfirm)
|
|
1454
2127
|
return "exit";
|
|
@@ -1456,56 +2129,25 @@ function handleResultsInput(state, key) {
|
|
|
1456
2129
|
}
|
|
1457
2130
|
// Any other key cancels quit confirm
|
|
1458
2131
|
const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
|
|
2132
|
+
const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0 };
|
|
1459
2133
|
const goBack = () => {
|
|
1460
|
-
const
|
|
1461
|
-
const
|
|
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;
|
|
2134
|
+
const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
|
|
2135
|
+
const hasParams = !isEmpty && s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
|
|
1464
2136
|
if (hasParams) {
|
|
2137
|
+
const f = filterFormFields(s.fields, "");
|
|
1465
2138
|
return {
|
|
1466
|
-
...s, view: "form",
|
|
2139
|
+
...s, view: "form", ...resultsClear,
|
|
1467
2140
|
formSearchQuery: "", formSearchCursorPos: 0,
|
|
1468
|
-
formFilteredIndices:
|
|
1469
|
-
formListCursor: defaultFormCursor(s.fields,
|
|
1470
|
-
formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
|
|
2141
|
+
formFilteredIndices: f,
|
|
2142
|
+
formListCursor: defaultFormCursor(s.fields, f, s.formValues), formScrollTop: 0,
|
|
2143
|
+
formEditing: false, formEditFieldIdx: -1, formShowRequired: false, formShowOptional: false,
|
|
1471
2144
|
};
|
|
1472
2145
|
}
|
|
1473
|
-
return { ...s,
|
|
2146
|
+
return { ...s, ...commandListReset(s.tools), ...resultsClear };
|
|
1474
2147
|
};
|
|
1475
|
-
|
|
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
|
-
}
|
|
2148
|
+
if (key.name === "return" || key.name === "escape") {
|
|
1485
2149
|
return goBack();
|
|
1486
2150
|
}
|
|
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
|
-
};
|
|
1506
|
-
}
|
|
1507
|
-
return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
|
|
1508
|
-
}
|
|
1509
2151
|
if (key.name === "up") {
|
|
1510
2152
|
return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
|
|
1511
2153
|
}
|
|
@@ -1719,10 +2361,11 @@ export async function runApp(tools) {
|
|
|
1719
2361
|
formEditFieldIdx: -1,
|
|
1720
2362
|
formEditing: false,
|
|
1721
2363
|
formInputBuf: "",
|
|
2364
|
+
formInputCursorPos: 0,
|
|
1722
2365
|
formEnumCursor: 0,
|
|
1723
2366
|
formEnumSelected: new Set(),
|
|
1724
2367
|
formValues: {},
|
|
1725
|
-
formShowRequired: false,
|
|
2368
|
+
formShowRequired: false, formShowOptional: false,
|
|
1726
2369
|
formStack: [],
|
|
1727
2370
|
dateParts: [],
|
|
1728
2371
|
datePartCursor: 0,
|