pi-crew 0.9.10 → 0.9.12

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.
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import type { ArtifactDescriptor } from "./types.ts";
5
5
  import { atomicWriteFile } from "./atomic-write.ts";
6
6
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
7
- import { redactSecretString } from "../utils/redaction.ts";
7
+ import { redactSecretString, redactSecrets } from "../utils/redaction.ts";
8
8
 
9
9
  function hashContent(content: string): string {
10
10
  return createHash("sha256").update(content).digest("hex");
@@ -127,7 +127,27 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
127
127
  const filePath = resolveInside(artifactsRoot, options.relativePath);
128
128
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
129
129
  resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
130
- const content = redactSecretString(options.content);
130
+ let content = options.content;
131
+ // Structural JSON redaction first — catches quoted-JSON secrets
132
+ // ("api_key":"sk-...") and nested keys that flat redactSecretString misses.
133
+ // The flat scan below still catches free-text patterns (Bearer/JWT/Auth
134
+ // headers) that may live inside JSON string values. See security review M2.
135
+ //
136
+ // Formatting preservation: re-stringify with the SAME indentation as the
137
+ // input so pretty-printed artifacts (e.g. group-join metadata expected by
138
+ // test/integration/phase4-runtime.test.ts to contain `"partial": false`)
139
+ // keep their whitespace. Detect pretty-vs-compact from the raw input.
140
+ const trimmed = content.trim();
141
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
142
+ try {
143
+ const parsed: unknown = JSON.parse(content);
144
+ const isPretty = /\n|"\s*:\s/.test(content);
145
+ content = JSON.stringify(redactSecrets(parsed), null, isPretty ? 2 : undefined);
146
+ } catch {
147
+ // not valid JSON — fall through to flat redaction only
148
+ }
149
+ }
150
+ content = redactSecretString(content);
131
151
  atomicWriteFile(filePath, content);
132
152
  // Compute hash on written bytes for integrity verification.
133
153
  // Read back the actual file content to handle atomicWrite fallback path
@@ -90,9 +90,9 @@ export class CrewFooter {
90
90
  ].join(" • ");
91
91
  const badges = this.data.badges?.length ? this.data.badges.map((badge) => `[${badge}]`).join(" ") : "";
92
92
  this.cacheLines = [
93
- this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth, "..."), lineWidth)),
94
- this.theme.fg("dim", pad(truncate(usageLine, lineWidth, "..."), lineWidth)),
95
- this.theme.fg("dim", pad(truncate(badges, lineWidth, "..."), lineWidth)),
93
+ this.theme.fg("dim", pad(truncate(firstParts.join(" • "), lineWidth), lineWidth)),
94
+ this.theme.fg("dim", pad(truncate(usageLine, lineWidth), lineWidth)),
95
+ this.theme.fg("dim", pad(truncate(badges, lineWidth), lineWidth)),
96
96
  ];
97
97
  this.cacheKey = key;
98
98
  this.cacheWidth = width;
@@ -86,7 +86,7 @@ export class CrewSelectList<T = string> {
86
86
  const suffix = item.description ? this.theme.fg("dim", ` — ${item.description}`) : "";
87
87
  const raw = `${prefix}${item.label}${suffix}`;
88
88
  const line = index === this.selectedIndex ? this.theme.inverse?.(raw) ?? raw : raw;
89
- lines.push(pad(truncate(line, width, "..."), Math.max(1, width)));
89
+ lines.push(pad(truncate(line, width), Math.max(1, width)));
90
90
  }
91
91
  if (hasBottom) lines.push(this.theme.fg("muted", `↓ ${this.items.length - (this.scrollOffset + slots)} more`));
92
92
  return lines.slice(0, maxHeight);
@@ -6,6 +6,28 @@ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
6
6
  import { formatCost } from "../../state/usage.ts";
7
7
  import { listLiveAgents, listLiveAgentsByWorkspace, type LiveAgentHandle } from "../../runtime/live-agent-manager.ts";
8
8
  import { computeLiveDurationMs } from "../live-duration.ts";
