toolcraft-openapi 0.0.151 → 0.0.153

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.
Files changed (55) hide show
  1. package/dist/composition.json +2 -2
  2. package/node_modules/toolcraft-design/README.md +30 -0
  3. package/node_modules/toolcraft-design/dist/dashboard/terminal-width.d.ts +1 -0
  4. package/node_modules/toolcraft-design/dist/dashboard/terminal-width.js +18 -1
  5. package/node_modules/toolcraft-design/dist/explorer/actions.d.ts +1 -1
  6. package/node_modules/toolcraft-design/dist/explorer/actions.js +22 -4
  7. package/node_modules/toolcraft-design/dist/explorer/filter.js +1 -1
  8. package/node_modules/toolcraft-design/dist/explorer/index.d.ts +2 -4
  9. package/node_modules/toolcraft-design/dist/explorer/index.js +1 -2
  10. package/node_modules/toolcraft-design/dist/explorer/jobs.d.ts +2 -2
  11. package/node_modules/toolcraft-design/dist/explorer/jobs.js +3 -5
  12. package/node_modules/toolcraft-design/dist/explorer/keymap.d.ts +13 -3
  13. package/node_modules/toolcraft-design/dist/explorer/keymap.js +89 -348
  14. package/node_modules/toolcraft-design/dist/explorer/layout.d.ts +1 -0
  15. package/node_modules/toolcraft-design/dist/explorer/layout.js +14 -4
  16. package/node_modules/toolcraft-design/dist/explorer/reducer.js +58 -9
  17. package/node_modules/toolcraft-design/dist/explorer/render/detail.d.ts +1 -1
  18. package/node_modules/toolcraft-design/dist/explorer/render/detail.js +43 -7
  19. package/node_modules/toolcraft-design/dist/explorer/render/footer.d.ts +1 -1
  20. package/node_modules/toolcraft-design/dist/explorer/render/footer.js +6 -3
  21. package/node_modules/toolcraft-design/dist/explorer/render/header.d.ts +1 -1
  22. package/node_modules/toolcraft-design/dist/explorer/render/header.js +6 -2
  23. package/node_modules/toolcraft-design/dist/explorer/render/index.d.ts +1 -1
  24. package/node_modules/toolcraft-design/dist/explorer/render/index.js +2 -1
  25. package/node_modules/toolcraft-design/dist/explorer/render/list.d.ts +16 -2
  26. package/node_modules/toolcraft-design/dist/explorer/render/list.js +13 -34
  27. package/node_modules/toolcraft-design/dist/explorer/render/modal.d.ts +1 -1
  28. package/node_modules/toolcraft-design/dist/explorer/render/pane.d.ts +5 -2
  29. package/node_modules/toolcraft-design/dist/explorer/render/pane.js +9 -6
  30. package/node_modules/toolcraft-design/dist/explorer/render/test-fixtures.js +2 -3
  31. package/node_modules/toolcraft-design/dist/explorer/runtime.js +68 -63
  32. package/node_modules/toolcraft-design/dist/explorer/runtime.test-helpers.d.ts +23 -37
  33. package/node_modules/toolcraft-design/dist/explorer/runtime.test-helpers.js +32 -83
  34. package/node_modules/toolcraft-design/dist/explorer/state.d.ts +47 -5
  35. package/node_modules/toolcraft-design/dist/explorer/state.js +56 -12
  36. package/node_modules/toolcraft-design/dist/index.d.ts +8 -2
  37. package/node_modules/toolcraft-design/dist/index.js +5 -1
  38. package/node_modules/toolcraft-design/dist/screen/ansi-text.d.ts +2 -0
  39. package/node_modules/toolcraft-design/dist/screen/ansi-text.js +35 -0
  40. package/node_modules/toolcraft-design/dist/screen/screen.d.ts +40 -0
  41. package/node_modules/toolcraft-design/dist/screen/screen.js +122 -0
  42. package/node_modules/toolcraft-design/dist/screen/style.d.ts +16 -0
  43. package/node_modules/toolcraft-design/dist/screen/style.js +55 -0
  44. package/node_modules/toolcraft-design/dist/terminal/driver.d.ts +33 -0
  45. package/node_modules/toolcraft-design/dist/terminal/driver.js +61 -0
  46. package/node_modules/toolcraft-design/dist/terminal/input.d.ts +25 -0
  47. package/node_modules/toolcraft-design/dist/terminal/input.js +181 -0
  48. package/node_modules/toolcraft-design/dist/terminal/output.d.ts +8 -0
  49. package/node_modules/toolcraft-design/dist/terminal/output.js +42 -0
  50. package/node_modules/toolcraft-schema/package.json +1 -1
  51. package/package.json +3 -3
  52. package/node_modules/toolcraft-design/dist/explorer/two-pane.d.ts +0 -102
  53. package/node_modules/toolcraft-design/dist/explorer/two-pane.js +0 -510
  54. package/node_modules/toolcraft-design/dist/run-two-pane-explorer.d.ts +0 -2
  55. package/node_modules/toolcraft-design/dist/run-two-pane-explorer.js +0 -1
