Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions admin/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,8 +1104,8 @@ function renderPanel(snapshot: any, width: number, state: any, styler: any): str
// coloured degrade would print `[31m` as text AND charge four phantom columns against the width. The
// answer then is an ANSI-aware cut, not this one.
//
// The width is still a UTF-16 count, which is wrong for CJK and combining marks exactly as it is
// everywhere else in this module. That is #401 and is not made worse here.
// The width is a COLUMN count under issue #401: `clipData` cuts through the one table in `panel.mjs`, so
// this branch is right about CJK, fullwidth and combining content for the same reason the framed pane is.
const w = degradeWidth(width);
return w === null ? lines : lines.map((l) => clipData(l, w));
}
Expand Down
9 changes: 7 additions & 2 deletions admin/src/graph-model.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
// SKILL_NAME_RE is a plain frozen RegExp; importing it keeps the charset single-sourced (the
// issue #92 lesson) without breaking this module's purity -- nothing here spawns or reads anything.
import { SKILL_NAME_RE } from "@edgehero/pi-dispatch/flow-gate";
// Pure-to-pure: `panel.mjs` is the admin's no-I/O module and owns the one answer to "how do you cut a
// string without leaving half a character behind" (issue #401).
import { dropLoneSurrogate } from "./panel.mjs";

// One frontmatter value line: `key: value`, an optional surrounding double quote, single-line only.
// The same block-isolation discipline as flow-gate.mjs's aiTriggerAllows, and deliberately NOT a YAML
Expand Down Expand Up @@ -41,7 +44,9 @@ function frontmatterValue(block, key) {
let value = m[1].trim();
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
if (value === "") return null;
return value.length > META_VALUE_MAX_CHARS ? `${value.slice(0, META_VALUE_MAX_CHARS)}…` : value;
// The cap is a CHARACTER cap, not a width, so a code-unit slice is the right shape here -- but it can
// still land between the halves of an astral pair, and half a pair is not a character (issue #401).
return value.length > META_VALUE_MAX_CHARS ? `${dropLoneSurrogate(value.slice(0, META_VALUE_MAX_CHARS))}…` : value;
}

// A mention is "strong" when it sits near chaining vocabulary -- the outbox protocol's own words.
Expand Down Expand Up @@ -667,5 +672,5 @@ function basenameOf(path) {
/** Clip an arbitrary (possibly hostile) flow string for node display; the honest badge needs the name. */
function clipName(name) {
const s = String(name);
return s.length > 64 ? `${s.slice(0, 64)}…` : s;
return s.length > 64 ? `${dropLoneSurrogate(s.slice(0, 64))}…` : s;
}
345 changes: 323 additions & 22 deletions admin/src/panel.mjs

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions admin/src/render.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { windowState } from "@edgehero/pi-dispatch/budget";
// Pure-to-pure, the same standing as the windowState import above: panel.mjs is the admin's other no-I/O
// text module (asserted so by panel.test.mjs), and fmtCost is THE single renderer of typed cost values,
// so the what-if below routes every dollar through it rather than grow a second money formatter here.
import { fmtCost, scrubControls } from "./panel.mjs";
import { columnsOf, fmtCost, pad, scrubControls } from "./panel.mjs";
// The overlay keys, IMPORTED rather than retyped. This was a verbatim copy of the worker's array, in the
// order the worker declares them, and the worker's side is pinned while this side was not -- so a key
// added there would have failed a test, been added, and left the settings VIEW silently ten keys wide
Expand Down Expand Up @@ -125,8 +125,11 @@ export function renderRuns(runs) {
// instead of each derive means a column added later cannot reintroduce this, and it costs nothing on
// the `-` and `r1/2` shapes a derive normally produces.
const rows = list.map((r) => RUN_COLUMNS.map((c) => cell(c.derive ? c.derive(r) : r?.[c.key])));
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i].length)));
const fmt = (cells) => cells.map((v, i) => v.padEnd(widths[i])).join(" ").trimEnd();
// COLUMNS, not code units (issue #401), and this table is the MODEL-visible channel rather than a pane.
// `target` is `local:<basename>` for a local run, so an operator's own folder name reaches it, and one
// CJK character there shifted every later column of that row against the rows around it.
const widths = headers.map((h, i) => Math.max(columnsOf(h), ...rows.map((row) => columnsOf(row[i]))));
const fmt = (cells) => cells.map((v, i) => pad(v, widths[i])).join(" ").trimEnd();
return [fmt(headers), ...rows.map(fmt)].join("\n");
}

