toolcraft-openapi 0.0.64 → 0.0.66

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.
@@ -0,0 +1,446 @@
1
+ import { ScreenBuffer, cellToAnsi } from "../dashboard/buffer.js";
2
+ import { createTerminalDriver } from "../dashboard/terminal.js";
3
+ const TOAST_MS = 2500;
4
+ export async function runTwoPaneExplorer(config) {
5
+ if (process.stdout.isTTY !== true) {
6
+ throw new Error("two-pane explorer requires a TTY");
7
+ }
8
+ return new TwoPaneExplorerRuntime(config, createTerminalDriver()).run();
9
+ }
10
+ export class TwoPaneExplorerRuntime {
11
+ config;
12
+ driver;
13
+ state;
14
+ unsubscribeKeypress;
15
+ unsubscribeResize;
16
+ toastTimer;
17
+ stopped = false;
18
+ rowsRequestToken = 0;
19
+ settle;
20
+ constructor(config, driver) {
21
+ this.config = config;
22
+ this.driver = driver;
23
+ const size = normalizeSize(driver.getSize());
24
+ this.state = {
25
+ title: config.title,
26
+ panes: [
27
+ initialPaneState(config.panes[0]),
28
+ initialPaneState(config.panes[1])
29
+ ],
30
+ activePaneIndex: 0,
31
+ filterFocused: false,
32
+ toast: null,
33
+ size
34
+ };
35
+ }
36
+ run() {
37
+ return new Promise((resolve, reject) => {
38
+ this.settle = { resolve, reject };
39
+ try {
40
+ this.startTerminal();
41
+ this.render();
42
+ this.loadRows().catch((error) => this.fail(error));
43
+ }
44
+ catch (error) {
45
+ this.fail(error);
46
+ }
47
+ });
48
+ }
49
+ startTerminal() {
50
+ this.driver.enterRawMode();
51
+ this.driver.enterAltScreen();
52
+ this.driver.disableLineWrap();
53
+ this.driver.hideCursor();
54
+ this.unsubscribeKeypress = this.driver.onKeypress((key) => {
55
+ this.dispatchKey(key);
56
+ });
57
+ this.unsubscribeResize = this.driver.onResize(() => {
58
+ this.state = { ...this.state, size: normalizeSize(this.driver.getSize()) };
59
+ this.render();
60
+ });
61
+ }
62
+ async loadRows(requestToken = ++this.rowsRequestToken) {
63
+ const [leftRows, rightRows] = await Promise.all([
64
+ this.config.panes[0].rows(),
65
+ this.config.panes[1].rows()
66
+ ]);
67
+ if (requestToken !== this.rowsRequestToken) {
68
+ return;
69
+ }
70
+ this.state = {
71
+ ...this.state,
72
+ panes: [
73
+ rowsLoaded(this.state.panes[0], leftRows),
74
+ rowsLoaded(this.state.panes[1], rightRows)
75
+ ]
76
+ };
77
+ this.render();
78
+ }
79
+ async refresh() {
80
+ await this.config.refresh?.();
81
+ await this.loadRows(++this.rowsRequestToken);
82
+ }
83
+ dispatchKey(key) {
84
+ if (this.stopped) {
85
+ return;
86
+ }
87
+ if (this.state.filterFocused) {
88
+ this.dispatchFilterKey(key);
89
+ return;
90
+ }
91
+ if (isQuitKey(key)) {
92
+ this.exit(null);
93
+ return;
94
+ }
95
+ if (key.name === "tab") {
96
+ this.state = {
97
+ ...this.state,
98
+ activePaneIndex: this.state.activePaneIndex === 0 ? 1 : 0
99
+ };
100
+ this.render();
101
+ return;
102
+ }
103
+ if (key.name === "up" || key.name === "down") {
104
+ this.moveCursor(key.name === "up" ? -1 : 1);
105
+ return;
106
+ }
107
+ if (key.name === "home") {
108
+ this.setCursor(0);
109
+ return;
110
+ }
111
+ if (key.name === "end") {
112
+ this.setCursor(filteredRows(this.activePane()).length - 1);
113
+ return;
114
+ }
115
+ if (key.ch === " ") {
116
+ this.toggleSelection();
117
+ return;
118
+ }
119
+ if (key.ch === "/") {
120
+ this.state = {
121
+ ...this.state,
122
+ filterFocused: true,
123
+ panes: updateActivePane(this.state, (pane) => ({ ...pane, filter: "", cursor: 0 }))
124
+ };
125
+ this.render();
126
+ return;
127
+ }
128
+ const action = this.config.actions.find((candidate) => actionMatchesKey(candidate, key));
129
+ if (action !== undefined) {
130
+ this.runAction(action);
131
+ }
132
+ }
133
+ dispatchFilterKey(key) {
134
+ if (key.name === "escape" || key.name === "return") {
135
+ this.state = { ...this.state, filterFocused: false };
136
+ this.render();
137
+ return;
138
+ }
139
+ if (key.name === "backspace") {
140
+ this.state = {
141
+ ...this.state,
142
+ panes: updateActivePane(this.state, (pane) => ({
143
+ ...pane,
144
+ filter: Array.from(pane.filter).slice(0, -1).join(""),
145
+ cursor: 0
146
+ }))
147
+ };
148
+ this.render();
149
+ return;
150
+ }
151
+ if (key.ch !== undefined && key.ch.length > 0 && key.ch !== "/") {
152
+ this.state = {
153
+ ...this.state,
154
+ panes: updateActivePane(this.state, (pane) => ({
155
+ ...pane,
156
+ filter: `${pane.filter}${key.ch}`,
157
+ cursor: 0
158
+ }))
159
+ };
160
+ this.render();
161
+ }
162
+ }
163
+ moveCursor(delta) {
164
+ const pane = this.activePane();
165
+ const rows = filteredRows(pane);
166
+ if (rows.length === 0) {
167
+ return;
168
+ }
169
+ this.setCursor(Math.max(0, Math.min(rows.length - 1, pane.cursor + delta)));
170
+ }
171
+ setCursor(cursor) {
172
+ this.state = {
173
+ ...this.state,
174
+ panes: updateActivePane(this.state, (pane) => ({
175
+ ...pane,
176
+ cursor: Math.max(0, cursor)
177
+ }))
178
+ };
179
+ this.render();
180
+ }
181
+ toggleSelection() {
182
+ const pane = this.activePane();
183
+ const row = currentRow(pane);
184
+ if (row === undefined) {
185
+ return;
186
+ }
187
+ const selected = new Set(pane.selected);
188
+ if (selected.has(row.id)) {
189
+ selected.delete(row.id);
190
+ }
191
+ else {
192
+ selected.add(row.id);
193
+ }
194
+ this.state = {
195
+ ...this.state,
196
+ panes: updateActivePane(this.state, (candidate) => ({ ...candidate, selected }))
197
+ };
198
+ this.render();
199
+ }
200
+ runAction(action) {
201
+ const activePane = this.activePane();
202
+ const inactivePane = this.inactivePane();
203
+ const row = currentRow(activePane);
204
+ if (row === undefined) {
205
+ this.showToast("Select an item first", "warning");
206
+ return;
207
+ }
208
+ const selectedRows = activePane.rows.filter((candidate) => activePane.selected.has(candidate.id));
209
+ const context = {
210
+ activePane,
211
+ inactivePane,
212
+ row,
213
+ rows: selectedRows.length > 0 ? selectedRows : [row],
214
+ refresh: async () => this.refresh(),
215
+ suspendAnd: async (fn) => this.suspendAnd(fn),
216
+ toast: (message, tone) => this.showToast(message, tone),
217
+ exit: (result) => this.exit(result ?? null)
218
+ };
219
+ Promise.resolve()
220
+ .then(() => action.handler(context))
221
+ .catch((error) => {
222
+ this.showToast(error instanceof Error ? error.message : "Action failed", "error");
223
+ });
224
+ }
225
+ async suspendAnd(fn) {
226
+ this.driver.exitAltScreen();
227
+ this.driver.enableLineWrap();
228
+ this.driver.showCursor();
229
+ this.driver.exitRawMode();
230
+ try {
231
+ return await fn();
232
+ }
233
+ finally {
234
+ if (!this.stopped) {
235
+ this.driver.enterRawMode();
236
+ this.driver.enterAltScreen();
237
+ this.driver.disableLineWrap();
238
+ this.driver.hideCursor();
239
+ this.state = { ...this.state, size: normalizeSize(this.driver.getSize()) };
240
+ this.render();
241
+ }
242
+ }
243
+ }
244
+ showToast(message, tone = "info") {
245
+ if (this.toastTimer !== undefined) {
246
+ clearTimeout(this.toastTimer);
247
+ }
248
+ this.state = { ...this.state, toast: { message, tone } };
249
+ this.render();
250
+ this.toastTimer = setTimeout(() => {
251
+ this.state = { ...this.state, toast: null };
252
+ this.render();
253
+ }, TOAST_MS);
254
+ }
255
+ activePane() {
256
+ return this.state.panes[this.state.activePaneIndex];
257
+ }
258
+ inactivePane() {
259
+ return this.state.panes[this.state.activePaneIndex === 0 ? 1 : 0];
260
+ }
261
+ render() {
262
+ if (this.stopped) {
263
+ return;
264
+ }
265
+ const size = normalizeSize(this.driver.getSize());
266
+ if (size.cols !== this.state.size.cols || size.rows !== this.state.size.rows) {
267
+ this.state = { ...this.state, size };
268
+ }
269
+ const next = new ScreenBuffer(this.state.size.cols, this.state.size.rows);
270
+ renderTwoPaneExplorer(this.state, this.config.actions, next);
271
+ this.driver.write(screenToAnsi(next));
272
+ }
273
+ exit(result) {
274
+ if (this.stopped) {
275
+ return;
276
+ }
277
+ this.stopped = true;
278
+ this.unsubscribeKeypress?.();
279
+ this.unsubscribeResize?.();
280
+ if (this.toastTimer !== undefined) {
281
+ clearTimeout(this.toastTimer);
282
+ }
283
+ this.driver.destroy();
284
+ this.settle?.resolve(result);
285
+ }
286
+ fail(error) {
287
+ if (!this.stopped) {
288
+ this.stopped = true;
289
+ this.driver.destroy();
290
+ }
291
+ this.settle?.reject(error);
292
+ }
293
+ }
294
+ export function renderTwoPaneExplorer(state, actions, screen) {
295
+ screen.clear();
296
+ if (state.size.cols < 60 || state.size.rows < 8) {
297
+ screen.put(0, 0, fit(` ${state.title} `, state.size.cols), { bold: true });
298
+ screen.put(0, 2, fit("Terminal is too small for two-pane view.", state.size.cols));
299
+ screen.put(0, state.size.rows - 1, fit("q quit", state.size.cols), { dim: true });
300
+ return;
301
+ }
302
+ const gap = 1;
303
+ const paneWidth = Math.floor((state.size.cols - gap) / 2);
304
+ const rightWidth = state.size.cols - paneWidth - gap;
305
+ const paneHeight = Math.max(5, state.size.rows - 2);
306
+ renderPane(screen, state.panes[0], {
307
+ x: 0,
308
+ y: 0,
309
+ width: paneWidth,
310
+ height: paneHeight,
311
+ active: state.activePaneIndex === 0
312
+ });
313
+ renderPane(screen, state.panes[1], {
314
+ x: paneWidth + gap,
315
+ y: 0,
316
+ width: rightWidth,
317
+ height: paneHeight,
318
+ active: state.activePaneIndex === 1
319
+ });
320
+ renderFooter(screen, state, actions, state.size.rows - 2);
321
+ if (state.toast !== null) {
322
+ screen.put(0, state.size.rows - 1, fit(` ${state.toast.message} `, state.size.cols), toneStyle(state.toast.tone));
323
+ }
324
+ }
325
+ function renderPane(screen, pane, layout) {
326
+ const borderStyle = layout.active ? { bold: true, fg: "cyan" } : { dim: true };
327
+ const title = `${layout.active ? ">" : " "} ${pane.title}`;
328
+ screen.put(layout.x, layout.y, `+${fit(title, layout.width - 2, "-")}+`, borderStyle);
329
+ for (let y = 1; y < layout.height - 1; y += 1) {
330
+ screen.put(layout.x, layout.y + y, "|", borderStyle);
331
+ screen.put(layout.x + layout.width - 1, layout.y + y, "|", borderStyle);
332
+ }
333
+ const filterLabel = pane.filter.length > 0 ? ` /${pane.filter}` : "";
334
+ screen.put(layout.x, layout.y + layout.height - 1, `+${fit(`${pane.selected.size} selected${filterLabel}`, layout.width - 2, "-")}+`, borderStyle);
335
+ const visibleRows = filteredRows(pane);
336
+ const bodyHeight = layout.height - 2;
337
+ if (visibleRows.length === 0) {
338
+ screen.put(layout.x + 2, layout.y + 2, fit(pane.emptyHint, Math.max(0, layout.width - 4)), { dim: true });
339
+ return;
340
+ }
341
+ const start = Math.max(0, Math.min(pane.cursor - Math.floor(bodyHeight / 2), visibleRows.length - bodyHeight));
342
+ for (let index = 0; index < bodyHeight; index += 1) {
343
+ const row = visibleRows[start + index];
344
+ if (row === undefined) {
345
+ continue;
346
+ }
347
+ const selected = pane.selected.has(row.id);
348
+ const cursor = start + index === pane.cursor;
349
+ const marker = selected ? "*" : " ";
350
+ const pointer = cursor && layout.active ? ">" : " ";
351
+ const badge = row.badge ? ` [${row.badge.text}]` : "";
352
+ const subtitle = row.subtitle ? ` ${row.subtitle}` : "";
353
+ const style = cursor && layout.active ? { inverse: true } : {};
354
+ screen.put(layout.x + 1, layout.y + 1 + index, fit(`${pointer}${marker} ${row.title}${badge}${subtitle}`, layout.width - 2), style);
355
+ }
356
+ }
357
+ function renderFooter(screen, state, actions, y) {
358
+ const actionHelp = actions.map((action) => `${firstKey(action.key)} ${action.label}`).join(" ");
359
+ const filter = state.filterFocused ? "search: typing, enter done" : "/ search";
360
+ const text = `tab pane ${filter} space select ${actionHelp} q quit`;
361
+ screen.put(0, y, fit(text, state.size.cols), { dim: true });
362
+ }
363
+ function initialPaneState(definition) {
364
+ return {
365
+ id: definition.id,
366
+ title: definition.title,
367
+ rows: [],
368
+ cursor: 0,
369
+ selected: new Set(),
370
+ filter: "",
371
+ emptyHint: definition.emptyHint ?? "No items"
372
+ };
373
+ }
374
+ function rowsLoaded(pane, rows) {
375
+ const ids = new Set(rows.map((row) => row.id));
376
+ return {
377
+ ...pane,
378
+ rows,
379
+ selected: new Set([...pane.selected].filter((id) => ids.has(id))),
380
+ cursor: Math.max(0, Math.min(pane.cursor, Math.max(0, filteredRows({ ...pane, rows }).length - 1)))
381
+ };
382
+ }
383
+ function filteredRows(pane) {
384
+ const query = pane.filter.trim().toLowerCase();
385
+ if (query.length === 0) {
386
+ return pane.rows;
387
+ }
388
+ return pane.rows.filter((row) => {
389
+ const haystack = `${row.title} ${row.subtitle ?? ""} ${row.badge?.text ?? ""}`.toLowerCase();
390
+ return haystack.includes(query);
391
+ });
392
+ }
393
+ function currentRow(pane) {
394
+ return filteredRows(pane)[pane.cursor];
395
+ }
396
+ function updateActivePane(state, update) {
397
+ return state.activePaneIndex === 0
398
+ ? [update(state.panes[0]), state.panes[1]]
399
+ : [state.panes[0], update(state.panes[1])];
400
+ }
401
+ function actionMatchesKey(action, key) {
402
+ const keys = Array.isArray(action.key) ? action.key : [action.key];
403
+ return keys.some((candidate) => key.ch === candidate || key.name === candidate);
404
+ }
405
+ function firstKey(key) {
406
+ return Array.isArray(key) ? key[0] ?? "" : key;
407
+ }
408
+ function isQuitKey(key) {
409
+ return key.ch === "q" || (key.name === "c" && key.ctrl);
410
+ }
411
+ function normalizeSize(size) {
412
+ return {
413
+ cols: Math.max(0, Math.floor(size.cols)),
414
+ rows: Math.max(0, Math.floor(size.rows))
415
+ };
416
+ }
417
+ function fit(text, width, pad = " ") {
418
+ if (width <= 0) {
419
+ return "";
420
+ }
421
+ const normalized = text.length > width ? text.slice(0, width) : text;
422
+ return normalized.padEnd(width, pad);
423
+ }
424
+ function toneStyle(tone) {
425
+ if (tone === "success") {
426
+ return { fg: "green", bold: true };
427
+ }
428
+ if (tone === "warning") {
429
+ return { fg: "yellow", bold: true };
430
+ }
431
+ if (tone === "error") {
432
+ return { fg: "red", bold: true };
433
+ }
434
+ return tone === "muted" ? { dim: true } : { fg: "cyan", bold: true };
435
+ }
436
+ function screenToAnsi(screen) {
437
+ let output = "";
438
+ for (let y = 0; y < screen.height; y += 1) {
439
+ output += `\u001b[${y + 1};1H`;
440
+ for (let x = 0; x < screen.width; x += 1) {
441
+ const cell = screen.get(x, y);
442
+ output += cellToAnsi(cell);
443
+ }
444
+ }
445
+ return output;
446
+ }
@@ -23,6 +23,10 @@ export { renderCatalog } from "./components/catalog.js";
23
23
  export type { CatalogGroup, CatalogItem, CatalogMetric, CatalogTone, RenderCatalogOptions } from "./components/catalog.js";
