@vincemakes/kiso-tui-cells 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -306,8 +306,18 @@ export declare function diffBody(diff: import("./diff.js").DiffLine[] | null, W:
306
306
  export declare const CAP_TASK_LIVE = 6;
307
307
  /** W20 — the settled block's duration, the `2h 14m` form (the task
308
308
  * narrative's long-horizon idiom): minutes+seconds under an hour,
309
- * hours+minutes past it. */
309
+ * hours+minutes past it. Unchanged. */
310
310
  export declare function formatDuration(totalSeconds: number): string;
311
+ /** The LIVE elapsed label — every place a duration is shown while it is
312
+ * still running, and on the card that settles from it, so a card and the
313
+ * status row can never disagree.
314
+ *
315
+ * It replaced the hand-written second counts, which is how the status
316
+ * row came to read "working 637s": ten minutes as a four-figure number,
317
+ * with no branch anywhere that said otherwise. Past an hour it keeps
318
+ * seconds, because a clock the user is watching tick should not stop
319
+ * ticking. */
320
+ export declare function elapsedLabel(totalSeconds: number): string;
311
321
  /** The status container's row: the status text (+ the tail) with the
312
322
  * right-aligned "/ commands · ↑ history" hint in the idle state —
313
323
  * the hint CUT FIRST when the width is short (the #16g rule); when
@@ -823,7 +823,7 @@ class ToolExecution {
823
823
  // rewrites it in place: `3s · esc stops` → `exit 0 · 90 lines ·
824
824
  // 3.2s`.
825
825
  const gestures = c.name === "shell" ? " · esc stops · alt+⏎ redirects" : "";
826
- const status = pickTier([`${elapsed}s${gestures}`, `${elapsed}s`], Math.max(1, W - visibleWidth(noteIndent())));
826
+ const status = pickTier([`${elapsedLabel(elapsed)}${gestures}`, elapsedLabel(elapsed)], Math.max(1, W - visibleWidth(noteIndent())));
827
827
  const live = toolBlockBody(c, W, ctx);
828
828
  if (live.length > 0)
829
829
  return slabBlock(gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, W)[0], live, status, W);
@@ -846,7 +846,7 @@ class ToolExecution {
846
846
  // way so the COMMAND keeps something to say, and the elapsed —
847
847
  // pin 4's core — never does.
848
848
  const MIN_TARGET = 10; // the gutter, the verb column, a character of command
849
- const oneRow = pickTier([`${elapsed}s${gestures}`, `${elapsed}s`], Math.max(1, W - MIN_TARGET - 3));
849
+ const oneRow = pickTier([`${elapsedLabel(elapsed)}${gestures}`, elapsedLabel(elapsed)], Math.max(1, W - MIN_TARGET - 3));
850
850
  const room = Math.max(4, W - visibleWidth(oneRow) - 3);
851
851
  const only = gutterCut(gutter, `${verbCol} ${liveTarget(c)}`, room)[0];
852
852
  return slabBlock(`${only}${p.dim} · ${oneRow}${p.reset}`, [], null, W);
@@ -854,7 +854,10 @@ class ToolExecution {
854
854
  // W2: ◦ replaces → for QUEUED — · is the separator inside every
855
855
  // metadata group; a queued marker that is also the separator
856
856
  // glyph reads as noise
857
- return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${liveTarget(c)}`, W);
857
+ // The `◦` carried "not started yet" ALONE, and a marker is not a
858
+ // word: on a screen where running and queued rows sit together, a
859
+ // reader has to already know the glyph. The suffix says it.
860
+ return gutterCut(`${p.dim}◦${p.reset} `, `${verbCol} ${liveTarget(c)}${p.dim} · queued${p.reset}`, W);
858
861
  }
859
862
  }
860
863
  // ---- TUI2-R1 (A): the self-naming expand affordance ----
@@ -1780,15 +1783,42 @@ class Banner {
1780
1783
  * done-collapse. Every live row CUTS at W (never folds) — the block's
1781
1784
  * height is its row count. */
1782
1785
  export const CAP_TASK_LIVE = 6;
1783
- /** W20 — the settled block's duration, the `2h 14m` form (the task
1784
- * narrative's long-horizon idiom): minutes+seconds under an hour,
1785
- * hours+minutes past it. */
1786
- export function formatDuration(totalSeconds) {
1786
+ /** The two duration idioms, from ONE implementation.
1787
+ *
1788
+ * They agree in every branch but the hour: W20's settled task block says
1789
+ * `2h 14m` (a long-horizon narrative does not care about seconds), and a
1790
+ * LIVE elapsed label says `1h 2m 3s` (a running clock does). A near-copy
1791
+ * differing in one branch is the drift that a shared helper exists to
1792
+ * prevent, so the branch is a parameter.
1793
+ *
1794
+ * Negative is clamped: a clock skew is not a negative duration. */
1795
+ function duration(totalSeconds, hoursKeepSeconds) {
1787
1796
  const s = Math.max(0, Math.round(totalSeconds));
1788
1797
  if (s < 60)
1789
1798
  return `${s}s`;
1790
1799
  const m = Math.floor(s / 60);
1791
- return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
1800
+ if (m < 60)
1801
+ return `${m}m ${s % 60}s`;
1802
+ const h = Math.floor(m / 60);
1803
+ return hoursKeepSeconds ? `${h}h ${m % 60}m ${s % 60}s` : `${h}h ${m % 60}m`;
1804
+ }
1805
+ /** W20 — the settled block's duration, the `2h 14m` form (the task
1806
+ * narrative's long-horizon idiom): minutes+seconds under an hour,
1807
+ * hours+minutes past it. Unchanged. */
1808
+ export function formatDuration(totalSeconds) {
1809
+ return duration(totalSeconds, false);
1810
+ }
1811
+ /** The LIVE elapsed label — every place a duration is shown while it is
1812
+ * still running, and on the card that settles from it, so a card and the
1813
+ * status row can never disagree.
1814
+ *
1815
+ * It replaced the hand-written second counts, which is how the status
1816
+ * row came to read "working 637s": ten minutes as a four-figure number,
1817
+ * with no branch anywhere that said otherwise. Past an hour it keeps
1818
+ * seconds, because a clock the user is watching tick should not stop
1819
+ * ticking. */
1820
+ export function elapsedLabel(totalSeconds) {
1821
+ return duration(totalSeconds, true);
1792
1822
  }
1793
1823
  /**
1794
1824
  * W20 — the task checklist as STATE: ONE live block that redraws in
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * package); the cli never imports it directly. Experimental — no
7
7
  * API-stability promise yet.
8
8
  */
9
- export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, foldTerms, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
9
+ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, foldTerms, CAP_TASK_LIVE, elapsedLabel, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, type FrameCtx, type RenderLine, type Component, type BodyCell, } from "./components.js";
10
10
  export { editFileDiff, truncateDiff, writeFileDiff, type DiffLine, type DiffResult } from "./diff.js";
11
11
  export { pendingQueueRows } from "./components.js";
12
12
  export { charWidth, displayWidth, leadWidth, widthOf } from "./width.js";
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * package); the cli never imports it directly. Experimental — no
7
7
  * API-stability promise yet.