@@ -53,6 +53,10 @@ function stepKey(state, key, runtimeHandles) {
53
53
  return stepFilterKey(state, key, target, runtimeHandles);
54
54
  }
55
55
  if (target?.type === "action") {
56
+ if (state.actionState.get(target.id)?.running === true) {
57
+ runtimeHandles.toast("Action already running", "info");
58
+ return mark(state, 0);
59
+ }
56
60
  if (state.actionState.get(target.id)?.source === "detail" && state.focused !== "detail") {
57
61
  return mark(state, 0);
58
62
  }
@@ -85,6 +89,14 @@ function stepKey(state, key, runtimeHandles) {
85
89
  return isDetailBlobFocused(state)
86
90
  ? detailScroll(state, detailBodyHeight(state))
87
91
  : moveCursor(state, pageSize(state));
92
+ case "halfPageUp":
93
+ return isDetailBlobFocused(state)
94
+ ? detailScroll(state, -Math.max(1, Math.floor(detailBodyHeight(state) / 2)))
95
+ : moveCursor(state, -Math.max(1, Math.floor(pageSize(state) / 2)));
96
+ case "halfPageDown":
97
+ return isDetailBlobFocused(state)
98
+ ? detailScroll(state, Math.max(1, Math.floor(detailBodyHeight(state) / 2)))
99
+ : moveCursor(state, Math.max(1, Math.floor(pageSize(state) / 2)));
88
100
  case "focusNext":
89
101
  return focusNext(state);
90
102
  case "escape":
@@ -112,12 +124,16 @@ function stepKey(state, key, runtimeHandles) {
112
124
  }
113
125
  }
114
126
  if (isBackspace(key)) {
127
+ if (isSecondListFocused(state))
128
+ return updateDetailFilter(state, (state.detail.filter ?? "").slice(0, -1));
115
129
  return updateFilter(state, state.filter.slice(0, -1));
116
130
  }
117
131
  if (!state.multiSelect && isSelectionSpace(key)) {
118
132
  return mark(state, 0);
119
133
  }
120
134
  if (isPrintable(key)) {
135
+ if (isSecondListFocused(state))
136
+ return updateDetailFilter(state, `${state.detail.filter ?? ""}${key.ch}`);
121
137
  return updateFilter(state, `${state.filter}${key.ch}`);
122
138
  }
123
139
  return mark(state, 0);
@@ -201,7 +217,7 @@ function stepModalKey(state, key, target, runtimeHandles) {
201
217
  }
202
218
  function resize(state, cols, rows) {
203
219
  const size = { cols: normalizeSize(cols), rows: normalizeSize(rows) };
204
- const layout = resolveExplorerLayoutMode(size.cols);
220
+ const layout = resolveExplorerLayoutMode(size.cols, size.rows);
205
221
  if (state.size.cols === size.cols && state.size.rows === size.rows && state.layout === layout) {
206
222
  return mark(state, 0);
207
223
  }
@@ -211,6 +227,7 @@ function resize(state, cols, rows) {
211
227
  };
212
228
  }
213
229
  function rowsLoaded(state, rows) {
230
+ const previousRowId = currentRow(state)?.id;
214
231
  const rowIds = new Set();
215
232
  for (const row of rows) {
216
233
  if (rowIds.has(row.id)) {
@@ -221,14 +238,19 @@ function rowsLoaded(state, rows) {
221
238
  const matches = filterRows(state.filter, rows);
222
239
  const filtered = matches.map((match) => match.index);
223
240
  const matchPositions = createMatchPositions(matches);
224
- const cursor = clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
241
+ const identityCursor = previousRowId === undefined
242
+ ? -1
243
+ : filtered.findIndex(index => rows[index]?.id === previousRowId);
244
+ const cursor = identityCursor >= 0
245
+ ? identityCursor
246
+ : clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
225
247
  const selected = state.multiSelect ? pruneSelection(state.selected, rows) : new Set();
226
248
  const detail = resetDetailForCursor(state, rows, filtered, cursor);
227
249
  const modal = modalStillValid(state.modal, rows);
228
250
  if (state.modal?.kind === "confirm" && modal === null) {
229
251
  state.modal.resolver(false);
230
252
  }
231
- const nextView = { ...state, rows, filtered, matchPositions, cursor, selected, detail, modal };
253
+ const nextView = { ...state, rows, rowsLoading: false, filtered, matchPositions, cursor, selected, detail, modal };
232
254
  const next = {
233
255
  ...nextView,
234
256
  actionState: recomputeActionState(nextView),
@@ -253,10 +275,12 @@ function detailLoaded(state, rowId, token, items) {
253
275
  if (state.detail.rowId !== rowId || state.detail.token !== token) {
254
276
  return mark(state, 0);
255
277
  }
278
+ const visibleItems = filterDetailItems(items, state.detail.filter ?? "");
256
279
  const detail = {
257
280
  ...state.detail,
258
- items,
259
- cursor: clamp(state.detail.cursor, 0, Math.max(0, items.length - 1)),
281
+ items: visibleItems,
282
+ allItems: items,
283
+ cursor: clamp(state.detail.cursor, 0, Math.max(0, visibleItems.length - 1)),
260
284
  scroll: 0,
261
285
  loading: false
262
286
  };
@@ -410,6 +434,22 @@ function updateFilter(state, filter) {
410
434
  const effect = detailEffect(next);
411
435
  return { state: next, effects: effect === undefined ? NO_EFFECTS : [effect] };
412
436
  }
437
+ function updateDetailFilter(state, filter) {
438
+ const allItems = state.detail.allItems ?? state.detail.items ?? [];
439
+ const items = filterDetailItems(allItems, filter);
440
+ const detail = { ...state.detail, filter, items, cursor: 0, scroll: 0 };
441
+ return {
442
+ state: { ...state, detail, actionState: recomputeActionState({ ...state, detail }), dirty: REGION_HEADER | REGION_DETAIL | REGION_FOOTER },
443
+ effects: NO_EFFECTS
444
+ };
445
+ }
446
+ function filterDetailItems(items, query) {
447
+ const rows = items.map(item => ({ id: item.id, title: item.title ?? item.id, subtitle: item.subtitle }));
448
+ return filterRows(query, rows).map(match => items[match.index]).filter((item) => item !== undefined);
449
+ }
450
+ function isSecondListFocused(state) {
451
+ return state.focused === "detail" && state.paneDefinitions[1]?.kind === "list";
452
+ }
413
453
  function focusFilter(state) {
414
454
  if (state.filterFocused) {
415
455
  return mark(state, 0);
@@ -432,6 +472,9 @@ function focusNext(state) {
432
472
  };
433
473
  }
434
474
  function escape(state, runtimeHandles) {
475
+ if (isSecondListFocused(state) && (state.detail.filter ?? "").length > 0) {
476
+ return updateDetailFilter(state, "");
477
+ }
435
478
  if (state.filterFocused || state.filter.length > 0) {
436
479
  const cleared = updateFilter({ ...state, filterFocused: false }, "");
437
480
  return {
@@ -455,13 +498,17 @@ function confirmKey(state, runtimeHandles) {
455
498
  if (state.modal?.kind === "confirm") {
456
499
  return modalDismissed(state, true, runtimeHandles);
457
500
  }
458
- return dispatchPrimary(state, runtimeHandles);
501
+ const hasActions = [...state.actionState.values()].some(entry => entry.available && entry.running !== true);
502
+ return hasActions
503
+ ? setModal(state, { kind: "palette", query: "", cursor: 0 })
504
+ : dispatchPrimary(state, runtimeHandles);
459
505
  }
460
506
  function toggleSelect(state) {
461
507
  if (!state.multiSelect) {
462
508
  return mark(state, 0);
463
509
  }
464
- const row = currentRow(state);
510
+ const detailItem = state.focused === "detail" ? state.detail.items?.[state.detail.cursor] : undefined;
511
+ const row = detailItem === undefined ? currentRow(state) : { id: detailItem.id, title: detailItem.title ?? detailItem.id };
465
512
  if (row === undefined) {
466
513
  return mark(state, 0);
467
514
  }
@@ -736,7 +783,9 @@ function recomputeActionState(view) {
736
783
  continue;
737
784
  }
738
785
  const ctx = buildActionContext(view, action, entry.source ?? actionSource(view, action), DEFAULT_ACTION_HANDLES);
739
- const available = action.predicate === undefined ? entry.available : action.predicate(ctx);
786
+ const row = currentRow(view);
787
+ const visible = action.visible === undefined || (row !== undefined && action.visible(row));
788
+ const available = visible && (action.predicate === undefined ? entry.available : action.predicate(ctx));
740
789
  const label = typeof action.label === "function" ? action.label() : action.label;
741
790
  next.set(id, { ...entry, available, label });
742
791
  }
@@ -756,7 +805,7 @@ function resetDetailForCursor(state, rows, filtered, cursor) {
756
805
  }
757
806
  return {
758
807
  rowId: row.id,
759
- items: null,
808
+ items: state.detail.items,
760
809
  cursor: 0,
761
810
  scroll: 0,
762
811
  token: state.detail.token + 1,
@@ -1,4 +1,4 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import type { ExplorerLayout } from "../layout.js";
3
3
  import type { ExplorerState } from "../state.js";
4
4
  export declare function renderDetail(state: ExplorerState, screen: ScreenBuffer, layout: ExplorerLayout): void;
@@ -1,8 +1,9 @@
1
- import { stripAnsi } from "../../internal/strip-ansi.js";
1
+ import { ansiToCells } from "../../screen/ansi-text.js";
2
2
  import { renderMarkdown } from "../../terminal-markdown/index.js";
3
3
  import { getExplorerStyles } from "../theme.js";
4
4
  import { drawPaneFrame, paneBodyRect } from "./pane.js";
5
5
  import { fitToWidth } from "./text.js";
6
+ const markdownCache = new Map();
6
7
  export function renderDetail(state, screen, layout) {
7
8
  const rect = layout.detail;
8
9
  const styles = getExplorerStyles();
@@ -12,11 +13,11 @@ export function renderDetail(state, screen, layout) {
12
13
  }
13
14
  const row = state.rows.find((r) => r.id === state.detail.rowId) ?? null;
14
15
  if (layout.mode === "narrow-vertical") {
15
- drawPaneFrame(screen, rect, "Preview", state.focused === "detail" ? styles.borderFocused : styles.border);
16
+ drawPaneFrame(screen, rect, state.paneDefinitions[1]?.title ?? "Preview", state.focused === "detail" ? styles.borderFocused : styles.border, { focused: state.focused === "detail", indicator: scrollIndicator(state) });
16
17
  renderDetailBody(state, screen, paneBodyRect(rect), row);
17
18
  return;
18
19
  }
19
- drawPaneFrame(screen, rect, "Preview", state.focused === "detail" ? styles.borderFocused : styles.border);
20
+ drawPaneFrame(screen, rect, state.paneDefinitions[1]?.title ?? "Preview", state.focused === "detail" ? styles.borderFocused : styles.border, { focused: state.focused === "detail", indicator: scrollIndicator(state) });
20
21
  renderDetailBody(state, screen, paneBodyRect(rect), row);
21
22
  }
22
23
  function renderDetailBody(state, screen, rect, row) {
@@ -47,7 +48,8 @@ function renderListMode(state, screen, rect, items, row) {
47
48
  const item = items[index];
48
49
  const cursor = index === state.detail.cursor;
49
50
  const title = item.title ?? item.id;
50
- const prefix = cursor ? "▌ " : " ";
51
+ const selected = state.multiSelect && state.selected.has(item.id);
52
+ const prefix = `${selected ? "*" : " "}${cursor ? "▌" : " "} `;
51
53
  const badge = item.badge ? ` ${item.badge.text}` : "";
52
54
  writeLine(screen, rect, y, `${prefix}${title}${badge}`, cursor ? styles.borderFocused : styles.accent);
53
55
  y += 1;
@@ -68,11 +70,23 @@ function renderListMode(state, screen, rect, items, row) {
68
70
  }
69
71
  }
70
72
  function renderBlob(screen, rect, text, scroll) {
71
- const allLines = text.split("\n");
73
+ const allLines = [[]];
74
+ for (const cell of ansiToCells(text)) {
75
+ if (cell.ch === "\n")
76
+ allLines.push([]);
77
+ else
78
+ allLines.at(-1).push(cell);
79
+ }
72
80
  const start = clamp(scroll, 0, Math.max(0, allLines.length - rect.height));
73
81
  const lines = allLines.slice(start);
74
82
  for (let row = 0; row < rect.height; row += 1) {
75
- writeLine(screen, rect, row, lines[row] ?? "");
83
+ let x = rect.x;
84
+ for (const cell of lines[row] ?? []) {
85
+ if (x + cell.width > rect.x + rect.width)
86
+ break;
87
+ screen.put(x, rect.y + row, cell.ch, cell.style);
88
+ x += cell.width;
89
+ }
76
90
  }
77
91
  }
78
92
  function renderItemMarkdown(item, rect, row) {
@@ -80,7 +94,14 @@ function renderItemMarkdown(item, rect, row) {
80
94
  if (content.trim().length === 0) {
81
95
  return "";
82
96
  }
83
- return stripAnsi(renderMarkdown(content, { width: Math.max(1, rect.width) }).trimEnd());
97
+ const width = Math.max(1, rect.width);
98
+ const key = `${contentHash(content)}:${width}`;
99
+ const cached = markdownCache.get(key);
100
+ if (cached !== undefined)
101
+ return cached;
102
+ const rendered = renderMarkdown(content, { width }).trimEnd();
103
+ markdownCache.set(key, rendered);
104
+ return rendered;
84
105
  }
85
106
  function renderItem(item, rect, row) {
86
107
  if (item.renderedContent !== undefined) {
@@ -108,3 +129,18 @@ function writeLine(screen, rect, row, text, style = {}) {
108
129
  function clamp(value, min, max) {
109
130
  return Math.min(max, Math.max(min, value));
110
131
  }
132
+ function scrollIndicator(state) {
133
+ if (state.detail.loading)
134
+ return "⠋";
135
+ const content = state.detail.items?.[state.detail.cursor]?.renderedContent ?? "";
136
+ const total = Math.max(1, content.split("\n").length);
137
+ return `${Math.min(100, Math.round((state.detail.scroll / Math.max(1, total - 1)) * 100))}%`;
138
+ }
139
+ function contentHash(content) {
140
+ let hash = 2166136261;
141
+ for (let index = 0; index < content.length; index += 1) {
142
+ hash ^= content.charCodeAt(index);
143
+ hash = Math.imul(hash, 16777619);
144
+ }
145
+ return hash >>> 0;
146
+ }
@@ -1,4 +1,4 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import type { ExplorerLayout } from "../layout.js";
3
3
  import type { ExplorerState } from "../state.js";
4
4
  export declare function renderFooter(state: ExplorerState, screen: ScreenBuffer, layout: ExplorerLayout): void;
@@ -39,8 +39,8 @@ function footerHints(state) {
39
39
  const hints = [];
40
40
  if (state.focused === "detail") {
41
41
  hints.push({ key: "Tab", label: "focus", running: false });
42
- hints.push({ key: "Enter", label: "sub", running: false });
43
42
  }
43
+ hints.push({ key: "Enter", label: "actions", running: false });
44
44
  for (const [id, entry] of state.actionState) {
45
45
  if (!entry.available || entry.action?.showInFooter === false) {
46
46
  continue;
@@ -51,12 +51,13 @@ function footerHints(state) {
51
51
  : entry.label;
52
52
  hints.push({ key, label, running: entry.running === true });
53
53
  }
54
- hints.push({ key: "?", label: "help", running: false });
55
54
  hints.push({ key: "Ctrl+P", label: "palette", running: false });
56
55
  if (hasShiftReorderBindings(state)) {
57
56
  hints.push({ key: "⇧↑↓", label: "reorder (within state)", running: false, bracketed: false });
58
57
  }
59
- hints.push({ key: "q", label: "quit", running: false });
58
+ hints.push({ key: "Esc", label: "clear/quit", running: false });
59
+ if (state.selected.size > 0)
60
+ hints.push({ key: `${state.selected.size}`, label: "selected", running: false, bracketed: false });
60
61
  return hints;
61
62
  }
62
63
  function hasShiftReorderBindings(state) {
@@ -65,6 +66,8 @@ function hasShiftReorderBindings(state) {
65
66
  return up.includes("Shift+up") && down.includes("Shift+down");
66
67
  }
67
68
  function actionKey(entry, fallback) {
69
+ if (entry.action?.accelerator !== undefined)
70
+ return `Ctrl+${entry.action.accelerator.toUpperCase()}`;
68
71
  const key = entry.action?.key;
69
72
  if (Array.isArray(key)) {
70
73
  return key[0] ?? fallback;
@@ -1,4 +1,4 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import type { ExplorerLayout } from "../layout.js";
3
3
  import type { ExplorerState } from "../state.js";
4
4
  export declare function renderHeader(state: ExplorerState, screen: ScreenBuffer, layout: ExplorerLayout): void;
@@ -14,8 +14,12 @@ export function renderHeader(state, screen, layout) {
14
14
  drawTopBorder(screen, state.title, rect.width, styles.border);
15
15
  if (rect.height > 1) {
16
16
  const prompt = `${state.title.toLocaleLowerCase()}>`;
17
- const filter = state.filter.length > 0 ? ` ${state.filter}` : "";
18
- const count = `${state.filtered.length}/${state.rows.length}`;
17
+ const secondList = state.focused === "detail" && state.paneDefinitions[1]?.kind === "list";
18
+ const activeFilter = secondList ? (state.detail.filter ?? "") : state.filter;
19
+ const filter = activeFilter.length > 0 ? ` ${activeFilter}` : "";
20
+ const count = secondList
21
+ ? `${state.detail.items?.length ?? 0}/${state.detail.allItems?.length ?? state.detail.items?.length ?? 0}`
22
+ : `${state.filtered.length}/${state.rows.length}`;
19
23
  const selected = state.multiSelect && state.selected.size > 0 ? ` (${state.selected.size} selected)` : "";
20
24
  const spinner = state.detail.loading ? " *" : "";
21
25
  const right = `${count}${selected}${spinner}`;
@@ -1,4 +1,4 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import { type ExplorerState } from "../state.js";
3
3
  export declare function renderExplorer(state: ExplorerState, screen: ScreenBuffer): void;
4
4
  export { renderDetail } from "./detail.js";
@@ -18,7 +18,8 @@ export function renderExplorer(state, screen) {
18
18
  const layout = computeExplorerLayout({
19
19
  cols: state.size.cols,
20
20
  rows: state.size.rows,
21
- detailHidden: state.layout === "narrow-list-only" || state.layout === "too-narrow"
21
+ detailHidden: state.layout === "narrow-list-only" || state.layout === "too-narrow",
22
+ focused: state.focused
22
23
  });
23
24
  const dirty = state.dirty === 0 ? REGION_ALL : state.dirty;
24
25
  for (const [region, render] of REGION_RENDERERS) {
@@ -1,4 +1,18 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import type { ExplorerLayout } from "../layout.js";
3
- import type { ExplorerState } from "../state.js";
3
+ import type { ExplorerState, Row } from "../state.js";
4
4
  export declare function renderList(state: ExplorerState, screen: ScreenBuffer, layout: ExplorerLayout): void;
5
+ export type DisplayLine = {
6
+ kind: "group";
7
+ label: string;
8
+ } | {
9
+ kind: "row";
10
+ rowIndex: number;
11
+ row: Row;
12
+ cursor: boolean;
13
+ } | {
14
+ kind: "subtitle";
15
+ row: Row;
16
+ text: string;
17
+ };
18
+ export declare function visibleStart(lines: DisplayLine[], height: number, scrolloff?: number): number;
@@ -1,7 +1,6 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
2
  import { drawPaneFrame, paneBodyRect } from "./pane.js";
3
3
  import { cellWidth, centerCells, fitToWidth, splitGraphemeCells, stripAnsi } from "./text.js";
4
- const listLineCache = new WeakMap();
5
4
  export function renderList(state, screen, layout) {
6
5
  const styles = getExplorerStyles();
7
6
  const rect = layout.list;
@@ -13,19 +12,14 @@ export function renderList(state, screen, layout) {
13
12
  writeLine(screen, rect, 0, "Terminal too narrow", styles.muted);
14
13
  return;
15
14
  }
16
- drawPaneFrame(screen, rect, state.title, state.focused === "list" ? styles.borderFocused : styles.border);
15
+ drawPaneFrame(screen, rect, state.paneDefinitions[0]?.title ?? state.title, state.focused === "list" ? styles.borderFocused : styles.border, { focused: state.focused === "list", indicator: state.rowsLoading ? "⠋" : `${state.filtered.length}/${state.rows.length}` });
17
16
  const bodyRect = paneBodyRect(rect);
18
17
  if (bodyRect.width <= 0 || bodyRect.height <= 0) {
19
18
  return;
20
19
  }
21
- const rectKey = `${bodyRect.x}:${bodyRect.y}:${bodyRect.width}:${bodyRect.height}`;
22
- const cached = listLineCache.get(screen);
23
- const cache = cached?.rectKey === rectKey ? cached.lines : new Map();
24
- listLineCache.set(screen, { rectKey, lines: cache });
25
20
  if (state.filtered.length === 0) {
26
- const hint = state.emptyHint;
21
+ const hint = state.rowsLoading ? "Loading…" : state.emptyHint;
27
22
  writeLine(screen, bodyRect, Math.floor(bodyRect.height / 2), centerCells(hint, bodyRect.width, bodyRect.x), styles.muted);
28
- cache.clear();
29
23
  return;
30
24
  }
31
25
  const lines = buildDisplayLines(state);
@@ -34,20 +28,12 @@ export function renderList(state, screen, layout) {
34
28
  for (let lineIndex = start; lineIndex < lines.length && y < bodyRect.height; lineIndex += 1) {
35
29
  const line = lines[lineIndex];
36
30
  if (line.kind === "group") {
37
- const hash = `group:${line.label}`;
38
- if (cache.get(y) !== hash) {
39
- writeLine(screen, bodyRect, y, formatGroupHeader(line.label, bodyRect), styles.muted);
40
- cache.set(y, hash);
41
- }
31
+ writeLine(screen, bodyRect, y, formatGroupHeader(line.label, bodyRect), styles.muted);
42
32
  y += 1;
43
33
  continue;
44
34
  }
45
35
  if (line.kind === "subtitle") {
46
- const subtitleHash = `subtitle:${line.row.id}:${line.text}`;
47
- if (cache.get(y) !== subtitleHash) {
48
- writeLine(screen, bodyRect, y, ` ${line.text}`, styles.muted);
49
- cache.set(y, subtitleHash);
50
- }
36
+ writeLine(screen, bodyRect, y, ` ${line.text}`, styles.muted);
51
37
  y += 1;
52
38
  continue;
53
39
  }
@@ -59,16 +45,12 @@ export function renderList(state, screen, layout) {
59
45
  const selected = state.multiSelect && state.selected.has(row.id);
60
46
  const cursor = rowIndex === state.filtered[state.cursor];
61
47
  const positions = state.matchPositions.get(rowIndex) ?? [];
62
- const hash = lineHash(row, selected, cursor, positions);
63
- if (cache.get(y) !== hash) {
64
- renderRow(screen, bodyRect, y, row, {
65
- selected,
66
- cursor,
67
- focused: state.focused === "list",
68
- positions
69
- });
70
- cache.set(y, hash);
71
- }
48
+ renderRow(screen, bodyRect, y, row, {
49
+ selected,
50
+ cursor,
51
+ focused: state.focused === "list",
52
+ positions
53
+ });
72
54
  y += 1;
73
55
  }
74
56
  }
@@ -91,7 +73,7 @@ function buildDisplayLines(state) {
91
73
  }
92
74
  return lines;
93
75
  }
94
- function visibleStart(lines, height) {
76
+ export function visibleStart(lines, height, scrolloff = 3) {
95
77
  if (height <= 0) {
96
78
  return 0;
97
79
  }
@@ -99,7 +81,7 @@ function visibleStart(lines, height) {
99
81
  if (cursorLine < 0) {
100
82
  return 0;
101
83
  }
102
- return Math.max(0, Math.min(cursorLine, cursorLine - height + 1));
84
+ return Math.max(0, cursorLine - Math.max(0, height - 1 - scrolloff));
103
85
  }
104
86
  function formatGroupHeader(label, rect) {
105
87
  const text = ` ${label} `;
@@ -111,7 +93,7 @@ function formatGroupHeader(label, rect) {
111
93
  }
112
94
  function renderRow(screen, rect, rowY, row, opts) {
113
95
  const styles = getExplorerStyles();
114
- const marker = opts.selected ? "" : " ";
96
+ const marker = opts.selected ? "*" : " ";
115
97
  const cursor = opts.cursor ? "●" : "◌";
116
98
  const focus = opts.cursor && opts.focused ? " ▌" : "";
117
99
  const prefix = `${marker} ${cursor} `;
@@ -149,9 +131,6 @@ function renderRow(screen, rect, rowY, row, opts) {
149
131
  function writeLine(screen, rect, row, text, style = {}) {
150
132
  screen.put(rect.x, rect.y + row, fitToWidth(text, rect.width, rect.x), style);
151
133
  }
152
- function lineHash(row, selected, cursor, positions) {
153
- return `${row.id}:${selected ? 1 : 0}:${cursor ? 1 : 0}:${positions.join(",")}`;
154
- }
155
134
  function hasMatchPosition(start, end, positions) {
156
135
  for (let position = start; position < end; position += 1) {
157
136
  if (positions.has(position)) {
@@ -1,3 +1,3 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import type { ExplorerState } from "../state.js";
3
3
  export declare function renderModal(state: ExplorerState, screen: ScreenBuffer): void;
@@ -1,5 +1,8 @@
1
- import { ScreenBuffer } from "../../dashboard/buffer.js";
1
+ import type { ScreenSurface as ScreenBuffer } from "../../screen/screen.js";
2
2
  import type { CellStyle } from "../../dashboard/types.js";
3
3
  import type { Rect } from "../layout.js";
4
- export declare function drawPaneFrame(screen: ScreenBuffer, rect: Rect, title: string, style?: CellStyle): void;
4
+ export declare function drawPaneFrame(screen: ScreenBuffer, rect: Rect, title: string, style?: CellStyle, options?: {
5
+ focused?: boolean;
6
+ indicator?: string;
7
+ }): void;
5
8
  export declare function paneBodyRect(rect: Rect): Rect;
@@ -1,5 +1,5 @@
1
1
  import { fitToWidth, padEndCells } from "./text.js";
2
- export function drawPaneFrame(screen, rect, title, style = {}) {
2
+ export function drawPaneFrame(screen, rect, title, style = {}, options = {}) {
3
3
  if (rect.width <= 0 || rect.height <= 0) {
4
4
  return;
5
5
  }
@@ -10,14 +10,17 @@ export function drawPaneFrame(screen, rect, title, style = {}) {
10
10
  return;
11
11
  }
12
12
  const innerWidth = Math.max(0, rect.width - 2);
13
- const titleSegment = padEndCells(fitToWidth(`─ ${title} `, innerWidth, rect.x + 1), innerWidth, "─", rect.x + 1);
14
- screen.put(rect.x, rect.y, `┌${titleSegment}┐`, style);
13
+ const horizontal = options.focused ? "━" : "─";
14
+ const indicator = options.indicator === undefined ? "" : ` ${options.indicator} `;
15
+ const availableTitle = Math.max(0, innerWidth - indicator.length);
16
+ const titleSegment = padEndCells(`${fitToWidth(`${horizontal} ${title} `, availableTitle, rect.x + 1)}${horizontal.repeat(Math.max(0, availableTitle - fitToWidth(`${horizontal} ${title} `, availableTitle, rect.x + 1).length))}${indicator}`, innerWidth, horizontal, rect.x + 1);
17
+ screen.put(rect.x, rect.y, `${options.focused ? "┏" : "┌"}${titleSegment}${options.focused ? "┓" : "┐"}`, style);
15
18
  for (let y = 1; y < rect.height - 1; y += 1) {
16
- screen.put(rect.x, rect.y + y, "│", style);
17
- screen.put(rect.x + rect.width - 1, rect.y + y, "│", style);
19
+ screen.put(rect.x, rect.y + y, options.focused ? "┃" : "│", style);
20
+ screen.put(rect.x + rect.width - 1, rect.y + y, options.focused ? "┃" : "│", style);
18
21
  }
19
22
  if (rect.height > 1) {
20
- screen.put(rect.x, rect.y + rect.height - 1, `└${"".repeat(innerWidth)}┘`, style);
23
+ screen.put(rect.x, rect.y + rect.height - 1, `${options.focused ? "" : "└"}${horizontal.repeat(innerWidth)}${options.focused ? "┛" : "┘"}`, style);
21
24
  }
22
25
  }
23
26
  export function paneBodyRect(rect) {
@@ -126,21 +126,20 @@ function fixtureActions() {
126
126
  {
127
127
  id: "edit",
128
128
  label: "edit",
129
- key: "e",
129
+ accelerator: "e",
130
130
  handler: () => undefined,
131
131
  showInFooter: true
132
132
  },
133
133
  {
134
134
  id: "archive",
135
135
  label: "archive",
136
- key: "a",
136
+ accelerator: "a",
137
137
  handler: () => undefined,
138
138
  showInFooter: true
139
139
  },
140
140
  {
141
141
  id: "delete",
142
142
  label: "delete",
143
- key: "d",
144
143
  handler: () => undefined,
145
144
  destructive: true,
146
145
  showInFooter: true