24
24
  export { renderDetailCard } from "./components/detail-card.js";
25
25
  export type { DetailCardRow, DetailCardSection, RenderDetailCardOptions } from "./components/detail-card.js";
26
+ export { renderInspectorCard } from "./components/inspector-card.js";
27
+ export type { InspectorField, InspectorSection, RenderInspectorCardOptions } from "./components/inspector-card.js";
28
+ export { renderResourceBrowser } from "./components/resource-browser.js";
29
+ export type { RenderResourceBrowserOptions, ResourceBrowserGroup, ResourceBrowserItem } from "./components/resource-browser.js";
26
30
  export { getTemplatePartialNames, renderTemplate, resolveTemplatePartials } from "./components/template.js";
27
31
  export type { RenderTemplateOptions, TemplateEscape } from "./components/template.js";
28
32
  export { openExternal } from "./components/browser.js";
@@ -31,8 +35,8 @@ export * as dashboard from "./dashboard/index.js";
31
35
  export { createDashboard, shouldUseInteractiveDashboard } from "./dashboard/index.js";
32
36
  export type { Dashboard, DashboardOptions } from "./dashboard/index.js";
33
37
  export * as explorer from "./explorer/index.js";
34
- export { runExplorer, singleDetail } from "./explorer/index.js";
35
- export type { Row, DetailItem, Detail, DetailCtx, Action, ActionContext, ExplorerConfig, ReorderContext, Tone } from "./explorer/index.js";
38
+ export { runExplorer, runTwoPaneExplorer, singleDetail } from "./explorer/index.js";
39
+ export type { Row, DetailItem, Detail, DetailCtx, Action, ActionContext, TwoPaneAction, TwoPaneActionContext, TwoPaneDefinition, TwoPaneExplorerConfig, TwoPanePaneState, TwoPaneRow, ExplorerConfig, ReorderContext, Tone } from "./explorer/index.js";
36
40
  export * as prompts from "./prompts/index.js";
