pi-crew 0.9.9 → 0.9.11
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.
- package/CHANGELOG.md +330 -0
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/config/role-tools.ts +39 -6
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/async-runner.ts +70 -74
- package/src/runtime/background-runner.ts +13 -2
- package/src/runtime/child-pi.ts +122 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/role-permission.ts +5 -21
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/task-output-context.ts +161 -27
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner.ts +76 -15
- package/src/state/artifact-store.ts +22 -2
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +66 -32
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
package/src/ui/widget/index.ts
CHANGED
|
@@ -18,13 +18,30 @@ import { spinnerBucket, spinnerFrame } from "../spinner.ts";
|
|
|
18
18
|
import { runEventBus } from "../run-event-bus.ts";
|
|
19
19
|
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
20
20
|
import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
21
|
-
import { buildWidgetLines, colorWidgetLine, renderLines } from "./widget-renderer.ts";
|
|
21
|
+
import { buildWidgetLines, colorWidgetLine, renderLines, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
|
|
22
22
|
import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
|
|
23
23
|
|
|
24
24
|
// Re-export types and helpers for backward compatibility
|
|
25
25
|
export type { WidgetRun, CrewWidgetModel, CrewWidgetState } from "./widget-types.ts";
|
|
26
26
|
export { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
27
|
-
export { buildWidgetLines as buildCrewWidgetLines, widgetHeader } from "./widget-renderer.ts";
|
|
27
|
+
export { buildWidgetLines as buildCrewWidgetLines, widgetHeader, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the real render width for widget lines, in priority order:
|
|
31
|
+
* 1. explicit `width` argument (e.g. from caller that already knows terminal width)
|
|
32
|
+
* 2. `process.stdout.columns` (works in Node when stdout is a TTY)
|
|
33
|
+
* 3. `DEFAULT_WIDGET_WIDTH` (100) — last-resort fallback so we never paint
|
|
34
|
+
* a line wider than the smallest expected TUI.
|
|
35
|
+
*
|
|
36
|
+
* Callers SHOULD pass the width they already hold (e.g. `WidgetRender.render(width)`
|
|
37
|
+
* in this file already receives one). This helper exists for paths that don't.
|
|
38
|
+
*/
|
|
39
|
+
export function getRenderWidth(width?: number): number {
|
|
40
|
+
if (Number.isFinite(width) && width! > 0) return Math.floor(width!);
|
|
41
|
+
const stdoutCols = (globalThis as { process?: { stdout?: { columns?: number } } }).process?.stdout?.columns;
|
|
42
|
+
if (Number.isFinite(stdoutCols) && stdoutCols! > 0) return Math.floor(stdoutCols!);
|
|
43
|
+
return DEFAULT_WIDGET_WIDTH;
|
|
44
|
+
}
|
|
28
45
|
export { notificationBadge } from "./widget-formatters.ts";
|
|
29
46
|
|
|
30
47
|
// ── Constants ─────────────────────────────────────────────────────────
|
|
@@ -57,7 +74,12 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
57
74
|
this.theme = asCrewTheme(themeLike);
|
|
58
75
|
this.cachedTheme = this.theme;
|
|
59
76
|
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
|
|
60
|
-
this.unsubscribeEventBus =
|
|
77
|
+
this.unsubscribeEventBus = (() => {
|
|
78
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
|
|
79
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
|
|
80
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
|
|
81
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
82
|
+
})();
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
private buildSignature(runs: WidgetRun[]): string {
|
|
@@ -103,7 +125,7 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
103
125
|
const runningGlyph = spinnerFrame("widget-header");
|
|
104
126
|
|
|
105
127
|
if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
106
|
-
this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
|
|
128
|
+
this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0, width).map((line, index) => {
|
|
107
129
|
if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
|
|
108
130
|
return line;
|
|
109
131
|
});
|
|
@@ -150,7 +172,7 @@ export function updateCrewWidget(
|
|
|
150
172
|
}
|
|
151
173
|
|
|
152
174
|
const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
|
|
153
|
-
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
|
|
175
|
+
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth());
|
|
154
176
|
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
155
177
|
|
|
156
178
|
ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
|
|
@@ -11,12 +11,21 @@ import { Box, Text } from "../layout-primitives.ts";
|
|
|
11
11
|
import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
|
|
12
12
|
import { computePhaseProgress, formatPhaseProgressLine } from "../../runtime/phase-progress.ts";
|
|
13
13
|
import { spinnerFrame } from "../spinner.ts";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { computeLiveDurationMs } from "../live-duration.ts";
|
|
15
|
+
import { getTaskUsage } from "../../runtime/usage-tracker.ts";
|
|
16
|
+
import { agentActivity, agentStats, elapsed, formatTokensCompact, notificationBadge } from "./widget-formatters.ts";
|
|
17
|
+
import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
|
|
16
18
|
import type { WidgetRun } from "./widget-types.ts";
|
|
17
19
|
|
|
18
20
|
const MAX_AGENTS_DISPLAY = 3;
|
|
19
21
|
const FINISHED_LINGER_MAX_AGE = 1;
|
|
22
|
+
/** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
|
|
23
|
+
* (the same default used elsewhere in pi-crew tool renderers) so we never paint
|
|
24
|
+
* a line wider than the smallest expected TUI. Callers SHOULD pass the real
|
|
25
|
+
* width when known (via ctx.width || process.stdout.columns). */
|
|
26
|
+
export const DEFAULT_WIDGET_WIDTH = 100;
|
|
27
|
+
/** Cap per-component text so a single field cannot blow past width on its own. */
|
|
28
|
+
export const TASK_DESC_MAX = 60;
|
|
20
29
|
const ERROR_LINGER_MAX_AGE = 2;
|
|
21
30
|
const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
|
|
22
31
|
|
|
@@ -37,8 +46,12 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
|
|
|
37
46
|
|
|
38
47
|
// ── Line builder ──────────────────────────────────────────────────────
|
|
39
48
|
|
|
40
|
-
export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0): string[] {
|
|
41
|
-
|
|
49
|
+
export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedRuns?: WidgetRun[], notificationCount = 0, width = DEFAULT_WIDGET_WIDTH): string[] {
|
|
50
|
+
// Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
|
|
51
|
+
// auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
|
|
52
|
+
// only `(cwd, frame)` would render an empty line set (regression vs. the
|
|
53
|
+
// pre-refactor implementation that called activeWidgetRuns here).
|
|
54
|
+
const runs = providedRuns ?? activeWidgetRuns(cwd);
|
|
42
55
|
if (!runs.length) return [];
|
|
43
56
|
|
|
44
57
|
const runningGlyph = spinnerFrame("widget-header");
|
|
@@ -56,9 +69,23 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
56
69
|
});
|
|
57
70
|
const completed = agents.filter((a) => a.status === "completed").length;
|
|
58
71
|
const runGlyph = iconForStatus(run.status, { runningGlyph });
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
// Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
|
|
73
|
+
// too minimal (`0/1 agents` only), v5 duplicated the worker activity
|
|
74
|
+
// line (tools/tokens/duration already shown one row below). v6 (this)
|
|
75
|
+
// shows only data that is RUN-level (not already in the per-agent
|
|
76
|
+
// activity line) and is GUARANTEED stable across ticks:
|
|
77
|
+
// - agents count — from `agents` array, always populated, never empty.
|
|
78
|
+
// - run elapsed — from `run.createdAt`, always set on manifest.
|
|
79
|
+
// Both come from sources with no race window — `agents` is read from
|
|
80
|
+
// snapshot.agents OR agentsFor(run) (both always return same length
|
|
81
|
+
// for a healthy run), and `run.createdAt` is immutable. The format
|
|
82
|
+
// shape `"X/Y agents · Ns"` is therefore truly invariant: same number
|
|
83
|
+
// of `·`-separated fields, same field meanings, every render tick.
|
|
84
|
+
const agentCountText = `${completed}/${agents.length} agents`;
|
|
85
|
+
const runElapsedMs = Math.max(0, Date.now() - new Date(run.createdAt).getTime());
|
|
86
|
+
const runElapsedText = `${Math.floor(runElapsedMs / 1000)}s`;
|
|
87
|
+
const progressPart = `${agentCountText} · ${runElapsedText}`;
|
|
88
|
+
lines.push(truncate(`├─ ${runGlyph} ${shortRunLabel(run)} · ${progressPart} · ${run.runId.slice(-8)}`, width));
|
|
62
89
|
|
|
63
90
|
const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
|
|
64
91
|
|
|
@@ -67,8 +94,9 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
67
94
|
const name = liveHandle?.agent ?? agent.agent;
|
|
68
95
|
const icon = agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
|
|
69
96
|
const stats = agentStats(agent, liveHandle);
|
|
70
|
-
const desc = liveHandle?.description ?? agent.role;
|
|
71
|
-
|
|
97
|
+
const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
|
|
98
|
+
const _finished = truncate(`│ ├─ ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
|
|
99
|
+
lines.push(_finished);
|
|
72
100
|
}
|
|
73
101
|
|
|
74
102
|
const visibleAgents = activeAgents.slice(0, MAX_AGENTS_DISPLAY);
|
|
@@ -79,13 +107,15 @@ export function buildWidgetLines(cwd: string, frame = 0, maxLines = 8, providedR
|
|
|
79
107
|
const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
|
|
80
108
|
const stats = agentStats(agent, liveHandle);
|
|
81
109
|
const name = liveHandle?.agent ?? agent.agent;
|
|
82
|
-
const desc = liveHandle?.description ?? agent.role;
|
|
83
|
-
|
|
84
|
-
lines.push(
|
|
110
|
+
const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
|
|
111
|
+
const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
|
|
112
|
+
lines.push(_activeMain);
|
|
113
|
+
const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
|
|
114
|
+
lines.push(_activity);
|
|
85
115
|
}
|
|
86
116
|
|
|
87
117
|
if (activeAgents.length > MAX_AGENTS_DISPLAY) {
|
|
88
|
-
lines.push(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents
|
|
118
|
+
lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
|
|
89
119
|
}
|
|
90
120
|
|
|
91
121
|
if (lines.length >= maxLines) break;
|
package/src/utils/redaction.ts
CHANGED
|
@@ -97,34 +97,54 @@ export function isSecretKey(keyName: string): boolean {
|
|
|
97
97
|
return false;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
//
|
|
100
|
+
// Boundary chars that may precede an "authorization:" or "Bearer " keyword.
|
|
101
|
+
// Includes '-' so prefixed headers (Proxy-Authorization, X-Authorization) and
|
|
102
|
+
// '\t' so tab-indented headers are recognized. See security review L5.
|
|
103
|
+
const AUTH_HEADER_BOUNDARY_CHARS = new Set([" ", ",", "{", "[", "\"", "\r", "\n", "-", "\t"]);
|
|
104
|
+
function isAuthHeaderBoundary(ch: string | undefined): boolean {
|
|
105
|
+
return ch !== undefined && AUTH_HEADER_BOUNDARY_CHARS.has(ch);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Linear-time Authorization header redaction.
|
|
109
|
+
// L3 fix: scan ALL occurrences (previously first-only via indexOf, so a second
|
|
110
|
+
// "authorization:" on a later line leaked). L5 fix: boundary set includes '-'
|
|
111
|
+
// and '\t' so Proxy-Authorization / X-Authorization / tab-indented headers are
|
|
112
|
+
// redacted. Bearer values are left for redactBearerTokens.
|
|
101
113
|
export function redactAuthHeader(line: string): string {
|
|
102
114
|
const lower = line.toLowerCase();
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return
|
|
115
|
+
let result = "";
|
|
116
|
+
let i = 0; // emit cursor into the original `line`
|
|
117
|
+
let searchFrom = 0; // cursor for the next indexOf scan
|
|
118
|
+
for (;;) {
|
|
119
|
+
const authIdx = lower.indexOf("authorization:", searchFrom);
|
|
120
|
+
if (authIdx === -1) {
|
|
121
|
+
result += line.substring(i);
|
|
122
|
+
return result;
|
|
111
123
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
end
|
|
124
|
+
// Emit the unchanged span up to this occurrence.
|
|
125
|
+
result += line.substring(i, authIdx);
|
|
126
|
+
const isBoundary = authIdx === 0 || isAuthHeaderBoundary(line[authIdx - 1]);
|
|
127
|
+
const afterAuth = lower.substring(authIdx + 14).trimStart();
|
|
128
|
+
if (isBoundary && !afterAuth.startsWith("bearer ")) {
|
|
129
|
+
// Regular Authorization header — blank the credential value (the rest
|
|
130
|
+
// of the line is the credential). Appending only a marker would leave
|
|
131
|
+
// the secret bytes visible; replace them with "***". Bearer values are
|
|
132
|
+
// left intact here for redactBearerTokens. See security review L3/L5.
|
|
133
|
+
let end = authIdx + 14;
|
|
134
|
+
while (end < line.length && line[end] !== "\r" && line[end] !== "\n") {
|
|
135
|
+
end++;
|
|
136
|
+
}
|
|
137
|
+
result += line.substring(authIdx, authIdx + 14) + " ***";
|
|
138
|
+
i = end;
|
|
139
|
+
searchFrom = end; // entire line consumed; resume after the line break
|
|
140
|
+
} else {
|
|
141
|
+
// Bearer token (handled by redactBearerTokens) OR not a boundary —
|
|
142
|
+
// keep the "authorization:" literal and continue scanning.
|
|
143
|
+
result += line.substring(authIdx, authIdx + 14);
|
|
144
|
+
i = authIdx + 14;
|
|
145
|
+
searchFrom = authIdx + 14;
|
|
122
146
|
}
|
|
123
|
-
return line.substring(0, end) + " ***" + (end < line.length ? line.substring(end) : "");
|
|
124
147
|
}
|
|
125
|
-
|
|
126
|
-
// It's a Bearer token format - don't redact here, let redactBearerTokens handle it
|
|
127
|
-
return line;
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
// Linear-time Bearer token redaction
|
|
@@ -135,14 +155,12 @@ export function redactBearerTokens(line: string): string {
|
|
|
135
155
|
|
|
136
156
|
while (i < line.length) {
|
|
137
157
|
if (upper.startsWith("BEARER ", i)) {
|
|
138
|
-
// Check word boundary:
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
continue;
|
|
145
|
-
}
|
|
158
|
+
// Check word boundary: start-of-string or a boundary char. Includes '-'
|
|
159
|
+
// and '\t' (L5) so "Proxy-Authorization: Bearer ..." is redacted.
|
|
160
|
+
if (i > 0 && !isAuthHeaderBoundary(line[i - 1])) {
|
|
161
|
+
result.push(line[i]);
|
|
162
|
+
i++;
|
|
163
|
+
continue;
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
// Found "Bearer " - now find the token
|
|
@@ -208,6 +226,22 @@ export function redactSecretString(value: string): string {
|
|
|
208
226
|
// i++ (advance 1 char) and re-scanned from i+1, so a long run was rescanned O(n)
|
|
209
227
|
// times = O(n^2). The P1f ReDoS test (300KB no-dot input) surfaced this pre-existing
|
|
210
228
|
// bug. Now advances past the whole run when it isn't a redactable secret -> O(n).
|
|
229
|
+
// FIX (BG2 follow-up): the inner-loop regex test /[a-zA-Z0-9_-]/.test(value[j])
|
|
230
|
+
// is O(1) per call but with measurable regex-engine overhead — for a 100KB
|
|
231
|
+
// underscore run that's 100K regex calls, well over the 200ms budget the
|
|
232
|
+
// P1f regression test allows. Replaced with a charCodeAt check (~5x faster
|
|
233
|
+
// in practice, O(1) per call with no regex allocation/eval cost).
|
|
234
|
+
function isKeyChar(c: string): boolean {
|
|
235
|
+
const code = c.charCodeAt(0);
|
|
236
|
+
return (
|
|
237
|
+
(code >= 48 && code <= 57) || // 0-9
|
|
238
|
+
(code >= 65 && code <= 90) || // A-Z
|
|
239
|
+
(code >= 97 && code <= 122) || // a-z
|
|
240
|
+
code === 95 || // _
|
|
241
|
+
code === 45 // -
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
211
245
|
function redactInlineSecrets(value: string): string {
|
|
212
246
|
const result: string[] = [];
|
|
213
247
|
let i = 0;
|
|
@@ -215,7 +249,7 @@ function redactInlineSecrets(value: string): string {
|
|
|
215
249
|
while (i < value.length) {
|
|
216
250
|
// Collect a run of key characters (alphanumeric, underscore, hyphen).
|
|
217
251
|
let j = i;
|
|
218
|
-
while (j < value.length &&
|
|
252
|
+
while (j < value.length && isKeyChar(value[j])) {
|
|
219
253
|
j++;
|
|
220
254
|
}
|
|
221
255
|
const keyLen = j - i;
|
package/src/utils/visual.ts
CHANGED
|
@@ -29,6 +29,12 @@ const WIDE_RANGES: Array<[number, number]> = [
|
|
|
29
29
|
[0x274C, 0x274C], [0x274E, 0x274E], [0x2753, 0x2755], [0x2757, 0x2757],
|
|
30
30
|
[0x2763, 0x2764], [0x2795, 0x2797], [0x27A1, 0x27A1], [0x27B0, 0x27B0],
|
|
31
31
|
[0x27BF, 0x27BF],
|
|
32
|
+
// Geometric Shapes / Misc Symbols-Arrows emoji that pi-tui upstream counts as
|
|
33
|
+
// width=2 (RGI emoji). Mismatch here caused the "Rendered line N exceeds
|
|
34
|
+
// terminal width (160 > 159)" TUI crash: pi-crew truncated to width 159 by
|
|
35
|
+
// its own (mismatched) measure, then Box padded to 159 chars, but pi-tui
|
|
36
|
+
// re-measured the padded line at 160 because ⬜ counts as 2 upstream.
|
|
37
|
+
[0x2B1B, 0x2B1C], // ⬛ BLACK LARGE SQUARE, ⬜ WHITE LARGE SQUARE
|
|
32
38
|
[0x1F300, 0x1F9FF], // Misc Symbols, Emoticons, Transport, Map, Supplement
|
|
33
39
|
[0x1FA00, 0x1FAFF], // Symbols Extended-A
|
|
34
40
|
[0x1F000, 0x1F02F], // Mahjong, Dominos
|