@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/src/tui/app.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
1
2
|
import type { ToolDef, SchemaProperty } from "../config.js";
|
|
2
|
-
import { resolveProperty
|
|
3
|
+
import { resolveProperty } from "../commands.js";
|
|
3
4
|
import { callTool } from "../mcp.js";
|
|
4
5
|
import { ensureValidToken } from "../auth.js";
|
|
5
6
|
import { LOGO } from "./logo.js";
|
|
6
|
-
import { style, paint, screenSize, fitWidth, ansiSlice,
|
|
7
|
+
import { style, paint, screenSize, fitWidth, ansiSlice, parseKey, type KeyEvent } from "./term.js";
|
|
7
8
|
import { VERSION } from "../version.js";
|
|
8
9
|
|
|
9
10
|
// --- Types ---
|
|
@@ -29,6 +30,18 @@ function isArrayOfObjects(prop: SchemaProperty): boolean {
|
|
|
29
30
|
return prop.type === "array" && !!prop.items?.properties;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
type CardKind = "document" | "highlight";
|
|
34
|
+
|
|
35
|
+
interface CardItem {
|
|
36
|
+
kind: CardKind;
|
|
37
|
+
title: string;
|
|
38
|
+
summary: string;
|
|
39
|
+
note: string; // highlight note (only for highlights)
|
|
40
|
+
meta: string; // "Source · Author · 12 min"
|
|
41
|
+
url: string; // for opening on enter
|
|
42
|
+
raw: Record<string, unknown>; // full object for fallback
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
interface AppState {
|
|
33
46
|
view: View;
|
|
34
47
|
tools: ToolDef[];
|
|
@@ -51,10 +64,12 @@ interface AppState {
|
|
|
51
64
|
formEditFieldIdx: number;
|
|
52
65
|
formEditing: boolean;
|
|
53
66
|
formInputBuf: string;
|
|
67
|
+
formInputCursorPos: number;
|
|
54
68
|
formEnumCursor: number;
|
|
55
69
|
formEnumSelected: Set<number>;
|
|
56
70
|
formValues: Record<string, string>;
|
|
57
71
|
formShowRequired: boolean;
|
|
72
|
+
formShowOptional: boolean;
|
|
58
73
|
formStack: FormStackEntry[];
|
|
59
74
|
// Date picker
|
|
60
75
|
dateParts: number[]; // [year, month, day] or [year, month, day, hour, minute]
|
|
@@ -64,6 +79,9 @@ interface AppState {
|
|
|
64
79
|
error: string;
|
|
65
80
|
resultScroll: number;
|
|
66
81
|
resultScrollX: number;
|
|
82
|
+
resultCards: CardItem[]; // parsed card items for card view
|
|
83
|
+
resultCursor: number; // selected card index
|
|
84
|
+
resultCardScroll: number; // scroll offset for cards
|
|
67
85
|
// Spinner
|
|
68
86
|
spinnerFrame: number;
|
|
69
87
|
}
|
|
@@ -184,6 +202,42 @@ function defaultFormCursor(fields: FormField[], filtered: number[], values: Reco
|
|
|
184
202
|
return firstBlank >= 0 ? firstBlank : executeIndex(filtered);
|
|
185
203
|
}
|
|
186
204
|
|
|
205
|
+
type FieldKind = "arrayObj" | "date" | "arrayEnum" | "enum" | "bool" | "arrayText" | "text";
|
|
206
|
+
|
|
207
|
+
function classifyField(prop: SchemaProperty): FieldKind {
|
|
208
|
+
if (isArrayOfObjects(prop)) return "arrayObj";
|
|
209
|
+
if (dateFieldFormat(prop)) return "date";
|
|
210
|
+
const eVals = prop.enum || prop.items?.enum;
|
|
211
|
+
if (prop.type === "boolean") return "bool";
|
|
212
|
+
if (eVals && prop.type === "array") return "arrayEnum";
|
|
213
|
+
if (eVals) return "enum";
|
|
214
|
+
if (prop.type === "array") return "arrayText";
|
|
215
|
+
return "text";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function fieldTypeBadge(prop: SchemaProperty): string {
|
|
219
|
+
const badges: Record<FieldKind, string> = {
|
|
220
|
+
arrayObj: "form", date: "date", bool: "yes/no", arrayEnum: "multi",
|
|
221
|
+
enum: "select", arrayText: "list", text: "text",
|
|
222
|
+
};
|
|
223
|
+
const badge = badges[classifyField(prop)];
|
|
224
|
+
if (badge !== "text") return badge;
|
|
225
|
+
if (prop.type === "integer" || prop.type === "number") return "number";
|
|
226
|
+
return "text";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function footerForFieldKind(kind: FieldKind): string {
|
|
230
|
+
switch (kind) {
|
|
231
|
+
case "arrayObj": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc back";
|
|
232
|
+
case "date": return "\u2190\u2192 part \u00B7 \u2191\u2193 adjust \u00B7 t today \u00B7 enter confirm \u00B7 esc cancel";
|
|
233
|
+
case "arrayEnum": return "space toggle \u00B7 enter confirm \u00B7 esc cancel";
|
|
234
|
+
case "arrayText": return "\u2191\u2193 navigate \u00B7 enter add/edit \u00B7 backspace delete \u00B7 esc confirm";
|
|
235
|
+
case "enum":
|
|
236
|
+
case "bool": return "\u2191\u2193 navigate \u00B7 enter confirm \u00B7 esc cancel";
|
|
237
|
+
case "text": return "enter confirm \u00B7 esc cancel";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
187
241
|
function formFieldValueDisplay(value: string, maxWidth: number): string {
|
|
188
242
|
if (!value) return style.dim("–");
|
|
189
243
|
// JSON array display (for array-of-objects)
|
|
@@ -253,8 +307,9 @@ function popFormStack(state: AppState): AppState {
|
|
|
253
307
|
formFilteredIndices: parentFiltered,
|
|
254
308
|
formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, newParentValues),
|
|
255
309
|
formScrollTop: 0,
|
|
256
|
-
formShowRequired: false,
|
|
310
|
+
formShowRequired: false, formShowOptional: false,
|
|
257
311
|
formInputBuf: "",
|
|
312
|
+
formInputCursorPos: 0,
|
|
258
313
|
};
|
|
259
314
|
}
|
|
260
315
|
|
|
@@ -296,6 +351,219 @@ function wrapText(text: string, width: number): string[] {
|
|
|
296
351
|
return lines.length > 0 ? lines : [""];
|
|
297
352
|
}
|
|
298
353
|
|
|
354
|
+
// --- Card parsing helpers ---
|
|
355
|
+
|
|
356
|
+
function extractCards(data: unknown): CardItem[] | null {
|
|
357
|
+
let items: unknown[];
|
|
358
|
+
if (Array.isArray(data)) {
|
|
359
|
+
items = data;
|
|
360
|
+
} else if (typeof data === "object" && data !== null) {
|
|
361
|
+
// Look for a "results" array or similar
|
|
362
|
+
const obj = data as Record<string, unknown>;
|
|
363
|
+
const arrayKey = Object.keys(obj).find((k) => Array.isArray(obj[k]) && (obj[k] as unknown[]).length > 0);
|
|
364
|
+
if (arrayKey) {
|
|
365
|
+
items = obj[arrayKey] as unknown[];
|
|
366
|
+
} else {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (items.length === 0) return null;
|
|
374
|
+
// Only works for arrays of objects
|
|
375
|
+
if (typeof items[0] !== "object" || items[0] === null || Array.isArray(items[0])) return null;
|
|
376
|
+
|
|
377
|
+
const cards = items.map((item) => {
|
|
378
|
+
const obj = item as Record<string, unknown>;
|
|
379
|
+
const kind = isHighlightObj(obj) ? "highlight" : "document";
|
|
380
|
+
if (kind === "highlight") {
|
|
381
|
+
const attrs = (typeof obj.attributes === "object" && obj.attributes !== null)
|
|
382
|
+
? obj.attributes as Record<string, unknown> : null;
|
|
383
|
+
return {
|
|
384
|
+
kind,
|
|
385
|
+
title: str(attrs?.document_title || obj.title || obj.readable_title || ""),
|
|
386
|
+
summary: str(attrs?.highlight_plaintext || obj.text || obj.summary || obj.content || ""),
|
|
387
|
+
note: str(attrs?.highlight_note || obj.note || obj.notes || ""),
|
|
388
|
+
meta: extractHighlightMeta(obj),
|
|
389
|
+
url: obj.id ? `https://readwise.io/open/${obj.id}` : extractCardUrl(obj),
|
|
390
|
+
raw: obj,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
kind,
|
|
395
|
+
title: extractDocTitle(obj),
|
|
396
|
+
summary: extractDocSummary(obj),
|
|
397
|
+
note: "",
|
|
398
|
+
meta: extractDocMeta(obj),
|
|
399
|
+
url: extractCardUrl(obj),
|
|
400
|
+
raw: obj,
|
|
401
|
+
};
|
|
402
|
+
}) as CardItem[];
|
|
403
|
+
|
|
404
|
+
// Skip card view if most items have no meaningful content (e.g. just URLs)
|
|
405
|
+
const hasContent = cards.filter((c) => c.summary || c.note || (c.kind === "document" && c.title !== "Untitled" && !c.raw.url?.toString().includes(c.title)));
|
|
406
|
+
if (hasContent.length < cards.length / 2) return null;
|
|
407
|
+
|
|
408
|
+
return cards;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function str(val: unknown): string {
|
|
412
|
+
if (val === null || val === undefined) return "";
|
|
413
|
+
return String(val);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function isHighlightObj(obj: Record<string, unknown>): boolean {
|
|
417
|
+
// Reader docs with category "highlight"
|
|
418
|
+
if (obj.category === "highlight") return true;
|
|
419
|
+
// Readwise search highlights: nested attributes with highlight_plaintext
|
|
420
|
+
const attrs = obj.attributes;
|
|
421
|
+
if (typeof attrs === "object" && attrs !== null && "highlight_plaintext" in (attrs as Record<string, unknown>)) return true;
|
|
422
|
+
// Readwise highlights: have text + highlight-specific fields
|
|
423
|
+
if (typeof obj.text === "string" &&
|
|
424
|
+
("highlighted_at" in obj || "color" in obj || "book_id" in obj || "location_type" in obj)) {
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
// Has text + note fields (common highlight shape even without highlighted_at)
|
|
428
|
+
if (typeof obj.text === "string" && "note" in obj) return true;
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// --- Document card helpers ---
|
|
433
|
+
|
|
434
|
+
function extractDocTitle(obj: Record<string, unknown>): string {
|
|
435
|
+
for (const key of ["title", "readable_title", "name"]) {
|
|
436
|
+
const val = obj[key];
|
|
437
|
+
if (val && typeof val === "string" && !String(val).startsWith("http")) return val as string;
|
|
438
|
+
}
|
|
439
|
+
// Last resort: show domain from URL
|
|
440
|
+
const url = str(obj.url || obj.source_url);
|
|
441
|
+
if (url) {
|
|
442
|
+
try { return new URL(url).hostname.replace(/^www\./, ""); } catch { /* */ }
|
|
443
|
+
}
|
|
444
|
+
return "Untitled";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function extractDocSummary(obj: Record<string, unknown>): string {
|
|
448
|
+
for (const key of ["summary", "description", "note", "notes", "content"]) {
|
|
449
|
+
const val = obj[key];
|
|
450
|
+
if (val && typeof val === "string") return val;
|
|
451
|
+
}
|
|
452
|
+
return "";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function extractDocMeta(obj: Record<string, unknown>): string {
|
|
456
|
+
const parts: string[] = [];
|
|
457
|
+
|
|
458
|
+
const siteName = str(obj.site_name);
|
|
459
|
+
if (siteName) parts.push(siteName);
|
|
460
|
+
|
|
461
|
+
const author = str(obj.author);
|
|
462
|
+
if (author && author !== siteName) parts.push(author);
|
|
463
|
+
|
|
464
|
+
const category = str(obj.category);
|
|
465
|
+
if (category) parts.push(category);
|
|
466
|
+
|
|
467
|
+
const wordCount = Number(obj.word_count);
|
|
468
|
+
if (wordCount > 0) {
|
|
469
|
+
const mins = Math.ceil(wordCount / 250);
|
|
470
|
+
parts.push(`${mins} min`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const progress = Number(obj.reading_progress);
|
|
474
|
+
if (progress > 0 && progress < 1) {
|
|
475
|
+
parts.push(`${Math.round(progress * 100)}% read`);
|
|
476
|
+
} else if (progress >= 1) {
|
|
477
|
+
parts.push("finished");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const date = str(obj.created_at || obj.saved_at || obj.published_date);
|
|
481
|
+
if (date) {
|
|
482
|
+
const d = new Date(date);
|
|
483
|
+
if (!isNaN(d.getTime())) {
|
|
484
|
+
parts.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return parts.join(" · ");
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// --- Highlight card helpers ---
|
|
492
|
+
|
|
493
|
+
function extractHighlightMeta(obj: Record<string, unknown>): string {
|
|
494
|
+
const attrs = (typeof obj.attributes === "object" && obj.attributes !== null)
|
|
495
|
+
? obj.attributes as Record<string, unknown> : null;
|
|
496
|
+
const parts: string[] = [];
|
|
497
|
+
|
|
498
|
+
// Source book/article author
|
|
499
|
+
const author = str(attrs?.document_author || obj.author || obj.book_author);
|
|
500
|
+
if (author && !author.startsWith("http")) parts.push(author);
|
|
501
|
+
|
|
502
|
+
// Category
|
|
503
|
+
const category = str(attrs?.document_category || obj.category);
|
|
504
|
+
if (category) parts.push(category);
|
|
505
|
+
|
|
506
|
+
const color = str(obj.color);
|
|
507
|
+
if (color) parts.push(color);
|
|
508
|
+
|
|
509
|
+
// Tags (from attributes or top-level)
|
|
510
|
+
const tags = attrs?.highlight_tags || obj.tags;
|
|
511
|
+
if (Array.isArray(tags) && tags.length > 0) {
|
|
512
|
+
const tagNames = tags.map((t: unknown) =>
|
|
513
|
+
typeof t === "object" && t !== null ? str((t as Record<string, unknown>).name) : str(t)
|
|
514
|
+
).filter(Boolean);
|
|
515
|
+
if (tagNames.length > 0) parts.push(tagNames.join(", "));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const date = str(obj.highlighted_at || obj.created_at);
|
|
519
|
+
if (date) {
|
|
520
|
+
const d = new Date(date);
|
|
521
|
+
if (!isNaN(d.getTime())) {
|
|
522
|
+
parts.push(d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }));
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return parts.join(" · ");
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function extractCardUrl(obj: Record<string, unknown>): string {
|
|
530
|
+
for (const key of ["url", "source_url", "reader_url", "readwise_url"]) {
|
|
531
|
+
if (obj[key] && typeof obj[key] === "string") return obj[key] as string;
|
|
532
|
+
}
|
|
533
|
+
return "";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --- Word boundary helpers ---
|
|
537
|
+
|
|
538
|
+
function prevWordBoundary(buf: string, pos: number): number {
|
|
539
|
+
if (pos <= 0) return 0;
|
|
540
|
+
let i = pos - 1;
|
|
541
|
+
// Skip whitespace
|
|
542
|
+
while (i > 0 && /\s/.test(buf[i]!)) i--;
|
|
543
|
+
// Skip word chars or non-word non-space chars
|
|
544
|
+
if (i >= 0 && /\w/.test(buf[i]!)) {
|
|
545
|
+
while (i > 0 && /\w/.test(buf[i - 1]!)) i--;
|
|
546
|
+
} else {
|
|
547
|
+
while (i > 0 && !/\w/.test(buf[i - 1]!) && !/\s/.test(buf[i - 1]!)) i--;
|
|
548
|
+
}
|
|
549
|
+
return i;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function nextWordBoundary(buf: string, pos: number): number {
|
|
553
|
+
const len = buf.length;
|
|
554
|
+
if (pos >= len) return len;
|
|
555
|
+
let i = pos;
|
|
556
|
+
// Skip current word chars or non-word non-space chars
|
|
557
|
+
if (/\w/.test(buf[i]!)) {
|
|
558
|
+
while (i < len && /\w/.test(buf[i]!)) i++;
|
|
559
|
+
} else if (!/\s/.test(buf[i]!)) {
|
|
560
|
+
while (i < len && !/\w/.test(buf[i]!) && !/\s/.test(buf[i]!)) i++;
|
|
561
|
+
}
|
|
562
|
+
// Skip whitespace
|
|
563
|
+
while (i < len && /\s/.test(buf[i]!)) i++;
|
|
564
|
+
return i;
|
|
565
|
+
}
|
|
566
|
+
|
|
299
567
|
// --- Date helpers ---
|
|
300
568
|
|
|
301
569
|
type DateFormat = "date" | "date-time";
|
|
@@ -449,7 +717,7 @@ function renderCommandList(state: AppState): string[] {
|
|
|
449
717
|
if (i === Math.floor(LOGO.length / 2) - 1) {
|
|
450
718
|
content.push(` ${logoLine} ${style.boldYellow("Readwise")} ${style.dim("v" + VERSION)}`);
|
|
451
719
|
} else if (i === Math.floor(LOGO.length / 2)) {
|
|
452
|
-
content.push(` ${logoLine} ${style.dim("
|
|
720
|
+
content.push(` ${logoLine} ${style.dim("Built for AI agents · This TUI is just for fun/learning")}`);
|
|
453
721
|
} else {
|
|
454
722
|
content.push(` ${logoLine}`);
|
|
455
723
|
}
|
|
@@ -510,8 +778,8 @@ function renderCommandList(state: AppState): string[] {
|
|
|
510
778
|
}
|
|
511
779
|
|
|
512
780
|
const footer = state.quitConfirm
|
|
513
|
-
? style.yellow("Press
|
|
514
|
-
: style.dim("type to search
|
|
781
|
+
? style.yellow("Press again to quit")
|
|
782
|
+
: style.dim("type to search · ↑↓ navigate · enter select · esc/ctrl+c quit");
|
|
515
783
|
|
|
516
784
|
return renderLayout({
|
|
517
785
|
breadcrumb: style.boldYellow("Readwise"),
|
|
@@ -525,16 +793,223 @@ function renderForm(state: AppState): string[] {
|
|
|
525
793
|
const tool = state.selectedTool!;
|
|
526
794
|
const fields = state.fields;
|
|
527
795
|
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
796
|
|
|
534
|
-
|
|
535
|
-
|
|
797
|
+
// Sub-forms (nested array-of-objects) keep existing form UI
|
|
798
|
+
if (state.formStack.length > 0) {
|
|
799
|
+
const stackParts = state.formStack.map((e) => e.parentFieldName);
|
|
800
|
+
const title = toolTitle + " › " + stackParts.join(" › ");
|
|
801
|
+
if (state.formEditing && state.formEditFieldIdx >= 0) {
|
|
802
|
+
return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
|
|
803
|
+
}
|
|
804
|
+
return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
|
|
536
805
|
}
|
|
537
|
-
|
|
806
|
+
|
|
807
|
+
// Top-level: command builder
|
|
808
|
+
return renderCommandBuilder(state, toolTitle, fields, contentHeight, innerWidth);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function renderCommandBuilder(
|
|
812
|
+
state: AppState, title: string, fields: FormField[],
|
|
813
|
+
contentHeight: number, innerWidth: number,
|
|
814
|
+
): string[] {
|
|
815
|
+
const tool = state.selectedTool!;
|
|
816
|
+
const cmdName = tool.name.replace(/_/g, "-");
|
|
817
|
+
const content: string[] = [];
|
|
818
|
+
const editField = state.formEditing && state.formEditFieldIdx >= 0
|
|
819
|
+
? fields[state.formEditFieldIdx]!
|
|
820
|
+
: null;
|
|
821
|
+
|
|
822
|
+
// Header
|
|
823
|
+
content.push("");
|
|
824
|
+
content.push(" " + style.bold(title));
|
|
825
|
+
if (tool.description) {
|
|
826
|
+
const wrapped = wrapText(tool.description, innerWidth - 4);
|
|
827
|
+
for (const line of wrapped) {
|
|
828
|
+
content.push(" " + style.dim(line));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
content.push("");
|
|
832
|
+
|
|
833
|
+
// Classify editing field type once for both content and footer
|
|
834
|
+
const editKind = editField ? classifyField(editField.prop) : null;
|
|
835
|
+
const isTextLikeEdit = editKind === "text";
|
|
836
|
+
|
|
837
|
+
// Build command lines
|
|
838
|
+
const argLines: string[] = [];
|
|
839
|
+
for (const field of fields) {
|
|
840
|
+
const flagName = field.name.replace(/_/g, "-");
|
|
841
|
+
if (field === editField) {
|
|
842
|
+
if (isTextLikeEdit) {
|
|
843
|
+
// Inline cursor for text fields
|
|
844
|
+
const buf = state.formInputBuf;
|
|
845
|
+
const before = buf.slice(0, state.formInputCursorPos);
|
|
846
|
+
const cursorChar = state.formInputCursorPos < buf.length ? buf[state.formInputCursorPos]! : " ";
|
|
847
|
+
const after = state.formInputCursorPos < buf.length ? buf.slice(state.formInputCursorPos + 1) : "";
|
|
848
|
+
argLines.push(" --" + flagName + "=" + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
|
|
849
|
+
} else if (isArrayOfObjects(field.prop)) {
|
|
850
|
+
// Array-of-objects: show item count
|
|
851
|
+
const existing = state.formValues[field.name] || "[]";
|
|
852
|
+
let items: unknown[] = [];
|
|
853
|
+
try { items = JSON.parse(existing); } catch { /* */ }
|
|
854
|
+
const label = items.length > 0 ? `[${items.length} item${items.length > 1 ? "s" : ""}]` : "[...]";
|
|
855
|
+
argLines.push(" --" + flagName + "=" + style.yellow(label));
|
|
856
|
+
} else {
|
|
857
|
+
// Non-text editors (enum, bool, date): show pending
|
|
858
|
+
argLines.push(" --" + flagName + "=" + style.inverse(" "));
|
|
859
|
+
}
|
|
860
|
+
} else {
|
|
861
|
+
const val = state.formValues[field.name];
|
|
862
|
+
if (val) {
|
|
863
|
+
const needsQuotes = val.includes(" ") || val.includes(",");
|
|
864
|
+
const displayVal = needsQuotes ? '"' + val + '"' : val;
|
|
865
|
+
argLines.push(" --" + flagName + "=" + style.cyan(displayVal));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Render command with line continuations
|
|
871
|
+
const cmdPrefix = " " + style.dim("$") + " " + style.dim("readwise") + " " + cmdName;
|
|
872
|
+
if (argLines.length === 0) {
|
|
873
|
+
content.push(cmdPrefix);
|
|
874
|
+
} else {
|
|
875
|
+
content.push(cmdPrefix + " \\");
|
|
876
|
+
for (let i = 0; i < argLines.length; i++) {
|
|
877
|
+
const isLast = i === argLines.length - 1;
|
|
878
|
+
content.push(argLines[i]! + (isLast ? "" : " \\"));
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
content.push("");
|
|
883
|
+
|
|
884
|
+
// Context area: field description, editor, or ready state
|
|
885
|
+
if (editField) {
|
|
886
|
+
// Field description
|
|
887
|
+
if (editField.prop.description) {
|
|
888
|
+
content.push(" " + style.dim(editField.prop.description));
|
|
889
|
+
}
|
|
890
|
+
if (editField.prop.examples?.length) {
|
|
891
|
+
const exStr = editField.prop.examples.map((e: unknown) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
|
|
892
|
+
content.push(" " + style.dim("e.g. ") + style.cyan(truncateVisible(exStr, innerWidth - 10)));
|
|
893
|
+
}
|
|
894
|
+
if (editField.prop.default != null) {
|
|
895
|
+
content.push(" " + style.dim("default: " + editField.prop.default));
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const eVals = editField.prop.enum || editField.prop.items?.enum;
|
|
899
|
+
|
|
900
|
+
if (editKind === "bool") {
|
|
901
|
+
content.push("");
|
|
902
|
+
const choices = ["true", "false"];
|
|
903
|
+
for (let ci = 0; ci < choices.length; ci++) {
|
|
904
|
+
const sel = ci === state.formEnumCursor;
|
|
905
|
+
content.push(sel ? " " + style.cyan(style.bold("\u203A " + choices[ci]!)) : " " + choices[ci]!);
|
|
906
|
+
}
|
|
907
|
+
} else if (editKind === "enum" && eVals) {
|
|
908
|
+
content.push("");
|
|
909
|
+
for (let ci = 0; ci < eVals.length; ci++) {
|
|
910
|
+
const sel = ci === state.formEnumCursor;
|
|
911
|
+
content.push(sel ? " " + style.cyan(style.bold("\u203A " + eVals[ci]!)) : " " + eVals[ci]!);
|
|
912
|
+
}
|
|
913
|
+
} else if (editKind === "arrayEnum" && eVals) {
|
|
914
|
+
content.push("");
|
|
915
|
+
for (let ci = 0; ci < eVals.length; ci++) {
|
|
916
|
+
const sel = ci === state.formEnumCursor;
|
|
917
|
+
const checked = state.formEnumSelected.has(ci);
|
|
918
|
+
const check = checked ? style.cyan("[x]") : style.dim("[ ]");
|
|
919
|
+
content.push((sel ? " \u203A " : " ") + check + " " + eVals[ci]!);
|
|
920
|
+
}
|
|
921
|
+
} else if (editKind === "date") {
|
|
922
|
+
const dateFmt = dateFieldFormat(editField.prop)!;
|
|
923
|
+
content.push("");
|
|
924
|
+
content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
|
|
925
|
+
} else if (editKind === "arrayText") {
|
|
926
|
+
const existing = state.formValues[editField.name] || "";
|
|
927
|
+
const items = existing ? existing.split(",").map((s: string) => s.trim()).filter(Boolean) : [];
|
|
928
|
+
content.push("");
|
|
929
|
+
for (let i = 0; i < items.length; i++) {
|
|
930
|
+
const isCursor = i === state.formEnumCursor;
|
|
931
|
+
content.push((isCursor ? " \u276F " : " ") + style.cyan(items[i]!));
|
|
932
|
+
}
|
|
933
|
+
const onInput = state.formEnumCursor === items.length;
|
|
934
|
+
content.push((onInput ? " \u276F " : " ") + style.cyan(state.formInputBuf) + (onInput ? style.inverse(" ") : ""));
|
|
935
|
+
} else if (editKind === "arrayObj") {
|
|
936
|
+
const existing = state.formValues[editField.name] || "[]";
|
|
937
|
+
let items: unknown[] = [];
|
|
938
|
+
try { items = JSON.parse(existing); } catch { /* */ }
|
|
939
|
+
content.push("");
|
|
940
|
+
for (let i = 0; i < items.length; i++) {
|
|
941
|
+
const item = items[i] as Record<string, unknown>;
|
|
942
|
+
const summary = Object.entries(item)
|
|
943
|
+
.filter(([, v]) => v != null && v !== "")
|
|
944
|
+
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
945
|
+
.join(", ");
|
|
946
|
+
const isCursor = i === state.formEnumCursor;
|
|
947
|
+
content.push((isCursor ? " \u276F " : " ") + truncateVisible(summary || "(empty)", innerWidth - 6));
|
|
948
|
+
}
|
|
949
|
+
if (items.length > 0) content.push("");
|
|
950
|
+
const addCursor = state.formEnumCursor === items.length;
|
|
951
|
+
content.push(addCursor
|
|
952
|
+
? " " + style.inverse(style.green(" + Add new item "))
|
|
953
|
+
: " " + style.dim("+") + " Add new item");
|
|
954
|
+
} else if (isTextLikeEdit) {
|
|
955
|
+
// Text field: cursor is already inline in the command string
|
|
956
|
+
// Just show a hint if empty
|
|
957
|
+
if (!state.formInputBuf) {
|
|
958
|
+
content.push("");
|
|
959
|
+
content.push(" " + style.dim("Type a value and press enter"));
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
} else {
|
|
963
|
+
// Ready state / optional picker
|
|
964
|
+
const missing = missingRequiredFields(fields, state.formValues);
|
|
965
|
+
if (missing.length > 0) {
|
|
966
|
+
content.push(" " + style.red("Missing: " + missing.map((f) => f.name).join(", ")));
|
|
967
|
+
} else {
|
|
968
|
+
content.push(" " + style.dim("Press enter to run"));
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Always show optional params
|
|
972
|
+
const optionalFields = fields
|
|
973
|
+
.map((f, i) => ({ field: f, idx: i }))
|
|
974
|
+
.filter(({ field }) => !field.required);
|
|
975
|
+
if (optionalFields.length > 0) {
|
|
976
|
+
content.push("");
|
|
977
|
+
content.push(" " + style.dim("Optional parameters (tab to add)"));
|
|
978
|
+
content.push("");
|
|
979
|
+
const maxFlagWidth = Math.max(...optionalFields.map(({ field }) => field.name.length), 0) + 2;
|
|
980
|
+
for (let i = 0; i < optionalFields.length; i++) {
|
|
981
|
+
const { field } = optionalFields[i]!;
|
|
982
|
+
const flagName = field.name.replace(/_/g, "-");
|
|
983
|
+
const hasValue = !!state.formValues[field.name]?.trim();
|
|
984
|
+
const sel = state.formShowOptional && i === state.formListCursor;
|
|
985
|
+
const prefix = sel ? " \u276F " : " ";
|
|
986
|
+
const paddedName = flagName.padEnd(maxFlagWidth);
|
|
987
|
+
const desc = field.prop.description
|
|
988
|
+
? style.dim(truncateVisible(field.prop.description, innerWidth - maxFlagWidth - 8))
|
|
989
|
+
: "";
|
|
990
|
+
if (sel) {
|
|
991
|
+
content.push(style.boldYellow(prefix + paddedName) + " " + desc);
|
|
992
|
+
} else if (hasValue) {
|
|
993
|
+
content.push(prefix + style.green(paddedName) + " " + desc);
|
|
994
|
+
} else {
|
|
995
|
+
content.push(prefix + style.dim(paddedName) + " " + desc);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Footer
|
|
1002
|
+
const footer = editKind
|
|
1003
|
+
? style.dim(footerForFieldKind(editKind))
|
|
1004
|
+
: state.formShowOptional
|
|
1005
|
+
? style.dim("\u2191\u2193 select \u00B7 enter add \u00B7 esc done")
|
|
1006
|
+
: style.dim("enter run \u00B7 tab add option \u00B7 esc back");
|
|
1007
|
+
|
|
1008
|
+
return renderLayout({
|
|
1009
|
+
breadcrumb: style.boldYellow("Readwise") + style.dim(" \u203A ") + style.bold(title),
|
|
1010
|
+
content,
|
|
1011
|
+
footer,
|
|
1012
|
+
});
|
|
538
1013
|
}
|
|
539
1014
|
|
|
540
1015
|
function renderFormPaletteMode(
|
|
@@ -558,28 +1033,51 @@ function renderFormPaletteMode(
|
|
|
558
1033
|
content.push(" " + style.dim(line));
|
|
559
1034
|
}
|
|
560
1035
|
}
|
|
1036
|
+
|
|
1037
|
+
// Progress indicator for required fields
|
|
1038
|
+
const requiredFields = fields.filter((f) => f.required);
|
|
1039
|
+
if (requiredFields.length > 0) {
|
|
1040
|
+
const filledRequired = requiredFields.filter((f) => {
|
|
1041
|
+
const val = state.formValues[f.name]?.trim();
|
|
1042
|
+
if (!val) return false;
|
|
1043
|
+
if (isArrayOfObjects(f.prop)) {
|
|
1044
|
+
try { return JSON.parse(val).length > 0; } catch { return false; }
|
|
1045
|
+
}
|
|
1046
|
+
return true;
|
|
1047
|
+
});
|
|
1048
|
+
const allFilled = filledRequired.length === requiredFields.length;
|
|
1049
|
+
const progressText = `${filledRequired.length} of ${requiredFields.length} required`;
|
|
1050
|
+
content.push(" " + (allFilled ? style.green("✓ " + progressText) : style.dim(progressText)));
|
|
1051
|
+
}
|
|
1052
|
+
|
|
561
1053
|
content.push("");
|
|
562
1054
|
|
|
563
|
-
// Search input
|
|
1055
|
+
// Search input (only show when there's a search query or many fields)
|
|
564
1056
|
const queryText = state.formSearchQuery;
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1057
|
+
if (queryText || fields.length > 6) {
|
|
1058
|
+
const before = queryText.slice(0, state.formSearchCursorPos);
|
|
1059
|
+
const cursorChar = state.formSearchCursorPos < queryText.length
|
|
1060
|
+
? queryText[state.formSearchCursorPos]!
|
|
1061
|
+
: " ";
|
|
1062
|
+
const after = state.formSearchCursorPos < queryText.length
|
|
1063
|
+
? queryText.slice(state.formSearchCursorPos + 1)
|
|
1064
|
+
: "";
|
|
1065
|
+
content.push(" " + style.dim("/") + " " + before + style.inverse(cursorChar) + after);
|
|
1066
|
+
content.push("");
|
|
1067
|
+
} else {
|
|
1068
|
+
content.push("");
|
|
1069
|
+
}
|
|
574
1070
|
|
|
575
|
-
// Compute maxLabelWidth
|
|
1071
|
+
// Compute maxLabelWidth (include " *" for required fields)
|
|
576
1072
|
const maxLabelWidth = Math.max(
|
|
577
1073
|
...fields.map((f) => f.name.length + (f.required ? 2 : 0)),
|
|
578
1074
|
6,
|
|
579
1075
|
) + 1;
|
|
580
1076
|
|
|
581
|
-
//
|
|
582
|
-
const
|
|
1077
|
+
// Badge width: " text" = ~7 chars max
|
|
1078
|
+
const badgeWidth = 8;
|
|
1079
|
+
// Value display width budget: innerWidth - cursor(3) - label - gap(2) - badge
|
|
1080
|
+
const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2 - badgeWidth);
|
|
583
1081
|
|
|
584
1082
|
const headerUsed = content.length;
|
|
585
1083
|
// Reserve space for: blank + Execute + blank + description (up to 4 lines)
|
|
@@ -588,29 +1086,69 @@ function renderFormPaletteMode(
|
|
|
588
1086
|
const filtered = state.formFilteredIndices;
|
|
589
1087
|
const hasOnlyExecute = filtered.length === 1 && filtered[0] === -1;
|
|
590
1088
|
|
|
1089
|
+
// Split fields into required and optional
|
|
1090
|
+
const requiredIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && fields[idx]!.required);
|
|
1091
|
+
const optionalIdxs = filtered.filter((idx) => idx >= 0 && idx < fields.length && !fields[idx]!.required);
|
|
1092
|
+
const hasOptional = optionalIdxs.length > 0;
|
|
1093
|
+
const showingOptional = state.formShowOptional || state.formSearchQuery;
|
|
1094
|
+
// Count optional fields that have been filled
|
|
1095
|
+
const filledOptionalCount = optionalIdxs.filter((idx) => !!state.formValues[fields[idx]!.name]?.trim()).length;
|
|
1096
|
+
|
|
1097
|
+
const renderField = (fieldIdx: number) => {
|
|
1098
|
+
const field = fields[fieldIdx]!;
|
|
1099
|
+
const val = state.formValues[field.name] || "";
|
|
1100
|
+
const isFilled = !!val.trim();
|
|
1101
|
+
const listPos = filtered.indexOf(fieldIdx);
|
|
1102
|
+
const selected = listPos === state.formListCursor;
|
|
1103
|
+
|
|
1104
|
+
// Value display
|
|
1105
|
+
const valStr = formFieldValueDisplay(val, valueAvail);
|
|
1106
|
+
|
|
1107
|
+
// Type badge
|
|
1108
|
+
const badge = style.dim(fieldTypeBadge(field.prop));
|
|
1109
|
+
|
|
1110
|
+
const cursor = selected ? " ❯ " : " ";
|
|
1111
|
+
if (selected) {
|
|
1112
|
+
const label = field.name + (field.required ? " *" : "");
|
|
1113
|
+
content.push(style.boldYellow(cursor) + style.boldYellow(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
|
|
1114
|
+
} else if (isFilled) {
|
|
1115
|
+
const label = field.name + (field.required ? " *" : "");
|
|
1116
|
+
content.push(cursor + style.green(label.padEnd(maxLabelWidth)) + " " + valStr + " " + badge);
|
|
1117
|
+
} else {
|
|
1118
|
+
// Unfilled: show required * in red
|
|
1119
|
+
const namePart = field.name;
|
|
1120
|
+
const starPart = field.required ? " *" : "";
|
|
1121
|
+
const plainLabel = namePart + starPart;
|
|
1122
|
+
const padAmount = Math.max(0, maxLabelWidth - plainLabel.length);
|
|
1123
|
+
const displayLabel = field.required ? namePart + style.red(" *") + " ".repeat(padAmount) : plainLabel.padEnd(maxLabelWidth);
|
|
1124
|
+
content.push(cursor + displayLabel + " " + style.dim("–") + " " + badge);
|
|
1125
|
+
}
|
|
1126
|
+
};
|
|
1127
|
+
|
|
591
1128
|
if (hasOnlyExecute && state.formSearchQuery) {
|
|
592
1129
|
content.push(" " + style.dim("No matching parameters"));
|
|
593
1130
|
content.push("");
|
|
594
1131
|
} 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);
|
|
1132
|
+
// Required fields (always visible)
|
|
1133
|
+
for (const fieldIdx of requiredIdxs) {
|
|
1134
|
+
renderField(fieldIdx);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Optional fields separator
|
|
1138
|
+
if (hasOptional) {
|
|
1139
|
+
if (showingOptional) {
|
|
1140
|
+
if (requiredIdxs.length > 0) content.push("");
|
|
1141
|
+
content.push(" " + style.dim("── optional ──"));
|
|
1142
|
+
const visibleOptional = optionalIdxs.slice(0, listHeight - requiredIdxs.length - 2);
|
|
1143
|
+
for (const fieldIdx of visibleOptional) {
|
|
1144
|
+
renderField(fieldIdx);
|
|
1145
|
+
}
|
|
612
1146
|
} else {
|
|
613
|
-
content.push(
|
|
1147
|
+
content.push("");
|
|
1148
|
+
const optLabel = filledOptionalCount > 0
|
|
1149
|
+
? `── ${optionalIdxs.length} optional (${filledOptionalCount} set) · 'o' to show ──`
|
|
1150
|
+
: `── ${optionalIdxs.length} optional · 'o' to show ──`;
|
|
1151
|
+
content.push(" " + style.dim(optLabel));
|
|
614
1152
|
}
|
|
615
1153
|
}
|
|
616
1154
|
}
|
|
@@ -639,23 +1177,34 @@ function renderFormPaletteMode(
|
|
|
639
1177
|
}
|
|
640
1178
|
}
|
|
641
1179
|
|
|
642
|
-
// Description of highlighted field
|
|
1180
|
+
// Description of highlighted field or Execute hint
|
|
643
1181
|
const highlightedIdx = filtered[state.formListCursor];
|
|
644
1182
|
if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
1183
|
+
const prop = fields[highlightedIdx]!.prop;
|
|
1184
|
+
if (prop.description) {
|
|
647
1185
|
content.push("");
|
|
648
|
-
const wrapped = wrapText(
|
|
1186
|
+
const wrapped = wrapText(prop.description, innerWidth - 4);
|
|
649
1187
|
for (const line of wrapped) {
|
|
650
1188
|
content.push(" " + style.dim(line));
|
|
651
1189
|
}
|
|
652
1190
|
}
|
|
1191
|
+
if (prop.examples?.length) {
|
|
1192
|
+
const exStr = prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
|
|
1193
|
+
content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
|
|
1194
|
+
}
|
|
1195
|
+
} else if (highlightedIdx === -1) {
|
|
1196
|
+
content.push("");
|
|
1197
|
+
content.push(" " + style.dim("Press enter to run"));
|
|
653
1198
|
}
|
|
654
1199
|
|
|
1200
|
+
// Footer hints
|
|
1201
|
+
const hasUnfilledRequired = requiredFields.some((f) => !state.formValues[f.name]?.trim());
|
|
1202
|
+
const tabHint = hasUnfilledRequired ? " · tab next required" : "";
|
|
1203
|
+
const optionalHint = hasOptional ? " · o optional" : "";
|
|
655
1204
|
return renderLayout({
|
|
656
1205
|
breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
|
|
657
1206
|
content,
|
|
658
|
-
footer: style.dim("
|
|
1207
|
+
footer: style.dim("↑↓ navigate · enter edit" + tabHint + optionalHint + " · esc back"),
|
|
659
1208
|
});
|
|
660
1209
|
}
|
|
661
1210
|
|
|
@@ -668,11 +1217,24 @@ function renderFormEditMode(
|
|
|
668
1217
|
|
|
669
1218
|
content.push("");
|
|
670
1219
|
content.push(" " + style.bold(title));
|
|
1220
|
+
|
|
1221
|
+
// Show tool description for context
|
|
1222
|
+
const toolDesc = state.formStack.length > 0
|
|
1223
|
+
? state.formStack[state.formStack.length - 1]!.parentFields
|
|
1224
|
+
.find((f) => f.name === state.formStack[state.formStack.length - 1]!.parentFieldName)
|
|
1225
|
+
?.prop.items?.description
|
|
1226
|
+
: state.selectedTool!.description;
|
|
1227
|
+
if (toolDesc) {
|
|
1228
|
+
const wrapped = wrapText(toolDesc, innerWidth - 4);
|
|
1229
|
+
for (const line of wrapped) {
|
|
1230
|
+
content.push(" " + style.dim(line));
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
671
1233
|
content.push("");
|
|
672
1234
|
|
|
673
|
-
// Field
|
|
1235
|
+
// Field label
|
|
674
1236
|
const nameLabel = field.name + (field.required ? " *" : "");
|
|
675
|
-
content.push("
|
|
1237
|
+
content.push(" " + style.bold(nameLabel));
|
|
676
1238
|
|
|
677
1239
|
// Field description
|
|
678
1240
|
if (field.prop.description) {
|
|
@@ -681,17 +1243,17 @@ function renderFormEditMode(
|
|
|
681
1243
|
content.push(" " + style.dim(line));
|
|
682
1244
|
}
|
|
683
1245
|
}
|
|
1246
|
+
if (field.prop.examples?.length) {
|
|
1247
|
+
const exStr = field.prop.examples.map((e) => typeof e === "string" ? e : JSON.stringify(e)).join(", ");
|
|
1248
|
+
content.push(" " + style.dim("e.g. ") + style.dim(style.cyan(truncateVisible(exStr, innerWidth - 10))));
|
|
1249
|
+
}
|
|
684
1250
|
content.push("");
|
|
685
1251
|
|
|
686
1252
|
// Editor area
|
|
1253
|
+
const kind = classifyField(field.prop);
|
|
687
1254
|
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
1255
|
|
|
694
|
-
if (
|
|
1256
|
+
if (kind === "arrayObj") {
|
|
695
1257
|
// Array-of-objects editor: show existing items + "Add new item"
|
|
696
1258
|
const existing = state.formValues[field.name] || "[]";
|
|
697
1259
|
let items: unknown[] = [];
|
|
@@ -715,9 +1277,10 @@ function renderFormEditMode(
|
|
|
715
1277
|
} else {
|
|
716
1278
|
content.push(" " + style.dim("+") + " Add new item");
|
|
717
1279
|
}
|
|
718
|
-
} else if (
|
|
1280
|
+
} else if (kind === "date") {
|
|
1281
|
+
const dateFmt = dateFieldFormat(field.prop)!;
|
|
719
1282
|
content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
|
|
720
|
-
} else if (
|
|
1283
|
+
} else if (kind === "arrayEnum" && eVals) {
|
|
721
1284
|
// Multi-select picker
|
|
722
1285
|
for (let ci = 0; ci < eVals.length; ci++) {
|
|
723
1286
|
const isCursor = ci === state.formEnumCursor;
|
|
@@ -727,7 +1290,7 @@ function renderFormEditMode(
|
|
|
727
1290
|
const label = marker + check + " " + eVals[ci]!;
|
|
728
1291
|
content.push(isCursor ? style.bold(label) : label);
|
|
729
1292
|
}
|
|
730
|
-
} else if (
|
|
1293
|
+
} else if (kind === "arrayText") {
|
|
731
1294
|
// Tag-style list editor: navigable items + text input at bottom
|
|
732
1295
|
const existing = state.formValues[field.name] || "";
|
|
733
1296
|
const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
@@ -750,45 +1313,76 @@ function renderFormEditMode(
|
|
|
750
1313
|
content.push(" " + style.dim("enter ") + style.dim("edit item"));
|
|
751
1314
|
content.push(" " + style.dim("bksp ") + style.dim("remove item"));
|
|
752
1315
|
}
|
|
753
|
-
} else if (
|
|
754
|
-
const choices =
|
|
1316
|
+
} else if (kind === "enum" || kind === "bool") {
|
|
1317
|
+
const choices = kind === "bool" ? ["true", "false"] : eVals!;
|
|
755
1318
|
for (let ci = 0; ci < choices.length; ci++) {
|
|
756
1319
|
const sel = ci === state.formEnumCursor;
|
|
757
1320
|
const choiceLine = (sel ? " › " : " ") + choices[ci]!;
|
|
758
1321
|
content.push(sel ? style.cyan(style.bold(choiceLine)) : choiceLine);
|
|
759
1322
|
}
|
|
760
1323
|
} else {
|
|
761
|
-
// Text editor
|
|
762
|
-
const
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1324
|
+
// Text editor with cursor position
|
|
1325
|
+
const prefix0 = " ";
|
|
1326
|
+
if (!state.formInputBuf) {
|
|
1327
|
+
// Show placeholder text when input is empty
|
|
1328
|
+
let placeholder = "type a value";
|
|
1329
|
+
if (field.prop.examples?.length) {
|
|
1330
|
+
placeholder = String(field.prop.examples[0]);
|
|
1331
|
+
} else if (field.prop.description) {
|
|
1332
|
+
placeholder = field.prop.description.toLowerCase().replace(/[.!]$/, "");
|
|
1333
|
+
} else if (field.prop.type === "integer" || field.prop.type === "number") {
|
|
1334
|
+
placeholder = "enter a number";
|
|
1335
|
+
}
|
|
1336
|
+
content.push(prefix0 + style.inverse(" ") + style.dim(" " + placeholder + "…"));
|
|
1337
|
+
} else {
|
|
1338
|
+
const lines = state.formInputBuf.split("\n");
|
|
1339
|
+
// Find cursor line and column from flat position
|
|
1340
|
+
let cursorLine = 0;
|
|
1341
|
+
let cursorCol = state.formInputCursorPos;
|
|
1342
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1343
|
+
if (cursorCol <= lines[li]!.length) {
|
|
1344
|
+
cursorLine = li;
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
cursorCol -= lines[li]!.length + 1;
|
|
1348
|
+
}
|
|
1349
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1350
|
+
const prefix = li === 0 ? prefix0 : " ";
|
|
1351
|
+
const lineText = lines[li]!;
|
|
1352
|
+
if (li === cursorLine) {
|
|
1353
|
+
const before = lineText.slice(0, cursorCol);
|
|
1354
|
+
const cursorChar = cursorCol < lineText.length ? lineText[cursorCol]! : " ";
|
|
1355
|
+
const after = cursorCol < lineText.length ? lineText.slice(cursorCol + 1) : "";
|
|
1356
|
+
content.push(prefix + style.cyan(before) + style.inverse(cursorChar) + style.cyan(after));
|
|
1357
|
+
} else {
|
|
1358
|
+
content.push(prefix + style.cyan(lineText));
|
|
1359
|
+
}
|
|
769
1360
|
}
|
|
770
1361
|
}
|
|
771
1362
|
}
|
|
772
1363
|
|
|
773
|
-
|
|
774
|
-
if (
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
1364
|
+
// Show remaining required fields hint (for text editors)
|
|
1365
|
+
if (kind === "text") {
|
|
1366
|
+
const requiredFields = fields.filter((f) => f.required);
|
|
1367
|
+
const filledCount = requiredFields.filter((f) => {
|
|
1368
|
+
if (f.name === field.name) return !!state.formInputBuf.trim(); // current field
|
|
1369
|
+
return !!state.formValues[f.name]?.trim();
|
|
1370
|
+
}).length;
|
|
1371
|
+
const remaining = requiredFields.length - filledCount;
|
|
1372
|
+
content.push("");
|
|
1373
|
+
if (remaining <= 0) {
|
|
1374
|
+
content.push(" " + style.dim("Then press enter to confirm → Execute"));
|
|
1375
|
+
} else if (remaining === 1 && !state.formInputBuf.trim()) {
|
|
1376
|
+
content.push(" " + style.dim("Type a value, then press enter"));
|
|
1377
|
+
} else {
|
|
1378
|
+
content.push(" " + style.dim(`${remaining} required field${remaining > 1 ? "s" : ""} remaining`));
|
|
1379
|
+
}
|
|
786
1380
|
}
|
|
787
1381
|
|
|
788
1382
|
return renderLayout({
|
|
789
1383
|
breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
|
|
790
1384
|
content,
|
|
791
|
-
footer,
|
|
1385
|
+
footer: style.dim(footerForFieldKind(kind)),
|
|
792
1386
|
});
|
|
793
1387
|
}
|
|
794
1388
|
|
|
@@ -822,6 +1416,122 @@ const SUCCESS_ICON = [
|
|
|
822
1416
|
" ╚═════╝ ╚═╝ ╚═╝",
|
|
823
1417
|
];
|
|
824
1418
|
|
|
1419
|
+
function cardLine(text: string, innerW: number, borderFn: (s: string) => string): string {
|
|
1420
|
+
return " " + borderFn("│") + " " + fitWidth(text, innerW) + " " + borderFn("│");
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
function buildCardLines(card: CardItem, ci: number, selected: boolean, cardWidth: number): string[] {
|
|
1424
|
+
const borderColor = selected ? style.cyan : style.dim;
|
|
1425
|
+
const innerW = Math.max(1, cardWidth - 4);
|
|
1426
|
+
const lines: string[] = [];
|
|
1427
|
+
|
|
1428
|
+
lines.push(" " + borderColor("╭" + "─".repeat(cardWidth - 2) + "╮"));
|
|
1429
|
+
|
|
1430
|
+
if (card.kind === "highlight") {
|
|
1431
|
+
// Highlight card: quote-style passage, optional note, meta
|
|
1432
|
+
const quotePrefix = "\u201c ";
|
|
1433
|
+
const quoteSuffix = "\u201d";
|
|
1434
|
+
const passage = card.summary || "\u2026";
|
|
1435
|
+
const maxQuoteW = innerW - quotePrefix.length;
|
|
1436
|
+
const wrapped = wrapText(passage, maxQuoteW);
|
|
1437
|
+
// Cap at 6 lines
|
|
1438
|
+
const showLines = wrapped.slice(0, 6);
|
|
1439
|
+
if (wrapped.length > 6) {
|
|
1440
|
+
const last = showLines[5]!;
|
|
1441
|
+
showLines[5] = truncateVisible(last, maxQuoteW - 1) + "…";
|
|
1442
|
+
}
|
|
1443
|
+
for (let i = 0; i < showLines.length; i++) {
|
|
1444
|
+
let lineText: string;
|
|
1445
|
+
if (i === 0) {
|
|
1446
|
+
lineText = quotePrefix + showLines[i]!;
|
|
1447
|
+
if (showLines.length === 1) lineText += quoteSuffix;
|
|
1448
|
+
} else if (i === showLines.length - 1) {
|
|
1449
|
+
lineText = " " + showLines[i]! + quoteSuffix;
|
|
1450
|
+
} else {
|
|
1451
|
+
lineText = " " + showLines[i]!;
|
|
1452
|
+
}
|
|
1453
|
+
const styled = selected ? style.cyan(lineText) : lineText;
|
|
1454
|
+
lines.push(cardLine(styled, innerW, borderColor));
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Note (if present)
|
|
1458
|
+
if (card.note) {
|
|
1459
|
+
const noteText = "✏ " + truncateVisible(card.note, innerW - 2);
|
|
1460
|
+
lines.push(cardLine(style.yellow(noteText), innerW, borderColor));
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Meta line
|
|
1464
|
+
if (card.meta) {
|
|
1465
|
+
lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
|
|
1466
|
+
}
|
|
1467
|
+
} else {
|
|
1468
|
+
// Document card: title, summary, meta
|
|
1469
|
+
const titleText = truncateVisible(card.title || "Untitled", innerW);
|
|
1470
|
+
const titleStyled = selected ? style.bold(style.cyan(titleText)) : style.bold(titleText);
|
|
1471
|
+
lines.push(cardLine(titleStyled, innerW, borderColor));
|
|
1472
|
+
|
|
1473
|
+
if (card.summary) {
|
|
1474
|
+
const summaryText = truncateVisible(card.summary, innerW);
|
|
1475
|
+
lines.push(cardLine(style.dim(summaryText), innerW, borderColor));
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (card.meta) {
|
|
1479
|
+
lines.push(cardLine(style.dim(truncateVisible(card.meta, innerW)), innerW, borderColor));
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
lines.push(" " + borderColor("╰" + "─".repeat(cardWidth - 2) + "╯"));
|
|
1484
|
+
return lines;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function renderCardView(state: AppState): string[] {
|
|
1488
|
+
const { contentHeight, innerWidth } = getBoxDimensions();
|
|
1489
|
+
const tool = state.selectedTool;
|
|
1490
|
+
const title = tool ? humanLabel(tool.name, toolPrefix(tool)) : "";
|
|
1491
|
+
const cards = state.resultCards;
|
|
1492
|
+
const cardWidth = Math.min(innerWidth - 4, 72);
|
|
1493
|
+
|
|
1494
|
+
const content: string[] = [];
|
|
1495
|
+
|
|
1496
|
+
// Header with count
|
|
1497
|
+
const countInfo = style.dim(` (${state.resultCursor + 1} of ${cards.length})`);
|
|
1498
|
+
content.push(" " + style.bold("Results") + countInfo);
|
|
1499
|
+
content.push("");
|
|
1500
|
+
|
|
1501
|
+
// Build all card lines
|
|
1502
|
+
const allLines: { line: string; cardIdx: number }[] = [];
|
|
1503
|
+
for (let ci = 0; ci < cards.length; ci++) {
|
|
1504
|
+
const cardContentLines = buildCardLines(cards[ci]!, ci, ci === state.resultCursor, cardWidth);
|
|
1505
|
+
for (const line of cardContentLines) {
|
|
1506
|
+
allLines.push({ line, cardIdx: ci });
|
|
1507
|
+
}
|
|
1508
|
+
if (ci < cards.length - 1) {
|
|
1509
|
+
allLines.push({ line: "", cardIdx: ci });
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// Scroll so selected card is visible
|
|
1514
|
+
const availableHeight = Math.max(1, contentHeight - content.length);
|
|
1515
|
+
const scroll = state.resultCardScroll;
|
|
1516
|
+
const visible = allLines.slice(scroll, scroll + availableHeight);
|
|
1517
|
+
for (const entry of visible) {
|
|
1518
|
+
content.push(entry.line);
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const hasUrl = cards[state.resultCursor]?.url;
|
|
1522
|
+
const footerParts = ["↑↓ navigate"];
|
|
1523
|
+
if (hasUrl) footerParts.push("enter open");
|
|
1524
|
+
footerParts.push("esc back", "q quit");
|
|
1525
|
+
|
|
1526
|
+
return renderLayout({
|
|
1527
|
+
breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › results"),
|
|
1528
|
+
content,
|
|
1529
|
+
footer: state.quitConfirm
|
|
1530
|
+
? style.yellow("Press q again to quit")
|
|
1531
|
+
: style.dim(footerParts.join(" · ")),
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
825
1535
|
function renderResults(state: AppState): string[] {
|
|
826
1536
|
const { contentHeight, innerWidth } = getBoxDimensions();
|
|
827
1537
|
const tool = state.selectedTool;
|
|
@@ -830,6 +1540,11 @@ function renderResults(state: AppState): string[] {
|
|
|
830
1540
|
const isEmptyList = !isError && state.result === EMPTY_LIST_SENTINEL;
|
|
831
1541
|
const isEmpty = !isError && !isEmptyList && !state.result.trim();
|
|
832
1542
|
|
|
1543
|
+
// Card view for list results
|
|
1544
|
+
if (!isError && !isEmptyList && !isEmpty && state.resultCards.length > 0) {
|
|
1545
|
+
return renderCardView(state);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
833
1548
|
// No results screen for empty lists
|
|
834
1549
|
if (isEmptyList) {
|
|
835
1550
|
const ghost = [
|
|
@@ -860,7 +1575,7 @@ function renderResults(state: AppState): string[] {
|
|
|
860
1575
|
content,
|
|
861
1576
|
footer: state.quitConfirm
|
|
862
1577
|
? style.yellow("Press q again to quit")
|
|
863
|
-
: style.dim("enter/esc back
|
|
1578
|
+
: style.dim("enter/esc back · q quit"),
|
|
864
1579
|
});
|
|
865
1580
|
}
|
|
866
1581
|
|
|
@@ -881,7 +1596,7 @@ function renderResults(state: AppState): string[] {
|
|
|
881
1596
|
content,
|
|
882
1597
|
footer: state.quitConfirm
|
|
883
1598
|
? style.yellow("Press q again to quit")
|
|
884
|
-
: style.dim("enter/esc back
|
|
1599
|
+
: style.dim("enter/esc back · q quit"),
|
|
885
1600
|
});
|
|
886
1601
|
}
|
|
887
1602
|
|
|
@@ -913,7 +1628,7 @@ function renderResults(state: AppState): string[] {
|
|
|
913
1628
|
content,
|
|
914
1629
|
footer: state.quitConfirm
|
|
915
1630
|
? style.yellow("Press q again to quit")
|
|
916
|
-
: style.dim(scrollHint + "↑↓←→ scroll
|
|
1631
|
+
: style.dim(scrollHint + "↑↓←→ scroll · esc back · q quit"),
|
|
917
1632
|
});
|
|
918
1633
|
}
|
|
919
1634
|
|
|
@@ -928,7 +1643,7 @@ function renderState(state: AppState): string[] {
|
|
|
928
1643
|
|
|
929
1644
|
// --- Input handling ---
|
|
930
1645
|
|
|
931
|
-
function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" {
|
|
1646
|
+
function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" | "openUrl" {
|
|
932
1647
|
switch (state.view) {
|
|
933
1648
|
case "commands": return handleCommandListInput(state, key);
|
|
934
1649
|
case "form": return handleFormInput(state, key);
|
|
@@ -945,10 +1660,8 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
|
|
|
945
1660
|
const logoUsed = LOGO.length + 3;
|
|
946
1661
|
const listHeight = Math.max(1, contentHeight - logoUsed);
|
|
947
1662
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
// Escape: clear query if non-empty, otherwise quit confirm
|
|
951
|
-
if (key.name === "escape") {
|
|
1663
|
+
// Escape / ctrl+c / q: clear query if non-empty, otherwise quit confirm
|
|
1664
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
952
1665
|
if (state.searchQuery) {
|
|
953
1666
|
const filtered = filterCommands(state.tools, "");
|
|
954
1667
|
const sel = selectableIndices(filtered);
|
|
@@ -1034,11 +1747,7 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
|
|
|
1034
1747
|
|
|
1035
1748
|
const formValues: Record<string, string> = {};
|
|
1036
1749
|
for (const f of fields) {
|
|
1037
|
-
|
|
1038
|
-
formValues[f.name] = String(f.prop.default);
|
|
1039
|
-
} else {
|
|
1040
|
-
formValues[f.name] = "";
|
|
1041
|
-
}
|
|
1750
|
+
formValues[f.name] = "";
|
|
1042
1751
|
}
|
|
1043
1752
|
|
|
1044
1753
|
if (fields.length === 0) {
|
|
@@ -1057,33 +1766,44 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
|
|
|
1057
1766
|
formEditFieldIdx: -1,
|
|
1058
1767
|
formEditing: false,
|
|
1059
1768
|
formInputBuf: "",
|
|
1769
|
+
formInputCursorPos: 0,
|
|
1060
1770
|
formEnumCursor: 0,
|
|
1061
1771
|
formEnumSelected: new Set(),
|
|
1062
|
-
formShowRequired: false,
|
|
1772
|
+
formShowRequired: false, formShowOptional: false,
|
|
1063
1773
|
formStack: [],
|
|
1064
1774
|
};
|
|
1065
1775
|
}
|
|
1066
1776
|
|
|
1067
|
-
|
|
1777
|
+
const filteredIndices = filterFormFields(fields, "");
|
|
1778
|
+
const firstBlankRequired = fields.findIndex((f) => f.required && !formValues[f.name]?.trim());
|
|
1779
|
+
|
|
1780
|
+
const baseState: AppState = {
|
|
1068
1781
|
...s,
|
|
1069
|
-
view: "form",
|
|
1782
|
+
view: "form" as View,
|
|
1070
1783
|
selectedTool: tool,
|
|
1071
1784
|
fields,
|
|
1072
1785
|
nameColWidth,
|
|
1073
1786
|
formValues,
|
|
1074
1787
|
formSearchQuery: "",
|
|
1075
1788
|
formSearchCursorPos: 0,
|
|
1076
|
-
formFilteredIndices:
|
|
1077
|
-
formListCursor: defaultFormCursor(fields,
|
|
1789
|
+
formFilteredIndices: filteredIndices,
|
|
1790
|
+
formListCursor: defaultFormCursor(fields, filteredIndices, formValues),
|
|
1078
1791
|
formScrollTop: 0,
|
|
1079
1792
|
formEditFieldIdx: -1,
|
|
1080
1793
|
formEditing: false,
|
|
1081
1794
|
formInputBuf: "",
|
|
1795
|
+
formInputCursorPos: 0,
|
|
1082
1796
|
formEnumCursor: 0,
|
|
1083
1797
|
formEnumSelected: new Set(),
|
|
1084
|
-
formShowRequired: false,
|
|
1798
|
+
formShowRequired: false, formShowOptional: false,
|
|
1085
1799
|
formStack: [],
|
|
1086
1800
|
};
|
|
1801
|
+
|
|
1802
|
+
// Auto-open first required field
|
|
1803
|
+
if (firstBlankRequired >= 0) {
|
|
1804
|
+
return startEditingField(baseState, firstBlankRequired);
|
|
1805
|
+
}
|
|
1806
|
+
return baseState;
|
|
1087
1807
|
}
|
|
1088
1808
|
}
|
|
1089
1809
|
return s;
|
|
@@ -1100,20 +1820,169 @@ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exi
|
|
|
1100
1820
|
return s;
|
|
1101
1821
|
}
|
|
1102
1822
|
|
|
1103
|
-
// Printable characters: insert into search query
|
|
1104
|
-
if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
|
|
1105
|
-
const
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1823
|
+
// Printable characters or paste: insert into search query
|
|
1824
|
+
if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
|
|
1825
|
+
const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
|
|
1826
|
+
if (text) {
|
|
1827
|
+
const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + text + s.searchQuery.slice(s.searchCursorPos);
|
|
1828
|
+
const filtered = filterCommands(s.tools, newQuery);
|
|
1829
|
+
const sel = selectableIndices(filtered);
|
|
1830
|
+
return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + text.length, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
|
|
1831
|
+
}
|
|
1109
1832
|
}
|
|
1110
1833
|
|
|
1111
1834
|
return s;
|
|
1112
1835
|
}
|
|
1113
1836
|
|
|
1837
|
+
function startEditingField(state: AppState, fieldIdx: number): AppState {
|
|
1838
|
+
const field = state.fields[fieldIdx]!;
|
|
1839
|
+
if (isArrayOfObjects(field.prop)) {
|
|
1840
|
+
const existing = state.formValues[field.name] || "[]";
|
|
1841
|
+
let items: unknown[] = [];
|
|
1842
|
+
try { items = JSON.parse(existing); } catch { /* */ }
|
|
1843
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: items.length };
|
|
1844
|
+
}
|
|
1845
|
+
const dateFmt = dateFieldFormat(field.prop);
|
|
1846
|
+
if (dateFmt) {
|
|
1847
|
+
const existing = state.formValues[field.name] || "";
|
|
1848
|
+
const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
|
|
1849
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, dateParts: parts, datePartCursor: 0 };
|
|
1850
|
+
}
|
|
1851
|
+
const enumValues = field.prop.enum || field.prop.items?.enum;
|
|
1852
|
+
const isBool = field.prop.type === "boolean";
|
|
1853
|
+
const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
|
|
1854
|
+
if (isArrayEnum && enumValues) {
|
|
1855
|
+
const curVal = state.formValues[field.name] || "";
|
|
1856
|
+
const selected = new Set<number>();
|
|
1857
|
+
if (curVal) {
|
|
1858
|
+
const parts = curVal.split(",").map((s) => s.trim());
|
|
1859
|
+
for (const p of parts) {
|
|
1860
|
+
const idx = enumValues.indexOf(p);
|
|
1861
|
+
if (idx >= 0) selected.add(idx);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: 0, formEnumSelected: selected };
|
|
1865
|
+
}
|
|
1866
|
+
if (enumValues || isBool) {
|
|
1867
|
+
const choices = isBool ? ["true", "false"] : enumValues!;
|
|
1868
|
+
const curVal = state.formValues[field.name] || "";
|
|
1869
|
+
const idx = choices.indexOf(curVal);
|
|
1870
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formEnumCursor: idx >= 0 ? idx : 0 };
|
|
1871
|
+
}
|
|
1872
|
+
if (field.prop.type === "array" && !field.prop.items?.enum) {
|
|
1873
|
+
const existing = state.formValues[field.name] || "";
|
|
1874
|
+
const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
|
|
1875
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: itemCount };
|
|
1876
|
+
}
|
|
1877
|
+
const editBuf = state.formValues[field.name] || "";
|
|
1878
|
+
return { ...state, formEditing: true, formEditFieldIdx: fieldIdx, formInputBuf: editBuf, formInputCursorPos: editBuf.length };
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1114
1881
|
function handleFormInput(state: AppState, key: KeyEvent): AppState | "submit" {
|
|
1115
|
-
|
|
1116
|
-
|
|
1882
|
+
// Sub-forms use existing palette/edit handlers
|
|
1883
|
+
if (state.formStack.length > 0) {
|
|
1884
|
+
if (state.formEditing) return handleFormEditInput(state, key);
|
|
1885
|
+
return handleFormPaletteInput(state, key);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Command builder: editing a field
|
|
1889
|
+
if (state.formEditing) {
|
|
1890
|
+
const result = handleFormEditInput(state, key);
|
|
1891
|
+
if (result === "submit") return result;
|
|
1892
|
+
// Auto-advance: if editing just ended via confirm (not cancel), jump to next blank required field
|
|
1893
|
+
if (!result.formEditing && state.formEditing) {
|
|
1894
|
+
const wasCancel = key.name === "escape";
|
|
1895
|
+
if (!wasCancel) {
|
|
1896
|
+
const nextBlank = result.fields.findIndex((f) => f.required && !result.formValues[f.name]?.trim());
|
|
1897
|
+
if (nextBlank >= 0) {
|
|
1898
|
+
return startEditingField(result, nextBlank);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
return result;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// Command builder: optional picker
|
|
1906
|
+
if (state.formShowOptional) {
|
|
1907
|
+
return handleOptionalPickerInput(state, key);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Command builder: ready state
|
|
1911
|
+
return handleCommandBuilderReadyInput(state, key);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function commandListReset(tools: ToolDef[]): Partial<AppState> {
|
|
1915
|
+
const filteredItems = buildCommandList(tools);
|
|
1916
|
+
const sel = selectableIndices(filteredItems);
|
|
1917
|
+
return {
|
|
1918
|
+
view: "commands" as View, selectedTool: null,
|
|
1919
|
+
searchQuery: "", searchCursorPos: 0,
|
|
1920
|
+
filteredItems, listCursor: sel[0] ?? 0, listScrollTop: 0,
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function handleCommandBuilderReadyInput(state: AppState, key: KeyEvent): AppState | "submit" {
|
|
1925
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
1926
|
+
return { ...state, ...commandListReset(state.tools) };
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
if (key.name === "return") {
|
|
1930
|
+
if (missingRequiredFields(state.fields, state.formValues).length === 0) {
|
|
1931
|
+
return "submit";
|
|
1932
|
+
}
|
|
1933
|
+
// Jump to first missing required field
|
|
1934
|
+
const nextBlank = state.fields.findIndex((f) => f.required && !state.formValues[f.name]?.trim());
|
|
1935
|
+
if (nextBlank >= 0) {
|
|
1936
|
+
return startEditingField(state, nextBlank);
|
|
1937
|
+
}
|
|
1938
|
+
return state;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
if (key.name === "tab") {
|
|
1942
|
+
const hasOptional = state.fields.some((f) => !f.required);
|
|
1943
|
+
if (hasOptional) {
|
|
1944
|
+
return { ...state, formShowOptional: true, formListCursor: 0 };
|
|
1945
|
+
}
|
|
1946
|
+
return state;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// Backspace: re-edit last set field
|
|
1950
|
+
if (key.name === "backspace") {
|
|
1951
|
+
for (let i = state.fields.length - 1; i >= 0; i--) {
|
|
1952
|
+
if (state.formValues[state.fields[i]!.name]?.trim()) {
|
|
1953
|
+
return startEditingField(state, i);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return state;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
return state;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
function handleOptionalPickerInput(state: AppState, key: KeyEvent): AppState | "submit" {
|
|
1963
|
+
const optionalFields = state.fields
|
|
1964
|
+
.map((f, i) => ({ field: f, idx: i }))
|
|
1965
|
+
.filter(({ field }) => !field.required);
|
|
1966
|
+
|
|
1967
|
+
if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
|
1968
|
+
return { ...state, formShowOptional: false };
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
if (key.name === "up") {
|
|
1972
|
+
return { ...state, formListCursor: state.formListCursor > 0 ? state.formListCursor - 1 : optionalFields.length - 1 };
|
|
1973
|
+
}
|
|
1974
|
+
if (key.name === "down") {
|
|
1975
|
+
return { ...state, formListCursor: state.formListCursor < optionalFields.length - 1 ? state.formListCursor + 1 : 0 };
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
if (key.name === "return") {
|
|
1979
|
+
const selected = optionalFields[state.formListCursor];
|
|
1980
|
+
if (selected) {
|
|
1981
|
+
return startEditingField({ ...state, formShowOptional: false }, selected.idx);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
return state;
|
|
1117
1986
|
}
|
|
1118
1987
|
|
|
1119
1988
|
function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "submit" {
|
|
@@ -1155,13 +2024,62 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
|
|
|
1155
2024
|
formFilteredIndices: parentFiltered,
|
|
1156
2025
|
formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, entry.parentValues),
|
|
1157
2026
|
formScrollTop: 0,
|
|
1158
|
-
formShowRequired: false,
|
|
2027
|
+
formShowRequired: false, formShowOptional: false,
|
|
1159
2028
|
formInputBuf: "",
|
|
2029
|
+
formInputCursorPos: 0,
|
|
1160
2030
|
};
|
|
1161
2031
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
2032
|
+
return { ...state, ...commandListReset(state.tools) };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Tab: jump to next unfilled required field
|
|
2036
|
+
if (key.name === "tab") {
|
|
2037
|
+
const unfilledRequired = filtered
|
|
2038
|
+
.map((idx, listPos) => ({ idx, listPos }))
|
|
2039
|
+
.filter(({ idx }) => {
|
|
2040
|
+
if (idx < 0 || idx >= fields.length) return false;
|
|
2041
|
+
const f = fields[idx]!;
|
|
2042
|
+
if (!f.required) return false;
|
|
2043
|
+
const val = state.formValues[f.name]?.trim();
|
|
2044
|
+
if (!val) return true;
|
|
2045
|
+
if (isArrayOfObjects(f.prop)) {
|
|
2046
|
+
try { return JSON.parse(val).length === 0; } catch { return true; }
|
|
2047
|
+
}
|
|
2048
|
+
return false;
|
|
2049
|
+
});
|
|
2050
|
+
if (unfilledRequired.length > 0) {
|
|
2051
|
+
// Find the next one after current cursor, wrapping around
|
|
2052
|
+
const after = unfilledRequired.find((u) => u.listPos > formListCursor);
|
|
2053
|
+
const target = after || unfilledRequired[0]!;
|
|
2054
|
+
let scroll = state.formScrollTop;
|
|
2055
|
+
const paramItems = filtered.filter((idx) => idx !== -1);
|
|
2056
|
+
if (target.idx >= 0) {
|
|
2057
|
+
const posInParams = paramItems.indexOf(target.idx);
|
|
2058
|
+
if (posInParams < scroll) scroll = posInParams;
|
|
2059
|
+
if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
|
|
2060
|
+
}
|
|
2061
|
+
return { ...state, formListCursor: target.listPos, formScrollTop: scroll };
|
|
2062
|
+
}
|
|
2063
|
+
// No unfilled required fields — jump to Execute
|
|
2064
|
+
const execPos = filtered.indexOf(-1);
|
|
2065
|
+
if (execPos >= 0) return { ...state, formListCursor: execPos };
|
|
2066
|
+
return state;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// 'o' key: toggle optional fields visibility (when not searching)
|
|
2070
|
+
if (key.raw === "o" && !key.ctrl && !formSearchQuery) {
|
|
2071
|
+
const optionalExists = filtered.some((idx) => idx >= 0 && idx < fields.length && !fields[idx]!.required);
|
|
2072
|
+
if (optionalExists) {
|
|
2073
|
+
const newShow = !state.formShowOptional;
|
|
2074
|
+
if (!newShow) {
|
|
2075
|
+
const curIdx = filtered[formListCursor];
|
|
2076
|
+
if (curIdx !== undefined && curIdx >= 0 && curIdx < fields.length && !fields[curIdx]!.required) {
|
|
2077
|
+
const execPos = filtered.indexOf(-1);
|
|
2078
|
+
return { ...state, formShowOptional: false, formListCursor: execPos >= 0 ? execPos : 0 };
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
return { ...state, formShowOptional: newShow };
|
|
2082
|
+
}
|
|
1165
2083
|
}
|
|
1166
2084
|
|
|
1167
2085
|
// Arrow left/right: move text cursor within search input
|
|
@@ -1172,29 +2090,44 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
|
|
|
1172
2090
|
return { ...state, formSearchCursorPos: Math.min(formSearchQuery.length, state.formSearchCursorPos + 1) };
|
|
1173
2091
|
}
|
|
1174
2092
|
|
|
1175
|
-
//
|
|
2093
|
+
// Helper: check if a position in filtered is navigable (skip collapsed optional fields)
|
|
2094
|
+
const isNavigable = (listPos: number) => {
|
|
2095
|
+
const idx = filtered[listPos];
|
|
2096
|
+
if (idx === undefined) return false;
|
|
2097
|
+
if (idx === -1) return true; // Execute always navigable
|
|
2098
|
+
if (!state.formShowOptional && !state.formSearchQuery && idx >= 0 && idx < fields.length && !fields[idx]!.required) return false;
|
|
2099
|
+
return true;
|
|
2100
|
+
};
|
|
2101
|
+
|
|
2102
|
+
// Arrow up/down: navigate filtered list (cycling, skipping hidden items)
|
|
1176
2103
|
if (key.name === "up") {
|
|
1177
|
-
|
|
2104
|
+
let next = formListCursor;
|
|
2105
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
2106
|
+
next = next > 0 ? next - 1 : filtered.length - 1;
|
|
2107
|
+
if (isNavigable(next)) break;
|
|
2108
|
+
}
|
|
1178
2109
|
let scroll = state.formScrollTop;
|
|
1179
2110
|
const itemIdx = filtered[next]!;
|
|
1180
2111
|
if (itemIdx !== -1) {
|
|
1181
2112
|
const paramItems = filtered.filter((idx) => idx !== -1);
|
|
1182
2113
|
const posInParams = paramItems.indexOf(itemIdx);
|
|
1183
2114
|
if (posInParams < scroll) scroll = posInParams;
|
|
1184
|
-
// Wrap to bottom: reset scroll to show end of list
|
|
1185
2115
|
if (next > formListCursor) scroll = Math.max(0, paramItems.length - listHeight);
|
|
1186
2116
|
}
|
|
1187
2117
|
return { ...state, formListCursor: next, formScrollTop: scroll };
|
|
1188
2118
|
}
|
|
1189
2119
|
if (key.name === "down") {
|
|
1190
|
-
|
|
2120
|
+
let next = formListCursor;
|
|
2121
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
2122
|
+
next = next < filtered.length - 1 ? next + 1 : 0;
|
|
2123
|
+
if (isNavigable(next)) break;
|
|
2124
|
+
}
|
|
1191
2125
|
let scroll = state.formScrollTop;
|
|
1192
2126
|
const itemIdx = filtered[next]!;
|
|
1193
2127
|
if (itemIdx !== -1) {
|
|
1194
2128
|
const paramItems = filtered.filter((idx) => idx !== -1);
|
|
1195
2129
|
const posInParams = paramItems.indexOf(itemIdx);
|
|
1196
2130
|
if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
|
|
1197
|
-
// Wrap to top: reset scroll
|
|
1198
2131
|
if (next < formListCursor) scroll = 0;
|
|
1199
2132
|
} else if (next < formListCursor) {
|
|
1200
2133
|
scroll = 0;
|
|
@@ -1217,47 +2150,7 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
|
|
|
1217
2150
|
return { ...state, formShowRequired: true };
|
|
1218
2151
|
}
|
|
1219
2152
|
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] || "" };
|
|
2153
|
+
return startEditingField(state, highlightedIdx);
|
|
1261
2154
|
}
|
|
1262
2155
|
return state;
|
|
1263
2156
|
}
|
|
@@ -1272,11 +2165,14 @@ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "sub
|
|
|
1272
2165
|
return state;
|
|
1273
2166
|
}
|
|
1274
2167
|
|
|
1275
|
-
// Printable characters: insert into search query
|
|
1276
|
-
if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
|
|
1277
|
-
const
|
|
1278
|
-
|
|
1279
|
-
|
|
2168
|
+
// Printable characters or paste: insert into search query
|
|
2169
|
+
if (key.name === "paste" || (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ")) {
|
|
2170
|
+
const text = (key.name === "paste" ? key.raw.replace(/[\x00-\x1f\x7f]/g, "") : key.raw) || "";
|
|
2171
|
+
if (text) {
|
|
2172
|
+
const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + text + formSearchQuery.slice(state.formSearchCursorPos);
|
|
2173
|
+
const newFiltered = filterFormFields(fields, newQuery);
|
|
2174
|
+
return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + text.length, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
|
|
2175
|
+
}
|
|
1280
2176
|
}
|
|
1281
2177
|
|
|
1282
2178
|
return state;
|
|
@@ -1291,7 +2187,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
|
|
|
1291
2187
|
|
|
1292
2188
|
const resetPalette = (updatedValues?: Record<string, string>) => {
|
|
1293
2189
|
const f = filterFormFields(fields, "");
|
|
1294
|
-
return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false };
|
|
2190
|
+
return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false, formShowOptional: false };
|
|
1295
2191
|
};
|
|
1296
2192
|
|
|
1297
2193
|
// Escape: cancel edit (for multi-select and tag editor, escape confirms since items are saved live)
|
|
@@ -1304,7 +2200,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
|
|
|
1304
2200
|
const newValues = { ...formValues, [field.name]: val };
|
|
1305
2201
|
return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette(newValues) };
|
|
1306
2202
|
}
|
|
1307
|
-
return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", ...resetPalette() };
|
|
2203
|
+
return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", formInputCursorPos: 0, ...resetPalette() };
|
|
1308
2204
|
}
|
|
1309
2205
|
|
|
1310
2206
|
if (key.ctrl && key.name === "c") return "submit";
|
|
@@ -1376,10 +2272,11 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
|
|
|
1376
2272
|
formFilteredIndices: subFiltered,
|
|
1377
2273
|
formListCursor: defaultFormCursor(subFields, subFiltered, subValues),
|
|
1378
2274
|
formScrollTop: 0,
|
|
1379
|
-
formShowRequired: false,
|
|
2275
|
+
formShowRequired: false, formShowOptional: false,
|
|
1380
2276
|
formEnumCursor: 0,
|
|
1381
2277
|
formEnumSelected: new Set(),
|
|
1382
2278
|
formInputBuf: "",
|
|
2279
|
+
formInputCursorPos: 0,
|
|
1383
2280
|
};
|
|
1384
2281
|
}
|
|
1385
2282
|
if (key.name === "backspace" && formEnumCursor < items.length) {
|
|
@@ -1491,7 +2388,7 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
|
|
|
1491
2388
|
const newItems = [...items];
|
|
1492
2389
|
newItems.splice(formEnumCursor, 1);
|
|
1493
2390
|
const newValues = { ...formValues, [field.name]: newItems.join(", ") };
|
|
1494
|
-
return { ...state, formValues: newValues, formInputBuf: editVal, formEnumCursor: newItems.length };
|
|
2391
|
+
return { ...state, formValues: newValues, formInputBuf: editVal, formInputCursorPos: editVal.length, formEnumCursor: newItems.length };
|
|
1495
2392
|
}
|
|
1496
2393
|
if (key.name === "backspace") {
|
|
1497
2394
|
// Delete item
|
|
@@ -1505,11 +2402,16 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
|
|
|
1505
2402
|
}
|
|
1506
2403
|
|
|
1507
2404
|
// Cursor on text input
|
|
2405
|
+
if (key.name === "paste") {
|
|
2406
|
+
// Paste: strip newlines for tag input
|
|
2407
|
+
const text = key.raw.replace(/\n/g, "");
|
|
2408
|
+
return { ...state, formInputBuf: formInputBuf + text, formInputCursorPos: formInputBuf.length + text.length };
|
|
2409
|
+
}
|
|
1508
2410
|
if (key.name === "return") {
|
|
1509
2411
|
if (formInputBuf.trim()) {
|
|
1510
2412
|
items.push(formInputBuf.trim());
|
|
1511
2413
|
const newValues = { ...formValues, [field.name]: items.join(", ") };
|
|
1512
|
-
return { ...state, formValues: newValues, formInputBuf: "", formEnumCursor: items.length };
|
|
2414
|
+
return { ...state, formValues: newValues, formInputBuf: "", formInputCursorPos: 0, formEnumCursor: items.length };
|
|
1513
2415
|
}
|
|
1514
2416
|
// Empty input: confirm and close
|
|
1515
2417
|
const newValues = { ...formValues, [field.name]: items.join(", ") };
|
|
@@ -1517,41 +2419,143 @@ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit
|
|
|
1517
2419
|
}
|
|
1518
2420
|
if (key.name === "backspace") {
|
|
1519
2421
|
if (formInputBuf) {
|
|
1520
|
-
return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
|
|
2422
|
+
return { ...state, formInputBuf: formInputBuf.slice(0, -1), formInputCursorPos: formInputBuf.length - 1 };
|
|
1521
2423
|
}
|
|
1522
2424
|
return state;
|
|
1523
2425
|
}
|
|
1524
2426
|
if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
|
|
1525
|
-
|
|
2427
|
+
const clean = key.raw.replace(/[\x00-\x1f\x7f]/g, ""); // strip control chars for tags
|
|
2428
|
+
if (clean) return { ...state, formInputBuf: formInputBuf + clean, formInputCursorPos: formInputBuf.length + clean.length };
|
|
1526
2429
|
}
|
|
1527
2430
|
return state;
|
|
1528
2431
|
}
|
|
1529
2432
|
|
|
1530
2433
|
// Text editing mode
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
2434
|
+
const pos = state.formInputCursorPos;
|
|
2435
|
+
if (key.name === "paste") {
|
|
2436
|
+
const newBuf = formInputBuf.slice(0, pos) + key.raw + formInputBuf.slice(pos);
|
|
2437
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + key.raw.length };
|
|
2438
|
+
}
|
|
2439
|
+
// Insert newline: Ctrl+J (\n), Shift+Enter, or Alt+Enter
|
|
2440
|
+
if (key.raw === "\n" || (key.name === "return" && key.shift)) {
|
|
2441
|
+
const newBuf = formInputBuf.slice(0, pos) + "\n" + formInputBuf.slice(pos);
|
|
2442
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + 1 };
|
|
1534
2443
|
}
|
|
1535
2444
|
if (key.name === "return") {
|
|
1536
|
-
// Enter: confirm
|
|
2445
|
+
// Enter (\r): confirm value
|
|
1537
2446
|
const newValues = { ...formValues, [field.name]: formInputBuf };
|
|
1538
|
-
return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
|
|
2447
|
+
return { ...state, formEditing: false, formEditFieldIdx: -1, formInputCursorPos: 0, formValues: newValues, ...resetPalette(newValues) };
|
|
2448
|
+
}
|
|
2449
|
+
if (key.name === "left") {
|
|
2450
|
+
return { ...state, formInputCursorPos: Math.max(0, pos - 1) };
|
|
2451
|
+
}
|
|
2452
|
+
if (key.name === "right") {
|
|
2453
|
+
return { ...state, formInputCursorPos: Math.min(formInputBuf.length, pos + 1) };
|
|
2454
|
+
}
|
|
2455
|
+
if (key.name === "wordLeft") {
|
|
2456
|
+
return { ...state, formInputCursorPos: prevWordBoundary(formInputBuf, pos) };
|
|
2457
|
+
}
|
|
2458
|
+
if (key.name === "wordRight") {
|
|
2459
|
+
return { ...state, formInputCursorPos: nextWordBoundary(formInputBuf, pos) };
|
|
2460
|
+
}
|
|
2461
|
+
if (key.name === "wordBackspace") {
|
|
2462
|
+
const boundary = prevWordBoundary(formInputBuf, pos);
|
|
2463
|
+
const newBuf = formInputBuf.slice(0, boundary) + formInputBuf.slice(pos);
|
|
2464
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: boundary };
|
|
2465
|
+
}
|
|
2466
|
+
if (key.name === "up") {
|
|
2467
|
+
// Move cursor up one line
|
|
2468
|
+
const lines = formInputBuf.split("\n");
|
|
2469
|
+
let rem = pos;
|
|
2470
|
+
let line = 0;
|
|
2471
|
+
for (; line < lines.length; line++) {
|
|
2472
|
+
if (rem <= lines[line]!.length) break;
|
|
2473
|
+
rem -= lines[line]!.length + 1;
|
|
2474
|
+
}
|
|
2475
|
+
if (line > 0) {
|
|
2476
|
+
const col = Math.min(rem, lines[line - 1]!.length);
|
|
2477
|
+
let newPos = 0;
|
|
2478
|
+
for (let i = 0; i < line - 1; i++) newPos += lines[i]!.length + 1;
|
|
2479
|
+
newPos += col;
|
|
2480
|
+
return { ...state, formInputCursorPos: newPos };
|
|
2481
|
+
}
|
|
2482
|
+
return state;
|
|
2483
|
+
}
|
|
2484
|
+
if (key.name === "down") {
|
|
2485
|
+
// Move cursor down one line
|
|
2486
|
+
const lines = formInputBuf.split("\n");
|
|
2487
|
+
let rem = pos;
|
|
2488
|
+
let line = 0;
|
|
2489
|
+
for (; line < lines.length; line++) {
|
|
2490
|
+
if (rem <= lines[line]!.length) break;
|
|
2491
|
+
rem -= lines[line]!.length + 1;
|
|
2492
|
+
}
|
|
2493
|
+
if (line < lines.length - 1) {
|
|
2494
|
+
const col = Math.min(rem, lines[line + 1]!.length);
|
|
2495
|
+
let newPos = 0;
|
|
2496
|
+
for (let i = 0; i <= line; i++) newPos += lines[i]!.length + 1;
|
|
2497
|
+
newPos += col;
|
|
2498
|
+
return { ...state, formInputCursorPos: newPos };
|
|
2499
|
+
}
|
|
2500
|
+
return state;
|
|
1539
2501
|
}
|
|
1540
2502
|
if (key.name === "backspace") {
|
|
1541
|
-
|
|
2503
|
+
if (pos > 0) {
|
|
2504
|
+
const newBuf = formInputBuf.slice(0, pos - 1) + formInputBuf.slice(pos);
|
|
2505
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos - 1 };
|
|
2506
|
+
}
|
|
2507
|
+
return state;
|
|
1542
2508
|
}
|
|
1543
2509
|
if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
|
|
1544
|
-
|
|
2510
|
+
// Preserve newlines in pasted text (multi-line supported), strip other control chars
|
|
2511
|
+
const clean = key.raw.replace(/[\x00-\x09\x0b\x0c\x0e-\x1f\x7f]/g, "");
|
|
2512
|
+
if (clean) {
|
|
2513
|
+
const newBuf = formInputBuf.slice(0, pos) + clean + formInputBuf.slice(pos);
|
|
2514
|
+
return { ...state, formInputBuf: newBuf, formInputCursorPos: pos + clean.length };
|
|
2515
|
+
}
|
|
1545
2516
|
}
|
|
1546
2517
|
return state;
|
|
1547
2518
|
}
|
|
1548
2519
|
|
|
1549
|
-
function
|
|
2520
|
+
function cardLineCount(card: CardItem, cardWidth: number): number {
|
|
2521
|
+
const innerW = Math.max(1, cardWidth - 4);
|
|
2522
|
+
if (card.kind === "highlight") {
|
|
2523
|
+
const quoteW = innerW - 2; // account for quote prefix
|
|
2524
|
+
const passage = card.summary || "\u2026";
|
|
2525
|
+
const wrapped = wrapText(passage, quoteW);
|
|
2526
|
+
const textLines = Math.min(wrapped.length, 6);
|
|
2527
|
+
return 2 + textLines + (card.note ? 1 : 0) + (card.meta ? 1 : 0); // top + text + note? + meta? + bottom
|
|
2528
|
+
}
|
|
2529
|
+
return 2 + 1 + (card.summary ? 1 : 0) + (card.meta ? 1 : 0); // top + title + summary? + meta? + bottom
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
function computeCardScroll(cards: CardItem[], cursor: number, currentScroll: number, availableHeight: number): number {
|
|
2533
|
+
const { innerWidth } = getBoxDimensions();
|
|
2534
|
+
const cardWidth = Math.min(innerWidth - 4, 72);
|
|
2535
|
+
let lineStart = 0;
|
|
2536
|
+
for (let ci = 0; ci < cards.length; ci++) {
|
|
2537
|
+
const card = cards[ci]!;
|
|
2538
|
+
const height = cardLineCount(card, cardWidth);
|
|
2539
|
+
const spacing = ci < cards.length - 1 ? 1 : 0;
|
|
2540
|
+
if (ci === cursor) {
|
|
2541
|
+
const lineEnd = lineStart + height + spacing;
|
|
2542
|
+
if (lineStart < currentScroll) return lineStart;
|
|
2543
|
+
if (lineEnd > currentScroll + availableHeight) return Math.max(0, lineEnd - availableHeight);
|
|
2544
|
+
return currentScroll;
|
|
2545
|
+
}
|
|
2546
|
+
lineStart += height + spacing;
|
|
2547
|
+
}
|
|
2548
|
+
return currentScroll;
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" | "openUrl" {
|
|
1550
2552
|
const { contentHeight } = getBoxDimensions();
|
|
1551
|
-
const
|
|
1552
|
-
const visibleCount = Math.max(1, contentHeight - 3);
|
|
2553
|
+
const inCardMode = state.resultCards.length > 0 && !state.error;
|
|
1553
2554
|
|
|
1554
|
-
if (key.ctrl && key.name === "c")
|
|
2555
|
+
if (key.ctrl && key.name === "c") {
|
|
2556
|
+
if (state.quitConfirm) return "exit";
|
|
2557
|
+
return { ...state, quitConfirm: true };
|
|
2558
|
+
}
|
|
1555
2559
|
|
|
1556
2560
|
if (key.name === "q" && !key.ctrl) {
|
|
1557
2561
|
if (state.quitConfirm) return "exit";
|
|
@@ -1561,59 +2565,76 @@ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
|
|
|
1561
2565
|
// Any other key cancels quit confirm
|
|
1562
2566
|
const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
|
|
1563
2567
|
|
|
2568
|
+
const resultsClear = { result: "", error: "", resultScroll: 0, resultScrollX: 0, resultCards: [] as CardItem[], resultCursor: 0, resultCardScroll: 0 };
|
|
2569
|
+
|
|
1564
2570
|
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;
|
|
2571
|
+
const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
|
|
2572
|
+
const hasParams = !isEmpty && s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
|
|
1569
2573
|
if (hasParams) {
|
|
2574
|
+
const f = filterFormFields(s.fields, "");
|
|
1570
2575
|
return {
|
|
1571
|
-
...s, view: "form" as View,
|
|
2576
|
+
...s, view: "form" as View, ...resultsClear,
|
|
1572
2577
|
formSearchQuery: "", formSearchCursorPos: 0,
|
|
1573
|
-
formFilteredIndices:
|
|
1574
|
-
formListCursor: defaultFormCursor(s.fields,
|
|
1575
|
-
formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
|
|
2578
|
+
formFilteredIndices: f,
|
|
2579
|
+
formListCursor: defaultFormCursor(s.fields, f, s.formValues), formScrollTop: 0,
|
|
2580
|
+
formEditing: false, formEditFieldIdx: -1, formShowRequired: false, formShowOptional: false,
|
|
1576
2581
|
};
|
|
1577
2582
|
}
|
|
1578
|
-
return { ...s,
|
|
2583
|
+
return { ...s, ...commandListReset(s.tools), ...resultsClear };
|
|
1579
2584
|
};
|
|
1580
2585
|
|
|
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
|
-
}
|
|
2586
|
+
if (key.name === "escape") {
|
|
1591
2587
|
return goBack();
|
|
1592
2588
|
}
|
|
1593
2589
|
|
|
1594
|
-
if (
|
|
1595
|
-
const
|
|
1596
|
-
|
|
1597
|
-
const
|
|
1598
|
-
|
|
1599
|
-
if (
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
if (
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2590
|
+
if (inCardMode) {
|
|
2591
|
+
const cards = s.resultCards;
|
|
2592
|
+
// 2 lines used by header + blank
|
|
2593
|
+
const availableHeight = Math.max(1, contentHeight - 2);
|
|
2594
|
+
|
|
2595
|
+
if (key.name === "return") {
|
|
2596
|
+
const card = cards[s.resultCursor];
|
|
2597
|
+
if (card?.url) return "openUrl";
|
|
2598
|
+
return goBack();
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
if (key.name === "up") {
|
|
2602
|
+
if (s.resultCursor > 0) {
|
|
2603
|
+
const newCursor = s.resultCursor - 1;
|
|
2604
|
+
const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
|
|
2605
|
+
return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
|
|
2606
|
+
}
|
|
2607
|
+
return s;
|
|
2608
|
+
}
|
|
2609
|
+
if (key.name === "down") {
|
|
2610
|
+
if (s.resultCursor < cards.length - 1) {
|
|
2611
|
+
const newCursor = s.resultCursor + 1;
|
|
2612
|
+
const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
|
|
2613
|
+
return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
|
|
2614
|
+
}
|
|
2615
|
+
return s;
|
|
2616
|
+
}
|
|
2617
|
+
if (key.name === "pageup") {
|
|
2618
|
+
const newCursor = Math.max(0, s.resultCursor - 5);
|
|
2619
|
+
const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
|
|
2620
|
+
return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
|
|
2621
|
+
}
|
|
2622
|
+
if (key.name === "pagedown") {
|
|
2623
|
+
const newCursor = Math.min(cards.length - 1, s.resultCursor + 5);
|
|
2624
|
+
const newScroll = computeCardScroll(cards, newCursor, s.resultCardScroll, availableHeight);
|
|
2625
|
+
return { ...s, resultCursor: newCursor, resultCardScroll: newScroll };
|
|
1613
2626
|
}
|
|
1614
|
-
|
|
2627
|
+
|
|
2628
|
+
return s;
|
|
1615
2629
|
}
|
|
1616
2630
|
|
|
2631
|
+
// Text mode fallback
|
|
2632
|
+
const contentLines = (state.error || state.result).split("\n");
|
|
2633
|
+
const visibleCount = Math.max(1, contentHeight - 3);
|
|
2634
|
+
|
|
2635
|
+
if (key.name === "return") {
|
|
2636
|
+
return goBack();
|
|
2637
|
+
}
|
|
1617
2638
|
if (key.name === "up") {
|
|
1618
2639
|
return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
|
|
1619
2640
|
}
|
|
@@ -1767,7 +2788,7 @@ async function executeTool(state: AppState): Promise<AppState> {
|
|
|
1767
2788
|
|
|
1768
2789
|
if (res.isError) {
|
|
1769
2790
|
const errMsg = res.content.map((c) => c.text || "").filter(Boolean).join("\n");
|
|
1770
|
-
return { ...state, view: "results", error: errMsg || "Unknown error", result: "", resultScroll: 0, resultScrollX: 0 };
|
|
2791
|
+
return { ...state, view: "results", error: errMsg || "Unknown error", result: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
|
|
1771
2792
|
}
|
|
1772
2793
|
|
|
1773
2794
|
const text = res.content.filter((c) => c.type === "text" && c.text).map((c) => c.text!).join("\n");
|
|
@@ -1775,21 +2796,24 @@ async function executeTool(state: AppState): Promise<AppState> {
|
|
|
1775
2796
|
const structured = (res as Record<string, unknown>).structuredContent;
|
|
1776
2797
|
let formatted: string;
|
|
1777
2798
|
let emptyList = false;
|
|
2799
|
+
let parsedData: unknown = undefined;
|
|
1778
2800
|
if (!text && structured !== undefined) {
|
|
2801
|
+
parsedData = structured;
|
|
1779
2802
|
emptyList = isEmptyListResult(structured);
|
|
1780
2803
|
formatted = formatJsonPretty(structured);
|
|
1781
2804
|
} else {
|
|
1782
2805
|
try {
|
|
1783
|
-
|
|
1784
|
-
emptyList = isEmptyListResult(
|
|
1785
|
-
formatted = formatJsonPretty(
|
|
2806
|
+
parsedData = JSON.parse(text);
|
|
2807
|
+
emptyList = isEmptyListResult(parsedData);
|
|
2808
|
+
formatted = formatJsonPretty(parsedData);
|
|
1786
2809
|
} catch {
|
|
1787
2810
|
formatted = text;
|
|
1788
2811
|
}
|
|
1789
2812
|
}
|
|
1790
|
-
|
|
2813
|
+
const cards = parsedData !== undefined ? extractCards(parsedData) || [] : [];
|
|
2814
|
+
return { ...state, view: "results", result: emptyList ? EMPTY_LIST_SENTINEL : formatted, error: "", resultScroll: 0, resultScrollX: 0, resultCards: cards, resultCursor: 0, resultCardScroll: 0 };
|
|
1791
2815
|
} catch (err) {
|
|
1792
|
-
return { ...state, view: "results", error: (err as Error).message, result: "", resultScroll: 0, resultScrollX: 0 };
|
|
2816
|
+
return { ...state, view: "results", error: (err as Error).message, result: "", resultScroll: 0, resultScrollX: 0, resultCards: [], resultCursor: 0, resultCardScroll: 0 };
|
|
1793
2817
|
}
|
|
1794
2818
|
}
|
|
1795
2819
|
|
|
@@ -1819,10 +2843,11 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
|
|
|
1819
2843
|
formEditFieldIdx: -1,
|
|
1820
2844
|
formEditing: false,
|
|
1821
2845
|
formInputBuf: "",
|
|
2846
|
+
formInputCursorPos: 0,
|
|
1822
2847
|
formEnumCursor: 0,
|
|
1823
2848
|
formEnumSelected: new Set(),
|
|
1824
2849
|
formValues: {},
|
|
1825
|
-
formShowRequired: false,
|
|
2850
|
+
formShowRequired: false, formShowOptional: false,
|
|
1826
2851
|
formStack: [],
|
|
1827
2852
|
dateParts: [],
|
|
1828
2853
|
datePartCursor: 0,
|
|
@@ -1830,6 +2855,9 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
|
|
|
1830
2855
|
error: "",
|
|
1831
2856
|
resultScroll: 0,
|
|
1832
2857
|
resultScrollX: 0,
|
|
2858
|
+
resultCards: [],
|
|
2859
|
+
resultCursor: 0,
|
|
2860
|
+
resultCardScroll: 0,
|
|
1833
2861
|
spinnerFrame: 0,
|
|
1834
2862
|
};
|
|
1835
2863
|
|
|
@@ -1889,6 +2917,15 @@ export async function runApp(tools: ToolDef[]): Promise<void> {
|
|
|
1889
2917
|
return;
|
|
1890
2918
|
}
|
|
1891
2919
|
|
|
2920
|
+
if (result === "openUrl") {
|
|
2921
|
+
const card = state.resultCards[state.resultCursor];
|
|
2922
|
+
if (card?.url) {
|
|
2923
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2924
|
+
exec(`${cmd} ${JSON.stringify(card.url)}`);
|
|
2925
|
+
}
|
|
2926
|
+
return;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
1892
2929
|
if (result === "submit") {
|
|
1893
2930
|
state = { ...state, view: "loading", spinnerFrame: 0 };
|
|
1894
2931
|
paint(renderState(state));
|