@visual-z/xeno 0.8.0 → 0.8.2

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.
@@ -31,8 +31,9 @@ import { screenLayout, sidebarContentWidth, type ScreenLayout } from "./layout.t
31
31
  import { greetingLines, mascotFrame, MASCOT_FRAME_MS, MASCOT_FRAMES, type Greeting } from "./mascot.ts";
32
32
  import { MessageList, type MessageFeatures } from "./messages.ts";
33
33
  import { Motion } from "./motion.ts";
34
- import { contextLabel, modeLabels, sidebarLines, topbarRow, type Line, type StatusModel } from "./status.ts";
34
+ import { contextLabel, goalLabel, idleActivityLabel, modeLabels, sidebarLines, speedText, topbarRow, type Line, type StatusModel } from "./status.ts";
35
35
  import { DEFAULT_CELL, stripImageRows } from "./image-size.ts";
36
+ import { formatElapsed } from "./duration.ts";
36
37
  import { Pictures, type PictureSource } from "./pictures.ts";
37
38
  import type { ImageView, ViewItem } from "./items.ts";
38
39
 
@@ -104,6 +105,8 @@ export class Screen {
104
105
  private readonly activity = new Motion();
105
106
  private spin = 0;
106
107
  private busySince = 0;
108
+ /** The last turn's wall time, shown beside its outcome until the next turn starts. */
109
+ private lastTurnMs: number | undefined;
107
110
  private lastEvent = 0;
108
111
  private activityMs = ACTIVITY_MS;
109
112
  private animations = true;
@@ -336,7 +339,8 @@ export class Screen {
336
339
  setDock(state: DockState): void {
337
340
  const wasBusy = this.dockState.busy;
338
341
  this.dockState = state;
339
- if (state.busy && !wasBusy) this.busySince = Date.now();
342
+ if (state.busy && !wasBusy) { this.busySince = Date.now(); this.lastTurnMs = undefined; }
343
+ if (!state.busy && wasBusy) this.lastTurnMs = Date.now() - this.busySince;
340
344
  this.syncActivity();
341
345
  this.paintEdges();
342
346
  }
@@ -376,11 +380,14 @@ export class Screen {
376
380
  // to recover on the existing edge, without putting control content in it.
377
381
  left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
378
382
  } else if (d.busy) {
379
- const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
383
+ const elapsed = formatElapsed(Date.now() - this.busySince);
380
384
  const activity = this.status?.activity;
381
385
  const label = activity ? `${activity.label}${activity.label === "Running tool" || activity.label === "Retrying" ? ` · ${activity.detail}` : ""}${activity.queued ? ` · ${activity.queued} queued` : ""}` : d.activity;
382
386
  const transient = this.transientActivity ? ` · ${this.transientActivityText()}` : "";
383
- left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
387
+ left = [{ segs: [{ text: "●", role: "accent", spin: activity?.state !== "blocked" }, { text: ` ${label}${transient}`, role: activity?.state === "blocked" ? "warning" : "muted" }, { text: ` ${elapsed}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
388
+ // The turn's speed so far, refreshed after each model request (runtime/usage.ts generationSpeed).
389
+ const speed = this.status?.usage?.speed;
390
+ if (speed !== undefined) right.push({ segs: [{ text: speedText(speed), role: "muted" }], priority: 3 });
384
391
  right.push({ segs: [{ text: "esc interrupt", role: "muted" }], priority: 4 });
385
392
  } else if (this.transientActivity) {
386
393
  left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${this.transientActivityText()}`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
@@ -389,15 +396,17 @@ export class Screen {
389
396
  const { above, below } = this.composer.overflow;
390
397
  if (above) left = [{ segs: [{ text: `↑ ${above} more`, role: "muted" }], priority: 1 }];
391
398
  if (below && !this.toastText) right.push({ segs: [{ text: `↓ ${below} more`, role: "muted" }], priority: 2 });
392
- if (!above && this.status?.activity) left = [{ segs: [{ text: this.status.activity.label, role: this.status.activity.state === "error" ? "error" : this.status.activity.state === "blocked" ? "warning" : "muted" }], priority: 1, min: 4 }];
399
+ if (!above && this.status?.activity) left = [idleActivityLabel(this.status.activity, this.lastTurnMs)];
393
400
  }
394
401
  this.topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], role, left, right })]);
395
402
  const bottomLeft: Label[] = [...(this.status ? modeLabels(this.status) : []), ...(d.model ? [
396
403
  { segs: [{ text: d.model, role: "text" as const, bold: true }], priority: 1, min: 8 },
397
404
  ...(d.thinking ? [{ segs: [{ text: d.thinking, role: "muted" as const }], priority: 2 }] : []),
398
405
  ] : [])];
406
+ // Without a sidebar the goal and the context share ride on this edge (layout.ts placement).
399
407
  const context = this.shape.tier !== "sidebar" && this.status ? contextLabel(this.status) : undefined;
400
- this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: context ? [context] : [] })]);
408
+ const goal = this.shape.tier !== "sidebar" && this.status ? goalLabel(this.status) : undefined;
409
+ this.bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"], role, left: bottomLeft, right: [...(goal ? [goal] : []), ...(context ? [context] : [])] })]);
401
410
  }
