toolcraft 0.0.25 → 0.0.27

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 (33) hide show
  1. package/dist/cli.js +11 -9
  2. package/dist/error-report.js +109 -36
  3. package/dist/redaction.d.ts +4 -0
  4. package/dist/redaction.js +70 -0
  5. package/node_modules/@poe-code/config-mutations/dist/execution/apply-mutation.js +33 -9
  6. package/node_modules/@poe-code/config-mutations/dist/formats/json.d.ts +2 -1
  7. package/node_modules/@poe-code/config-mutations/dist/formats/json.js +36 -9
  8. package/node_modules/@poe-code/config-mutations/dist/types.d.ts +2 -0
  9. package/node_modules/@poe-code/design-system/dist/components/browser.js +1 -1
  10. package/node_modules/@poe-code/design-system/dist/explorer/actions.js +1 -1
  11. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +11 -1
  12. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +64 -8
  13. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +9 -11
  14. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +18 -8
  15. package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +11 -18
  16. package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +2 -10
  17. package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +32 -22
  18. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +5 -9
  19. package/node_modules/@poe-code/design-system/dist/explorer/render/text.d.ts +12 -0
  20. package/node_modules/@poe-code/design-system/dist/explorer/render/text.js +81 -0
  21. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +1 -0
  22. package/node_modules/@poe-code/design-system/dist/explorer/state.js +2 -0
  23. package/node_modules/@poe-code/design-system/dist/prompts/index.js +3 -3
  24. package/node_modules/@poe-code/process-runner/dist/docker/docker-execution-env.js +24 -3
  25. package/node_modules/@poe-code/process-runner/dist/docker/docker-runner.js +1 -0
  26. package/node_modules/@poe-code/task-list/dist/backends/gh-issues-sync.js +8 -0
  27. package/node_modules/auth-store/dist/keychain-store.js +20 -1
  28. package/node_modules/mcp-oauth/dist/client/auth-store-session-store.js +6 -3
  29. package/node_modules/tiny-mcp-client/dist/internal.d.ts +2 -0
  30. package/node_modules/tiny-mcp-client/dist/internal.js +30 -13
  31. package/node_modules/tiny-mcp-client/src/internal.ts +35 -16
  32. package/node_modules/tiny-mcp-client/src/transports.test.ts +68 -0
  33. package/package.json +2 -2
@@ -101,6 +101,9 @@ function stepKey(state, key, runtimeHandles) {
101
101
  if (isBackspace(key)) {
102
102
  return updateFilter(state, state.filter.slice(0, -1));
103
103
  }
104
+ if (!state.multiSelect && isSelectionSpace(key)) {
105
+ return mark(state, 0);
106
+ }
104
107
  if (isPrintable(key)) {
105
108
  return updateFilter(state, `${state.filter}${key.ch}`);
106
109
  }
@@ -170,7 +173,7 @@ function resize(state, cols, rows) {
170
173
  return mark(state, 0);
171
174
  }
172
175
  return {
173
- state: { ...state, size, layout, dirty: REGION_ALL },
176
+ state: clampDetailScroll({ ...state, size, layout, dirty: REGION_ALL }),
174
177
  effects: NO_EFFECTS
175
178
  };
176
179
  }