8
8
  */
9
- export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, foldTerms, CAP_TASK_LIVE, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
9
+ export { SPINNER, foldLine, foldWords, visibleWidth, bodySpacing, Container, cellComponent, foldTerms, CAP_TASK_LIVE, elapsedLabel, formatDuration, statusLine, boxTop, boxBottom, terminalPipe, } from "./components.js";
10
10
  export { editFileDiff, truncateDiff, writeFileDiff } from "./diff.js";
11
11
  // W22 (the v8 input round): the pending-queue chips — the SAME
12
12
  // UserMessage chip with the □ gutter, pre-rendered above the input
package/dist/md.js CHANGED
@@ -713,35 +713,36 @@ function tableRows(b, W) {
713
713
  if (cols === null)
714
714
  return recordRows(t, W);
715
715
  const p = palette();
716
- // R2: no rails. The drawn width is two columns of inset plus the
717
- // columns and their two-space gutters — a table is bounded by the
718
- // blank lines above and below it, exactly as every other block on the
719
- // screen is, and it was the last box left on a screen that has decided
720
- // not to have boxes. Alignment does the work the rails were doing, and
721
- // a copied table is closer to markdown without them.
716
+ // DECLARED REVERSAL (2026-09-17), TABLES ONLY. R2 removed the rails at
717
+ // the nineteen-screen review and MD-1.3 put ONE rule under the header;
718
+ // both are superseded here. Every other R2 hairline rule stands, and
719
+ // the record form keeps NO borders — it is the fallback, not a table.
720
+ //
721
+ // The rails are hairlines in the R2 hairline colour, and the styling
722
+ // goes on AFTER the measure, exactly as it does for a cell: a colour
723
+ // can never move a column.
724
+ const rule = (l, m, r) => `${p.dim}${l}${cols.map((w) => "\u2500".repeat(w + 2)).join(m)}${r}${p.reset}`;
725
+ const V = `${p.dim}\u2502${p.reset}`;
722
726
  const row = (cells, bold) => {
723
727
  const boxes = cells.map((c, i) => cellBox(c, cols[i], t.align[i], bold));
724
728
  const rows = [];
725
729
  for (let k = 0; k < Math.max(...boxes.map((x) => x.length)); k += 1) {
726
730
  // a short box pays its blanks so the columns to its right do not
727
731
  // move: a cell is a BOX, and the row is as tall as its tallest.
728
- rows.push(` ${boxes.map((x, i) => x[k] ?? " ".repeat(cols[i])).join(" ")}`.replace(/\s+$/, ""));
732
+ // Trailing space is NOT stripped any more — it sits inside the
733
+ // closing rail, and stripping it would pull the rail left.
734
+ rows.push(`${V}${boxes.map((x, i) => ` ${x[k] ?? " ".repeat(cols[i])} `).join(V)}${V}`);
729
735
  }
730
736
  return rows;
731
737
  };
732
- // MD-1.3 / R2 AMENDMENT 1 (owner ruling, 2026-09-11) — ONE rule under
733
- // the header row, at the grid's own width. R2 removed the RAILS: the
734
- // four-sided box that BOUNDS a table. This bounds nothing; it SEPARATES
735
- // the header from the body, which is the one job the round's governing
736
- // distinction gives a rule — a rule separates, a gutter scopes, a rail
737
- // bounds. Rails stay out.
738
- //
739
- // What it buys is not decoration: without it a six-row table's header
740
- // was carried by SGR bold ALONE, so in a pipe, under NO_COLOR, or on a
741
- // terminal with weak bold, seven identical rows arrived with nothing
742
- // saying which one names the columns.
743
- const ruleW = cols.reduce((n, w) => n + w, 0) + Math.max(0, cols.length - 1) * 2;
744
- return [...row(t.header, true), ` ${p.dim}${"\u2500".repeat(ruleW)}${p.reset}`, ...t.rows.flatMap((r) => row(r, false))];
738
+ const body = t.rows.flatMap((r, i) => (i === 0 ? row(r, false) : [rule("\u251c", "\u253c", "\u2524"), ...row(r, false)]));
739
+ return [
740
+ rule("\u250c", "\u252c", "\u2510"),
741
+ ...row(t.header, true),
742
+ rule("\u251c", "\u253c", "\u2524"),
743
+ ...body,
744
+ rule("\u2514", "\u2534", "\u2518"),
745
+ ];
745
746
  }
