@readwise/cli 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui/app.ts ADDED
@@ -0,0 +1,1917 @@
1
+ import type { ToolDef, SchemaProperty } from "../config.js";
2
+ import { resolveProperty, resolveRef } from "../commands.js";
3
+ import { callTool } from "../mcp.js";
4
+ import { ensureValidToken } from "../auth.js";
5
+ import { LOGO } from "./logo.js";
6
+ import { style, paint, screenSize, fitWidth, ansiSlice, stripAnsi, parseKey, type KeyEvent } from "./term.js";
7
+ import { VERSION } from "../version.js";
8
+
9
+ // --- Types ---
10
+
11
+ type View = "commands" | "form" | "loading" | "results";
12
+
13
+ interface FormField {
14
+ name: string;
15
+ prop: SchemaProperty;
16
+ required: boolean;
17
+ }
18
+
19
+ interface FormStackEntry {
20
+ parentFieldName: string; // field name in parent form (e.g. "highlights")
21
+ parentFields: FormField[];
22
+ parentValues: Record<string, string>;
23
+ parentNameColWidth: number;
24
+ parentTitle: string; // for breadcrumb
25
+ editIndex: number; // -1 = adding new item, >= 0 = editing existing item at this index
26
+ }
27
+
28
+ function isArrayOfObjects(prop: SchemaProperty): boolean {
29
+ return prop.type === "array" && !!prop.items?.properties;
30
+ }
31
+
32
+ interface AppState {
33
+ view: View;
34
+ tools: ToolDef[];
35
+ // Command list
36
+ listCursor: number;
37
+ listScrollTop: number;
38
+ quitConfirm: boolean;
39
+ searchQuery: string;
40
+ searchCursorPos: number;
41
+ filteredItems: ListItem[];
42
+ // Form
43
+ selectedTool: ToolDef | null;
44
+ fields: FormField[];
45
+ nameColWidth: number;
46
+ formSearchQuery: string;
47
+ formSearchCursorPos: number;
48
+ formFilteredIndices: number[];
49
+ formListCursor: number;
50
+ formScrollTop: number;
51
+ formEditFieldIdx: number;
52
+ formEditing: boolean;
53
+ formInputBuf: string;
54
+ formEnumCursor: number;
55
+ formEnumSelected: Set<number>;
56
+ formValues: Record<string, string>;
57
+ formShowRequired: boolean;
58
+ formStack: FormStackEntry[];
59
+ // Date picker
60
+ dateParts: number[]; // [year, month, day] or [year, month, day, hour, minute]
61
+ datePartCursor: number; // which part is focused
62
+ // Results
63
+ result: string;
64
+ error: string;
65
+ resultScroll: number;
66
+ resultScrollX: number;
67
+ // Spinner
68
+ spinnerFrame: number;
69
+ }
70
+
71
+ // --- Helpers ---
72
+
73
+ function humanLabel(toolName: string, prefix: string): string {
74
+ return toolName
75
+ .replace(prefix + "_", "")
76
+ .split("_")
77
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
78
+ .join(" ");
79
+ }
80
+
81
+ function toolPrefix(tool: ToolDef): string {
82
+ return tool.name.startsWith("reader_") ? "reader" : "readwise";
83
+ }
84
+
85
+ interface ListItem {
86
+ label: string;
87
+ value: string;
88
+ description?: string;
89
+ isSeparator?: boolean;
90
+ }
91
+
92
+ function buildCommandList(tools: ToolDef[]): ListItem[] {
93
+ const groups: Record<string, { label: string; items: ListItem[] }> = {};
94
+ for (const tool of tools) {
95
+ let groupKey: string;
96
+ let prefix: string;
97
+ if (tool.name.startsWith("readwise_")) { groupKey = "Readwise"; prefix = "readwise"; }
98
+ else if (tool.name.startsWith("reader_")) { groupKey = "Reader"; prefix = "reader"; }
99
+ else { groupKey = "Other"; prefix = ""; }
100
+
101
+ if (!groups[groupKey]) groups[groupKey] = { label: groupKey, items: [] };
102
+ groups[groupKey].items.push({
103
+ label: prefix ? humanLabel(tool.name, prefix) : tool.name,
104
+ value: tool.name,
105
+ description: tool.description,
106
+ });
107
+ }
108
+ // Reader first, then Readwise, then anything else
109
+ const order = ["Reader", "Readwise", "Other"];
110
+ const result: ListItem[] = [];
111
+ for (const key of order) {
112
+ const group = groups[key];
113
+ if (group) {
114
+ result.push({ label: group.label, value: "", isSeparator: true });
115
+ result.push(...group.items);
116
+ }
117
+ }
118
+ return result;
119
+ }
120
+
121
+ function selectableIndices(items: ListItem[]): number[] {
122
+ return items.map((item, i) => item.isSeparator ? -1 : i).filter((i) => i >= 0);
123
+ }
124
+
125
+ function filterCommands(tools: ToolDef[], query: string): ListItem[] {
126
+ if (!query) return buildCommandList(tools);
127
+ const q = query.toLowerCase();
128
+ const items: ListItem[] = [];
129
+ for (const tool of tools) {
130
+ const prefix = toolPrefix(tool);
131
+ const label = prefix ? humanLabel(tool.name, prefix) : tool.name;
132
+ const haystack = (label + " " + tool.name + " " + (tool.description || "")).toLowerCase();
133
+ if (haystack.includes(q)) {
134
+ items.push({ label, value: tool.name, description: tool.description });
135
+ }
136
+ }
137
+ return items;
138
+ }
139
+
140
+ function truncateVisible(s: string, maxWidth: number): string {
141
+ if (s.length <= maxWidth) return s;
142
+ if (maxWidth <= 1) return "\u2026";
143
+ return s.slice(0, maxWidth - 1) + "\u2026";
144
+ }
145
+
146
+ function filterFormFields(fields: FormField[], query: string): number[] {
147
+ if (!query) {
148
+ const indices = fields.map((_, i) => i);
149
+ indices.push(-1); // Execute sentinel
150
+ return indices;
151
+ }
152
+ const q = query.toLowerCase();
153
+ const indices: number[] = [];
154
+ for (let i = 0; i < fields.length; i++) {
155
+ const f = fields[i]!;
156
+ const haystack = f.name.toLowerCase();
157
+ if (haystack.includes(q)) indices.push(i);
158
+ }
159
+ indices.push(-1); // Execute sentinel always present
160
+ return indices;
161
+ }
162
+
163
+ function executeIndex(filtered: number[]): number {
164
+ return filtered.indexOf(-1);
165
+ }
166
+
167
+ function missingRequiredFields(fields: FormField[], values: Record<string, string>): FormField[] {
168
+ return fields.filter((f) => {
169
+ if (!f.required) return false;
170
+ const val = values[f.name]?.trim();
171
+ if (!val) return true;
172
+ // Array-of-objects: require at least one item
173
+ if (isArrayOfObjects(f.prop)) {
174
+ try { return JSON.parse(val).length === 0; } catch { return true; }
175
+ }
176
+ return false;
177
+ });
178
+ }
179
+
180
+ function defaultFormCursor(fields: FormField[], filtered: number[], values: Record<string, string>): number {
181
+ // Focus first blank required field if any, otherwise Execute
182
+ const missing = new Set(missingRequiredFields(fields, values).map((f) => f.name));
183
+ const firstBlank = filtered.findIndex((idx) => idx >= 0 && missing.has(fields[idx]!.name));
184
+ return firstBlank >= 0 ? firstBlank : executeIndex(filtered);
185
+ }
186
+
187
+ function formFieldValueDisplay(value: string, maxWidth: number): string {
188
+ if (!value) return style.dim("–");
189
+ // JSON array display (for array-of-objects)
190
+ try {
191
+ const parsed = JSON.parse(value);
192
+ if (Array.isArray(parsed)) {
193
+ return style.dim(`[${parsed.length} item${parsed.length !== 1 ? "s" : ""}]`);
194
+ }
195
+ } catch { /* not JSON */ }
196
+ const lines = value.split("\n");
197
+ if (lines.length > 1) {
198
+ const first = truncateVisible(lines[0]!, Math.max(1, maxWidth - 12));
199
+ return first + " " + style.dim(`[+${lines.length - 1} lines]`);
200
+ }
201
+ return truncateVisible(value, maxWidth);
202
+ }
203
+
204
+ function popFormStack(state: AppState): AppState {
205
+ const stack = [...state.formStack];
206
+ const entry = stack.pop()!;
207
+ // Serialize sub-form values into a JSON object (only non-empty values)
208
+ const subObj: Record<string, unknown> = {};
209
+ for (const f of state.fields) {
210
+ const val = state.formValues[f.name];
211
+ if (!val) continue;
212
+ if (f.prop.type === "integer" || f.prop.type === "number") {
213
+ const n = Number(val);
214
+ if (!isNaN(n)) subObj[f.name] = n;
215
+ } else if (f.prop.type === "boolean") {
216
+ subObj[f.name] = val === "true";
217
+ } else if (f.prop.type === "array") {
218
+ try {
219
+ const parsed = JSON.parse(val);
220
+ subObj[f.name] = Array.isArray(parsed) ? parsed : val.split(",").map((s) => s.trim()).filter(Boolean);
221
+ } catch {
222
+ subObj[f.name] = val.split(",").map((s) => s.trim()).filter(Boolean);
223
+ }
224
+ } else {
225
+ subObj[f.name] = val;
226
+ }
227
+ }
228
+ // Append or replace in parent array
229
+ const parentVal = entry.parentValues[entry.parentFieldName] || "[]";
230
+ let parentArr: unknown[] = [];
231
+ try { parentArr = JSON.parse(parentVal); } catch { /* */ }
232
+ if (entry.editIndex >= 0) {
233
+ parentArr[entry.editIndex] = subObj;
234
+ } else {
235
+ parentArr.push(subObj);
236
+ }
237
+ const newParentValues = { ...entry.parentValues, [entry.parentFieldName]: JSON.stringify(parentArr) };
238
+ const parentFiltered = filterFormFields(entry.parentFields, "");
239
+ // Return to parent's array editor (editing the array-of-objects field)
240
+ const parentFieldIdx = entry.parentFields.findIndex((f) => f.name === entry.parentFieldName);
241
+ return {
242
+ ...state,
243
+ formStack: stack,
244
+ fields: entry.parentFields,
245
+ nameColWidth: entry.parentNameColWidth,
246
+ formValues: newParentValues,
247
+ formEditing: true,
248
+ formEditFieldIdx: parentFieldIdx,
249
+ formEnumCursor: parentArr.length, // cursor on "Add new item"
250
+ formEnumSelected: new Set(),
251
+ formSearchQuery: "",
252
+ formSearchCursorPos: 0,
253
+ formFilteredIndices: parentFiltered,
254
+ formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, newParentValues),
255
+ formScrollTop: 0,
256
+ formShowRequired: false,
257
+ formInputBuf: "",
258
+ };
259
+ }
260
+
261
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
262
+
263
+ const shuffledLoadingMessages = (() => {
264
+ const msgs = [
265
+ "Fetching data…", "Processing…", "Reaching out to Readwise…",
266
+ "Loading…", "Crunching…", "Almost there…", "Querying…",
267
+ "Thinking…", "Connecting…", "Gathering results…", "Brewing…",
268
+ "Searching…", "Talking to the API…", "Hang tight…",
269
+ "One moment…", "Just a sec…",
270
+ ];
271
+ for (let i = msgs.length - 1; i > 0; i--) {
272
+ const j = Math.floor(Math.random() * (i + 1));
273
+ [msgs[i], msgs[j]] = [msgs[j]!, msgs[i]!];
274
+ }
275
+ return msgs;
276
+ })();
277
+ const RESET = "\x1b[0m";
278
+ const EMPTY_LIST_SENTINEL = "\x00__EMPTY_LIST__";
279
+
280
+ function wrapText(text: string, width: number): string[] {
281
+ if (width <= 0) return [text];
282
+ const words = text.split(/\s+/);
283
+ const lines: string[] = [];
284
+ let current = "";
285
+ for (const word of words) {
286
+ if (!current) {
287
+ current = word;
288
+ } else if (current.length + 1 + word.length <= width) {
289
+ current += " " + word;
290
+ } else {
291
+ lines.push(current);
292
+ current = word;
293
+ }
294
+ }
295
+ if (current) lines.push(current);
296
+ return lines.length > 0 ? lines : [""];
297
+ }
298
+
299
+ // --- Date helpers ---
300
+
301
+ type DateFormat = "date" | "date-time";
302
+
303
+ function dateFieldFormat(prop: SchemaProperty): DateFormat | null {
304
+ if (prop.format === "date") return "date";
305
+ if (prop.format === "date-time") return "date-time";
306
+ return null;
307
+ }
308
+
309
+ function daysInMonth(year: number, month: number): number {
310
+ return new Date(year, month, 0).getDate();
311
+ }
312
+
313
+ function todayParts(fmt: DateFormat): number[] {
314
+ const now = new Date();
315
+ const parts = [now.getFullYear(), now.getMonth() + 1, now.getDate()];
316
+ if (fmt === "date-time") parts.push(now.getHours(), now.getMinutes());
317
+ return parts;
318
+ }
319
+
320
+ function parseDateParts(value: string, fmt: DateFormat): number[] | null {
321
+ if (!value) return null;
322
+ const m = value.match(/^(\d{4})-(\d{2})-(\d{2})/);
323
+ if (!m) return null;
324
+ const parts = [Number(m[1]), Number(m[2]), Number(m[3])];
325
+ if (fmt === "date-time") {
326
+ const tm = value.match(/T(\d{2}):(\d{2})/);
327
+ parts.push(tm ? Number(tm[1]) : 0, tm ? Number(tm[2]) : 0);
328
+ }
329
+ return parts;
330
+ }
331
+
332
+ function datePartsToString(parts: number[], fmt: DateFormat): string {
333
+ const y = String(parts[0]).padStart(4, "0");
334
+ const mo = String(parts[1]).padStart(2, "0");
335
+ const d = String(parts[2]).padStart(2, "0");
336
+ if (fmt === "date") return `${y}-${mo}-${d}`;
337
+ const h = String(parts[3] ?? 0).padStart(2, "0");
338
+ const mi = String(parts[4] ?? 0).padStart(2, "0");
339
+ return `${y}-${mo}-${d}T${h}:${mi}:00Z`;
340
+ }
341
+
342
+ function renderDateParts(parts: number[], cursor: number, fmt: DateFormat): string {
343
+ const segments: string[] = [
344
+ String(parts[0]).padStart(4, "0"),
345
+ String(parts[1]).padStart(2, "0"),
346
+ String(parts[2]).padStart(2, "0"),
347
+ ];
348
+ if (fmt === "date-time") {
349
+ segments.push(
350
+ String(parts[3] ?? 0).padStart(2, "0"),
351
+ String(parts[4] ?? 0).padStart(2, "0"),
352
+ );
353
+ }
354
+ const labels = fmt === "date" ? ["Y", "M", "D"] : ["Y", "M", "D", "h", "m"];
355
+ const seps = fmt === "date" ? ["-", "-"] : ["-", "-", " ", ":"];
356
+ let out = "";
357
+ for (let i = 0; i < segments.length; i++) {
358
+ if (i > 0) out += style.dim(seps[i - 1]!);
359
+ const seg = segments[i]!;
360
+ if (i === cursor) {
361
+ out += style.inverse(style.cyan(seg));
362
+ } else {
363
+ out += style.cyan(seg);
364
+ }
365
+ }
366
+ out += " " + style.dim("←→ part ↑↓ adjust " + labels.map((l, i) => (i === cursor ? `[${l}]` : l)).join(" "));
367
+ return out;
368
+ }
369
+
370
+ function adjustDatePart(parts: number[], cursor: number, delta: number, fmt: DateFormat): number[] {
371
+ const p = [...parts];
372
+ if (cursor === 0) {
373
+ // Year
374
+ p[0] = Math.max(1900, Math.min(2100, p[0]! + delta));
375
+ } else if (cursor === 1) {
376
+ // Month: wrap 1-12
377
+ p[1] = ((p[1]! - 1 + delta + 120) % 12) + 1;
378
+ } else if (cursor === 2) {
379
+ // Day: wrap 1-daysInMonth
380
+ const max = daysInMonth(p[0]!, p[1]!);
381
+ p[2] = ((p[2]! - 1 + delta + max * 100) % max) + 1;
382
+ } else if (cursor === 3 && fmt === "date-time") {
383
+ // Hour: wrap 0-23
384
+ p[3] = ((p[3]! + delta + 240) % 24);
385
+ } else if (cursor === 4 && fmt === "date-time") {
386
+ // Minute: wrap 0-59
387
+ p[4] = ((p[4]! + delta + 600) % 60);
388
+ }
389
+ // Clamp day to valid range after month/year changes
390
+ const maxDay = daysInMonth(p[0]!, p[1]!);
391
+ if (p[2]! > maxDay) p[2] = maxDay;
392
+ return p;
393
+ }
394
+
395
+ function datePartCount(fmt: DateFormat): number {
396
+ return fmt === "date" ? 3 : 5;
397
+ }
398
+
399
+ // --- Layout ---
400
+
401
+ function getBoxDimensions(): { innerWidth: number; fillWidth: number; contentHeight: number } {
402
+ const { cols, rows } = screenSize();
403
+ return {
404
+ innerWidth: Math.max(0, cols - 5), // visible content width inside │ ... │
405
+ fillWidth: Math.max(0, cols - 3), // dash count between ╭ and ╮
406
+ contentHeight: Math.max(1, rows - 4), // rows available inside the box
407
+ };
408
+ }
409
+
410
+ function renderLayout(opts: {
411
+ breadcrumb: string;
412
+ content: string[];
413
+ footer: string;
414
+ }): string[] {
415
+ const { innerWidth, fillWidth, contentHeight } = getBoxDimensions();
416
+ const lines: string[] = [];
417
+
418
+ // Header
419
+ lines.push(" " + opts.breadcrumb);
420
+
421
+ // Top border
422
+ lines.push(` ╭${"─".repeat(fillWidth)}╮`);
423
+
424
+ // Content (pad or truncate to fill box)
425
+ for (let i = 0; i < contentHeight; i++) {
426
+ const raw = i < opts.content.length ? opts.content[i] ?? "" : "";
427
+ lines.push(` │ ${fitWidth(raw, innerWidth)}${RESET} │`);
428
+ }
429
+
430
+ // Bottom border
431
+ lines.push(` ╰${"─".repeat(fillWidth)}╯`);
432
+
433
+ // Footer
434
+ lines.push(" " + opts.footer);
435
+
436
+ return lines;
437
+ }
438
+
439
+ // --- Rendering ---
440
+
441
+ function renderCommandList(state: AppState): string[] {
442
+ const { contentHeight, innerWidth } = getBoxDimensions();
443
+ const items = state.filteredItems;
444
+ const content: string[] = [];
445
+
446
+ // Logo
447
+ for (let i = 0; i < LOGO.length; i++) {
448
+ const logoLine = style.blue(LOGO[i]!);
449
+ if (i === Math.floor(LOGO.length / 2) - 1) {
450
+ content.push(` ${logoLine} ${style.boldYellow("Readwise")} ${style.dim("v" + VERSION)}`);
451
+ } else if (i === Math.floor(LOGO.length / 2)) {
452
+ content.push(` ${logoLine} ${style.dim("Command-line interface")}`);
453
+ } else {
454
+ content.push(` ${logoLine}`);
455
+ }
456
+ }
457
+ content.push("");
458
+
459
+ // Search input line
460
+ const queryText = state.searchQuery;
461
+ const before = queryText.slice(0, state.searchCursorPos);
462
+ const cursorChar = state.searchCursorPos < queryText.length
463
+ ? queryText[state.searchCursorPos]!
464
+ : " ";
465
+ const after = state.searchCursorPos < queryText.length
466
+ ? queryText.slice(state.searchCursorPos + 1)
467
+ : "";
468
+ const searchLine = " " + style.yellow("❯") + " " + before + style.inverse(cursorChar) + after;
469
+ content.push(searchLine);
470
+ content.push("");
471
+
472
+ // List area (remaining space)
473
+ const logoUsed = content.length;
474
+ const listHeight = Math.max(1, contentHeight - logoUsed);
475
+
476
+ if (items.length === 0) {
477
+ content.push(" " + style.dim("No matching commands"));
478
+ } else {
479
+ // Find max label width for alignment
480
+ const labelWidths = items.filter((it) => !it.isSeparator).map((it) => it.label.length);
481
+ const maxLabelWidth = Math.max(...labelWidths, 0);
482
+ // Space budget: " " prefix (3) + label + " " gap (2) + description
483
+ const descAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2);
484
+
485
+ const hiddenBelow = Math.max(0, items.length - (state.listScrollTop + listHeight));
486
+ const visibleSlots = hiddenBelow > 0 ? listHeight - 1 : listHeight;
487
+ const visible = items.slice(state.listScrollTop, state.listScrollTop + visibleSlots);
488
+ for (let i = 0; i < visible.length; i++) {
489
+ const item = visible[i]!;
490
+ const realIdx = state.listScrollTop + i;
491
+ if (item.isSeparator) {
492
+ content.push(` ${style.dim("── " + item.label + " ──")}`);
493
+ } else {
494
+ const selected = realIdx === state.listCursor;
495
+ const prefix = selected ? " ❯ " : " ";
496
+ const paddedLabel = item.label.padEnd(maxLabelWidth);
497
+ const desc = item.description && descAvail > 3
498
+ ? " " + style.dim(truncateVisible(item.description, descAvail))
499
+ : "";
500
+ if (selected) {
501
+ content.push(style.boldYellow(prefix + paddedLabel) + desc);
502
+ } else {
503
+ content.push(prefix + paddedLabel + desc);
504
+ }
505
+ }
506
+ }
507
+ if (hiddenBelow > 0) {
508
+ content.push(" " + style.dim(`(${hiddenBelow} more)`));
509
+ }
510
+ }
511
+
512
+ const footer = state.quitConfirm
513
+ ? style.yellow("Press q or esc again to quit")
514
+ : style.dim("type to search ↑↓ navigate enter select esc clear/quit");
515
+
516
+ return renderLayout({
517
+ breadcrumb: style.boldYellow("Readwise"),
518
+ content,
519
+ footer,
520
+ });
521
+ }
522
+
523
+ function renderForm(state: AppState): string[] {
524
+ const { contentHeight, innerWidth } = getBoxDimensions();
525
+ const tool = state.selectedTool!;
526
+ const fields = state.fields;
527
+ 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
+
534
+ if (state.formEditing && state.formEditFieldIdx >= 0) {
535
+ return renderFormEditMode(state, title, fields, contentHeight, innerWidth);
536
+ }
537
+ return renderFormPaletteMode(state, title, fields, contentHeight, innerWidth);
538
+ }
539
+
540
+ function renderFormPaletteMode(
541
+ state: AppState, title: string, fields: FormField[],
542
+ contentHeight: number, innerWidth: number,
543
+ ): string[] {
544
+ const content: string[] = [];
545
+
546
+ // Tool header
547
+ content.push("");
548
+ content.push(" " + style.bold(title));
549
+ // In sub-form, show the item schema description; otherwise show tool description
550
+ const headerDesc = state.formStack.length > 0
551
+ ? state.formStack[state.formStack.length - 1]!.parentFields
552
+ .find((f) => f.name === state.formStack[state.formStack.length - 1]!.parentFieldName)
553
+ ?.prop.items?.description
554
+ : state.selectedTool!.description;
555
+ if (headerDesc) {
556
+ const wrapped = wrapText(headerDesc, innerWidth - 4);
557
+ for (const line of wrapped) {
558
+ content.push(" " + style.dim(line));
559
+ }
560
+ }
561
+ content.push("");
562
+
563
+ // Search input
564
+ const queryText = state.formSearchQuery;
565
+ const before = queryText.slice(0, state.formSearchCursorPos);
566
+ const cursorChar = state.formSearchCursorPos < queryText.length
567
+ ? queryText[state.formSearchCursorPos]!
568
+ : " ";
569
+ const after = state.formSearchCursorPos < queryText.length
570
+ ? queryText.slice(state.formSearchCursorPos + 1)
571
+ : "";
572
+ content.push(" " + style.yellow("❯") + " " + before + style.inverse(cursorChar) + after);
573
+ content.push("");
574
+
575
+ // Compute maxLabelWidth
576
+ const maxLabelWidth = Math.max(
577
+ ...fields.map((f) => f.name.length + (f.required ? 2 : 0)),
578
+ 6,
579
+ ) + 1;
580
+
581
+ // Value display width budget: innerWidth - prefix(3) - label - gap(2)
582
+ const valueAvail = Math.max(0, innerWidth - 3 - maxLabelWidth - 2);
583
+
584
+ const headerUsed = content.length;
585
+ // Reserve space for: blank + Execute + blank + description (up to 4 lines)
586
+ const listHeight = Math.max(1, contentHeight - headerUsed - 8);
587
+
588
+ const filtered = state.formFilteredIndices;
589
+ const hasOnlyExecute = filtered.length === 1 && filtered[0] === -1;
590
+
591
+ if (hasOnlyExecute && state.formSearchQuery) {
592
+ content.push(" " + style.dim("No matching parameters"));
593
+ content.push("");
594
+ } else {
595
+ // Scrolling: items before the Execute sentinel
596
+ const paramItems = filtered.filter((idx) => idx !== -1);
597
+ const visStart = state.formScrollTop;
598
+ const visEnd = Math.min(paramItems.length, visStart + listHeight);
599
+ const visible = paramItems.slice(visStart, visEnd);
600
+
601
+ for (const fieldIdx of visible) {
602
+ const field = fields[fieldIdx]!;
603
+ const nameLabel = field.name + (field.required ? " *" : "");
604
+ const paddedName = nameLabel.padEnd(maxLabelWidth);
605
+ const val = state.formValues[field.name] || "";
606
+ const valStr = formFieldValueDisplay(val, valueAvail);
607
+ const listPos = filtered.indexOf(fieldIdx);
608
+ const selected = listPos === state.formListCursor;
609
+ const prefix = selected ? " ❯ " : " ";
610
+ if (selected) {
611
+ content.push(style.boldYellow(prefix + paddedName) + " " + valStr);
612
+ } else {
613
+ content.push(prefix + paddedName + " " + valStr);
614
+ }
615
+ }
616
+ }
617
+
618
+ // Execute / Add / Save entry
619
+ const inSubForm = state.formStack.length > 0;
620
+ const isEditing = inSubForm && state.formStack[state.formStack.length - 1]!.editIndex >= 0;
621
+ const actionLabel = inSubForm ? (isEditing ? "Save" : "Add") : "Execute";
622
+ const actionIcon = inSubForm ? (isEditing ? "✓" : "+") : "▶";
623
+ content.push("");
624
+ const executeListPos = filtered.indexOf(-1);
625
+ const executeSelected = executeListPos === state.formListCursor;
626
+ if (executeSelected) {
627
+ content.push(" " + style.inverse(style.green(` ${actionIcon} ${actionLabel} `)));
628
+ } else {
629
+ content.push(" " + style.dim(actionIcon) + " " + actionLabel);
630
+ }
631
+
632
+ // Show missing required fields only after a failed submit attempt
633
+ if (state.formShowRequired) {
634
+ const missing = missingRequiredFields(fields, state.formValues);
635
+ if (missing.length > 0) {
636
+ content.push("");
637
+ const names = missing.map((f) => f.name).join(", ");
638
+ content.push(" " + style.red("Required: " + names));
639
+ }
640
+ }
641
+
642
+ // Description of highlighted field
643
+ const highlightedIdx = filtered[state.formListCursor];
644
+ if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
645
+ const desc = fields[highlightedIdx]!.prop.description;
646
+ if (desc) {
647
+ content.push("");
648
+ const wrapped = wrapText(desc, innerWidth - 4);
649
+ for (const line of wrapped) {
650
+ content.push(" " + style.dim(line));
651
+ }
652
+ }
653
+ }
654
+
655
+ return renderLayout({
656
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
657
+ content,
658
+ footer: style.dim("type to filter ↑↓ navigate enter edit/run esc back"),
659
+ });
660
+ }
661
+
662
+ function renderFormEditMode(
663
+ state: AppState, title: string, fields: FormField[],
664
+ _contentHeight: number, innerWidth: number,
665
+ ): string[] {
666
+ const field = fields[state.formEditFieldIdx]!;
667
+ const content: string[] = [];
668
+
669
+ content.push("");
670
+ content.push(" " + style.bold(title));
671
+ content.push("");
672
+
673
+ // Field name
674
+ const nameLabel = field.name + (field.required ? " *" : "");
675
+ content.push(" " + style.boldYellow("❯ " + nameLabel));
676
+
677
+ // Field description
678
+ if (field.prop.description) {
679
+ const wrapped = wrapText(field.prop.description, innerWidth - 4);
680
+ for (const line of wrapped) {
681
+ content.push(" " + style.dim(line));
682
+ }
683
+ }
684
+ content.push("");
685
+
686
+ // Editor area
687
+ 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
+
694
+ if (isArrayObj) {
695
+ // Array-of-objects editor: show existing items + "Add new item"
696
+ const existing = state.formValues[field.name] || "[]";
697
+ let items: unknown[] = [];
698
+ try { items = JSON.parse(existing); } catch { /* */ }
699
+ for (let i = 0; i < items.length; i++) {
700
+ const item = items[i] as Record<string, unknown>;
701
+ const summary = Object.entries(item)
702
+ .filter(([, v]) => v != null && v !== "")
703
+ .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
704
+ .join(", ");
705
+ const isCursor = i === state.formEnumCursor;
706
+ const prefix = isCursor ? " ❯ " : " ";
707
+ const line = prefix + truncateVisible(summary || "(empty)", innerWidth - 6);
708
+ content.push(isCursor ? style.boldYellow(line) : style.dim(line));
709
+ }
710
+ if (items.length > 0) content.push("");
711
+ const addIdx = items.length;
712
+ const addCursor = state.formEnumCursor === addIdx;
713
+ if (addCursor) {
714
+ content.push(" " + style.inverse(style.green(" + Add new item ")));
715
+ } else {
716
+ content.push(" " + style.dim("+") + " Add new item");
717
+ }
718
+ } else if (dateFmt) {
719
+ content.push(" " + renderDateParts(state.dateParts, state.datePartCursor, dateFmt));
720
+ } else if (isArrayEnum && eVals) {
721
+ // Multi-select picker
722
+ for (let ci = 0; ci < eVals.length; ci++) {
723
+ const isCursor = ci === state.formEnumCursor;
724
+ const isChecked = state.formEnumSelected.has(ci);
725
+ const check = isChecked ? style.cyan("[x]") : style.dim("[ ]");
726
+ const marker = isCursor ? " › " : " ";
727
+ const label = marker + check + " " + eVals[ci]!;
728
+ content.push(isCursor ? style.bold(label) : label);
729
+ }
730
+ } else if (isArrayText) {
731
+ // Tag-style list editor: navigable items + text input at bottom
732
+ const existing = state.formValues[field.name] || "";
733
+ const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
734
+ const inputIdx = items.length; // cursor index for the text input line
735
+ for (let i = 0; i < items.length; i++) {
736
+ const isCursor = i === state.formEnumCursor;
737
+ const prefix = isCursor ? " ❯ " : " ";
738
+ const line = prefix + items[i]!;
739
+ content.push(isCursor ? style.boldYellow(line) : style.cyan(line));
740
+ }
741
+ if (items.length > 0) content.push("");
742
+ const onInput = state.formEnumCursor === inputIdx;
743
+ const inputPrefix = onInput ? " " + style.yellow("❯") + " " : " ";
744
+ content.push(inputPrefix + style.cyan(state.formInputBuf) + (onInput ? style.inverse(" ") : ""));
745
+ content.push("");
746
+ if (onInput) {
747
+ content.push(" " + style.dim("enter ") + style.dim(state.formInputBuf ? "add item" : "confirm"));
748
+ content.push(" " + style.dim("esc ") + style.dim("confirm"));
749
+ } else {
750
+ content.push(" " + style.dim("enter ") + style.dim("edit item"));
751
+ content.push(" " + style.dim("bksp ") + style.dim("remove item"));
752
+ }
753
+ } else if (eVals || isBool) {
754
+ const choices = isBool ? ["true", "false"] : eVals!;
755
+ for (let ci = 0; ci < choices.length; ci++) {
756
+ const sel = ci === state.formEnumCursor;
757
+ const choiceLine = (sel ? " › " : " ") + choices[ci]!;
758
+ content.push(sel ? style.cyan(style.bold(choiceLine)) : choiceLine);
759
+ }
760
+ } else {
761
+ // Text editor
762
+ const lines = state.formInputBuf.split("\n");
763
+ for (let li = 0; li < lines.length; li++) {
764
+ const prefix = li === 0 ? " " + style.yellow("❯") + " " : " ";
765
+ if (li === lines.length - 1) {
766
+ content.push(prefix + style.cyan(lines[li]!) + style.inverse(" "));
767
+ } else {
768
+ content.push(prefix + style.cyan(lines[li]!));
769
+ }
770
+ }
771
+ }
772
+
773
+ let footer: string;
774
+ if (isArrayObj) {
775
+ footer = style.dim("↑↓ navigate enter add/select backspace delete esc back");
776
+ } else if (dateFmt) {
777
+ footer = style.dim("←→ part ↑↓ adjust t today enter confirm esc cancel");
778
+ } else if (isArrayEnum) {
779
+ footer = style.dim("space toggle enter select esc confirm");
780
+ } else if (isArrayText) {
781
+ footer = style.dim("↑↓ navigate enter add/edit backspace delete esc confirm");
782
+ } else if (eVals || isBool) {
783
+ footer = style.dim("↑↓ navigate enter confirm esc cancel");
784
+ } else {
785
+ footer = style.dim("enter confirm shift+enter newline esc cancel");
786
+ }
787
+
788
+ return renderLayout({
789
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title),
790
+ content,
791
+ footer,
792
+ });
793
+ }
794
+
795
+ function renderLoading(state: AppState): string[] {
796
+ const { contentHeight } = getBoxDimensions();
797
+ const tool = state.selectedTool;
798
+ const title = tool ? humanLabel(tool.name, toolPrefix(tool)) : "";
799
+ const content: string[] = [];
800
+
801
+ const midRow = Math.floor(contentHeight / 2);
802
+ while (content.length < midRow) content.push("");
803
+
804
+ const msgIdx = Math.floor(state.spinnerFrame / 13) % shuffledLoadingMessages.length; // ~1s per message (80ms × 13)
805
+ const loadingMsg = shuffledLoadingMessages[msgIdx]!;
806
+ const frame = SPINNER_FRAMES[state.spinnerFrame % SPINNER_FRAMES.length]!;
807
+ content.push(` ${style.cyan(frame)} ${loadingMsg}`);
808
+
809
+ return renderLayout({
810
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › running…"),
811
+ content,
812
+ footer: "",
813
+ });
814
+ }
815
+
816
+ const SUCCESS_ICON = [
817
+ " ██████╗ ██╗ ██╗",
818
+ "██╔═══██╗██║ ██╔╝",
819
+ "██║ ██║█████╔╝ ",
820
+ "██║ ██║██╔═██╗ ",
821
+ "╚██████╔╝██║ ██╗",
822
+ " ╚═════╝ ╚═╝ ╚═╝",
823
+ ];
824
+
825
+ function renderResults(state: AppState): string[] {
826
+ const { contentHeight, innerWidth } = getBoxDimensions();
827
+ const tool = state.selectedTool;
828
+ const title = tool ? humanLabel(tool.name, toolPrefix(tool)) : "";
829
+ const isError = !!state.error;
830
+ const isEmptyList = !isError && state.result === EMPTY_LIST_SENTINEL;
831
+ const isEmpty = !isError && !isEmptyList && !state.result.trim();
832
+
833
+ // No results screen for empty lists
834
+ if (isEmptyList) {
835
+ const ghost = [
836
+ " ╔══════════╗ ",
837
+ " ╔╝░░░░░░░░░░╚╗ ",
838
+ "╔╝░░╔══╗░╔══╗░░╚╗",
839
+ "║░░░║ ║░║ ║░░░║",
840
+ "║░░░╚══╝░╚══╝░░░║",
841
+ "║░░░░░░░░░░░░░░░║",
842
+ "║░░░░╔══════╗░░░║",
843
+ "╚╗░░╚╝░░░░░░╚╝░╔╝",
844
+ " ╚╗░░╗░╔╗░╔╗░╔╝ ",
845
+ " ╚══╝░╚╝░╚╝░╚╝ ",
846
+ ];
847
+ const content: string[] = [];
848
+ const midRow = Math.floor(contentHeight / 2) - Math.floor(ghost.length / 2) - 2;
849
+ while (content.length < midRow) content.push("");
850
+ for (const line of ghost) {
851
+ content.push(" " + style.dim(line));
852
+ }
853
+ content.push("");
854
+ content.push(" " + "No results found");
855
+ content.push("");
856
+ content.push(" " + style.dim("Try adjusting your search parameters."));
857
+
858
+ return renderLayout({
859
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › done"),
860
+ content,
861
+ footer: state.quitConfirm
862
+ ? style.yellow("Press q again to quit")
863
+ : style.dim("enter/esc back q quit"),
864
+ });
865
+ }
866
+
867
+ // Success screen for empty results
868
+ if (isEmpty) {
869
+ const content: string[] = [];
870
+ const toolLabel = tool ? humanLabel(tool.name, toolPrefix(tool)) : "Command";
871
+ const midRow = Math.floor(contentHeight / 2) - Math.floor(SUCCESS_ICON.length / 2) - 1;
872
+ while (content.length < midRow) content.push("");
873
+ for (const line of SUCCESS_ICON) {
874
+ content.push(" " + style.green(line));
875
+ }
876
+ content.push("");
877
+ content.push(" " + style.bold(style.green(toolLabel + " completed successfully")));
878
+
879
+ return renderLayout({
880
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › done"),
881
+ content,
882
+ footer: state.quitConfirm
883
+ ? style.yellow("Press q again to quit")
884
+ : style.dim("enter/esc back q quit"),
885
+ });
886
+ }
887
+
888
+ const rawContent = state.error || state.result;
889
+ const contentLines = rawContent.split("\n");
890
+ const content: string[] = [];
891
+
892
+ // Results header
893
+ let resultHeader = isError ? style.red(style.bold(" Error")) : style.bold(" Results");
894
+ const visibleCount = Math.max(1, contentHeight - 3); // header + blank + content
895
+ if (contentLines.length > visibleCount) {
896
+ const from = state.resultScroll + 1;
897
+ const to = Math.min(state.resultScroll + visibleCount, contentLines.length);
898
+ resultHeader += style.dim(` (${from}–${to} of ${contentLines.length})`);
899
+ }
900
+ content.push(resultHeader);
901
+ content.push("");
902
+
903
+ // Content (with horizontal scroll)
904
+ const visible = contentLines.slice(state.resultScroll, state.resultScroll + visibleCount);
905
+ for (const line of visible) {
906
+ const shifted = state.resultScrollX > 0 ? ansiSlice(line, state.resultScrollX) : line;
907
+ content.push(" " + (isError ? style.red(shifted) : shifted));
908
+ }
909
+
910
+ const scrollHint = state.resultScrollX > 0 ? `←${state.resultScrollX} ` : "";
911
+ return renderLayout({
912
+ breadcrumb: style.boldYellow("Readwise") + style.dim(" › ") + style.bold(title) + style.dim(" › results"),
913
+ content,
914
+ footer: state.quitConfirm
915
+ ? style.yellow("Press q again to quit")
916
+ : style.dim(scrollHint + "↑↓←→ scroll esc back q quit"),
917
+ });
918
+ }
919
+
920
+ function renderState(state: AppState): string[] {
921
+ switch (state.view) {
922
+ case "commands": return renderCommandList(state);
923
+ case "form": return renderForm(state);
924
+ case "loading": return renderLoading(state);
925
+ case "results": return renderResults(state);
926
+ }
927
+ }
928
+
929
+ // --- Input handling ---
930
+
931
+ function handleInput(state: AppState, key: KeyEvent): AppState | "exit" | "submit" {
932
+ switch (state.view) {
933
+ case "commands": return handleCommandListInput(state, key);
934
+ case "form": return handleFormInput(state, key);
935
+ case "results": return handleResultsInput(state, key);
936
+ default: return state;
937
+ }
938
+ }
939
+
940
+ function handleCommandListInput(state: AppState, key: KeyEvent): AppState | "exit" {
941
+ const items = state.filteredItems;
942
+ const selectable = selectableIndices(items);
943
+ const { contentHeight } = getBoxDimensions();
944
+ // search input uses: LOGO.length + 1 (blank) + 1 (search line) + 1 (blank)
945
+ const logoUsed = LOGO.length + 3;
946
+ const listHeight = Math.max(1, contentHeight - logoUsed);
947
+
948
+ if (key.ctrl && key.name === "c") return "exit";
949
+
950
+ // Escape: clear query if non-empty, otherwise quit confirm
951
+ if (key.name === "escape") {
952
+ if (state.searchQuery) {
953
+ const filtered = filterCommands(state.tools, "");
954
+ const sel = selectableIndices(filtered);
955
+ return { ...state, searchQuery: "", searchCursorPos: 0, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0, quitConfirm: false };
956
+ }
957
+ if (state.quitConfirm) return "exit";
958
+ return { ...state, quitConfirm: true };
959
+ }
960
+
961
+ // q: quit confirm when query empty, otherwise insert as text
962
+ if (key.name === "q" && !key.ctrl && !state.searchQuery) {
963
+ if (state.quitConfirm) return "exit";
964
+ return { ...state, quitConfirm: true };
965
+ }
966
+
967
+ // Any other key cancels quit confirm
968
+ const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
969
+
970
+ // Arrow left/right: move text cursor within search input
971
+ if (key.name === "left") {
972
+ return { ...s, searchCursorPos: Math.max(0, s.searchCursorPos - 1) };
973
+ }
974
+ if (key.name === "right") {
975
+ return { ...s, searchCursorPos: Math.min(s.searchQuery.length, s.searchCursorPos + 1) };
976
+ }
977
+
978
+ // Arrow up/down: navigate filtered command list
979
+ if (key.name === "up") {
980
+ const curIdx = selectable.indexOf(s.listCursor);
981
+ if (curIdx > 0) {
982
+ const next = selectable[curIdx - 1]!;
983
+ let scroll = s.listScrollTop;
984
+ if (next < scroll) scroll = next;
985
+ return { ...s, listCursor: next, listScrollTop: scroll };
986
+ }
987
+ return s;
988
+ }
989
+ if (key.name === "down") {
990
+ const curIdx = selectable.indexOf(s.listCursor);
991
+ if (curIdx < selectable.length - 1) {
992
+ const next = selectable[curIdx + 1]!;
993
+ let scroll = s.listScrollTop;
994
+ if (next >= scroll + listHeight) scroll = next - listHeight + 1;
995
+ return { ...s, listCursor: next, listScrollTop: scroll };
996
+ }
997
+ return s;
998
+ }
999
+
1000
+ // PgUp/PgDown: jump by a page of selectable items
1001
+ if (key.name === "pageup") {
1002
+ const curIdx = selectable.indexOf(s.listCursor);
1003
+ const next = selectable[Math.max(0, curIdx - listHeight)]!;
1004
+ let scroll = s.listScrollTop;
1005
+ if (next < scroll) scroll = next;
1006
+ return { ...s, listCursor: next, listScrollTop: scroll };
1007
+ }
1008
+ if (key.name === "pagedown") {
1009
+ const curIdx = selectable.indexOf(s.listCursor);
1010
+ const next = selectable[Math.min(selectable.length - 1, curIdx + listHeight)]!;
1011
+ let scroll = s.listScrollTop;
1012
+ if (next >= scroll + listHeight) scroll = next - listHeight + 1;
1013
+ return { ...s, listCursor: next, listScrollTop: scroll };
1014
+ }
1015
+
1016
+ // Enter: select highlighted command
1017
+ if (key.name === "return") {
1018
+ const item = items[s.listCursor];
1019
+ if (item && !item.isSeparator) {
1020
+ const tool = s.tools.find((t) => t.name === item.value);
1021
+ if (tool) {
1022
+ const properties = tool.inputSchema.properties || {};
1023
+ const requiredSet = new Set(tool.inputSchema.required || []);
1024
+ const defs = tool.inputSchema.$defs;
1025
+ const fields = Object.entries(properties).map(([name, rawProp]) => ({
1026
+ name,
1027
+ prop: resolveProperty(rawProp, defs),
1028
+ required: requiredSet.has(name),
1029
+ }));
1030
+ const nameColWidth = Math.max(
1031
+ ...fields.map((f) => f.name.length + (f.required ? 2 : 0)),
1032
+ 6
1033
+ ) + 1;
1034
+
1035
+ const formValues: Record<string, string> = {};
1036
+ for (const f of fields) {
1037
+ if (f.prop.default != null) {
1038
+ formValues[f.name] = String(f.prop.default);
1039
+ } else {
1040
+ formValues[f.name] = "";
1041
+ }
1042
+ }
1043
+
1044
+ if (fields.length === 0) {
1045
+ return {
1046
+ ...s,
1047
+ view: "loading",
1048
+ selectedTool: tool,
1049
+ fields,
1050
+ nameColWidth,
1051
+ formValues,
1052
+ formSearchQuery: "",
1053
+ formSearchCursorPos: 0,
1054
+ formFilteredIndices: [],
1055
+ formListCursor: 0,
1056
+ formScrollTop: 0,
1057
+ formEditFieldIdx: -1,
1058
+ formEditing: false,
1059
+ formInputBuf: "",
1060
+ formEnumCursor: 0,
1061
+ formEnumSelected: new Set(),
1062
+ formShowRequired: false,
1063
+ formStack: [],
1064
+ };
1065
+ }
1066
+
1067
+ return {
1068
+ ...s,
1069
+ view: "form",
1070
+ selectedTool: tool,
1071
+ fields,
1072
+ nameColWidth,
1073
+ formValues,
1074
+ formSearchQuery: "",
1075
+ formSearchCursorPos: 0,
1076
+ formFilteredIndices: filterFormFields(fields, ""),
1077
+ formListCursor: defaultFormCursor(fields, filterFormFields(fields, ""), formValues),
1078
+ formScrollTop: 0,
1079
+ formEditFieldIdx: -1,
1080
+ formEditing: false,
1081
+ formInputBuf: "",
1082
+ formEnumCursor: 0,
1083
+ formEnumSelected: new Set(),
1084
+ formShowRequired: false,
1085
+ formStack: [],
1086
+ };
1087
+ }
1088
+ }
1089
+ return s;
1090
+ }
1091
+
1092
+ // Backspace: delete char before cursor
1093
+ if (key.name === "backspace") {
1094
+ if (s.searchCursorPos > 0) {
1095
+ const newQuery = s.searchQuery.slice(0, s.searchCursorPos - 1) + s.searchQuery.slice(s.searchCursorPos);
1096
+ const filtered = filterCommands(s.tools, newQuery);
1097
+ const sel = selectableIndices(filtered);
1098
+ return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos - 1, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1099
+ }
1100
+ return s;
1101
+ }
1102
+
1103
+ // Printable characters: insert into search query
1104
+ if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
1105
+ const newQuery = s.searchQuery.slice(0, s.searchCursorPos) + key.raw + s.searchQuery.slice(s.searchCursorPos);
1106
+ const filtered = filterCommands(s.tools, newQuery);
1107
+ const sel = selectableIndices(filtered);
1108
+ return { ...s, searchQuery: newQuery, searchCursorPos: s.searchCursorPos + 1, filteredItems: filtered, listCursor: sel[0] ?? 0, listScrollTop: 0 };
1109
+ }
1110
+
1111
+ return s;
1112
+ }
1113
+
1114
+ function handleFormInput(state: AppState, key: KeyEvent): AppState | "submit" {
1115
+ if (state.formEditing) return handleFormEditInput(state, key);
1116
+ return handleFormPaletteInput(state, key);
1117
+ }
1118
+
1119
+ function handleFormPaletteInput(state: AppState, key: KeyEvent): AppState | "submit" {
1120
+ const { fields, formFilteredIndices: filtered, formListCursor, formSearchQuery } = state;
1121
+ const { contentHeight } = getBoxDimensions();
1122
+ // Approximate list height (same as in renderFormPaletteMode)
1123
+ const headerUsed = 6 + (state.selectedTool?.description ? wrapText(state.selectedTool.description, getBoxDimensions().innerWidth - 4).length : 0);
1124
+ const listHeight = Math.max(1, contentHeight - headerUsed - 8);
1125
+
1126
+ if (key.ctrl && key.name === "c") return "submit";
1127
+
1128
+ // Escape: cancel search or go back
1129
+ if (key.name === "escape") {
1130
+ if (formSearchQuery) {
1131
+ const newFiltered = filterFormFields(fields, "");
1132
+ return { ...state, formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: newFiltered, formListCursor: defaultFormCursor(fields, newFiltered, state.formValues), formScrollTop: 0 };
1133
+ }
1134
+ // If in sub-form, cancel and go back to parent array editor
1135
+ if (state.formStack.length > 0) {
1136
+ const stack = [...state.formStack];
1137
+ const entry = stack.pop()!;
1138
+ const parentFiltered = filterFormFields(entry.parentFields, "");
1139
+ const parentFieldIdx = entry.parentFields.findIndex((f) => f.name === entry.parentFieldName);
1140
+ const existing = entry.parentValues[entry.parentFieldName] || "[]";
1141
+ let items: unknown[] = [];
1142
+ try { items = JSON.parse(existing); } catch { /* */ }
1143
+ return {
1144
+ ...state,
1145
+ formStack: stack,
1146
+ fields: entry.parentFields,
1147
+ nameColWidth: entry.parentNameColWidth,
1148
+ formValues: entry.parentValues,
1149
+ formEditing: true,
1150
+ formEditFieldIdx: parentFieldIdx,
1151
+ formEnumCursor: items.length, // cursor on "Add new item"
1152
+ formEnumSelected: new Set(),
1153
+ formSearchQuery: "",
1154
+ formSearchCursorPos: 0,
1155
+ formFilteredIndices: parentFiltered,
1156
+ formListCursor: defaultFormCursor(entry.parentFields, parentFiltered, entry.parentValues),
1157
+ formScrollTop: 0,
1158
+ formShowRequired: false,
1159
+ formInputBuf: "",
1160
+ };
1161
+ }
1162
+ const resetFiltered = buildCommandList(state.tools);
1163
+ const resetSel = selectableIndices(resetFiltered);
1164
+ return { ...state, view: "commands", selectedTool: null, searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1165
+ }
1166
+
1167
+ // Arrow left/right: move text cursor within search input
1168
+ if (key.name === "left") {
1169
+ return { ...state, formSearchCursorPos: Math.max(0, state.formSearchCursorPos - 1) };
1170
+ }
1171
+ if (key.name === "right") {
1172
+ return { ...state, formSearchCursorPos: Math.min(formSearchQuery.length, state.formSearchCursorPos + 1) };
1173
+ }
1174
+
1175
+ // Arrow up/down: navigate filtered list (cycling)
1176
+ if (key.name === "up") {
1177
+ const next = formListCursor > 0 ? formListCursor - 1 : filtered.length - 1;
1178
+ let scroll = state.formScrollTop;
1179
+ const itemIdx = filtered[next]!;
1180
+ if (itemIdx !== -1) {
1181
+ const paramItems = filtered.filter((idx) => idx !== -1);
1182
+ const posInParams = paramItems.indexOf(itemIdx);
1183
+ if (posInParams < scroll) scroll = posInParams;
1184
+ // Wrap to bottom: reset scroll to show end of list
1185
+ if (next > formListCursor) scroll = Math.max(0, paramItems.length - listHeight);
1186
+ }
1187
+ return { ...state, formListCursor: next, formScrollTop: scroll };
1188
+ }
1189
+ if (key.name === "down") {
1190
+ const next = formListCursor < filtered.length - 1 ? formListCursor + 1 : 0;
1191
+ let scroll = state.formScrollTop;
1192
+ const itemIdx = filtered[next]!;
1193
+ if (itemIdx !== -1) {
1194
+ const paramItems = filtered.filter((idx) => idx !== -1);
1195
+ const posInParams = paramItems.indexOf(itemIdx);
1196
+ if (posInParams >= scroll + listHeight) scroll = posInParams - listHeight + 1;
1197
+ // Wrap to top: reset scroll
1198
+ if (next < formListCursor) scroll = 0;
1199
+ } else if (next < formListCursor) {
1200
+ scroll = 0;
1201
+ }
1202
+ return { ...state, formListCursor: next, formScrollTop: scroll };
1203
+ }
1204
+
1205
+ // Enter: edit field or execute/add
1206
+ if (key.name === "return") {
1207
+ const highlightedIdx = filtered[formListCursor];
1208
+ if (highlightedIdx === -1) {
1209
+ // Execute/Add — only if all required fields are filled
1210
+ if (missingRequiredFields(fields, state.formValues).length === 0) {
1211
+ if (state.formStack.length > 0) {
1212
+ // Pop sub-form: serialize values and append to parent array
1213
+ return popFormStack(state);
1214
+ }
1215
+ return "submit";
1216
+ }
1217
+ return { ...state, formShowRequired: true };
1218
+ }
1219
+ if (highlightedIdx !== undefined && highlightedIdx >= 0 && highlightedIdx < fields.length) {
1220
+ const field = fields[highlightedIdx]!;
1221
+ // Array-of-objects: enter edit mode with cursor on "Add new item"
1222
+ if (isArrayOfObjects(field.prop)) {
1223
+ const existing = state.formValues[field.name] || "[]";
1224
+ let items: unknown[] = [];
1225
+ try { items = JSON.parse(existing); } catch { /* */ }
1226
+ return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: items.length };
1227
+ }
1228
+ const dateFmt = dateFieldFormat(field.prop);
1229
+ const enumValues = field.prop.enum || field.prop.items?.enum;
1230
+ const isBool = field.prop.type === "boolean";
1231
+ if (dateFmt) {
1232
+ const existing = state.formValues[field.name] || "";
1233
+ const parts = parseDateParts(existing, dateFmt) || todayParts(dateFmt);
1234
+ return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, dateParts: parts, datePartCursor: 0 };
1235
+ }
1236
+ const isArrayEnum = !isArrayOfObjects(field.prop) && field.prop.type === "array" && !!field.prop.items?.enum;
1237
+ if (isArrayEnum && enumValues) {
1238
+ const curVal = state.formValues[field.name] || "";
1239
+ const selected = new Set<number>();
1240
+ if (curVal) {
1241
+ const parts = curVal.split(",").map((s) => s.trim());
1242
+ for (const p of parts) {
1243
+ const idx = enumValues.indexOf(p);
1244
+ if (idx >= 0) selected.add(idx);
1245
+ }
1246
+ }
1247
+ return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: 0, formEnumSelected: selected };
1248
+ }
1249
+ if (enumValues || isBool) {
1250
+ const choices = isBool ? ["true", "false"] : enumValues!;
1251
+ const curVal = state.formValues[field.name] || "";
1252
+ const idx = choices.indexOf(curVal);
1253
+ return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formEnumCursor: idx >= 0 ? idx : 0 };
1254
+ }
1255
+ if (field.prop.type === "array" && !field.prop.items?.enum) {
1256
+ const existing = state.formValues[field.name] || "";
1257
+ const itemCount = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean).length : 0;
1258
+ return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formInputBuf: "", formEnumCursor: itemCount };
1259
+ }
1260
+ return { ...state, formEditing: true, formEditFieldIdx: highlightedIdx, formInputBuf: state.formValues[field.name] || "" };
1261
+ }
1262
+ return state;
1263
+ }
1264
+
1265
+ // Backspace: delete char before cursor
1266
+ if (key.name === "backspace") {
1267
+ if (state.formSearchCursorPos > 0) {
1268
+ const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos - 1) + formSearchQuery.slice(state.formSearchCursorPos);
1269
+ const newFiltered = filterFormFields(fields, newQuery);
1270
+ return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos - 1, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
1271
+ }
1272
+ return state;
1273
+ }
1274
+
1275
+ // Printable characters: insert into search query
1276
+ if (!key.ctrl && key.raw && key.raw.length === 1 && key.raw >= " ") {
1277
+ const newQuery = formSearchQuery.slice(0, state.formSearchCursorPos) + key.raw + formSearchQuery.slice(state.formSearchCursorPos);
1278
+ const newFiltered = filterFormFields(fields, newQuery);
1279
+ return { ...state, formSearchQuery: newQuery, formSearchCursorPos: state.formSearchCursorPos + 1, formFilteredIndices: newFiltered, formListCursor: 0, formScrollTop: 0 };
1280
+ }
1281
+
1282
+ return state;
1283
+ }
1284
+
1285
+ function handleFormEditInput(state: AppState, key: KeyEvent): AppState | "submit" {
1286
+ const { fields, formEditFieldIdx, formInputBuf, formEnumCursor, formValues } = state;
1287
+ const field = fields[formEditFieldIdx]!;
1288
+ const dateFmt = dateFieldFormat(field.prop);
1289
+ const enumValues = field.prop.enum || field.prop.items?.enum;
1290
+ const isBool = field.prop.type === "boolean";
1291
+
1292
+ const resetPalette = (updatedValues?: Record<string, string>) => {
1293
+ const f = filterFormFields(fields, "");
1294
+ return { formSearchQuery: "", formSearchCursorPos: 0, formFilteredIndices: f, formListCursor: defaultFormCursor(fields, f, updatedValues ?? formValues), formScrollTop: 0, formShowRequired: false };
1295
+ };
1296
+
1297
+ // Escape: cancel edit (for multi-select and tag editor, escape confirms since items are saved live)
1298
+ if (key.name === "escape") {
1299
+ const isArrayEnum = field.prop.type === "array" && !!field.prop.items?.enum;
1300
+ if (isArrayEnum && enumValues) {
1301
+ // Confirm current selections
1302
+ const selected = [...state.formEnumSelected].sort((a, b) => a - b).map((i) => enumValues[i]!);
1303
+ const val = selected.join(", ");
1304
+ const newValues = { ...formValues, [field.name]: val };
1305
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette(newValues) };
1306
+ }
1307
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formInputBuf: "", ...resetPalette() };
1308
+ }
1309
+
1310
+ if (key.ctrl && key.name === "c") return "submit";
1311
+
1312
+ // Array-of-objects mode
1313
+ if (isArrayOfObjects(field.prop)) {
1314
+ const existing = formValues[field.name] || "[]";
1315
+ let items: unknown[] = [];
1316
+ try { items = JSON.parse(existing); } catch { /* */ }
1317
+ const addIdx = items.length;
1318
+ const total = items.length + 1; // items + "Add new item"
1319
+
1320
+ if (key.name === "up") {
1321
+ return { ...state, formEnumCursor: formEnumCursor > 0 ? formEnumCursor - 1 : total - 1 };
1322
+ }
1323
+ if (key.name === "down") {
1324
+ return { ...state, formEnumCursor: formEnumCursor < total - 1 ? formEnumCursor + 1 : 0 };
1325
+ }
1326
+ if (key.name === "return") {
1327
+ // Push form stack and enter sub-form (add new or edit existing)
1328
+ const editingExisting = formEnumCursor < addIdx;
1329
+ const itemSchema = field.prop.items!;
1330
+ const defs = state.selectedTool?.inputSchema.$defs;
1331
+ const subProperties = itemSchema.properties || {};
1332
+ const subRequired = new Set(itemSchema.required || []);
1333
+ const subFields = Object.entries(subProperties).map(([name, rawProp]) => ({
1334
+ name,
1335
+ prop: resolveProperty(rawProp, defs),
1336
+ required: subRequired.has(name),
1337
+ }));
1338
+ const subValues: Record<string, string> = {};
1339
+ if (editingExisting) {
1340
+ // Pre-populate from existing item
1341
+ const existingItem = items[formEnumCursor] as Record<string, unknown>;
1342
+ for (const f of subFields) {
1343
+ const v = existingItem[f.name];
1344
+ if (v == null) {
1345
+ subValues[f.name] = f.prop.default != null ? String(f.prop.default) : "";
1346
+ } else if (Array.isArray(v)) {
1347
+ subValues[f.name] = JSON.stringify(v);
1348
+ } else {
1349
+ subValues[f.name] = String(v);
1350
+ }
1351
+ }
1352
+ } else {
1353
+ for (const f of subFields) {
1354
+ subValues[f.name] = f.prop.default != null ? String(f.prop.default) : "";
1355
+ }
1356
+ }
1357
+ const subFiltered = filterFormFields(subFields, "");
1358
+ const toolTitle = humanLabel(state.selectedTool!.name, toolPrefix(state.selectedTool!));
1359
+ return {
1360
+ ...state,
1361
+ formStack: [...state.formStack, {
1362
+ parentFieldName: field.name,
1363
+ parentFields: fields,
1364
+ parentValues: formValues,
1365
+ parentNameColWidth: state.nameColWidth,
1366
+ parentTitle: toolTitle,
1367
+ editIndex: editingExisting ? formEnumCursor : -1,
1368
+ }],
1369
+ fields: subFields,
1370
+ nameColWidth: Math.max(...subFields.map((f) => f.name.length + (f.required ? 2 : 0)), 6) + 1,
1371
+ formValues: subValues,
1372
+ formEditing: false,
1373
+ formEditFieldIdx: -1,
1374
+ formSearchQuery: "",
1375
+ formSearchCursorPos: 0,
1376
+ formFilteredIndices: subFiltered,
1377
+ formListCursor: defaultFormCursor(subFields, subFiltered, subValues),
1378
+ formScrollTop: 0,
1379
+ formShowRequired: false,
1380
+ formEnumCursor: 0,
1381
+ formEnumSelected: new Set(),
1382
+ formInputBuf: "",
1383
+ };
1384
+ }
1385
+ if (key.name === "backspace" && formEnumCursor < items.length) {
1386
+ // Delete item at cursor
1387
+ const newItems = [...items];
1388
+ newItems.splice(formEnumCursor, 1);
1389
+ const newValues = { ...formValues, [field.name]: JSON.stringify(newItems) };
1390
+ const newCursor = Math.min(formEnumCursor, newItems.length);
1391
+ return { ...state, formValues: newValues, formEnumCursor: newCursor };
1392
+ }
1393
+ return state;
1394
+ }
1395
+
1396
+ // Date picker mode
1397
+ if (dateFmt) {
1398
+ const maxPart = datePartCount(dateFmt) - 1;
1399
+ if (key.name === "left") {
1400
+ return { ...state, datePartCursor: Math.max(0, state.datePartCursor - 1) };
1401
+ }
1402
+ if (key.name === "right") {
1403
+ return { ...state, datePartCursor: Math.min(maxPart, state.datePartCursor + 1) };
1404
+ }
1405
+ if (key.name === "up") {
1406
+ return { ...state, dateParts: adjustDatePart(state.dateParts, state.datePartCursor, 1, dateFmt) };
1407
+ }
1408
+ if (key.name === "down") {
1409
+ return { ...state, dateParts: adjustDatePart(state.dateParts, state.datePartCursor, -1, dateFmt) };
1410
+ }
1411
+ if (key.name === "return") {
1412
+ const val = datePartsToString(state.dateParts, dateFmt);
1413
+ const newValues = { ...formValues, [field.name]: val };
1414
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
1415
+ }
1416
+ if (key.name === "t") {
1417
+ return { ...state, dateParts: todayParts(dateFmt), datePartCursor: 0 };
1418
+ }
1419
+ if (key.name === "backspace") {
1420
+ const newValues = { ...formValues, [field.name]: "" };
1421
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
1422
+ }
1423
+ return state;
1424
+ }
1425
+
1426
+ // Array enum multi-select mode
1427
+ const isArrayEnum = field.prop.type === "array" && !!field.prop.items?.enum;
1428
+ if (isArrayEnum && enumValues) {
1429
+ if (key.name === "up") {
1430
+ return { ...state, formEnumCursor: formEnumCursor <= 0 ? enumValues.length - 1 : formEnumCursor - 1 };
1431
+ }
1432
+ if (key.name === "down") {
1433
+ return { ...state, formEnumCursor: formEnumCursor >= enumValues.length - 1 ? 0 : formEnumCursor + 1 };
1434
+ }
1435
+ if (key.name === " " || key.raw === " ") {
1436
+ // Toggle selection
1437
+ const next = new Set(state.formEnumSelected);
1438
+ if (next.has(formEnumCursor)) next.delete(formEnumCursor);
1439
+ else next.add(formEnumCursor);
1440
+ return { ...state, formEnumSelected: next };
1441
+ }
1442
+ if (key.name === "return") {
1443
+ // Select current option and exit
1444
+ const next = new Set(state.formEnumSelected);
1445
+ next.add(formEnumCursor);
1446
+ const selected = [...next].sort((a, b) => a - b).map((i) => enumValues[i]!);
1447
+ const val = selected.join(", ");
1448
+ const newValues = { ...formValues, [field.name]: val };
1449
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, formEnumSelected: new Set(), ...resetPalette() };
1450
+ }
1451
+ return state;
1452
+ }
1453
+
1454
+ // Enum/bool picker mode
1455
+ if (enumValues || isBool) {
1456
+ const choices = isBool ? ["true", "false"] : enumValues!;
1457
+ if (key.name === "up") {
1458
+ return { ...state, formEnumCursor: formEnumCursor <= 0 ? choices.length - 1 : formEnumCursor - 1 };
1459
+ }
1460
+ if (key.name === "down") {
1461
+ return { ...state, formEnumCursor: formEnumCursor >= choices.length - 1 ? 0 : formEnumCursor + 1 };
1462
+ }
1463
+ if (key.name === "return") {
1464
+ const val = choices[formEnumCursor]!;
1465
+ const newValues = { ...formValues, [field.name]: val };
1466
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
1467
+ }
1468
+ return state;
1469
+ }
1470
+
1471
+ // Array text (list editor) mode
1472
+ const isArrayText = field.prop.type === "array" && !field.prop.items?.enum;
1473
+ if (isArrayText) {
1474
+ const existing = formValues[field.name] || "";
1475
+ const items = existing ? existing.split(",").map((s) => s.trim()).filter(Boolean) : [];
1476
+ const inputIdx = items.length; // index of the text input line
1477
+ const total = items.length + 1;
1478
+
1479
+ if (key.name === "up") {
1480
+ return { ...state, formEnumCursor: formEnumCursor > 0 ? formEnumCursor - 1 : total - 1 };
1481
+ }
1482
+ if (key.name === "down") {
1483
+ return { ...state, formEnumCursor: formEnumCursor < total - 1 ? formEnumCursor + 1 : 0 };
1484
+ }
1485
+
1486
+ // Cursor on an existing item
1487
+ if (formEnumCursor < inputIdx) {
1488
+ if (key.name === "return") {
1489
+ // Edit: move item value to input, remove from list
1490
+ const editVal = items[formEnumCursor]!;
1491
+ const newItems = [...items];
1492
+ newItems.splice(formEnumCursor, 1);
1493
+ const newValues = { ...formValues, [field.name]: newItems.join(", ") };
1494
+ return { ...state, formValues: newValues, formInputBuf: editVal, formEnumCursor: newItems.length };
1495
+ }
1496
+ if (key.name === "backspace") {
1497
+ // Delete item
1498
+ const newItems = [...items];
1499
+ newItems.splice(formEnumCursor, 1);
1500
+ const newValues = { ...formValues, [field.name]: newItems.join(", ") };
1501
+ const newCursor = Math.min(formEnumCursor, newItems.length);
1502
+ return { ...state, formValues: newValues, formEnumCursor: newCursor };
1503
+ }
1504
+ return state;
1505
+ }
1506
+
1507
+ // Cursor on text input
1508
+ if (key.name === "return") {
1509
+ if (formInputBuf.trim()) {
1510
+ items.push(formInputBuf.trim());
1511
+ const newValues = { ...formValues, [field.name]: items.join(", ") };
1512
+ return { ...state, formValues: newValues, formInputBuf: "", formEnumCursor: items.length };
1513
+ }
1514
+ // Empty input: confirm and close
1515
+ const newValues = { ...formValues, [field.name]: items.join(", ") };
1516
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
1517
+ }
1518
+ if (key.name === "backspace") {
1519
+ if (formInputBuf) {
1520
+ return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
1521
+ }
1522
+ return state;
1523
+ }
1524
+ if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1525
+ return { ...state, formInputBuf: formInputBuf + key.raw };
1526
+ }
1527
+ return state;
1528
+ }
1529
+
1530
+ // Text editing mode
1531
+ if (key.name === "return" && key.shift) {
1532
+ // Shift+Enter: insert newline
1533
+ return { ...state, formInputBuf: formInputBuf + "\n" };
1534
+ }
1535
+ if (key.name === "return") {
1536
+ // Enter: confirm
1537
+ const newValues = { ...formValues, [field.name]: formInputBuf };
1538
+ return { ...state, formEditing: false, formEditFieldIdx: -1, formValues: newValues, ...resetPalette(newValues) };
1539
+ }
1540
+ if (key.name === "backspace") {
1541
+ return { ...state, formInputBuf: formInputBuf.slice(0, -1) };
1542
+ }
1543
+ if (!key.ctrl && key.name !== "escape" && !key.raw.startsWith("\x1b")) {
1544
+ return { ...state, formInputBuf: formInputBuf + key.raw };
1545
+ }
1546
+ return state;
1547
+ }
1548
+
1549
+ function handleResultsInput(state: AppState, key: KeyEvent): AppState | "exit" {
1550
+ const { contentHeight } = getBoxDimensions();
1551
+ const contentLines = (state.error || state.result).split("\n");
1552
+ const visibleCount = Math.max(1, contentHeight - 3);
1553
+
1554
+ if (key.ctrl && key.name === "c") return "exit";
1555
+
1556
+ if (key.name === "q" && !key.ctrl) {
1557
+ if (state.quitConfirm) return "exit";
1558
+ return { ...state, quitConfirm: true };
1559
+ }
1560
+
1561
+ // Any other key cancels quit confirm
1562
+ const s = state.quitConfirm ? { ...state, quitConfirm: false } : state;
1563
+
1564
+ const goBack = (): AppState => {
1565
+ const resetFiltered = buildCommandList(s.tools);
1566
+ const resetSel = selectableIndices(resetFiltered);
1567
+ const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1568
+ const hasParams = s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1569
+ if (hasParams) {
1570
+ return {
1571
+ ...s, view: "form" as View, result: "", error: "", resultScroll: 0, resultScrollX: 0,
1572
+ formSearchQuery: "", formSearchCursorPos: 0,
1573
+ formFilteredIndices: filterFormFields(s.fields, ""),
1574
+ formListCursor: defaultFormCursor(s.fields, filterFormFields(s.fields, ""), s.formValues), formScrollTop: 0,
1575
+ formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
1576
+ };
1577
+ }
1578
+ return { ...s, view: "commands" as View, selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1579
+ };
1580
+
1581
+ // Enter on success/empty-list/error screens → go back
1582
+ if (key.name === "return") {
1583
+ const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
1584
+ if (isEmpty) {
1585
+ // Success screen → back to main menu
1586
+ const resetFiltered = buildCommandList(s.tools);
1587
+ const resetSel = selectableIndices(resetFiltered);
1588
+ const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1589
+ return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1590
+ }
1591
+ return goBack();
1592
+ }
1593
+
1594
+ if (key.name === "escape") {
1595
+ const isEmpty = !s.error && s.result !== EMPTY_LIST_SENTINEL && !s.result.trim();
1596
+ const resetFiltered = buildCommandList(s.tools);
1597
+ const resetSel = selectableIndices(resetFiltered);
1598
+ const searchReset = { searchQuery: "", searchCursorPos: 0, filteredItems: resetFiltered, listCursor: resetSel[0] ?? 0, listScrollTop: 0 };
1599
+ if (isEmpty) {
1600
+ // Success screen → back to main menu
1601
+ return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1602
+ }
1603
+ // Data or error → back to form if it has params, otherwise main menu
1604
+ const hasParams = s.selectedTool && Object.keys(s.selectedTool.inputSchema.properties || {}).length > 0;
1605
+ if (hasParams) {
1606
+ return {
1607
+ ...s, view: "form", result: "", error: "", resultScroll: 0, resultScrollX: 0,
1608
+ formSearchQuery: "", formSearchCursorPos: 0,
1609
+ formFilteredIndices: filterFormFields(s.fields, ""),
1610
+ formListCursor: defaultFormCursor(s.fields, filterFormFields(s.fields, ""), s.formValues), formScrollTop: 0,
1611
+ formEditing: false, formEditFieldIdx: -1, formShowRequired: false,
1612
+ };
1613
+ }
1614
+ return { ...s, view: "commands", selectedTool: null, result: "", error: "", resultScroll: 0, resultScrollX: 0, ...searchReset };
1615
+ }
1616
+
1617
+ if (key.name === "up") {
1618
+ return { ...s, resultScroll: Math.max(0, s.resultScroll - 1) };
1619
+ }
1620
+ if (key.name === "down") {
1621
+ return { ...s, resultScroll: Math.min(Math.max(0, contentLines.length - visibleCount), s.resultScroll + 1) };
1622
+ }
1623
+ if (key.name === "left") {
1624
+ return { ...s, resultScrollX: Math.max(0, s.resultScrollX - 4) };
1625
+ }
1626
+ if (key.name === "right") {
1627
+ return { ...s, resultScrollX: s.resultScrollX + 4 };
1628
+ }
1629
+ if (key.name === "pageup") {
1630
+ return { ...s, resultScroll: Math.max(0, s.resultScroll - visibleCount) };
1631
+ }
1632
+ if (key.name === "pagedown") {
1633
+ return { ...s, resultScroll: Math.min(Math.max(0, contentLines.length - visibleCount), s.resultScroll + visibleCount) };
1634
+ }
1635
+
1636
+ return s;
1637
+ }
1638
+
1639
+ // --- Parse form values to tool args ---
1640
+
1641
+ function formValuesToArgs(fields: FormField[], values: Record<string, string>): Record<string, unknown> {
1642
+ const args: Record<string, unknown> = {};
1643
+ for (const field of fields) {
1644
+ const val = values[field.name];
1645
+ if (!val) continue;
1646
+ const p = field.prop;
1647
+ if (p.type === "integer" || p.type === "number") {
1648
+ const n = Number(val);
1649
+ if (!isNaN(n)) args[field.name] = n;
1650
+ } else if (p.type === "boolean") {
1651
+ args[field.name] = val === "true";
1652
+ } else if (p.type === "array") {
1653
+ try {
1654
+ const parsed = JSON.parse(val);
1655
+ args[field.name] = Array.isArray(parsed) ? parsed : val.split(",").map((s) => s.trim());
1656
+ } catch {
1657
+ args[field.name] = val.split(",").map((s) => s.trim()).filter(Boolean);
1658
+ }
1659
+ } else {
1660
+ args[field.name] = val;
1661
+ }
1662
+ }
1663
+ return args;
1664
+ }
1665
+
1666
+ // --- Pretty JSON formatting ---
1667
+
1668
+ function isComplex(val: unknown): boolean {
1669
+ if (Array.isArray(val)) return val.length > 0;
1670
+ return typeof val === "object" && val !== null;
1671
+ }
1672
+
1673
+ function scalarStr(val: unknown): string {
1674
+ if (val === null || val === undefined) return style.dim("null");
1675
+ if (typeof val === "number") return style.cyan(String(val));
1676
+ if (typeof val === "boolean") return style.yellow(String(val));
1677
+ const s = String(val);
1678
+ if (s === "") return style.dim("–");
1679
+ return s;
1680
+ }
1681
+
1682
+ function emitValue(value: unknown, indent: string, lines: string[]): void {
1683
+ if (Array.isArray(value)) {
1684
+ if (value.length === 0) return;
1685
+ for (let i = 0; i < value.length; i++) {
1686
+ const item = value[i];
1687
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
1688
+ if (i > 0) lines.push("");
1689
+ emitArrayObject(item as Record<string, unknown>, indent, lines);
1690
+ } else {
1691
+ lines.push(indent + style.dim("─ ") + scalarStr(item));
1692
+ }
1693
+ }
1694
+ } else if (typeof value === "object" && value !== null) {
1695
+ emitObject(value as Record<string, unknown>, indent, lines);
1696
+ } else {
1697
+ lines.push(indent + scalarStr(value));
1698
+ }
1699
+ }
1700
+
1701
+ function emitObject(obj: Record<string, unknown>, indent: string, lines: string[]): void {
1702
+ const keys = Object.keys(obj);
1703
+ if (keys.length === 0) return;
1704
+ const maxLen = Math.max(...keys.map((k) => k.length));
1705
+ for (const key of keys) {
1706
+ const val = obj[key];
1707
+ if (isComplex(val)) {
1708
+ lines.push(indent + style.bold(key) + style.dim(":"));
1709
+ emitValue(val, indent + " ", lines);
1710
+ } else {
1711
+ lines.push(indent + style.bold(key.padEnd(maxLen)) + " " + scalarStr(val));
1712
+ }
1713
+ }
1714
+ }
1715
+
1716
+ function emitArrayObject(obj: Record<string, unknown>, indent: string, lines: string[]): void {
1717
+ const keys = Object.keys(obj);
1718
+ if (keys.length === 0) { lines.push(indent + style.dim("─")); return; }
1719
+ const maxLen = Math.max(...keys.map((k) => k.length));
1720
+ let first = true;
1721
+ for (const key of keys) {
1722
+ const val = obj[key];
1723
+ const marker = first ? style.dim("─ ") : " ";
1724
+ if (isComplex(val)) {
1725
+ lines.push(indent + marker + style.bold(key) + style.dim(":"));
1726
+ emitValue(val, indent + " ", lines);
1727
+ } else {
1728
+ lines.push(indent + marker + style.bold(key.padEnd(maxLen)) + " " + scalarStr(val));
1729
+ }
1730
+ first = false;
1731
+ }
1732
+ }
1733
+
1734
+ function isEmptyListResult(data: unknown): boolean {
1735
+ // Top-level empty array
1736
+ if (Array.isArray(data) && data.length === 0) return true;
1737
+ // Object where all values are empty arrays, empty strings, zeros, or nulls
1738
+ // (e.g. { results: [], count: 0 })
1739
+ if (typeof data === "object" && data !== null && !Array.isArray(data)) {
1740
+ const obj = data as Record<string, unknown>;
1741
+ const values = Object.values(obj);
1742
+ if (values.length === 0) return false;
1743
+ const hasArray = values.some((v) => Array.isArray(v));
1744
+ if (!hasArray) return false;
1745
+ return values.every((v) =>
1746
+ (Array.isArray(v) && v.length === 0) ||
1747
+ v === 0 || v === null || v === ""
1748
+ );
1749
+ }
1750
+ return false;
1751
+ }
1752
+
1753
+ function formatJsonPretty(data: unknown): string {
1754
+ const lines: string[] = [];
1755
+ emitValue(data, "", lines);
1756
+ return lines.join("\n");
1757
+ }
1758
+
1759
+ // --- Execute tool ---
1760
+
1761
+ async function executeTool(state: AppState): Promise<AppState> {
1762
+ const tool = state.selectedTool!;
1763
+ const args = formValuesToArgs(state.fields, state.formValues);
1764
+ try {
1765
+ const { token, authType } = await ensureValidToken();
1766
+ const res = await callTool(token, authType, tool.name, args);
1767
+
1768
+ if (res.isError) {
1769
+ 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 };
1771
+ }
1772
+
1773
+ const text = res.content.filter((c) => c.type === "text" && c.text).map((c) => c.text!).join("\n");
1774
+ // Check structuredContent if text content is empty
1775
+ const structured = (res as Record<string, unknown>).structuredContent;
1776
+ let formatted: string;
1777
+ let emptyList = false;
1778
+ if (!text && structured !== undefined) {
1779
+ emptyList = isEmptyListResult(structured);
1780
+ formatted = formatJsonPretty(structured);
1781
+ } else {
1782
+ try {
1783
+ const parsed = JSON.parse(text);
1784
+ emptyList = isEmptyListResult(parsed);
1785
+ formatted = formatJsonPretty(parsed);
1786
+ } catch {
1787
+ formatted = text;
1788
+ }
1789
+ }
1790
+ return { ...state, view: "results", result: emptyList ? EMPTY_LIST_SENTINEL : formatted, error: "", resultScroll: 0, resultScrollX: 0 };
1791
+ } catch (err) {
1792
+ return { ...state, view: "results", error: (err as Error).message, result: "", resultScroll: 0, resultScrollX: 0 };
1793
+ }
1794
+ }
1795
+
1796
+ // --- Main loop ---
1797
+
1798
+ export async function runApp(tools: ToolDef[]): Promise<void> {
1799
+ const items = buildCommandList(tools);
1800
+ const selectable = selectableIndices(items);
1801
+
1802
+ let state: AppState = {
1803
+ view: "commands",
1804
+ tools,
1805
+ listCursor: selectable[0] ?? 0,
1806
+ listScrollTop: 0,
1807
+ quitConfirm: false,
1808
+ searchQuery: "",
1809
+ searchCursorPos: 0,
1810
+ filteredItems: buildCommandList(tools),
1811
+ selectedTool: null,
1812
+ fields: [],
1813
+ nameColWidth: 6,
1814
+ formSearchQuery: "",
1815
+ formSearchCursorPos: 0,
1816
+ formFilteredIndices: [],
1817
+ formListCursor: 0,
1818
+ formScrollTop: 0,
1819
+ formEditFieldIdx: -1,
1820
+ formEditing: false,
1821
+ formInputBuf: "",
1822
+ formEnumCursor: 0,
1823
+ formEnumSelected: new Set(),
1824
+ formValues: {},
1825
+ formShowRequired: false,
1826
+ formStack: [],
1827
+ dateParts: [],
1828
+ datePartCursor: 0,
1829
+ result: "",
1830
+ error: "",
1831
+ resultScroll: 0,
1832
+ resultScrollX: 0,
1833
+ spinnerFrame: 0,
1834
+ };
1835
+
1836
+ paint(renderState(state));
1837
+
1838
+ process.stdout.on("resize", () => {
1839
+ paint(renderState(state));
1840
+ });
1841
+
1842
+ const spinnerInterval = setInterval(() => {
1843
+ if (state.view === "loading") {
1844
+ state = { ...state, spinnerFrame: state.spinnerFrame + 1 };
1845
+ paint(renderState(state));
1846
+ }
1847
+ }, 80);
1848
+
1849
+ process.stdin.setRawMode(true);
1850
+ process.stdin.resume();
1851
+
1852
+ return new Promise<void>((resolve) => {
1853
+ let quitTimer: ReturnType<typeof setTimeout> | null = null;
1854
+
1855
+ const resetQuitTimer = () => {
1856
+ if (quitTimer) { clearTimeout(quitTimer); quitTimer = null; }
1857
+ if (state.quitConfirm) {
1858
+ quitTimer = setTimeout(() => {
1859
+ quitTimer = null;
1860
+ state = { ...state, quitConfirm: false };
1861
+ paint(renderState(state));
1862
+ }, 2000);
1863
+ }
1864
+ };
1865
+
1866
+ const cleanup = () => {
1867
+ clearInterval(spinnerInterval);
1868
+ if (quitTimer) clearTimeout(quitTimer);
1869
+ process.stdin.setRawMode(false);
1870
+ process.stdin.pause();
1871
+ process.stdin.removeListener("data", onData);
1872
+ resolve();
1873
+ };
1874
+
1875
+ const onData = async (data: Buffer) => {
1876
+ const key = parseKey(data);
1877
+
1878
+ if (key.ctrl && key.name === "c") {
1879
+ cleanup();
1880
+ return;
1881
+ }
1882
+
1883
+ if (state.view === "loading") return;
1884
+
1885
+ const result = handleInput(state, key);
1886
+
1887
+ if (result === "exit") {
1888
+ cleanup();
1889
+ return;
1890
+ }
1891
+
1892
+ if (result === "submit") {
1893
+ state = { ...state, view: "loading", spinnerFrame: 0 };
1894
+ paint(renderState(state));
1895
+ state = await executeTool(state);
1896
+ paint(renderState(state));
1897
+ resetQuitTimer();
1898
+ return;
1899
+ }
1900
+
1901
+ if (result.view === "loading") {
1902
+ state = { ...result, spinnerFrame: 0 };
1903
+ paint(renderState(state));
1904
+ state = await executeTool(state);
1905
+ paint(renderState(state));
1906
+ resetQuitTimer();
1907
+ return;
1908
+ }
1909
+
1910
+ state = result;
1911
+ paint(renderState(state));
1912
+ resetQuitTimer();
1913
+ };
1914
+
1915
+ process.stdin.on("data", onData);
1916
+ });
1917
+ }