402
411
 
403
412
  /** A stream event arrived (delta, tool progress, shell output): the spinner returns to full speed. */
@@ -426,9 +435,9 @@ export class Screen {
426
435
 
427
436
  private transientActivityText(): string {
428
437
  const activity = this.transientActivity!;
429
- const elapsed = Math.max(0, Math.floor((Date.now() - activity.startedAt) / 1000));
430
- const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000));
431
- return `${activity.label} · ${elapsed}s / ${deadline}s max${activity.detail ? ` · ${activity.detail}` : ""}`;
438
+ // The limit is a whole number of seconds, rounded up so it is never shown shorter than it is.
439
+ const deadline = Math.max(1, Math.ceil(activity.timeoutMs / 1000)) * 1000;
440
+ return `${activity.label} · ${formatElapsed(Date.now() - activity.startedAt)} / ${formatElapsed(deadline)} max${activity.detail ? ` · ${activity.detail}` : ""}`;
432
441
  }
433
442
 
434
443
  /** The spinner's current interval (tests). */
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { clipSegs, clipText, segsWidth, splitLine, type Label, type Seg } from "./edge.ts";
16
16
  import { displayWidth } from "../width.ts";
17
+ import { formatElapsed } from "./duration.ts";
17
18
 
18
19
  export interface ContextStatus {
19
20
  readonly used: number;
@@ -30,9 +31,13 @@ export interface StatusModel {
30
31
  readonly version: string;
31
32
  /** `/name`, else the first prompt; null for a session with neither. */
32
33
  readonly title: string | null;
33
- readonly goal?: { readonly text: string; readonly status: string; readonly execution: string; readonly observations: number; readonly retries: number;
34
- /** S.1: one glyph per rung (ran, tests the change, independent, human) and the reading of them, prepared by apps. */
35
- readonly ladder?: { readonly glyphs: string; readonly summary: string } };
34
+ /**
35
+ * An explicit /goal, prepared by apps from the log (core/goal-view.ts). The
36
+ * block exists only while a goal is set, and for one message after it is met.
37
+ */
38
+ readonly goal?: GoalStatusView;
39
+ /** Without a goal: the latest check after this request's last edit, when it changed files. */
40
+ readonly checks?: { readonly status: "unchecked" | "pass" | "fail" | "stale" | "partial"; readonly command?: string; readonly exit?: number };
36
41
  readonly workspace: string;
37
42
  readonly workspaceShort: string;
38
43
  readonly branch: string | null;
@@ -41,7 +46,8 @@ export interface StatusModel {
41
46
  readonly activity?: { readonly state: string; readonly label: string; readonly detail: string; readonly queued: number };
42
47
  readonly git?: { readonly staged: number; readonly changed: number; readonly untracked: number; readonly conflicted: number; readonly ahead: number; readonly behind: number } | null;
43
48
  readonly context?: ContextStatus;
44
- readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null };
49
+ /** `speed`: output tokens per second over this turn's model requests (runtime/usage.ts generationSpeed). */
50
+ readonly usage?: { readonly input: number; readonly output: number; readonly cacheRate: number; readonly cost: string | null; readonly speed?: number };
45
51
  readonly tools: { readonly running: number; readonly done: number; readonly failed: number };
46
52
  readonly handoff?: { readonly verified: readonly string[]; readonly inFlight: readonly string[]; readonly unverified: readonly string[] };
47
53
  readonly tasks?: readonly { readonly what: string; readonly status: "active" | "blocked" }[];
@@ -51,6 +57,18 @@ export interface StatusModel {
51
57
  readonly extension?: string;
52
58
  }
53
59
 
60
+ export interface GoalStatusView {
61
+ readonly phase: "drafting" | "working" | "needs_you" | "met" | "stopped";
62
+ readonly objective: string;
63
+ readonly elapsedMs: number;
64
+ readonly checks: readonly { readonly id: string; readonly kind: "command" | "human" | "fresh_check"; readonly status: string; readonly guard?: true; readonly exit?: number }[];
65
+ /** S.1: one glyph per rung (ran, tests the change, independent, human), prepared by apps. */
66
+ readonly ladder?: { readonly glyphs: string; readonly summary: string };
67
+ /** Model requests since the goal was set (from the log) and output tokens this process saw. */
68
+ readonly requests: number;
69
+ readonly tokens?: number;
70
+ }
71
+
54
72
  export type Line = readonly Seg[];
55
73
 
56
74
  /** 1234 → "1.2k", 1234567 → "1.2m". The same compact form everywhere a token count appears. */
@@ -150,6 +168,10 @@ interface Block {
150
168
  readonly items: readonly Line[];
151
169
  /** A list block with no items is not shown at all. */
152
170
  readonly list: boolean;
171
+ /** Rows shown after the items, kept with the head (the goal's ladder and spend). */
172
+ readonly tail?: readonly Line[];
173
+ /** A list shown even without items: a goal being drafted has no checks yet. */
174
+ readonly keep?: boolean;
153
175
  }
154
176
 
155
177
  /**
@@ -160,12 +182,12 @@ interface Block {
160
182
  */
161
183
  export function sidebarLines(model: StatusModel, width: number, height: number): Line[] {
162
184
  const w = Math.max(1, Math.floor(width)), h = Math.max(0, Math.floor(height));
163
- const blocks = sidebarBlocks(model, w).filter(block => !block.list || block.items.length > 0);
185
+ const blocks = sidebarBlocks(model, w).filter(block => !block.list || block.items.length > 0 || block.keep);
164
186
  const plan: { block: Block; rows: number }[] = [];
165
187
  let used = 0;
166
188
  for (const block of blocks) {
167
189
  const minimum = block.list ? Math.min(block.items.length, 2) : 0;
168
- const need = (plan.length ? 1 : 0) + block.head.length + minimum;
190
+ const need = (plan.length ? 1 : 0) + block.head.length + minimum + (block.tail?.length ?? 0);
169
191
  if (used + need > h) continue;
170
192
  used += need;
171
193
  plan.push({ block, rows: minimum });
@@ -185,6 +207,7 @@ export function sidebarLines(model: StatusModel, width: number, height: number):
185
207
  out.push(...items.slice(0, entry.rows - 1));
186
208
  out.push([{ text: `+${items.length - entry.rows + 1} more`, role: "muted" }]);
187
209
  }
210
+ out.push(...entry.block.tail ?? []);
188
211
  });
189
212
  return out.slice(0, h).map(line => clipSegs(line, w));
190
213
  }