9
+ import { visibleWidth } from "../../utils/visual.ts";
10
+
11
+ /**
12
+ * Fixed visible widths for the per-agent numeric metrics (finding V-1).
13
+ * Right-aligning each field to a constant width stops the right edge of the
14
+ * stats block from jittering every tick as values change width
15
+ * (e.g. `9.9s`→`10.0s`, `950`→`1.0k`, `$0.9`→`$1.0`).
16
+ */
17
+ const TOKENS_METRIC_WIDTH = 5; // `1.2k`, `12.3k`, `123`
18
+ const COST_METRIC_WIDTH = 7; // `$0.0500`, `$1.50`
19
+ const DURATION_METRIC_WIDTH = 6; // `3.0s`, `59.9s`, `120.0s`
20
+
21
+ /**
22
+ * Width-aware right-align (padStart) for a numeric metric so its column stays
23
+ * stable across ticks. Uses `visibleWidth` so wide/CJK glyphs never misalign
24
+ * the padding. Values wider than `width` are returned verbatim (no truncation)
25
+ * so outliers overflow gracefully instead of crashing the render.
26
+ */
27
+ function alignMetric(value: string, width: number): string {
28
+ const gap = width - visibleWidth(value);
29
+ return gap > 0 ? " ".repeat(gap) + value : value;
30
+ }
9
31
 
10
32
  /**
11
33
  * Returns true if this agent did real work (LLM call, tool use, or non-trivial duration).
@@ -89,12 +111,12 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
89
111
  const tokenTotal = (agent.usage?.input ?? 0) + (agent.usage?.output ?? 0) + (agent.usage?.cacheRead ?? 0) + (agent.usage?.cacheWrite ?? 0);
90
112
  if (tokenTotal > 0) {
91
113
  const tok = tokenTotal >= 1000 ? `${(tokenTotal / 1000).toFixed(1)}k` : `${tokenTotal}`;
92
- stats.push(tok);
114
+ stats.push(alignMetric(tok, TOKENS_METRIC_WIDTH));
93
115
  }
94
116
  // Per-agent cost (Round 17 BS-1): the data is already on task.usage.cost;
95
117
  // surface it live so the user sees $ burn per agent during a run.
96
118
  if (agent.usage?.cost && agent.usage.cost > 0) {
97
- stats.push(formatCost(agent.usage.cost));
119
+ stats.push(alignMetric(formatCost(agent.usage.cost), COST_METRIC_WIDTH));
98
120
  }
99
121
  if (liveHandle) {
100
122
  // Round 23 (BUG 1): the duration math here was naive —
@@ -104,13 +126,13 @@ export function renderAgentsPane(snapshot: RunUiSnapshot | undefined, options: R
104
126
  // fired for EVERY running live agent in the dashboard. Use the shared,
105
127
  // validated computeLiveDurationMs (mirrors widget-formatters.ts).
106
128
  const ms = computeLiveDurationMs(liveHandle.activity);
107
- stats.push(`${(ms / 1000).toFixed(1)}s`);
129
+ stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
108
130
  if (options.showModel !== false && liveHandle.modelName && liveHandle.modelName !== "default") {
109
131
  stats.push(liveHandle.modelName);
110
132
  }
111
133
  } else if (agent.startedAt) {
112
134
  const ms = Date.now() - new Date(agent.startedAt).getTime();
113
- if (Number.isFinite(ms)) stats.push(`${(ms / 1000).toFixed(1)}s`);
135
+ if (Number.isFinite(ms)) stats.push(alignMetric(`${(ms / 1000).toFixed(1)}s`, DURATION_METRIC_WIDTH));
114
136
  }
115
137
 
116
138
  const statsStr = stats.length ? ` · ${stats.join(" ")}` : "";
@@ -40,4 +40,27 @@ export function renderCancellationPane(manifest: TeamRunManifest, tasks: TeamTas
40
40
  }
41
41
 
42
42
  return lines;
43
+ }
44
+
45
+ /**
46
+ * D-1 / L-2 — a one-line terminal-run reason for the dashboard detail row.
47
+ *
48
+ * Surfaces *why* a failed/cancelled/stopped run ended without forcing the
49
+ * user to switch panes. This also gives this module a real consumer (it was
50
+ * previously zero-importer dead code per the UI/UX review), wiring it in as
51
+ * the natural home for cancellation/failure reason display.
52
+ *
53
+ * Resolution order: structured `run.cancelled` event reason → first failed
54
+ * task's error → first policy-decision message.
55
+ */
56
+ export function summarizeTerminalReason(
57
+ manifest: TeamRunManifest,
58
+ tasks: TeamTaskState[],
59
+ cancellationReason?: string,
60
+ ): string | undefined {
61
+ if (cancellationReason) return cancellationReason;
62
+ const failed = tasks.find((task) => task.status === "failed" && task.error);
63
+ if (failed?.error) return failed.error;
64
+ if (manifest.policyDecisions?.length) return manifest.policyDecisions[0]?.message;
65
+ return undefined;
43
66
  }