37
41
  export { intro, introPlain, outro, note, select, multiselect, text as promptText, confirm, confirmOrCancel, password, spinner, withSpinner, isCancel, cancel, log, PromptCancelledError } from "./prompts/index.js";
38
42
  export type { SelectOptions, MultiselectOptions, TextOptions, ConfirmOptions, PasswordOptions, SpinnerOptions, WithSpinnerOptions } from "./prompts/index.js";
@@ -17,6 +17,8 @@ export { formatCommandNotFoundPanel } from "./components/command-errors.js";
17
17
  export { renderTable } from "./components/table.js";
18
18
  export { renderCatalog } from "./components/catalog.js";
19
19
  export { renderDetailCard } from "./components/detail-card.js";
20
+ export { renderInspectorCard } from "./components/inspector-card.js";
21
+ export { renderResourceBrowser } from "./components/resource-browser.js";
20
22
  export { getTemplatePartialNames, renderTemplate, resolveTemplatePartials } from "./components/template.js";
21
23
  export { openExternal } from "./components/browser.js";
22
24
  // ACP rendering
@@ -26,7 +28,7 @@ export * as dashboard from "./dashboard/index.js";
26
28
  export { createDashboard, shouldUseInteractiveDashboard } from "./dashboard/index.js";
27
29
  // Explorer
28
30
  export * as explorer from "./explorer/index.js";
29
- export { runExplorer, singleDetail } from "./explorer/index.js";
31
+ export { runExplorer, runTwoPaneExplorer, singleDetail } from "./explorer/index.js";
30
32
  // Prompts
31
33
  export * as prompts from "./prompts/index.js";
32
34
  export { intro, introPlain, outro, note, select, multiselect, text as promptText, confirm, confirmOrCancel, password, spinner, withSpinner, isCancel, cancel, log, PromptCancelledError } from "./prompts/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-openapi",
3
- "version": "0.0.64",
3
+ "version": "0.0.66",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -28,7 +28,7 @@
28
28
  "toolcraft-openapi-generate": "dist/bin/generate.js"
29
29
  },
30
30
  "dependencies": {
31
- "toolcraft": "0.0.64",
31
+ "toolcraft": "0.0.66",
32
32
  "auth-store": "^0.0.1",
33
33
  "fast-string-width": "^3.0.2",
34
34
  "fast-wrap-ansi": "^0.2.0",