@@ -203,36 +226,37 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
203
226
  ...(model.git.conflicted ? [[{ text: `${model.git.conflicted} conflicts`, role: "warning" }] as Line] : []),
204
227
  ...(model.git.ahead || model.git.behind ? [[{ text: `↑${model.git.ahead} ↓${model.git.behind}`, role: "muted" }] as Line] : [])] : model.git === null ? [[{ text: "Git unavailable", role: "muted" }] as Line] : []),
205
228
  ] });
229
+ const goal = model.goal;
230
+ if (goal) blocks.push(goalBlock(goal, w, row));
231
+ else if (model.checks) blocks.push({ list: false, items: [], head: [checksLine(model.checks, w)] });
206
232
  const context = model.context;
207
- if (model.goal) blocks.push({ list: false, items: [], head: [
208
- [{ text: "Goal", role: "text", bold: true }, { text: ` · ${model.goal.status}`, role: model.goal.status === "verified" || model.goal.status === "checked" ? "success" : model.goal.status === "not_applicable" ? "muted" : "warning" }],
209
- ...(model.goal.ladder ? [[{ text: model.goal.ladder.glyphs, role: "text" }] as Line,
210
- ...wrap(model.goal.ladder.summary, w, 2).map(text => [{ text, role: "muted" }] as Line)] : []),
211
- ...wrap(firstLine(model.goal.text), w, 2).map(text => [{ text, role: "text" }] as Line),
212
- [{ text: model.goal.execution, role: "muted" }],
213
- [{ text: `${model.goal.observations} observations · ${model.goal.retries} retries`, role: "muted" }],
214
- [{ text: "/control · goal, evidence, context", role: "muted" }],
215
- ] });
216
233
  if (context) {
217
234
  const share = contextShare(context), est = context.estimated ? "~" : "";
218
235
  const filled = share === undefined ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * w));
