@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/src/tui/app.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ToolDef, SchemaProperty } from "../config.js";
|
|
2
|
-
import { resolveProperty
|
|
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,
|
|
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("
|
|
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
|
|
514
|
-
: style.dim("type to search
|
|
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
|
-
|
|
535
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
//
|
|
582
|
-
const
|
|
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
|
-
//
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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(
|
|
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
|
|
646
|
-
if (
|
|
985
|
+
const prop = fields[highlightedIdx]!.prop;
|
|
986
|
+
if (prop.description) {
|
|
647
987
|
content.push("");
|
|
648
|
-
const wrapped = wrapText(
|
|
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("
|
|
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
|
|
1037
|
+
// Field label
|
|
674
1038
|
const nameLabel = field.name + (field.required ? " *" : "");
|
|
675
|
-
content.push("
|
|
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 (
|
|
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 (
|
|
1082
|
+
} else if (kind === "date") {
|
|
1083
|
+
const dateFmt = dateFieldFormat(field.prop)!;
|
|
719
1084
|
content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
|
|
720
|
-
} else if (
|
|
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 (
|
|
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 (
|
|
754
|
-
const choices =
|
|
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
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
774
|
-
if (
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1077
|
-
formListCursor: defaultFormCursor(fields,
|
|
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
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
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
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
|
1566
|
-
const
|
|
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,
|
|
2227
|
+
...s, view: "form" as View, ...resultsClear,
|
|
1572
2228
|
formSearchQuery: "", formSearchCursorPos: 0,
|
|
1573
|
-
formFilteredIndices:
|
|
1574
|
-
formListCursor: defaultFormCursor(s.fields,
|
|
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,
|
|
2234
|
+
return { ...s, ...commandListReset(s.tools), ...resultsClear };
|
|
1579
2235
|
};
|
|
1580
2236
|
|
|
1581
|
-
|
|
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,
|