terminal-pilot 0.0.50 → 0.0.52

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 (104) hide show
  1. package/assets/jetbrains-mono-400-italic.ttf +0 -0
  2. package/assets/jetbrains-mono-400-normal.ttf +0 -0
  3. package/assets/jetbrains-mono-700-italic.ttf +0 -0
  4. package/assets/jetbrains-mono-700-normal.ttf +0 -0
  5. package/dist/cli.js +64 -32
  6. package/dist/cli.js.map +4 -4
  7. package/dist/commands/close-session.js +26 -0
  8. package/dist/commands/close-session.js.map +2 -2
  9. package/dist/commands/create-session.js +26 -0
  10. package/dist/commands/create-session.js.map +2 -2
  11. package/dist/commands/daemon-runtime.js.map +1 -1
  12. package/dist/commands/fill.js +26 -0
  13. package/dist/commands/fill.js.map +2 -2
  14. package/dist/commands/get-session.js +26 -0
  15. package/dist/commands/get-session.js.map +2 -2
  16. package/dist/commands/index.js +39 -6
  17. package/dist/commands/index.js.map +4 -4
  18. package/dist/commands/install.js +42 -3
  19. package/dist/commands/install.js.map +4 -4
  20. package/dist/commands/installer.js +16 -3
  21. package/dist/commands/installer.js.map +4 -4
  22. package/dist/commands/list-sessions.js +26 -0
  23. package/dist/commands/list-sessions.js.map +2 -2
  24. package/dist/commands/press-key.js +26 -0
  25. package/dist/commands/press-key.js.map +2 -2
  26. package/dist/commands/read-history.js +26 -0
  27. package/dist/commands/read-history.js.map +2 -2
  28. package/dist/commands/read-screen.js +26 -0
  29. package/dist/commands/read-screen.js.map +2 -2
  30. package/dist/commands/resize.js +26 -0
  31. package/dist/commands/resize.js.map +2 -2
  32. package/dist/commands/runtime.js.map +1 -1
  33. package/dist/commands/screenshot.js +26 -0
  34. package/dist/commands/screenshot.js.map +2 -2
  35. package/dist/commands/send-signal.js +26 -0
  36. package/dist/commands/send-signal.js.map +2 -2
  37. package/dist/commands/type.js +26 -0
  38. package/dist/commands/type.js.map +2 -2
  39. package/dist/commands/uninstall.js +42 -3
  40. package/dist/commands/uninstall.js.map +4 -4
  41. package/dist/commands/wait-for-exit.js +26 -0
  42. package/dist/commands/wait-for-exit.js.map +2 -2
  43. package/dist/commands/wait-for.js +26 -0
  44. package/dist/commands/wait-for.js.map +2 -2
  45. package/dist/composition.json +1 -1
  46. package/dist/testing/cli-repl.js +64 -32
  47. package/dist/testing/cli-repl.js.map +4 -4
  48. package/dist/testing/qa-cli.js +64 -32
  49. package/dist/testing/qa-cli.js.map +4 -4
  50. package/node_modules/toolcraft-design/README.md +30 -0
  51. package/node_modules/toolcraft-design/dist/dashboard/terminal-width.d.ts +1 -0
  52. package/node_modules/toolcraft-design/dist/dashboard/terminal-width.js +18 -1
  53. package/node_modules/toolcraft-design/dist/explorer/actions.d.ts +3 -2
  54. package/node_modules/toolcraft-design/dist/explorer/actions.js +50 -4
  55. package/node_modules/toolcraft-design/dist/explorer/events.d.ts +7 -2
  56. package/node_modules/toolcraft-design/dist/explorer/filter.js +1 -1
  57. package/node_modules/toolcraft-design/dist/explorer/index.d.ts +2 -4
  58. package/node_modules/toolcraft-design/dist/explorer/index.js +1 -2
  59. package/node_modules/toolcraft-design/dist/explorer/jobs.d.ts +2 -2
  60. package/node_modules/toolcraft-design/dist/explorer/jobs.js +3 -5
  61. package/node_modules/toolcraft-design/dist/explorer/keymap.d.ts +13 -3
  62. package/node_modules/toolcraft-design/dist/explorer/keymap.js +89 -348
  63. package/node_modules/toolcraft-design/dist/explorer/layout.d.ts +1 -0
  64. package/node_modules/toolcraft-design/dist/explorer/layout.js +14 -4
  65. package/node_modules/toolcraft-design/dist/explorer/reducer.js +116 -16
  66. package/node_modules/toolcraft-design/dist/explorer/render/detail.d.ts +1 -1
  67. package/node_modules/toolcraft-design/dist/explorer/render/detail.js +45 -7
  68. package/node_modules/toolcraft-design/dist/explorer/render/footer.d.ts +1 -1
  69. package/node_modules/toolcraft-design/dist/explorer/render/footer.js +18 -3
  70. package/node_modules/toolcraft-design/dist/explorer/render/header.d.ts +1 -1
  71. package/node_modules/toolcraft-design/dist/explorer/render/header.js +6 -2
  72. package/node_modules/toolcraft-design/dist/explorer/render/index.d.ts +1 -1
  73. package/node_modules/toolcraft-design/dist/explorer/render/index.js +2 -1
  74. package/node_modules/toolcraft-design/dist/explorer/render/list.d.ts +16 -2
  75. package/node_modules/toolcraft-design/dist/explorer/render/list.js +13 -34
  76. package/node_modules/toolcraft-design/dist/explorer/render/modal.d.ts +1 -1
  77. package/node_modules/toolcraft-design/dist/explorer/render/modal.js +11 -11
  78. package/node_modules/toolcraft-design/dist/explorer/render/pane.d.ts +5 -2
  79. package/node_modules/toolcraft-design/dist/explorer/render/pane.js +9 -6
  80. package/node_modules/toolcraft-design/dist/explorer/render/test-fixtures.js +2 -3
  81. package/node_modules/toolcraft-design/dist/explorer/runtime.js +115 -73
  82. package/node_modules/toolcraft-design/dist/explorer/runtime.test-helpers.d.ts +23 -37
  83. package/node_modules/toolcraft-design/dist/explorer/runtime.test-helpers.js +32 -83
  84. package/node_modules/toolcraft-design/dist/explorer/state.d.ts +79 -8
  85. package/node_modules/toolcraft-design/dist/explorer/state.js +70 -18
  86. package/node_modules/toolcraft-design/dist/index.d.ts +8 -2
  87. package/node_modules/toolcraft-design/dist/index.js +5 -1
  88. package/node_modules/toolcraft-design/dist/screen/ansi-text.d.ts +2 -0
  89. package/node_modules/toolcraft-design/dist/screen/ansi-text.js +35 -0
  90. package/node_modules/toolcraft-design/dist/screen/screen.d.ts +40 -0
  91. package/node_modules/toolcraft-design/dist/screen/screen.js +122 -0
  92. package/node_modules/toolcraft-design/dist/screen/style.d.ts +16 -0
  93. package/node_modules/toolcraft-design/dist/screen/style.js +55 -0
  94. package/node_modules/toolcraft-design/dist/terminal/driver.d.ts +34 -0
  95. package/node_modules/toolcraft-design/dist/terminal/driver.js +65 -0
  96. package/node_modules/toolcraft-design/dist/terminal/input.d.ts +25 -0
  97. package/node_modules/toolcraft-design/dist/terminal/input.js +181 -0
  98. package/node_modules/toolcraft-design/dist/terminal/output.d.ts +10 -0
  99. package/node_modules/toolcraft-design/dist/terminal/output.js +44 -0
  100. package/package.json +3 -2
  101. package/node_modules/toolcraft-design/dist/explorer/two-pane.d.ts +0 -102
  102. package/node_modules/toolcraft-design/dist/explorer/two-pane.js +0 -510
  103. package/node_modules/toolcraft-design/dist/run-two-pane-explorer.d.ts +0 -2
  104. package/node_modules/toolcraft-design/dist/run-two-pane-explorer.js +0 -1