@@ -186,7 +189,7 @@ function rowsLoaded(state, rows) {
186
189
  const filtered = matches.map((match) => match.index);
187
190
  const matchPositions = createMatchPositions(matches);
188
191
  const cursor = clamp(state.cursor, 0, Math.max(0, filtered.length - 1));
189
- const selected = pruneSelection(state.selected, rows);
192
+ const selected = state.multiSelect ? pruneSelection(state.selected, rows) : new Set();
190
193
  const detail = resetDetailForCursor(state, rows, filtered, cursor);
191
194
  const modal = modalStillValid(state.modal, rows);
192
195
  if (state.modal?.kind === "confirm" && modal === null) {
@@ -239,8 +242,9 @@ function detailItemRendered(state, rowId, token, itemIndex, content) {
239
242
  return mark(state, 0);
240
243
  }
241
244
  const items = state.detail.items.map((item, index) => index === itemIndex ? { ...item, renderedContent: content } : item);
245
+ const detail = { ...state.detail, items };
242
246
  return {
243
- state: { ...state, detail: { ...state.detail, items }, dirty: REGION_DETAIL },
247
+ state: clampDetailScroll({ ...state, detail, dirty: REGION_DETAIL }),
244
248
  effects: NO_EFFECTS
245
249
  };
246
250
  }
@@ -400,6 +404,9 @@ function confirmKey(state, runtimeHandles) {
400
404
  return dispatchPrimary(state, runtimeHandles);
401
405
  }
402
406
  function toggleSelect(state) {
407
+ if (!state.multiSelect) {
408
+ return mark(state, 0);
409
+ }
403
410
  const row = currentRow(state);
404
411
  if (row === undefined) {
405
412
  return mark(state, 0);
@@ -414,6 +421,9 @@ function toggleSelect(state) {
414
421
  return selectionChanged(state, selected);
415
422
  }
416
423
  function selectAll(state) {
424
+ if (!state.multiSelect) {
425
+ return mark(state, 0);
426
+ }
417
427
  const selected = new Set(state.selected);
418
428
  for (const index of state.filtered) {
419
429
  const row = state.rows[index];
@@ -430,13 +440,14 @@ function clearSelection(state) {
430
440
  return selectionChanged(state, new Set());
431
441
  }
432
442
  function selectionChanged(state, selected) {
433
- if (setsEqual(state.selected, selected)) {
443
+ const normalized = state.multiSelect ? selected : new Set();
444
+ if (setsEqual(state.selected, normalized)) {
434
445
  return mark(state, 0);
435
446
  }
436
447
  const next = {
437
448
  ...state,
438
- selected,
439
- actionState: recomputeActionState({ ...state, selected }),
449
+ selected: normalized,
450
+ actionState: recomputeActionState({ ...state, selected: normalized }),
440
451
  dirty: REGION_LIST | REGION_FOOTER
441
452
  };
442
453
  return { state: next, effects: NO_EFFECTS };
@@ -445,7 +456,7 @@ function detailScroll(state, delta) {
445
456
  if (state.focused !== "detail") {
446
457
  return mark(state, 0);
447
458
  }
448
- const scroll = Math.max(0, state.detail.scroll + delta);
459
+ const scroll = clamp(state.detail.scroll + delta, 0, maxDetailScroll(state));
449
460
  if (scroll === state.detail.scroll) {
450
461
  return mark(state, 0);
451
462
  }
@@ -454,7 +465,49 @@ function detailScroll(state, delta) {
454
465
  effects: NO_EFFECTS
455
466
  };
456
467
  }
468
+ function clampDetailScroll(state) {
469
+ const scroll = clamp(state.detail.scroll, 0, maxDetailScroll(state));
470
+ if (scroll === state.detail.scroll) {
471
+ return state;
472
+ }
473
+ return { ...state, detail: { ...state.detail, scroll } };
474
+ }
475
+ function maxDetailScroll(state) {
476
+ const items = state.detail.items;
477
+ if (items === null || items.length === 0) {
478
+ return 0;
479
+ }
480
+ if (items.length === 1 && items[0]?.title === undefined) {
481
+ const visibleHeight = detailBodyHeight(state);
482
+ if (visibleHeight <= 0) {
483
+ return 0;
484
+ }
485
+ return Math.max(0, detailContentLineCount(items[0]) - visibleHeight);
486
+ }
487
+ return Math.max(0, items.length - 1);
488
+ }
489
+ function detailContentLineCount(item) {
490
+ return (item.renderedContent ?? "").split("\n").length;
491
+ }
492
+ function detailBodyHeight(state) {
493
+ if (state.layout === "too-narrow" || state.layout === "narrow-list-only") {
494
+ return 0;
495
+ }
496
+ const rows = normalizeSize(state.size.rows);
497
+ const footerHeight = rows > 0 ? Math.min(1, rows) : 0;
498
+ const headerHeight = Math.min(3, Math.max(0, rows - footerHeight));
499
+ const contentHeight = Math.max(0, rows - headerHeight - footerHeight);
500
+ if (state.layout === "narrow-vertical") {
501
+ const listHeight = Math.ceil(contentHeight / 2);
502
+ const detailHeight = contentHeight - listHeight;
503
+ return Math.max(0, detailHeight - 1);
504
+ }
505
+ return contentHeight;
506
+ }
457
507
  function extendSelection(state, delta) {
508
+ if (!state.multiSelect) {
509
+ return moveCursor(state, delta);
510
+ }
458
511
  const moved = moveCursor(state, delta);
459
512
  const row = currentRow(moved.state);
460
513
  if (row === undefined) {
@@ -646,7 +699,7 @@ function actionSource(state, action) {
646
699
  return state.actionState.get(action.id)?.source ?? "row";
647
700
  }
648
701
  function selectedRows(state) {
649
- if (state.selected.size === 0) {
702
+ if (!state.multiSelect || state.selected.size === 0) {
650
703
  const row = currentRow(state);
651
704
  return row === undefined ? [] : [row];
652
705
  }
@@ -718,6 +771,9 @@ function isPrintable(key) {
718
771
  function isBackspace(key) {
719
772
  return key.name === "backspace" || key.name === "delete";
720
773
  }
774
+ function isSelectionSpace(key) {
775
+ return key.name === "space" || key.ch === " ";
776
+ }
721
777
  function isConfirmYes(key) {
722
778
  return key.ch === "y" || key.ch === "Y";
723
779
  }
@@ -1,4 +1,5 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
+ import { fitToWidth } from "./text.js";
2
3
  export function renderDetail(state, screen, layout) {
3
4
  const rect = layout.detail;
4
5
  const styles = getExplorerStyles();
@@ -35,7 +36,8 @@ function renderDetailBody(state, screen, rect, row) {
35
36
  function renderListMode(state, screen, rect, items, row) {
36
37
  const styles = getExplorerStyles();
37
38
  let y = 0;
38
- for (let index = state.detail.scroll; index < items.length && y < rect.height; index += 1) {
39
+ const start = clamp(state.detail.scroll, 0, Math.max(0, items.length - 1));
40
+ for (let index = start; index < items.length && y < rect.height; index += 1) {
39
41
  const item = items[index];
40
42
  const cursor = index === state.detail.cursor;
41
43
  const title = item.title ?? item.id;
@@ -60,7 +62,9 @@ function renderListMode(state, screen, rect, items, row) {
60
62
  }
61
63
  }
62
64
  function renderBlob(screen, rect, text, scroll) {
63
- const lines = text.split("\n").slice(scroll);
65
+ const allLines = text.split("\n");
66
+ const start = clamp(scroll, 0, Math.max(0, allLines.length - rect.height));
67
+ const lines = allLines.slice(start);
64
68
  for (let row = 0; row < rect.height; row += 1) {
65
69
  writeLine(screen, rect, row, lines[row] ?? "");
66
70
  }
@@ -86,14 +90,8 @@ function writeLine(screen, rect, row, text, style = {}) {
86
90
  if (row < 0 || row >= rect.height) {
87
91
  return;
88
92
  }
89
- screen.put(rect.x, rect.y + row, fit(text, rect.width), style);
93
+ screen.put(rect.x, rect.y + row, fitToWidth(text, rect.width, rect.x), style);
90
94
  }
91
- function fit(text, width) {
92
- if (width <= 0) {
93
- return "";
94
- }
95
- if (text.length <= width) {
96
- return text;
97
- }
98
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
95
+ function clamp(value, min, max) {
96
+ return Math.min(max, Math.max(min, value));
99
97
  }
@@ -1,4 +1,5 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
+ import { cellWidth, fitToWidth } from "./text.js";
2
3
  export function renderFooter(state, screen, layout) {
3
4
  const rect = layout.footer;
4
5
  const styles = getExplorerStyles();
@@ -9,22 +10,31 @@ export function renderFooter(state, screen, layout) {
9
10
  const hints = footerHints(state);
10
11
  let x = rect.x + 2;
11
12
  const y = rect.y;
13
+ const endX = rect.x + rect.width;
12
14
  for (const hint of hints) {
13
- if (x >= rect.x + rect.width) {
15
+ if (x >= endX) {
14
16
  break;
15
17
  }
16
18
  if (hint.bracketed === false) {
17
19
  const text = `${hint.key} ${hint.label}`;
18
- screen.put(x, y, text, hint.running ? styles.muted : {});
19
- x += text.length + 2;
20
+ x += putFooterText(screen, x, y, endX, text, hint.running ? styles.muted : {}) + 2;
20
21
  continue;
21
22
  }
22
- screen.put(x, y, `[${hint.key}]`, hint.running ? styles.muted : styles.accent);
23
- x += hint.key.length + 2;
24
- screen.put(x, y, ` ${hint.label}`, hint.running ? styles.muted : {});
25
- x += hint.label.length + 3;
23
+ const keyText = `[${hint.key}]`;
24
+ const keyWidth = putFooterText(screen, x, y, endX, keyText, hint.running ? styles.muted : styles.accent);
25
+ x += keyWidth;
26
+ if (keyWidth < cellWidth(keyText) || x >= endX) {
27
+ break;
28
+ }
29
+ x += putFooterText(screen, x, y, endX, ` ${hint.label}`, hint.running ? styles.muted : {}) + 2;
26
30
  }
27
31
  }
32
+ function putFooterText(screen, x, y, endX, text, style = {}) {
33
+ const remaining = Math.max(0, endX - x);
34
+ const fitted = fitToWidth(text, remaining, x);
35
+ screen.put(x, y, fitted, style);
36
+ return cellWidth(fitted, x);
37
+ }
28
38
  function footerHints(state) {
29
39
  const hints = [];
30
40
  if (state.focused === "detail") {
@@ -36,7 +46,7 @@ function footerHints(state) {
36
46
  continue;
37
47
  }
38
48
  const key = actionKey(entry, id);
39
- const label = state.selected.size > 0 && entry.source === "row"
49
+ const label = state.multiSelect && state.selected.size > 0 && entry.source === "row"
40
50
  ? `${entry.label} ${state.selected.size}`
41
51
  : entry.label;
42
52
  hints.push({ key, label, running: entry.running === true });
@@ -1,4 +1,5 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
+ import { cellWidth, fitToWidth, padEndCells } from "./text.js";
2
3
  export function renderHeader(state, screen, layout) {
3
4
  const rect = layout.header;
4
5
  const styles = getExplorerStyles();
@@ -7,7 +8,7 @@ export function renderHeader(state, screen, layout) {
7
8
  return;
8
9
  }
9
10
  if (layout.mode === "too-narrow") {
10
- screen.put(0, 0, fit("Terminal too narrow", rect.width), styles.borderFocused);
11
+ screen.put(0, 0, fitToWidth("Terminal too narrow", rect.width), styles.borderFocused);
11
12
  return;
12
13
  }
13
14
  drawTopBorder(screen, state.title, rect.width, styles.border);
@@ -15,13 +16,15 @@ export function renderHeader(state, screen, layout) {
15
16
  const prompt = `${state.title.toLocaleLowerCase()}>`;
16
17
  const filter = state.filter.length > 0 ? ` ${state.filter}` : "";
17
18
  const count = `${state.filtered.length}/${state.rows.length}`;
18
- const selected = state.selected.size > 0 ? ` (${state.selected.size} selected)` : "";
19
+ const selected = state.multiSelect && state.selected.size > 0 ? ` (${state.selected.size} selected)` : "";
19
20
  const spinner = state.detail.loading ? " *" : "";
20
21
  const right = `${count}${selected}${spinner}`;
21
22
  screen.put(0, 1, "│", styles.border);
22
23
  screen.put(Math.max(0, rect.width - 1), 1, "│", styles.border);
23
- screen.put(2, 1, fit(`${prompt}${filter}`, Math.max(0, rect.width - right.length - 5)), styles.accent);
24
- screen.put(Math.max(2, rect.width - right.length - 2), 1, right, styles.muted);
24
+ const rightWidth = cellWidth(right);
25
+ const promptWidth = Math.max(0, rect.width - rightWidth - 5);
26
+ screen.put(2, 1, fitToWidth(`${prompt}${filter}`, promptWidth, 2), styles.accent);
27
+ screen.put(Math.max(2, rect.width - rightWidth - 2), 1, right, styles.muted);
25
28
  }
26
29
  if (rect.height > 2) {
27
30
  drawHorizontal(screen, 2, rect.width, styles.border);
@@ -32,11 +35,10 @@ function drawTopBorder(screen, title, width, style) {
32
35
  screen.put(0, 0, "┌", style);
33
36
  return;
34
37
  }
35
- const label = `─ ${title} `;
36
- const middle = label.length < width - 1
37
- ? `${label}${"─".repeat(width - 1 - label.length)}`
38
- : label.slice(0, Math.max(0, width - 1));
39
- screen.put(0, 0, `┌${middle.slice(0, Math.max(0, width - 2))}┐`, style);
38
+ const innerWidth = Math.max(0, width - 2);
39
+ const label = fitToWidth(`─ ${title} `, innerWidth, 1);
40
+ const middle = padEndCells(label, innerWidth, "─", 1);
41
+ screen.put(0, 0, `┌${middle}┐`, style);
40
42
  }
41
43
  function drawHorizontal(screen, y, width, style) {
42
44
  if (width === 1) {
@@ -45,12 +47,3 @@ function drawHorizontal(screen, y, width, style) {
45
47
  }
46
48
  screen.put(0, y, `├${"─".repeat(Math.max(0, width - 2))}┤`, style);
47
49
  }
48
- function fit(text, width) {
49
- if (width <= 0) {
50
- return "";
51
- }
52
- if (text.length <= width) {
53
- return text;
54
- }
55
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
56
- }
@@ -6,6 +6,7 @@ import { renderFooter } from "./footer.js";
6
6
  import { renderHeader } from "./header.js";
7
7
  import { renderList } from "./list.js";
8
8
  import { renderModal } from "./modal.js";
9
+ import { fitToWidth } from "./text.js";
9
10
  const REGION_RENDERERS = [
10
11
  [REGION_HEADER, renderHeader],
11
12
  [REGION_LIST, renderList],
@@ -42,18 +43,9 @@ function renderToast(state, screen) {
42
43
  return;
43
44
  }
44
45
  const styles = getExplorerStyles();
45
- const message = fit(` ${state.toast.message} `, screen.width);
46
+ const message = fitToWidth(` ${state.toast.message} `, screen.width);
46
47
  screen.put(0, y, message, styles.accent);
47
48
  }
48
- function fit(text, width) {
49
- if (width <= 0) {
50
- return "";
51
- }
52
- if (text.length <= width) {
53
- return text;
54
- }
55
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
56
- }
57
49
  export { renderDetail } from "./detail.js";
58
50
  export { renderFooter } from "./footer.js";
59
51
  export { renderHeader } from "./header.js";
@@ -1,4 +1,5 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
+ import { cellWidth, centerCells, fitToWidth, splitGraphemeCells, stripAnsi } from "./text.js";
2
3
  const listLineCache = new WeakMap();
3
4
  export function renderList(state, screen, layout) {
4
5
  const styles = getExplorerStyles();
@@ -17,7 +18,7 @@ export function renderList(state, screen, layout) {
17
18
  listLineCache.set(screen, { rectKey, lines: cache });
18
19
  if (state.filtered.length === 0) {
19
20
  const hint = state.emptyHint;
20
- writeLine(screen, rect, Math.floor(rect.height / 2), center(hint, rect.width), styles.muted);
21
+ writeLine(screen, rect, Math.floor(rect.height / 2), centerCells(hint, rect.width, rect.x), styles.muted);
21
22
  cache.clear();
22
23
  return;
23
24
  }
@@ -43,7 +44,7 @@ export function renderList(state, screen, layout) {
43
44
  if (y >= rect.height) {
44
45
  break;
45
46
  }
46
- const selected = state.selected.has(row.id);
47
+ const selected = state.multiSelect && state.selected.has(row.id);
47
48
  const cursor = rowIndex === state.filtered[state.cursor];
48
49
  const positions = state.matchPositions.get(rowIndex) ?? [];
49
50
  const hash = lineHash(row, selected, cursor, positions);
@@ -67,40 +68,49 @@ function renderRow(screen, rect, rowY, row, opts) {
67
68
  const marker = opts.selected ? "┃" : " ";
68
69
  const cursor = opts.cursor ? "●" : "◌";
69
70
  const focus = opts.cursor && opts.focused ? " ▌" : "";
70
- const badge = row.badge ? ` ${row.badge.text}` : "";
71
71
  const prefix = `${marker} ${cursor} `;
72
- const available = Math.max(0, rect.width - prefix.length - focus.length - badge.length);
73
- const title = stripAnsi(row.title).slice(0, available);
72
+ const prefixWidth = cellWidth(prefix, rect.x);
73
+ const focusWidth = cellWidth(focus);
74
+ const badge = row.badge
75
+ ? fitToWidth(` ${row.badge.text}`, Math.max(0, rect.width - prefixWidth - focusWidth), rect.x + prefixWidth)
76
+ : "";
77
+ const badgeWidth = cellWidth(badge);
78
+ const available = Math.max(0, rect.width - prefixWidth - focusWidth - badgeWidth);
79
+ const rawTitle = stripAnsi(row.title);
80
+ const titleX = rect.x + prefixWidth;
81
+ const title = fitToWidth(rawTitle, available, titleX);
82
+ const titleWasTruncated = cellWidth(rawTitle, titleX) > available;
83
+ const positions = new Set(opts.positions);
74
84
  let x = rect.x;
75
85
  const y = rect.y + rowY;
76
86
  screen.put(x, y, prefix, opts.cursor ? styles.accent : styles.muted);
77
- x += prefix.length;
78
- for (let index = 0; index < title.length; index += 1) {
79
- const style = opts.positions.includes(index) ? styles.matchHighlight : {};
80
- screen.put(x + index, y, title[index], style);
87
+ x += prefixWidth;
88
+ for (const segment of splitGraphemeCells(title, x)) {
89
+ const isTruncationMarker = titleWasTruncated && segment.end === title.length && segment.value === "…";
90
+ const style = !isTruncationMarker && hasMatchPosition(segment.start, segment.end, positions)
91
+ ? styles.matchHighlight
92
+ : {};
93
+ screen.put(x, y, segment.value, style);
94
+ x += segment.width;
81
95
  }
82
96
  if (row.badge) {
83
- screen.put(rect.x + rect.width - badge.length - focus.length, y, badge, styles.tones[row.badge.tone ?? "muted"]);
97
+ screen.put(rect.x + rect.width - badgeWidth - focusWidth, y, badge, styles.tones[row.badge.tone ?? "muted"]);
84
98
  }
85
99
  if (focus) {
86
- screen.put(rect.x + rect.width - focus.length, y, focus, styles.borderFocused);
100
+ screen.put(rect.x + rect.width - focusWidth, y, focus, styles.borderFocused);
87
101
  }
88
102
  }
89
103
  function writeLine(screen, rect, row, text, style = {}) {
90
- screen.put(rect.x, rect.y + row, fit(text, rect.width), style);
104
+ screen.put(rect.x, rect.y + row, fitToWidth(text, rect.width, rect.x), style);
91
105
  }
92
106
  function lineHash(row, selected, cursor, positions) {
93
107
  return `${row.id}:${selected ? 1 : 0}:${cursor ? 1 : 0}:${positions.join(",")}`;
94
108
  }
95
- function center(text, width) {
96
- return `${" ".repeat(Math.max(0, Math.floor((width - text.length) / 2)))}${text}`;
97
- }
98
- function fit(text, width) {
99
- if (text.length <= width) {
100
- return text;
109
+ function hasMatchPosition(start, end, positions) {
110
+ for (let position = start; position < end; position += 1) {
111
+ if (positions.has(position)) {
112
+ return true;
113
+ }
101
114
  }
102
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
103
- }
104
- function stripAnsi(value) {
105
- return value.replace(/\u001b\[[0-9;]*m/g, "");
115
+ return false;
106
116
  }
@@ -1,4 +1,5 @@
1
1
  import { getExplorerStyles } from "../theme.js";
2
+ import { fitToWidth, padEndCells } from "./text.js";
2
3
  export function renderModal(state, screen) {
3
4
  if (state.modal === null || screen.width <= 0 || screen.height <= 0) {
4
5
  return;
@@ -11,7 +12,7 @@ export function renderModal(state, screen) {
11
12
  drawBox(screen, x, y, width, height, title(state), styles.borderFocused);
12
13
  const lines = modalLines(state);
13
14
  for (let row = 0; row < Math.min(lines.length, height - 2); row += 1) {
14
- screen.put(x + 2, y + 1 + row, fit(lines[row], width - 4), row === 1 ? styles.accent : {});
15
+ screen.put(x + 2, y + 1 + row, fitToWidth(lines[row], width - 4, x + 2), row === 1 ? styles.accent : {});
15
16
  }
16
17
  }
17
18
  function modalLines(state) {
@@ -54,8 +55,9 @@ function modalHeight(state) {
54
55
  }
55
56
  function drawBox(screen, x, y, width, height, boxTitle, style) {
56
57
  screen.clearRect({ x, y, width, height });
57
- const titleSegment = `─ ${boxTitle} `;
58
- screen.put(x, y, `╭${titleSegment}${"─".repeat(Math.max(0, width - titleSegment.length - 2))}╮`, style);
58
+ const innerWidth = Math.max(0, width - 2);
59
+ const titleSegment = fitToWidth(`─ ${boxTitle} `, innerWidth, x + 1);
60
+ screen.put(x, y, `╭${padEndCells(titleSegment, innerWidth, "─", x + 1)}╮`, style);
59
61
  for (let row = 1; row < height - 1; row += 1) {
60
62
  screen.put(x, y + row, "│", style);
61
63
  screen.put(x + width - 1, y + row, "│", style);
@@ -83,9 +85,3 @@ function paletteLines(state) {
83
85
  function labelFor(action) {
84
86
  return typeof action.label === "function" ? action.label() : action.label;
85
87
  }
86
- function fit(text, width) {
87
- if (text.length <= width) {
88
- return text;
89
- }
90
- return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`;
91
- }
@@ -0,0 +1,12 @@
1
+ export interface GraphemeCell {
2
+ value: string;
3
+ start: number;
4
+ end: number;
5
+ width: number;
6
+ }
7
+ export declare function cellWidth(value: string, startColumn?: number): number;
8
+ export declare function fitToWidth(text: string, width: number, startColumn?: number): string;
9
+ export declare function centerCells(text: string, width: number, startColumn?: number): string;
10
+ export declare function padEndCells(text: string, width: number, fill?: string, startColumn?: number): string;
11
+ export declare function splitGraphemeCells(value: string, startColumn?: number): GraphemeCell[];
12
+ export declare function stripAnsi(value: string): string;
@@ -0,0 +1,81 @@
1
+ import { displayWidth, graphemes } from "../../dashboard/terminal-width.js";
2
+ const ELLIPSIS = "…";
3
+ export function cellWidth(value, startColumn = 0) {
4
+ return displayWidth(value, startColumn);
5
+ }
6
+ export function fitToWidth(text, width, startColumn = 0) {
7
+ if (width <= 0) {
8
+ return "";
9
+ }
10
+ if (cellWidth(text, startColumn) <= width) {
11
+ return text;
12
+ }
13
+ const ellipsisWidth = cellWidth(ELLIPSIS, startColumn);
14
+ if (ellipsisWidth > width) {
15
+ return takeCells(text, width, startColumn);
16
+ }
17
+ const prefix = takeCells(text, width - ellipsisWidth, startColumn);
18
+ return `${prefix}${ELLIPSIS}`;
19
+ }
20
+ export function centerCells(text, width, startColumn = 0) {
21
+ const fitted = fitToWidth(text, width, startColumn);
22
+ const padding = Math.max(0, Math.floor((width - cellWidth(fitted, startColumn)) / 2));
23
+ return `${" ".repeat(padding)}${fitted}`;
24
+ }
25
+ export function padEndCells(text, width, fill = " ", startColumn = 0) {
26
+ let output = takeCells(text, width, startColumn);
27
+ let used = cellWidth(output, startColumn);
28
+ let column = startColumn + used;
29
+ while (used < width) {
30
+ const fillWidth = cellWidth(fill, column);
31
+ if (fill.length === 0 || fillWidth <= 0 || used + fillWidth > width) {
32
+ const spaces = width - used;
33
+ output += " ".repeat(spaces);
34
+ used += spaces;
35
+ column += spaces;
36
+ continue;
37
+ }
38
+ output += fill;
39
+ used += fillWidth;
40
+ column += fillWidth;
41
+ }
42
+ return output;
43
+ }
44
+ export function splitGraphemeCells(value, startColumn = 0) {
45
+ const cells = [];
46
+ let offset = 0;
47
+ let column = startColumn;
48
+ for (const segment of graphemes(value)) {
49
+ const width = cellWidth(segment, column);
50
+ cells.push({
51
+ value: segment,
52
+ start: offset,
53
+ end: offset + segment.length,
54
+ width
55
+ });
56
+ offset += segment.length;
57
+ column += width;
58
+ }
59
+ return cells;
60
+ }
61
+ export function stripAnsi(value) {
62
+ return value.replace(/\u001b\[[0-9;]*m/g, "");
63
+ }
64
+ function takeCells(text, width, startColumn) {
65
+ if (width <= 0) {
66
+ return "";
67
+ }
68
+ let output = "";
69
+ let used = 0;
70
+ let column = startColumn;
71
+ for (const segment of graphemes(text)) {
72
+ const segmentWidth = cellWidth(segment, column);
73
+ if (used + segmentWidth > width) {
74
+ break;
75
+ }
76
+ output += segment;
77
+ used += segmentWidth;
78
+ column += segmentWidth;
79
+ }
80
+ return output;
81
+ }
@@ -102,6 +102,7 @@ export interface ExplorerState {
102
102
  loading: boolean;
103
103
  };
104
104
  selected: Set<string>;
105
+ multiSelect: boolean;
105
106
  modal: null | {
106
107
  kind: "help";
107
108
  } | {
@@ -16,6 +16,7 @@ export function createInitialState(config, size) {
16
16
  cols: normalizeSize(size.cols),
17
17
  rows: normalizeSize(size.rows)
18
18
  };
19
+ const multiSelect = config.multiSelect ?? true;
19
20
  return {
20
21
  title: config.title,
21
22
  emptyHint: config.emptyHint ?? "No detail",
@@ -35,6 +36,7 @@ export function createInitialState(config, size) {
35
36
  loading: false
36
37
  },
37
38
  selected: new Set(),
39
+ multiSelect,
38
40
  modal: null,
39
41
  toast: null,
40
42
  dirty: REGION_ALL,
@@ -95,12 +95,12 @@ export async function withSpinner(options) {
95
95
  const result = await fn();
96
96
  const msg = stopMessage ? stopMessage(result) : undefined;
97
97
  if (msg) {
98
- process.stdout.write(`\x1b[32m◆\x1b[0m ${msg}\n`);
98
+ process.stdout.write(`${color.green("◆")} ${msg}\n`);
99
99
  }
100
100
  const sub = subtext ? subtext(result) : undefined;
101
101
  if (sub) {
102
102
  for (const line of sub.split("\n")) {
103
- process.stdout.write(`\x1b[90m│\x1b[0m ${line}\n`);
103
+ process.stdout.write(`${color.gray("│")} ${line}\n`);
104
104
  }
105
105
  }
106
106
  return result;
@@ -120,7 +120,7 @@ export async function withSpinner(options) {
120
120
  const sub = subtext ? subtext(result) : undefined;
121
121
  if (sub) {
122
122
  for (const line of sub.split("\n")) {
123
- process.stdout.write(`\x1b[90m│\x1b[0m ${line}\n`);
123
+ process.stdout.write(`${color.gray("│")} ${line}\n`);
124
124
  }
125
125
  }
126
126
  return result;
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
- import { readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { readdir, readFile, realpath, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { buildDockerEnvArgs, buildDockerRunArgs } from "./args.js";
@@ -183,8 +183,7 @@ export async function buildDockerRuntimeTemplate(input) {
183
183
  const runner = input.runner ?? createHostRunner();
184
184
  const engine = input.runtime.engine ?? detectEngine();
185
185
  const context = detectContext();
186
- const dockerfilePath = path.resolve(input.cwd, input.runtime.dockerfile ?? path.join(".poe-code", "Dockerfile"));
187
- const buildContext = path.resolve(input.cwd, input.runtime.build_context ?? ".");
186
+ const { dockerfilePath, buildContext } = await resolveRuntimeBuildPaths(input.cwd, input.runtime);
188
187
  const dockerfileBytes = await readFile(dockerfilePath);
189
188
  const buildContextFiles = await readBuildContextFiles(buildContext);
190
189
  const hash = hashDockerTemplate(dockerfileBytes, buildContextFiles, input.runtime.build_args ?? {}, engine);
@@ -221,6 +220,28 @@ export async function buildDockerRuntimeTemplate(input) {
221
220
  cached: false
222
221
  };
223
222
  }
223
+ async function resolveRuntimeBuildPaths(cwd, runtime) {
224
+ const dockerfilePath = path.resolve(cwd, runtime.dockerfile ?? path.join(".poe-code", "Dockerfile"));
225
+ const buildContext = path.resolve(cwd, runtime.build_context ?? ".");
226
+ const canonicalCwd = await realpath(cwd);
227
+ const canonicalDockerfilePath = await realpath(dockerfilePath);
228
+ const canonicalBuildContext = await realpath(buildContext);
229
+ assertRuntimePathInsideCwd(canonicalCwd, canonicalDockerfilePath, "runtime.dockerfile");
230
+ assertRuntimePathInsideCwd(canonicalCwd, canonicalBuildContext, "runtime.build_context");
231
+ return {
232
+ dockerfilePath: canonicalDockerfilePath,
233
+ buildContext: canonicalBuildContext
234
+ };
235
+ }
236
+ function assertRuntimePathInsideCwd(cwd, targetPath, fieldName) {
237
+ if (!isPathInsideOrEqual(cwd, targetPath)) {
238
+ throw new Error(`${fieldName} must remain inside runtime cwd ${cwd}.`);
239
+ }
240
+ }
241
+ function isPathInsideOrEqual(rootPath, targetPath) {
242
+ const relativePath = path.relative(rootPath, targetPath);
243
+ return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
244
+ }
224
245
  function hashDockerTemplate(dockerfileBytes, buildContextFiles, buildArgs, engine) {
225
246
  const hash = createHash("sha256");
226
247
  hash.update(dockerfileBytes);