Expand Down
63 changes: 43 additions & 20 deletions admin/src/style.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,27 @@
* can assert both the plain content and the width math without a real terminal.
*/

import { LINE_INPUT_CURSOR, dropLoneSurrogate, fmtCost as plainFmtCost, scrubControls, scrubKeepingStyle, sparkline as plainSparkline } from "./panel.mjs";
import { LINE_INPUT_CURSOR, columnsOf, fmtCost as plainFmtCost, scrubControls, scrubKeepingStyle, sliceColumns, sparkline as plainSparkline } from "./panel.mjs";

// Strip SGR (and OSC-8 hyperlink) escapes to recover the visible text / column count. Content is
// ASCII + box-drawing + a handful of width-1 glyphs, so post-strip `.length` is a safe column proxy.
// Strip SGR (and OSC-8 hyperlink) escapes to recover the visible text, which is then measured in COLUMNS.
// This comment used to end "so post-strip `.length` is a safe column proxy", and issue #401 is what that
// sentence cost: content is not ASCII, a repository name or a job id carries whatever the forge allows, and
// a CJK one measured 80 here while the terminal drew 90.
const ANSI = /\x1b\[[0-9;]*m|\x1b\]8;;[^\x07]*\x07/g;

export function stripAnsi(s) {
return String(s ?? "").replace(ANSI, "");
}

/** Visible column count of a (possibly colored) string. */
/**
* Visible COLUMN count of a (possibly colored) string -- not its code-unit length (issue #401).
*
* Through `panel.mjs`'s table, so the framed pane and the monochrome one measure the same string the same
* way. They draw the same geometry, and a width rule that holds in one and not the other is how a frame
* ends up ten columns wider than the line above it.
*/
export function visibleLen(s) {
return stripAnsi(s).length;
return columnsOf(stripAnsi(s));
}