746
747
  /** A cell's column count: what a human sees, styling removed. */
747
748
  function cellWidth(cell) {
@@ -753,13 +754,22 @@ function cellWidth(cell) {
753
754
  * report's sample stopped reading as a table — and it is the one number
754
755
  * that decides when the record form is still the better answer. */
755
756
  const CELL_FLOOR = 8;
756
- /** The drawn width of a grid with these columns, by the measure the R2
757
- * table has always used: the two-column inset plus every column AND its
758
- * two-space gutter. Conservative by one gutter (the last column has
759
- * none), which is where the table's right margin comes from — kept as
760
- * it was, because the record threshold has always been stated in it. */
757
+ /** The drawn width of a grid with these columns.
758
+ *
759
+ * DECLARED REVERSAL (2026-09-17, tables only): the R2 measure was the
760
+ * two-column inset plus every column and its two-space gutter — the
761
+ * shape of a table with NO RAILS. With rails it is `sum + 3n + 1`: one
762
+ * rail between every pair of columns and one at each edge (n+1), and one
763
+ * space of padding inside every rail (2n).
764
+ *
765
+ * This function's old comment said the record threshold "has always been
766
+ * stated in it", and that is exactly why changing it IS the re-cut: the
767
+ * grid costs n−1 columns more than the railless form — 2 for a
768
+ * three-column table, 6 for a seven-column one — and the record fallback
769
+ * arrives that much earlier. Nothing else moves: the greedy shrink, the
770
+ * CELL_FLOOR of 8 and the fallback criterion are untouched. */
761
771
  function gridWidth(cols) {
762
- return cols.reduce((n, w) => n + w + 2, 2);
772
+ return cols.reduce((n, w) => n + w, 0) + 3 * cols.length + 1;
763
773
  }
764
774
  /**
765
775
  * MD-1.1 — take one column off the WIDEST column until the grid fits.
package/dist/render.js CHANGED
@@ -220,8 +220,14 @@ function toolSummaryDetail(name, input, result) {
220
220
  const exit = exitCodeOf(result);
221
221
  return `${command} (exit ${exit})`;
222
222
  }
223
- case "list_dir":
224
- return String(input.path ?? "(root)");
223
+ case "list_dir": {
224
+ // "(root)" already answered an ABSENT path. A model that sends
225
+ // "." explicitly is making the same request and was getting the
226
+ // dot on the row. `./` likewise. Anything else — including `..`
227
+ // and a dotfile directory like `.github` — is a real path.
228
+ const dir = String(input.path ?? ".");
229
+ return dir === "." || dir === "./" ? "(root)" : dir;
230
+ }
225
231
  case "search_text":
226
232
  return searchSubject(input);
227
233
  default:
@@ -261,8 +267,14 @@ function toolTargetRaw(name, input) {
261
267
  return String(input.path ?? "?");
262
268
  case "shell":
263
269
  return String(input.command ?? "?");
264
- case "list_dir":
265
- return String(input.path ?? "(root)");
270
+ case "list_dir": {
271
+ // "(root)" already answered an ABSENT path. A model that sends
272
+ // "." explicitly is making the same request and was getting the
273
+ // dot on the row. `./` likewise. Anything else — including `..`
274
+ // and a dotfile directory like `.github` — is a real path.
275
+ const dir = String(input.path ?? ".");
276
+ return dir === "." || dir === "./" ? "(root)" : dir;
277
+ }
266
278
  case "search_text":
267
279
  return searchSubject(input);
268
280
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-tui-cells",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "kiso tui-cells \u2014 the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
5
5
  "type": "module",
6
6
  "license": "MIT",