@@ -9,6 +9,7 @@ const DEFAULT_ACTION_HANDLES = {
9
9
  openModal: () => undefined,
10
10
  toast: () => undefined,
11
11
  confirm: async () => false,
12
+ promptText: async () => null,
12
13
  exit: () => undefined
13
14
  };
14
15
  export function step(state, event, runtimeHandles = DEFAULT_ACTION_HANDLES) {
@@ -53,6 +54,10 @@ function stepKey(state, key, runtimeHandles) {
53
54
  return stepFilterKey(state, key, target, runtimeHandles);
54
55
  }
55
56
  if (target?.type === "action") {
57
+ if (state.actionState.get(target.id)?.running === true) {
58
+ runtimeHandles.toast("Action already running", "info");
59
+ return mark(state, 0);
60
+ }
56
61
  if (state.actionState.get(target.id)?.source === "detail" && state.focused !== "detail") {
57
62
  return mark(state, 0);
58
63
  }
@@ -85,6 +90,14 @@ function stepKey(state, key, runtimeHandles) {
85
90
  return isDetailBlobFocused(state)
86
91
  ? detailScroll(state, detailBodyHeight(state))
87
92
  : moveCursor(state, pageSize(state));
93
+ case "halfPageUp":
94
+ return isDetailBlobFocused(state)
95
+ ? detailScroll(state, -Math.max(1, Math.floor(detailBodyHeight(state) / 2)))
96
+ : moveCursor(state, -Math.max(1, Math.floor(pageSize(state) / 2)));
97
+ case "halfPageDown":
98
+ return isDetailBlobFocused(state)
99
+ ? detailScroll(state, Math.max(1, Math.floor(detailBodyHeight(state) / 2)))
100
+ : moveCursor(state, Math.max(1, Math.floor(pageSize(state) / 2)));
88
101
  case "focusNext":
89
102
  return focusNext(state);
90
103
  case "escape":
@@ -112,12 +125,16 @@ function stepKey(state, key, runtimeHandles) {
112
125
  }
113
126
  }
114
127
  if (isBackspace(key)) {
128
+ if (isSecondListFocused(state))
129
+ return updateDetailFilter(state, (state.detail.filter ?? "").slice(0, -1));
115
130
  return updateFilter(state, state.filter.slice(0, -1));
116
131
  }
117
132
  if (!state.multiSelect && isSelectionSpace(key)) {
118
133
  return mark(state, 0);
119
134
  }
120
135
  if (isPrintable(key)) {
136
+ if (isSecondListFocused(state))
137
+ return updateDetailFilter(state, `${state.detail.filter ?? ""}${key.ch}`);
121
138
  return updateFilter(state, `${state.filter}${key.ch}`);
122
139
  }
123
140
  return mark(state, 0);
@@ -141,8 +158,23 @@ function stepFilterKey(state, key, target, runtimeHandles) {
141
158
  return mark(state, 0);
142
159
  }
143
160
  function stepModalKey(state, key, target, runtimeHandles) {
161
+ if (state.modal?.kind === "input") {
162
+ if (target?.type === "builtin" && (target.id === "escape" || target.id === "quit")) {
163
+ return modalDismissed(state, null, runtimeHandles);
164
+ }
165
+ if (target?.type === "builtin" && target.id === "confirm") {
166
+ return modalDismissed(state, state.modal.value, runtimeHandles);
167
+ }
168
+ if (isBackspace(key)) {
169
+ return setModal(state, { ...state.modal, value: state.modal.value.slice(0, -1) });
170
+ }
171
+ if (isPrintable(key)) {
172
+ return setModal(state, { ...state.modal, value: `${state.modal.value}${key.ch}` });
173
+ }
174
+ return mark(state, 0);
175
+ }
144
176
  if (state.modal?.kind === "confirm") {
145
- if (target?.type === "builtin" && target.id === "escape") {
177
+ if (target?.type === "builtin" && (target.id === "escape" || target.id === "quit")) {
146
178
  return modalDismissed(state, false, runtimeHandles);
147
179
  }
148
180
  if (target?.type === "builtin" && target.id === "confirm") {
@@ -201,7 +233,7 @@ function stepModalKey(state, key, target, runtimeHandles) {
201
233
  }
202
234
  function resize(state, cols, rows) {
203
235
  const size = { cols: normalizeSize(cols), rows: normalizeSize(rows) };
204
- const layout = resolveExplorerLayoutMode(size.cols);
236
+ const layout = resolveExplorerLayoutMode(size.cols, size.rows);
205
237
  if (state.size.cols === size.cols && state.size.rows === size.rows && state.layout === layout) {
206
238
  return mark(state, 0);
207
239
  }
@@ -211,6 +243,7 @@ function resize(state, cols, rows) {
211
243
  };
212
244
  }
213
245
  function rowsLoaded(state, rows) {
246
+ const previousRowId = currentRow(state)?.id;
214
247
  const rowIds = new Set();
215
248
  for (const row of rows) {
216
249
  if (rowIds.has(row.id)) {
@@ -221,14 +254,27 @@ function rowsLoaded(state, rows) {
221
254
  const matches = filterRows(state.filter, rows);
222
255
  const filtered = matches.map((match) => match.index);
223
256
  const matchPositions = createMatchPositions(matches);
224
- const cursor = clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
257
+ const identityCursor = previousRowId === undefined
258
+ ? -1
259
+ : filtered.findIndex((index) => rows[index]?.id === previousRowId);
260
+ const cursor = identityCursor >= 0 ? identityCursor : clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
225
261
  const selected = state.multiSelect ? pruneSelection(state.selected, rows) : new Set();
226
262
  const detail = resetDetailForCursor(state, rows, filtered, cursor);
227
263
  const modal = modalStillValid(state.modal, rows);
228
264
  if (state.modal?.kind === "confirm" && modal === null) {
229
265
  state.modal.resolver(false);
230
266
  }
231
- const nextView = { ...state, rows, filtered, matchPositions, cursor, selected, detail, modal };
267
+ const nextView = {
268
+ ...state,
269
+ rows,
270
+ rowsLoading: false,
271
+ filtered,
272
+ matchPositions,
273
+ cursor,
274
+ selected,
275
+ detail,
276
+ modal
277
+ };
232
278
  const next = {
233
279
  ...nextView,
234
280
  actionState: recomputeActionState(nextView),
@@ -253,10 +299,12 @@ function detailLoaded(state, rowId, token, items) {
253
299
  if (state.detail.rowId !== rowId || state.detail.token !== token) {
254
300
  return mark(state, 0);
255
301
  }
302
+ const visibleItems = filterDetailItems(items, state.detail.filter ?? "");
256
303
  const detail = {
257
304
  ...state.detail,
258
- items,
259
- cursor: clamp(state.detail.cursor, 0, Math.max(0, items.length - 1)),
305
+ items: visibleItems,
306
+ allItems: items,
307
+ cursor: clamp(state.detail.cursor, 0, Math.max(0, visibleItems.length - 1)),
260
308
  scroll: 0,
261
309
  loading: false
262
310
  };
@@ -324,10 +372,13 @@ function modalDismissed(state, result, runtimeHandles) {
324
372
  if (modal?.kind === "confirm") {
325
373
  modal.resolver(result === true);
326
374
  }
327
- if (modal?.kind !== "confirm" || result !== true) {
375
+ if (modal?.kind === "input") {
376
+ modal.resolver(typeof result === "string" ? result : null);
377
+ }
378
+ if (modal?.kind !== "confirm" || result !== true || modal.action === undefined) {
328
379
  return { state: closed, effects: NO_EFFECTS };
329
380
  }
330
- return dispatchAction(closed, modal.action, true, runtimeHandles, modal.rows);
381
+ return dispatchAction(closed, modal.action, true, runtimeHandles, modal.rows ?? []);
331
382
  }
332
383
  function moveCursor(state, delta) {
333
384
  if (state.focused === "detail" && hasDetailCursor(state)) {
@@ -339,9 +390,7 @@ function moveCursor(state, delta) {
339
390
  return setCursor(state, state.cursor + delta);
340
391
  }
341
392
  function isDetailBlobFocused(state) {
342
- return (state.focused === "detail" &&
343
- !hasDetailCursor(state) &&
344
- (state.detail.items?.length ?? 0) > 0);
393
+ return (state.focused === "detail" && !hasDetailCursor(state) && (state.detail.items?.length ?? 0) > 0);
345
394
  }
346
395
  function moveDetailCursor(state, delta) {
347
396
  const max = Math.max(0, (state.detail.items?.length ?? 0) - 1);
@@ -410,6 +459,33 @@ function updateFilter(state, filter) {
410
459
  const effect = detailEffect(next);
411
460
  return { state: next, effects: effect === undefined ? NO_EFFECTS : [effect] };
412
461
  }
462
+ function updateDetailFilter(state, filter) {
463
+ const allItems = state.detail.allItems ?? state.detail.items ?? [];
464
+ const items = filterDetailItems(allItems, filter);
465
+ const detail = { ...state.detail, filter, items, cursor: 0, scroll: 0 };
466
+ return {
467
+ state: {
468
+ ...state,
469
+ detail,
470
+ actionState: recomputeActionState({ ...state, detail }),
471
+ dirty: REGION_HEADER | REGION_DETAIL | REGION_FOOTER
472
+ },
473
+ effects: NO_EFFECTS
474
+ };
475
+ }
476
+ function filterDetailItems(items, query) {
477
+ const rows = items.map((item) => ({
478
+ id: item.id,
479
+ title: item.title ?? item.id,
480
+ subtitle: item.subtitle
481
+ }));
482
+ return filterRows(query, rows)
483
+ .map((match) => items[match.index])
484
+ .filter((item) => item !== undefined);
485
+ }
486
+ function isSecondListFocused(state) {
487
+ return state.focused === "detail" && state.paneDefinitions[1]?.kind === "list";
488
+ }
413
489
  function focusFilter(state) {
414
490
  if (state.filterFocused) {
415
491
  return mark(state, 0);
@@ -432,6 +508,9 @@ function focusNext(state) {
432
508
  };
433
509
  }
434
510
  function escape(state, runtimeHandles) {
511
+ if (isSecondListFocused(state) && (state.detail.filter ?? "").length > 0) {
512
+ return updateDetailFilter(state, "");
513
+ }
435
514
  if (state.filterFocused || state.filter.length > 0) {
436
515
  const cleared = updateFilter({ ...state, filterFocused: false }, "");
437
516
  return {
@@ -455,13 +534,19 @@ function confirmKey(state, runtimeHandles) {
455
534
  if (state.modal?.kind === "confirm") {
456
535
  return modalDismissed(state, true, runtimeHandles);
457
536
  }
458
- return dispatchPrimary(state, runtimeHandles);
537
+ const hasActions = [...state.actionState.values()].some((entry) => entry.available && entry.running !== true);
538
+ return hasActions
539
+ ? setModal(state, { kind: "palette", query: "", cursor: 0 })
540
+ : dispatchPrimary(state, runtimeHandles);
459
541
  }
460
542
  function toggleSelect(state) {
461
543
  if (!state.multiSelect) {
462
544
  return mark(state, 0);
463
545
  }
464
- const row = currentRow(state);
546
+ const detailItem = state.focused === "detail" ? state.detail.items?.[state.detail.cursor] : undefined;
547
+ const row = detailItem === undefined
548
+ ? currentRow(state)
549
+ : { id: detailItem.id, title: detailItem.title ?? detailItem.id };
465
550
  if (row === undefined) {
466
551
  return mark(state, 0);
467
552
  }
@@ -701,10 +786,21 @@ function dispatchAction(state, action, confirmed, runtimeHandles, modalRows) {
701
786
  return mark(state, 0);
702
787
  }
703
788
  if (action.destructive === true && !confirmed) {
789
+ const actionLabel = typeof action.label === "function" ? action.label() : action.label;
704
790
  return {
705
791
  state: {
706
792
  ...state,
707
- modal: { kind: "confirm", action, rows, resolver: () => undefined },
793
+ modal: {
794
+ kind: "confirm",
795
+ title: action.destructive ? "Confirm destructive action" : "Confirm action",
796
+ message: `${actionLabel} ${rows.length === 1 ? rows[0].title : `${rows.length} items`}?`,
797
+ confirmLabel: actionLabel,
798
+ cancelLabel: "Cancel",
799
+ destructive: action.destructive === true,
800
+ action,
801
+ rows,
802
+ resolver: () => undefined
803
+ },
708
804
  dirty: REGION_MODAL | REGION_FOOTER
709
805
  },
710
806
  effects: NO_EFFECTS
@@ -736,7 +832,9 @@ function recomputeActionState(view) {
736
832
  continue;
737
833
  }
738
834
  const ctx = buildActionContext(view, action, entry.source ?? actionSource(view, action), DEFAULT_ACTION_HANDLES);
739
- const available = action.predicate === undefined ? entry.available : action.predicate(ctx);
835
+ const row = currentRow(view);
836
+ const visible = action.visible === undefined || (row !== undefined && action.visible(row));
837
+ const available = visible && (action.predicate === undefined ? entry.available : action.predicate(ctx));
740
838
  const label = typeof action.label === "function" ? action.label() : action.label;
741
839
  next.set(id, { ...entry, available, label });
742
840
  }
@@ -756,7 +854,7 @@ function resetDetailForCursor(state, rows, filtered, cursor) {
756
854
  }
757
855
  return {
758
856
  rowId: row.id,
759
- items: null,
857
+ items: state.detail.items,
760
858
  cursor: 0,
761
859
  scroll: 0,
762
860
  token: state.detail.token + 1,
@@ -816,6 +914,8 @@ function modalStillValid(modal, rows) {
816
914
  if (modal?.kind !== "confirm") {
817
915
  return modal;
818
916
  }
917
+ if (modal.rows === undefined)
918
+ return modal;
819
919
  const ids = new Set(rows.map((row) => row.id));
820
920
  return modal.rows.every((row) => ids.has(row.id)) ? modal : null;
821
921
  }
@@ -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();
@@ -11,12 +12,14 @@ export function renderDetail(state, screen, layout) {
11
12
  return;
12
13
  }
13
14
  const row = state.rows.find((r) => r.id === state.detail.rowId) ?? null;
15
+ const pane = state.paneDefinitions[1];
16
+ const title = pane?.titleForRow?.(row ?? undefined) ?? pane?.title ?? "Preview";
14
17
  if (layout.mode === "narrow-vertical") {
15
- drawPaneFrame(screen, rect, "Preview", state.focused === "detail" ? styles.borderFocused : styles.border);
18
+ drawPaneFrame(screen, rect, title, state.focused === "detail" ? styles.borderFocused : styles.border, { focused: state.focused === "detail", indicator: scrollIndicator(state) });
16
19
  renderDetailBody(state, screen, paneBodyRect(rect), row);
17
20
  return;
18
21
  }
19
- drawPaneFrame(screen, rect, "Preview", state.focused === "detail" ? styles.borderFocused : styles.border);
22
+ drawPaneFrame(screen, rect, title, state.focused === "detail" ? styles.borderFocused : styles.border, { focused: state.focused === "detail", indicator: scrollIndicator(state) });
20
23
  renderDetailBody(state, screen, paneBodyRect(rect), row);
21
24
  }
22
25
  function renderDetailBody(state, screen, rect, row) {
@@ -47,7 +50,8 @@ function renderListMode(state, screen, rect, items, row) {
47
50
  const item = items[index];
48
51
  const cursor = index === state.detail.cursor;
49
52
  const title = item.title ?? item.id;
50
- const prefix = cursor ? "▌ " : " ";
53
+ const selected = state.multiSelect && state.selected.has(item.id);
54
+ const prefix = `${selected ? "*" : " "}${cursor ? "▌" : " "} `;
51
55
  const badge = item.badge ? ` ${item.badge.text}` : "";
52
56
  writeLine(screen, rect, y, `${prefix}${title}${badge}`, cursor ? styles.borderFocused : styles.accent);
53
57
  y += 1;
@@ -68,11 +72,23 @@ function renderListMode(state, screen, rect, items, row) {
68
72
  }
69
73
  }
70
74
  function renderBlob(screen, rect, text, scroll) {
71
- const allLines = text.split("\n");
75
+ const allLines = [[]];
76
+ for (const cell of ansiToCells(text)) {
77
+ if (cell.ch === "\n")
78
+ allLines.push([]);
79
+ else
80
+ allLines.at(-1).push(cell);
81
+ }
72
82
  const start = clamp(scroll, 0, Math.max(0, allLines.length - rect.height));
73
83
  const lines = allLines.slice(start);
74
84
  for (let row = 0; row < rect.height; row += 1) {
75
- writeLine(screen, rect, row, lines[row] ?? "");
85
+ let x = rect.x;
86
+ for (const cell of lines[row] ?? []) {
87
+ if (x + cell.width > rect.x + rect.width)
88
+ break;
89
+ screen.put(x, rect.y + row, cell.ch, cell.style);
90
+ x += cell.width;
91
+ }
76
92
  }
77
93
  }
78
94
  function renderItemMarkdown(item, rect, row) {
@@ -80,7 +96,14 @@ function renderItemMarkdown(item, rect, row) {
80
96
  if (content.trim().length === 0) {
81
97
  return "";
82
98
  }
83
- return stripAnsi(renderMarkdown(content, { width: Math.max(1, rect.width) }).trimEnd());
99
+ const width = Math.max(1, rect.width);
100
+ const key = `${contentHash(content)}:${width}`;
101
+ const cached = markdownCache.get(key);
102
+ if (cached !== undefined)
103
+ return cached;
104
+ const rendered = renderMarkdown(content, { width }).trimEnd();
105
+ markdownCache.set(key, rendered);
106
+ return rendered;
84
107
  }
85
108
  function renderItem(item, rect, row) {
86
109
  if (item.renderedContent !== undefined) {
@@ -108,3 +131,18 @@ function writeLine(screen, rect, row, text, style = {}) {
108
131
  function clamp(value, min, max) {
109
132
  return Math.min(max, Math.max(min, value));
110
133
  }
134
+ function scrollIndicator(state) {
135
+ if (state.detail.loading)
136
+ return "⠋";
137
+ const content = state.detail.items?.[state.detail.cursor]?.renderedContent ?? "";
138
+ const total = Math.max(1, content.split("\n").length);
139
+ return `${Math.min(100, Math.round((state.detail.scroll / Math.max(1, total - 1)) * 100))}%`;
140
+ }
141
+ function contentHash(content) {
142
+ let hash = 2166136261;
143
+ for (let index = 0; index < content.length; index += 1) {
144
+ hash ^= content.charCodeAt(index);
145
+ hash = Math.imul(hash, 16777619);
146
+ }
147
+ return hash >>> 0;
148
+ }
@@ -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;
@@ -36,11 +36,23 @@ function putFooterText(screen, x, y, endX, text, style = {}) {
36
36
  return cellWidth(fitted, x);
37
37
  }
38
38
  function footerHints(state) {
39
+ if (state.modal?.kind === "input") {
40
+ return [
41
+ { key: "Enter", label: "submit", running: false },
42
+ { key: "Esc", label: "cancel", running: false }
43
+ ];
44
+ }
45
+ if (state.modal?.kind === "confirm") {
46
+ return [
47
+ { key: "Y/Enter", label: state.modal.confirmLabel, running: false },
48
+ { key: "N/Esc", label: state.modal.cancelLabel, running: false }
49
+ ];
50
+ }
39
51
  const hints = [];
40
52
  if (state.focused === "detail") {
41
53
  hints.push({ key: "Tab", label: "focus", running: false });
42
- hints.push({ key: "Enter", label: "sub", running: false });
43
54
  }
55
+ hints.push({ key: "Enter", label: "actions", running: false });
44
56
  for (const [id, entry] of state.actionState) {
45
57
  if (!entry.available || entry.action?.showInFooter === false) {
46
58
  continue;
@@ -51,12 +63,13 @@ function footerHints(state) {
51
63
  : entry.label;
52
64
  hints.push({ key, label, running: entry.running === true });
53
65
  }
54
- hints.push({ key: "?", label: "help", running: false });
55
66
  hints.push({ key: "Ctrl+P", label: "palette", running: false });
56
67
  if (hasShiftReorderBindings(state)) {
57
68
  hints.push({ key: "⇧↑↓", label: "reorder (within state)", running: false, bracketed: false });
58
69
  }
59
- hints.push({ key: "q", label: "quit", running: false });
70
+ hints.push({ key: "Esc", label: "clear/quit", running: false });
71
+ if (state.selected.size > 0)
72
+ hints.push({ key: `${state.selected.size}`, label: "selected", running: false, bracketed: false });
60
73
  return hints;
61
74
  }
62
75
  function hasShiftReorderBindings(state) {
@@ -65,6 +78,8 @@ function hasShiftReorderBindings(state) {
65
78
  return up.includes("Shift+up") && down.includes("Shift+down");
66
79
  }
67
80
  function actionKey(entry, fallback) {
81
+ if (entry.action?.accelerator !== undefined)
82
+ return `Ctrl+${entry.action.accelerator.toUpperCase()}`;
68
83
  const key = entry.action?.key;
69
84
  if (Array.isArray(key)) {
70
85
  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;
@@ -29,16 +29,16 @@ function modalLines(state) {
29
29
  }
30
30
  if (state.modal?.kind === "confirm") {
31
31
  return [
32
- state.modal.action.destructive ? "Destructive action" : "Confirm action",
33
- `${labelFor(state.modal.action)} ${state.modal.rows.length} row(s)?`,
34
- "Enter confirms · Esc cancels"
32
+ state.modal.message,
33
+ ` [ ${state.modal.confirmLabel} ] ${state.modal.cancelLabel}`
35
34
  ];
36
35
  }
36
+ if (state.modal?.kind === "input") {
37
+ const value = state.modal.value.length > 0 ? state.modal.value : (state.modal.placeholder ?? "");
38
+ return [state.modal.label, ` ${value}▌`];
39
+ }
37
40
  if (state.modal?.kind === "palette") {
38
- return [
39
- `Query: ${state.modal.query}`,
40
- ...paletteLines(state)
41
- ];
41
+ return [`Query: ${state.modal.query}`, ...paletteLines(state)];
42
42
  }
43
43
  if (state.modal?.kind === "content") {
44
44
  return state.modal.content.split("\n").slice(state.modal.scroll);
@@ -50,7 +50,10 @@ function title(state) {
50
50
  return "Keybindings";
51
51
  }
52
52
  if (state.modal?.kind === "confirm") {
53
- return "Confirm";
53
+ return state.modal.title;
54
+ }
55
+ if (state.modal?.kind === "input") {
56
+ return state.modal.title;
54
57
  }
55
58
  if (state.modal?.kind === "content") {
56
59
  return state.modal.title;
@@ -95,6 +98,3 @@ function paletteLines(state) {
95
98
  }
96
99
  return lines.length === 0 ? [" No actions"] : lines;
97
100
  }
98
- function labelFor(action) {
99
- return typeof action.label === "function" ? action.label() : action.label;
100
- }