@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/README.md +140 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +194 -0
- package/dist/commands.d.ts +14 -0
- package/dist/commands.js +152 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +24 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +179 -0
- package/dist/mcp.d.ts +10 -0
- package/dist/mcp.js +53 -0
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +1806 -0
- package/dist/tui/index.d.ts +2 -0
- package/dist/tui/index.js +11 -0
- package/dist/tui/logo.d.ts +1 -0
- package/dist/tui/logo.js +16 -0
- package/dist/tui/term.d.ts +32 -0
- package/dist/tui/term.js +147 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +4 -0
- package/package.json +30 -0
- package/src/auth.ts +248 -0
- package/src/commands.ts +158 -0
- package/src/config.ts +64 -0
- package/src/index.ts +136 -0
- package/src/mcp.ts +66 -0
- package/src/tui/app.ts +1917 -0
- package/src/tui/index.ts +12 -0
- package/src/tui/logo.ts +16 -0
- package/src/tui/term.ts +151 -0
- package/src/version.ts +4 -0
- package/tsconfig.json +14 -0
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
|
+
}
|