219
236
  const tone = share !== undefined && share >= CONTEXT_WARNING ? "warning" : "accent";
220
237
  const shareText = shareSeg(context);
238
+ // Two budgets, each named: the segment cut (what the bar shows) and the model window. Both shares
239
+ // ride on the heading, both denominators on one row below the bar.
240
+ const modelShare = context.window && context.trigger !== null ? `${est}${Math.round(context.used / context.window * 100)}%` : undefined;
241
+ const segment = context.trigger !== null ? `seg ${est}${compact(context.used)} / ${est}${compact(context.trigger)}` : `${est}${compact(context.used)} · no cut`;
221
242
  blocks.push({ list: false, items: [], head: [
222
- row([{ text: "Context", role: "text", bold: true }], shareText ? [{ text: context.trigger === null ? "model " : "seg ", role: "muted" }, shareText] : []),
243
+ row([{ text: "Context", role: "text", bold: true }], [
244
+ ...(shareText ? [{ text: context.trigger === null ? "model " : "seg ", role: "muted" as const }, shareText] : []),
245
+ ...(modelShare ? [{ text: ` · model ${modelShare}`, role: "muted" as const }] : [])]),
223
246
  ...(share === undefined ? [] : [[{ text: "━".repeat(filled), role: tone }, { text: "─".repeat(Math.max(0, w - filled)), role: "line" }] as Line]),
224
- [{ text: `Segment ${est}${compact(context.used)}${context.trigger !== null ? ` / ${est}${compact(context.trigger)}` : " · cut disabled"}`, role: "text" }],
225
- [{ text: context.window ? `Model ${est}${compact(context.used)} / ${compact(context.window)} · ${est}${Math.round(context.used / context.window * 100)}%` : "Model window unknown", role: "muted" }],
226
- [{ text: `node ${context.segment}`, role: "muted" }],
247
+ row([{ text: segment, role: "muted" }], [{ text: context.window ? `model ${compact(context.window)}` : "window unknown", role: "muted" }]),
227
248
  ] });
228
249
  }
229
250
  const usage = model.usage, tools = model.tools;
230
251
  const toolCount = tools.done + tools.failed + tools.running;