/** A no-op theme: `fg`/`bg`/`bold`/… return the text unchanged. Used in tests and when no TUI theme exists. */
Expand Down Expand Up @@ -96,17 +104,27 @@ export function makeStyler(theme, { ascii = false } = {}) {
const cell = (text, width, { color = null, align = "left", strong = false } = {}) => {
const w = Math.max(0, Math.trunc(width) || 0);
let plain = scrubControls(stripAnsi(String(text ?? "")));
// `dropLoneSurrogate` on every cut, like `clip`: slicing UTF-16 units can land between the halves of an
// astral character, and half a pair is not a character. Measured at 89 lines of a framed LIST printing
// one, from a target field of emoji -- the first repair reached `clip` alone and three other cutters
// slice the same way.
if (plain.length > w) plain = w <= G.ellipsis.length ? dropLoneSurrogate(plain.slice(0, w)) : dropLoneSurrogate(plain.slice(0, w - G.ellipsis.length)) + G.ellipsis;
plain = align === "right" ? plain.padStart(w) : plain.padEnd(w);
// BY COLUMNS, not by code units (issue #401), and through `sliceColumns`, which walks whole characters.
// This used to call `dropLoneSurrogate` after a UTF-16 slice, a repair measured at 89 lines of a framed
// LIST printing half a surrogate pair from a target field of emoji. Cutting by character makes that
// repair unnecessary rather than merely correct: the cut also never lands inside a TWO-COLUMN
// character, which a terminal draws as one blank column plus one of overflow.
if (columnsOf(plain) > w) {
const ell = G.ellipsis;
// The narrow branch slices the CONTENT, not the ellipsis, which is what this line did before #401 and
// what `clipPlain` below still does. `panel.mjs`'s `clip` shows the ellipsis instead at such a width.
// That disagreement is pre-existing and left alone here: the three cutters differ only where the
// budget is narrower than the ellipsis glyph itself, and changing which one is right is a question
// about what to show, not about how wide it is.
plain = w <= columnsOf(ell) ? sliceColumns(plain, w) : sliceColumns(plain, w - columnsOf(ell)) + ell;
}
const gap = " ".repeat(Math.max(0, w - columnsOf(plain)));
plain = align === "right" ? gap + plain : plain + gap;
let out = color ? fg(color, plain) : plain;
return strong ? bold(out) : out;
};

/** A small colored token (no padding). Visible width === label.length (+ padding if `pad`). */
/** A small colored token (no padding). Visible width is `columnsOf(label)`, plus 2 if `pad`. */
const badge = (label, color, { pad = false } = {}) => {
const text = pad ? ` ${label} ` : String(label);
return fg(color, text);
Expand Down Expand Up @@ -154,9 +172,9 @@ export function makeStyler(theme, { ascii = false } = {}) {
// without: clip the META first, and the LABEL only if it alone still does not fit. Getting this wrong by
// one is why the clamp existed in the first place -- `Math.max(1, ...)` hid the overflow instead of
// preventing it, and the line ran over its own width.
met = dropLoneSurrogate(met.slice(0, Math.max(0, w - lab.length - 3)));
const labClipped = dropLoneSurrogate(lab.slice(0, Math.max(0, w - met.length - (met ? 3 : 2))));
const ruleLen = Math.max(1, w - labClipped.length - met.length - (met ? 2 : 1));
met = sliceColumns(met, Math.max(0, w - columnsOf(lab) - 3));
const labClipped = sliceColumns(lab, Math.max(0, w - columnsOf(met) - (met ? 3 : 2)));
const ruleLen = Math.max(1, w - columnsOf(labClipped) - columnsOf(met) - (met ? 2 : 1));
const labPart = labClipped ? bold(fg("muted", labClipped)) + " " : "";
const rulePart = fg("border", G.h.repeat(ruleLen));
const metPart = met ? " " + fg("dim", met) : "";
Expand Down Expand Up @@ -234,7 +252,12 @@ export function frame(styler, { title = "", width = 40, lines = [], footer = nul
const out = [];

const titleText = title ? ` ${clipPlain(title, Math.max(0, inner - 2), G.ellipsis)} ` : "";
const topFill = Math.max(0, w - 2 - 1 - titleText.length);
// THE TOP RULE IS FILLED IN COLUMNS, not code units (issue #401). A CJK title is half as many code units
// as the terminal draws columns, so `.length` here over-filled the rule by the title's own width: a
// 20-column pane came out 23 wide on its FIRST line only, with every body line correct, which is the
// shape that hides such a bug. `titleText` is plain by construction (`clipPlain` strips), so the plain
// column count is the whole measurement and no ANSI-aware pass is needed.
const topFill = Math.max(0, w - 2 - 1 - styler.visibleLen(titleText));
out.push(B(G.tl + G.h) + styler.bold(styler.fg("accent", titleText)) + B(G.h.repeat(topFill) + G.tr));

const side = (content) => B(G.v) + " " + content + " " + B(G.v);
Expand Down Expand Up @@ -283,11 +306,11 @@ function padVisible(styler, line, width) {
* caller left the other carrying what the lines inside the frame no longer did. `panel.mjs`'s own `box`
* already titles through the same operation now, so the two frame builders agree on the CLASS -- which
* lives in `panel.mjs` and is imported, not respelled here (issue #382) -- AND on what to do with a match.
* Both SUBSTITUTE a space, because `frame` computes its top rule from the title's length at the call site
* and a deleting strip would silently change that arithmetic.
* Both SUBSTITUTE a space, because `frame` computes its top rule from the title's own width at the call
* site and a deleting strip would silently change that arithmetic.
*/
function clipPlain(s, width, ellipsis = "…") {
const plain = scrubControls(s);
if (plain.length <= width) return plain;
return width <= ellipsis.length ? dropLoneSurrogate(plain.slice(0, width)) : dropLoneSurrogate(plain.slice(0, width - ellipsis.length)) + ellipsis;
if (columnsOf(plain) <= width) return plain;
return width <= columnsOf(ellipsis) ? sliceColumns(plain, width) : sliceColumns(plain, width - columnsOf(ellipsis)) + ellipsis;
}
Loading
Loading