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