231
252
  if (usage || toolCount) {
253
+ const figures = usage ? [`↑${compact(usage.input)} ↓${compact(usage.output)}`, `cache ${Math.round(usage.cacheRate * 100)}%`,
254
+ ...(usage.speed !== undefined ? [speedText(usage.speed)] : [])].join(" · ") : "";
255
+ // Heading with the cost; one row of figures below it. Together they need about 49 columns, more than the 34-42 the sidebar has.
232
256
  blocks.push({ list: false, items: [], head: [
233
257
  row([{ text: "Usage", role: "text", bold: true }], usage?.cost ? [{ text: usage.cost, role: "text" }] : []),
234
- ...(usage ? [[{ text: `↑${compact(usage.input)} ↓${compact(usage.output)}`, role: "text" }, { text: ` cache ${Math.round(usage.cacheRate * 100)}%`, role: "muted" }] as Line] : []),
235
- ...(toolCount ? [[
258
+ ...(figures ? [[{ text: figures, role: "muted" }] as Line] : []),
259
+ ...(tools.failed || tools.running ? [[
236
260
  { text: `${tools.done} done`, role: "muted" },
237
261
  ...(tools.failed ? [{ text: " · ", role: "muted" as const }, { text: `${tools.failed} failed`, role: "error" as const }] : []),
238
262
  ...(tools.running ? [{ text: " · ", role: "muted" as const }, { text: `${tools.running} running`, role: "accent" as const }] : []),
@@ -270,6 +294,60 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
270
294
  return blocks;
271
295
  }
272
296
 
297
+ /** `42 tok/s`; under 10 one decimal, so a slow upstream does not read as zero. */
298
+ export function speedText(speed: number): string {
299
+ return `${speed < 10 ? speed.toFixed(1) : Math.round(speed)} tok/s`;
300
+ }
301
+
302
+ const PHASE: Record<GoalStatusView["phase"], { label: string; role: Seg["role"] }> = {
303
+ drafting: { label: "drafting", role: "accent" }, working: { label: "working", role: "accent" },
304
+ needs_you: { label: "needs you", role: "warning" }, met: { label: "met", role: "success" }, stopped: { label: "stopped", role: "warning" },
305
+ };
306
+
307
+ /**
308
+ * `Goal · needs you 4m 12s`, the objective, one row per accepted check with
309
+ * what it needs, the evidence ladder and what the goal has spent. A met goal
310
+ * is one summary row. The sidebar may cut the checks; a cut ends in "+N more".
311
+ */
312
+ function goalBlock(goal: GoalStatusView, w: number, row: (left: readonly Seg[], right?: readonly Seg[]) => Line): Block {
313
+ const phase = PHASE[goal.phase];
314
+ const passed = goal.checks.filter(check => check.status === "pass" || check.status === "accepted").length;
315
+ const elapsed = goal.phase === "drafting" ? [] : [{ text: formatElapsed(goal.elapsedMs), role: "muted" as const }];
316
+ const title = [{ text: "Goal", role: "text" as const, bold: true }, { text: ` · ${phase.label}`, role: phase.role }];
317
+ if (goal.phase === "met") return { list: false, items: [], head: [row(title, [{ text: `${passed}/${goal.checks.length} · `, role: "muted" }, ...elapsed])] };
318
+ const items = goal.checks.map(check => {
319
+ const done = check.status === "pass" || check.status === "accepted";
320
+ const glyph: Seg = done ? { text: "✓ ", role: "success" } : check.status === "fail" ? { text: "✗ ", role: "error" } : { text: "○ ", role: "muted" };
321
+ const need = check.kind === "human" && !done ? `/accept ${check.id}` : check.status === "fail" && check.exit !== undefined ? `exit ${check.exit}`
322
+ : check.status === "stale" ? "rerun" : check.guard ? "guard" : "";
323
+ return row([glyph, { text: check.id, role: "text" }], need ? [{ text: need, role: check.kind === "human" && !done ? "warning" : "muted" }] : []);
324
+ });
325
+ const spent = [`${goal.requests} ${goal.requests === 1 ? "request" : "requests"}`, ...(goal.tokens ? [`${compact(goal.tokens)} tokens this run`] : [])].join(" · ");
326
+ return { list: true, keep: true, head: [row(title, elapsed), ...wrap(firstLine(goal.objective), w, 2).map(text => [{ text, role: "text" }] as Line)],
327
+ items, ...(goal.phase === "drafting" ? {} : { tail: [...(goal.ladder ? [[{ text: goal.ladder.glyphs, role: "text" }] as Line] : []), [{ text: spent, role: "muted" }] as Line] }) };
328
+ }
329
+
330
+ /** Without a goal: whether a check has passed since this request's last edit. */
331
+ function checksLine(checks: NonNullable<StatusModel["checks"]>, w: number): Line {
332
+ if (checks.status === "unchecked" || !checks.command) return [{ text: "Checks", role: "text", bold: true }, { text: " · not run since last edit", role: "warning" }];
333
+ // partial: some of a declared contract holds; the rest may only be waiting for the user, so it is not a failure mark.
334
+ const mark: Seg[] = checks.status === "pass" ? [{ text: " ✓", role: "success" }] : checks.status === "partial" ? []
335
+ : checks.status === "fail" ? [{ text: " ✗", role: "error" }, ...(checks.exit === undefined ? [] : [{ text: ` exit ${checks.exit}`, role: "muted" as const }])]
336
+ : [{ text: " stale", role: "warning" }];
337
+ const room = Math.max(1, w - segsWidth(mark) - "Checks · ".length);
338
+ return [{ text: "Checks", role: "text", bold: true }, { text: ` · ${clipText(checks.command, room)}`, role: "muted" }, ...mark];
339
+ }
340
+
341
+ /** Without a sidebar the goal rides on the input box's bottom edge: `goal 2/3 · needs you · 4m 12s`. */
342
+ export function goalLabel(model: StatusModel): Label | undefined {
343
+ const goal = model.goal;
344
+ if (!goal) return undefined;
345
+ const passed = goal.checks.filter(check => check.status === "pass" || check.status === "accepted").length;
346
+ const parts = [`goal ${goal.phase === "drafting" ? "" : `${passed}/${goal.checks.length} · `}${PHASE[goal.phase].label}`,
347
+ ...(goal.phase === "drafting" ? [] : [formatElapsed(goal.elapsedMs)])];
348
+ return { segs: [{ text: parts.join(" · "), role: goal.phase === "needs_you" ? "warning" : "muted" }], priority: 2, min: 8 };
349
+ }
350
+
273
351
  function fileLine(file: FileStatus, w: number): Line {
274
352
  const name = file.path.split("/").pop() || file.path;
275
353
  const counts: Seg[] = file.edited && (file.added || file.removed)
@@ -307,3 +385,19 @@ export function wrap(text: string, width: number, lines: number): string[] {
307
385
  kept[lines - 1] = `${displayWidth(last) + 1 <= w ? last : cells(last, w - 1).trimEnd()}…`;
308
386
  return kept;
309
387
  }
388
+
389
+ /**
390
+ * The input box's top-edge label while no turn runs: the outcome in its own
391
+ * colour, then how long the last turn took (Codex "Worked for 2m 5s", Claude
392
+ * Code "Cooked for 1m 6s"). A plain grey "Turn finished" read the same as
393
+ * "nothing happened" (fix-finished-turn-status).
394
+ */
395
+ export function idleActivityLabel(activity: NonNullable<StatusModel["activity"]>, turnMs?: number): Label {
396
+ const tone = activity.state === "done" ? { glyph: "✓", role: "success" as const }
397
+ : activity.state === "error" ? { glyph: "✗", role: "error" as const }
398
+ : activity.state === "blocked" ? { glyph: activity.label === "Stopped" ? "■" : "", role: "warning" as const }
399
+ : { glyph: "", role: "muted" as const };
400
+ const text = activity.label === "Stopped" && activity.detail ? `Stopped · ${activity.detail}` : activity.label;
401
+ return { segs: [...(tone.glyph ? [{ text: tone.glyph, role: tone.role }, { text: " ", role: "muted" as const }] : []), { text, role: tone.role },
402
+ ...(turnMs !== undefined ? [{ text: ` · ${formatElapsed(turnMs)}`, role: "muted" as const }] : [])], priority: 1, min: 4, shrink: tone.glyph ? 2 : 0 };
403
+ }