@@ -26,6 +26,7 @@
26
26
  export const DASHBOARD_KEYS = {
27
27
  close: ["q", "\u001b"],
28
28
  select: ["\r", "\n", "s"],
29
+ help: ["?"],
29
30
  root: {
30
31
  summary: ["u"],
31
32
  artifacts: ["a"],
@@ -67,6 +68,7 @@ export interface KeyBinding {
67
68
 
68
69
  export type DashboardKeyAction =
69
70
  | "close"
71
+ | "help"
70
72
  | "select"
71
73
  | "summary"
72
74
  | "artifacts"
@@ -112,6 +114,7 @@ export type DashboardKeyAction =
112
114
  */
113
115
  const BINDINGS: readonly KeyBinding[] = [
114
116
  { keys: DASHBOARD_KEYS.close, action: "close" },
117
+ { keys: DASHBOARD_KEYS.help, action: "help" },
115
118
  { keys: DASHBOARD_KEYS.mailbox.openDetail, action: "mailbox-detail", pane: "mailbox" },
116
119
  { keys: DASHBOARD_KEYS.health.recovery, action: "health-recovery", pane: "health" },
117
120
  { keys: DASHBOARD_KEYS.health.killStale, action: "health-kill-stale", pane: "health" },
@@ -145,13 +148,14 @@ const BINDINGS: readonly KeyBinding[] = [
145
148
  * overlays. Derived from `DASHBOARD_KEYS` (the full key set) rather than from
146
149
  * `BINDINGS` (the dispatched subset) so overlay-handled keys stay reserved.
147
150
  *
148
- * @internal Currently unused outside this module but retained to document
149
- * intent and support future callers that need to know which keys the
150
- * dashboard ecosystem owns.
151
+ * @internal Consumed by `test/unit/keybinding-map.parity.test.ts` (asserts
152
+ * reserved-key membership) and the L2 dispatch smoke script. It is the
153
+ * canonical "keys the dashboard ecosystem owns" set — NOT dead code.
151
154
  */
152
155
  const KEY_RESERVED = new Set<string>([
153
156
  ...DASHBOARD_KEYS.close,
154
157
  ...DASHBOARD_KEYS.select,
158
+ ...DASHBOARD_KEYS.help,
155
159
  ...Object.values(DASHBOARD_KEYS.root).flat(),
156
160
  ...Object.values(DASHBOARD_KEYS.pane).flat(),
157
161
  ...Object.values(DASHBOARD_KEYS.navigation).flat(),
@@ -8,6 +8,7 @@ import type { LiveAgentHandle } from "../runtime/live-agent-manager.ts";
8
8
  import { iconForStatus } from "./status-colors.ts";
9
9
  import { spinnerFrame } from "./spinner.ts";
10
10
  import type { CrewTheme } from "./theme-adapter.ts";
11
+ import { pad, truncate } from "../utils/visual.ts";
11
12
 
12
13
  const CHROME_LINES = 6;
13
14
  const MIN_VIEWPORT = 3;
@@ -119,9 +120,8 @@ export class LiveConversationOverlay {
119
120
  if (w < 6) return [];
120
121
  const th = this.theme;
121
122
  const innerW = w - 4;
122
- const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - s.length));
123
123
  const row = (content: string) =>
124
- th.fg("border", "│") + " " + pad(content.slice(0, innerW), innerW) + " " + th.fg("border", "│");
124
+ th.fg("border", "│") + " " + pad(truncate(content, innerW), innerW) + " " + th.fg("border", "│");
125
125
  const hrTop = th.fg("border", `╭${"─".repeat(w - 2)}╮`);
126
126
  const hrBot = th.fg("border", `╰${"─".repeat(w - 2)}╯`);
127
127
  const hrMid = row(th.fg("dim", "─".repeat(innerW)));
@@ -8,7 +8,7 @@ import { aggregateUsage, formatUsage } from "../state/usage.ts";
8
8
  import type { TeamTaskState } from "../state/types.ts";
9
9
  import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
10
10
  import { pad, truncate } from "../utils/visual.ts";
11
- import { iconForStatus } from "./status-colors.ts";
11
+ import { colorizeStatusGlyphs, iconForStatus } from "./status-colors.ts";
12
12
  import type { CrewTheme } from "./theme-adapter.ts";
13
13
  import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
14
14
  import { Box, Text } from "./layout-primitives.ts";
@@ -93,13 +93,11 @@ export class LiveRunSidebar {
93
93
  }
94
94
 
95
95
  private colorLine(line: string): string {
96
- const iconColor = (icon: string): Parameters<CrewTheme["fg"]>[0] => {
97
- if (icon === "✓") return "success";
98
- if (icon === "✗") return "error";
99
- if (icon === "■" || icon === "⏸") return "warning";
100
- return "accent";
101
- };
102
- return line.replace(/[✓✗■⏸◦·▶]/g, (icon) => this.theme.fg(iconColor(icon), icon));
96
+ // F-1 / V-3: delegate to the shared glyph colorizer so ⏳ (waiting),
97
+ // (needs_attention) and the braille spinner frames are colored
98
+ // consistently with the rest of the UI. The previous local map/regex
99
+ // omitted all three, leaving the most attention-demanding states uncolored.
100
+ return colorizeStatusGlyphs(line, this.theme);
103
101
  }
104
102
 
105
103
  invalidate(): void {
@@ -150,10 +148,17 @@ export class LiveRunSidebar {
150
148
  const waiting = tasks.filter((task) => task.status === "queued");
151
149
  const signature = this.buildSignature(run.updatedAt, tasks, agents, waiting.length, snapshot);
152
150
  if (signature !== this.cachedSignature || w !== this.cachedWidth) {
151
+ // L-2: surface the cancellation/failure reason for terminal runs so the
152
+ // user sees *why* a run ended without having to switch panes. The reason
153
+ // is already computed on the consumed snapshot (cancellationReason).
154
+ const TERMINAL_WITH_REASON = ["failed", "cancelled", "stopped"];
155
+ const reasonSuffix = TERMINAL_WITH_REASON.includes(run.status) && snapshot?.cancellationReason
156
+ ? ` · ${truncate(snapshot.cancellationReason, 40)}`
157
+ : "";
153
158
  const lines: string[] = [
154
159
  border("╭", "─", "╮", w),
155
160
  line(`${this.theme.fg("accent", "▐")} ${this.theme.bold("pi-crew live sidebar")}`, w),
156
- line(`${run.runId.slice(-12)} · ${run.status} · right default`, w),
161
+ line(`${run.runId.slice(-12)} · ${run.status}${reasonSuffix} · right default`, w),
157
162
  line(`${run.team}/${run.workflow ?? "none"} · ${shortUsage(tasks)}`, w),
158
163
  border("├", "─", "┤", w),
159
164
  line(`Active agents (${active.length})`, w),
@@ -180,7 +185,9 @@ export class LiveRunSidebar {
180
185
  if (completed.length === 0) lines.push(line("- none", w));
181
186
  lines.push(border("├", "─", "┤", w));
182
187
  for (const entry of formatTaskGraphLines(tasks).slice(0, 6)) lines.push(line(entry, w));
183
- lines.push(line("q close · /team-dashboard details", w), border("╰", "─", "╯", w));
188
+ lines.push(line("q close · /team-dashboard details", w));
189
+ // F-6: compute the auto-close countdown BEFORE pushing the bottom border
190
+ // so the countdown renders inside the bordered box rather than below it.
184
191
  // Auto-close logic: if run is terminal and no active agents, close after delay
185
192
  const isTerminal = ["completed", "failed", "cancelled", "blocked"].includes(run.status);
186
193
  const hasActiveAgents = agents.some((a) => a.status === "running");
@@ -199,6 +206,7 @@ export class LiveRunSidebar {
199
206
  clearTimeout(this.autoCloseTimeout);
200
207
  this.autoCloseTimeout = undefined;
201
208
  }
209
+ lines.push(border("╰", "─", "╯", w));
202
210
  this.cachedLines = renderLines(lines.map((entry) => this.colorLine(entry)), w);
203
211
  this.cachedSignature = signature;
204
212
  this.cachedWidth = w;
@@ -0,0 +1,166 @@
1
+ /**
2
+ * K-1 — dashboard keybinding cheatsheet overlay.
3
+ *
4
+ * Toggled by `?` (bound in keybinding-map.ts). Renders the dashboard's
5
+ * keybindings grouped by scope (general / navigation / panes / run actions /
6
+ * mailbox / health) directly from `DASHBOARD_KEYS`, so adding a key in one
7
+ * place is reflected here automatically.
8
+ *
9
+ * Uses the same `innerWidth = max(20, width - 4)` formula as `run-dashboard.ts`
10
+ * so the overlay's border column aligns with the dashboard's stable-height
11
+ * blank-padding rows (preserves the "stable overlay height" strength — the
12
+ * blank rows injected by the dashboard's pad/trim must land in the same
13
+ * column as this overlay's `│` borders).
14
+ *
15
+ * Template origin: `confirm-overlay.ts`.
16
+ */
17
+ import { Box, Text } from "../layout-primitives.ts";
18
+ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
19
+ import { pad, truncate } from "../../utils/visual.ts";
20
+ import { DASHBOARD_KEYS } from "../keybinding-map.ts";
21
+
22
+ /** Translate a raw key sequence into a readable token for the cheatsheet. */
23
+ function keyToken(key: string): string {
24
+ switch (key) {
25
+ case "\u001b":
26
+ return "Esc";
27
+ case "\u001b[A":
28
+ return "↑";
29
+ case "\u001b[B":
30
+ return "↓";
31
+ case "\r":
32
+ case "\n":
33
+ return "Enter";
34
+ default:
35
+ return key;
36
+ }
37
+ }
38
+
39
+ function keyList(keys: readonly string[]): string {
40
+ return [...keys].map(keyToken).join("/");
41
+ }
42
+
43
+ interface HelpEntry {
44
+ readonly keys: string;
45
+ readonly label: string;
46
+ }
47
+
48
+ interface HelpGroup {
49
+ readonly title: string;
50
+ readonly entries: readonly HelpEntry[];
51
+ }
52
+
53
+ const ROOT_LABELS: Record<string, string> = {
54
+ summary: "summary",
55
+ artifacts: "artifacts",
56
+ api: "api",
57
+ agents: "agents",
58
+ mailbox: "mailbox",
59
+ events: "events",
60
+ output: "output",
61
+ transcript: "transcript",
62
+ liveConversation: "live conv",
63
+ reload: "reload",
64
+ progressToggle: "progress",
65
+ };
66
+
67
+ const MAILBOX_LABELS: Record<string, string> = {
68
+ ack: "ack",
69
+ nudge: "nudge",
70
+ compose: "compose",
71
+ preview: "preview",
72
+ ackAll: "ack all",
73
+ };
74
+
75
+ const HEALTH_LABELS: Record<string, string> = {
76
+ recovery: "recover",
77
+ killStale: "kill stale",
78
+ diagnosticExport: "diag export",
79
+ };
80
+
81
+ function buildHelpGroups(): HelpGroup[] {
82
+ const paneEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.pane).map(([name, keys]) => ({
83
+ keys: keyList(keys),
84
+ label: name,
85
+ }));
86
+ const rootEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.root).map(([name, keys]) => ({
87
+ keys: keyList(keys),
88
+ label: ROOT_LABELS[name] ?? name,
89
+ }));
90
+ const mailboxEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.mailbox)
91
+ .filter(([name]) => name !== "openDetail")
92
+ .map(([name, keys]) => ({ keys: keyList(keys), label: MAILBOX_LABELS[name] ?? name }));
93
+ const healthEntries: HelpEntry[] = Object.entries(DASHBOARD_KEYS.health).map(([name, keys]) => ({
94
+ keys: keyList(keys),
95
+ label: HEALTH_LABELS[name] ?? name,
96
+ }));
97
+ return [
98
+ {
99
+ title: "General",
100
+ entries: [
101
+ { keys: keyList(DASHBOARD_KEYS.close), label: "close dashboard" },
102
+ { keys: keyList(DASHBOARD_KEYS.select), label: "open run status" },
103
+ { keys: "?", label: "toggle this help" },
104
+ ],
105
+ },
106
+ {
107
+ title: "Navigation",
108
+ entries: [
109
+ { keys: `${keyList(DASHBOARD_KEYS.navigation.up)}/${keyList(DASHBOARD_KEYS.navigation.down)}`, label: "move selection" },
110
+ ],
111
+ },
112
+ { title: "Panes", entries: paneEntries },
113
+ { title: "Run actions", entries: rootEntries },
114
+ { title: "Mailbox (pane 3)", entries: mailboxEntries },
115
+ {
116
+ title: "Health & notifications",
117
+ entries: [
118
+ ...healthEntries,
119
+ { keys: keyList(DASHBOARD_KEYS.notification.dismissAll), label: "dismiss notifs" },
120
+ ],
121
+ },
122
+ ];
123
+ }
124
+
125
+ export class HelpOverlay {
126
+ private readonly theme: CrewTheme;
127
+
128
+ constructor(theme: unknown = {}) {
129
+ this.theme = asCrewTheme(theme);
130
+ }
131
+
132
+ invalidate(): void {
133
+ // Stateless overlay.
134
+ }
135
+
136
+ render(width: number): string[] {
137
+ // MUST mirror run-dashboard.ts's innerWidth so the dashboard's
138
+ // stable-height blank-padding rows align with this overlay's borders.
139
+ const innerWidth = Math.max(20, width - 4);
140
+ const fg = (color: Parameters<CrewTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
141
+ const row = (text: string) => `│ ${pad(truncate(text, innerWidth - 1), innerWidth - 1)}│`;
142
+ const bar = "─".repeat(innerWidth);
143
+ const top = fg("border", `╭${bar}╮`);
144
+ const mid = fg("border", `├${bar}┤`);
145
+ const bot = fg("border", `╰${bar}╯`);
146
+ const keyCol = 9;
147
+ const lines: string[] = [
148
+ top,
149
+ row(`${fg("accent", "pi-crew dashboard")} ${fg("dim", "— key reference (press ? to close)")}`),
150
+ mid,
151
+ ];
152
+ for (const group of buildHelpGroups()) {
153
+ lines.push(row(fg("accent", group.title)));
154
+ for (let i = 0; i < group.entries.length; i += 2) {
155
+ const pair = group.entries.slice(i, i + 2);
156
+ const cell = (entry: HelpEntry) =>
157
+ `${this.theme.bold(pad(entry.keys, keyCol))}${fg("dim", truncate(entry.label, 16))}`;
158
+ lines.push(row(pair.map(cell).join(" ")));
159
+ }
160
+ }
161
+ lines.push(bot);
162
+ const box = new Box(0, 0);
163
+ for (const line of lines) box.addChild(new Text(line));
164
+ return box.render(width);
165
+ }
166
+ }