mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
fix(tui): stop drawing from the unicode blocks a plain terminal font lacks
Three separate "why are there boxes" reports, and I fixed them one glyph at a
time instead of as a class, so the next one was always waiting. Grouping the
tester's terminal by unicode block made the rule obvious:
RENDERS Latin-1 (·), Box Drawing (─ │), Block Elements (█ ▛ ▐),
Geometric Shapes (○ ▶), General Punctuation (…), Arrows
TOFU Miscellaneous Technical (⏎ U+23CE, ⏵ U+23F5), the sparse end
of Dingbats (❯ U+276F)
That is an ordinary font, not a broken one, so it is the profile to design
against. The working spinner moves off Dingbats and Math Operators onto
quadrant blocks (▖▘▝▗) — the same block as the `▛█▐` art claude itself draws,
which that font renders fine — and the blocked marker moves off `⚠`
(Misc Symbols, emoji presentation on many terminals) onto `▲`, the block that
already gives us `▶` and `○`.
The preview fold gains claude's own spinner dingbats (✢ ✳ ∗ ✻ ✽ ✴ → `*`) and
`⚠` → `!`. Its animated status line is exactly where a reader looks, so tofu
there is the most visible kind there is.
A test now enforces this as a CLASS: no glyph in the unicode set may come from
Misc Technical, Misc Symbols or Dingbats, with U+2714 the single documented
exception because it was observed rendering on the very font that failed the
others. Verified by scanning a live frame driven with the tester's exact
environment: zero glyphs from any of the three blocks.
This commit is contained in:
@@ -354,6 +354,16 @@ const PREVIEW_GLYPH_FOLD: ReadonlyMap<string, string> = new Map([
|
|||||||
['\u23F7', 'v'], // ⏷
|
['\u23F7', 'v'], // ⏷
|
||||||
['\u2771', '>'], // ❱
|
['\u2771', '>'], // ❱
|
||||||
['\u2770', '<'], // ❰
|
['\u2770', '<'], // ❰
|
||||||
|
// claude's own working/done spinner cycles through these, and they are the
|
||||||
|
// same sparse-Dingbats class as `❯`: the animated line is exactly where a
|
||||||
|
// reader looks, so tofu there is the most visible kind.
|
||||||
|
['\u2722', '*'], // ✢
|
||||||
|
['\u2733', '*'], // ✳
|
||||||
|
['\u2217', '*'], // ∗
|
||||||
|
['\u273B', '*'], // ✻
|
||||||
|
['\u273D', '*'], // ✽
|
||||||
|
['\u2734', '*'], // ✴
|
||||||
|
['\u26A0', '!'], // ⚠ Misc Symbols, and emoji-presentation on many terminals
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+20
-8
@@ -134,17 +134,29 @@ export interface TuiGlyphSet {
|
|||||||
*
|
*
|
||||||
* COVERAGE: a plain terminal font carries far less than the unicode TIER
|
* COVERAGE: a plain terminal font carries far less than the unicode TIER
|
||||||
* implies. The tier answers "is the locale UTF-8", which says nothing about
|
* implies. The tier answers "is the locale UTF-8", which says nothing about
|
||||||
* whether a given codepoint has a glyph. A beta tester's font drew `·`, `─`,
|
* whether a given codepoint has a glyph.
|
||||||
* `│`, `○`, `▶` and `✔` perfectly while drawing `⏎` (U+23CE) as an empty box.
|
*
|
||||||
* Prefer Latin-1, Arrows (U+2190–21FF), Box Drawing, Block Elements and
|
* One beta tester's font mapped the blocks like this, and it is the profile to
|
||||||
* Geometric Shapes, which every monospace font ships; treat Dingbats,
|
* design against because it is an ordinary terminal font, not a broken one:
|
||||||
* Miscellaneous Symbols and anything with emoji presentation as suspect.
|
*
|
||||||
|
* RENDERS Latin-1 (·), Box Drawing (─ │), Block Elements (█ ▛ ▐),
|
||||||
|
* Geometric Shapes (○ ▶), General Punctuation (…), Arrows
|
||||||
|
* TOFU Misc Technical (⏎ U+23CE, ⏵ U+23F5), the sparse end of
|
||||||
|
* Dingbats (❯ U+276F)
|
||||||
|
*
|
||||||
|
* So: draw from the blocks on the first line. Dingbats, Miscellaneous
|
||||||
|
* Technical, Miscellaneous Symbols and anything with emoji presentation are
|
||||||
|
* out — that class produced three separate "why are there boxes" reports, one
|
||||||
|
* per glyph, because each was fixed on its own instead of as a class.
|
||||||
*/
|
*/
|
||||||
const UNICODE_GLYPHS: TuiGlyphSet = {
|
const UNICODE_GLYPHS: TuiGlyphSet = {
|
||||||
blockedPermission: '⚠',
|
blockedPermission: '▲',
|
||||||
blockedQuestion: '⚠',
|
blockedQuestion: '▲',
|
||||||
waiting: '!',
|
waiting: '!',
|
||||||
working: ['·', '✢', '✳', '∗', '✻', '✽'],
|
// Quadrant blocks, which rotate as a spinner and live in the same block as
|
||||||
|
// the `▛█▐` art claude itself draws — proven to render on the font that
|
||||||
|
// failed the dingbats this used to use.
|
||||||
|
working: ['▖', '▘', '▝', '▗'],
|
||||||
idle: '○',
|
idle: '○',
|
||||||
recent: '✔',
|
recent: '✔',
|
||||||
cursor: '▶',
|
cursor: '▶',
|
||||||
|
|||||||
+50
-23
@@ -129,12 +129,12 @@ function frameLines(frame: string): string[] {
|
|||||||
describe('renderFrame structure', () => {
|
describe('renderFrame structure', () => {
|
||||||
it('paints the wide layout at 100x30', () => {
|
it('paints the wide layout at 100x30', () => {
|
||||||
expect(frameLines(render(fixture(), 100, 30))).toEqual([
|
expect(frameLines(render(fixture(), 100, 30))).toEqual([
|
||||||
' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit',
|
' codeman ▲ 2 tnode · v1.19.0 · 4 sessions · 5h 32% wk 61% ? help q quit',
|
||||||
' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked',
|
' NEEDS YOU ─────────────────────────│ w4-api-refactor · claude · /home/dev/api · blocked',
|
||||||
' 1 w6-docs ! 11m│ ⚠ requests: Bash(git push origin main)',
|
' 1 w6-docs ! 11m│ ▲ requests: Bash(git push origin main)',
|
||||||
'▶ 2 w4-api-refactor ⚠ 2m 12.3k│ 1. Yes',
|
'▶ 2 w4-api-refactor ▲ 2m 12.3k│ 1. Yes',
|
||||||
" WORKING ───────────────────────────│ 2. Yes, don't ask again",
|
" WORKING ───────────────────────────│ 2. Yes, don't ask again",
|
||||||
' 3 w1-codeman ✻ 17m 45.2k│ 3. No, tell Claude what to do',
|
' 3 w1-codeman ▖ 17m 45.2k│ 3. No, tell Claude what to do',
|
||||||
' IDLE ──────────────────────────────│ y approve · n deny · digit chooses',
|
' IDLE ──────────────────────────────│ y approve · n deny · digit chooses',
|
||||||
' 4 w2-gallery codex ○ 2h│',
|
' 4 w2-gallery codex ○ 2h│',
|
||||||
' RECENT ────────────────────────────│ Actualizing... (2m 14s)',
|
' RECENT ────────────────────────────│ Actualizing... (2m 14s)',
|
||||||
@@ -164,14 +164,14 @@ describe('renderFrame structure', () => {
|
|||||||
|
|
||||||
it('paints the narrow two-line layout at 44x20', () => {
|
it('paints the narrow two-line layout at 44x20', () => {
|
||||||
expect(frameLines(render(fixture(), 44, 20))).toEqual([
|
expect(frameLines(render(fixture(), 44, 20))).toEqual([
|
||||||
' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions',
|
' codeman ▲ 2 tnode · v1.19.0 · 4 sessions',
|
||||||
' NEEDS YOU ─────────────────────────────────',
|
' NEEDS YOU ─────────────────────────────────',
|
||||||
' 1 w6-docs ! 11m',
|
' 1 w6-docs ! 11m',
|
||||||
' /home/dev/docs',
|
' /home/dev/docs',
|
||||||
'▶ 2 w4-api-refactor ⚠ 2m',
|
'▶ 2 w4-api-refactor ▲ 2m',
|
||||||
' /home/dev/api · 12.3k',
|
' /home/dev/api · 12.3k',
|
||||||
' WORKING ───────────────────────────────────',
|
' WORKING ───────────────────────────────────',
|
||||||
' 3 w1-codeman ✻ 17m',
|
' 3 w1-codeman ▖ 17m',
|
||||||
' /home/dev/codeman · 45.2k',
|
' /home/dev/codeman · 45.2k',
|
||||||
' IDLE ──────────────────────────────────────',
|
' IDLE ──────────────────────────────────────',
|
||||||
' 4 w2-gallery codex ○ 2h',
|
' 4 w2-gallery codex ○ 2h',
|
||||||
@@ -223,8 +223,8 @@ describe('color', () => {
|
|||||||
const model = fixture();
|
const model = fixture();
|
||||||
model.select('eee5');
|
model.select('eee5');
|
||||||
const frame = render(model, 100, 30, { color: true });
|
const frame = render(model, 100, 30, { color: true });
|
||||||
expect(frame).toContain('\x1b[32m✻');
|
expect(frame).toContain('\x1b[32m▖');
|
||||||
expect(frame).toContain('\x1b[31m⚠');
|
expect(frame).toContain('\x1b[31m▲');
|
||||||
expect(frame).toContain('\x1b[33m!');
|
expect(frame).toContain('\x1b[33m!');
|
||||||
expect(frame).toContain('\x1b[1mcodeman');
|
expect(frame).toContain('\x1b[1mcodeman');
|
||||||
});
|
});
|
||||||
@@ -235,7 +235,7 @@ describe('color', () => {
|
|||||||
const frame = render(fixture(), 100, 30, { color: true });
|
const frame = render(fixture(), 100, 30, { color: true });
|
||||||
const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? '';
|
const highlighted = frame.split('\x1b[7m')[1]?.split('\x1b[0m')[0] ?? '';
|
||||||
expect(highlighted).toContain('w4-api-refactor');
|
expect(highlighted).toContain('w4-api-refactor');
|
||||||
expect(highlighted).toContain('⚠');
|
expect(highlighted).toContain('▲');
|
||||||
expect(highlighted).not.toContain('\x1b[');
|
expect(highlighted).not.toContain('\x1b[');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,20 +263,22 @@ describe('glyph tiers', () => {
|
|||||||
expect(list[7]).toContain('[-]');
|
expect(list[7]).toContain('[-]');
|
||||||
expect(list[9]).toContain('[v]');
|
expect(list[9]).toContain('[v]');
|
||||||
expect(list[3].startsWith('>')).toBe(true);
|
expect(list[3].startsWith('>')).toBe(true);
|
||||||
expect(lines.join('')).not.toContain('✻');
|
expect(lines.join('')).not.toContain('▝');
|
||||||
expect(lines.join('')).not.toContain('─');
|
expect(lines.join('')).not.toContain('─');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('animates the working glyph with the tick', () => {
|
it('animates the working glyph with the tick, and cycles', () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
const frames = [0, 1, 2, 3, 4, 5].map((tick) => frameLines(render(model, 100, 30, { tick }))[5]);
|
const frames = [0, 1, 2, 3, 4, 5].map((tick) => frameLines(render(model, 100, 30, { tick }))[5]);
|
||||||
expect(frames[0]).toContain('·');
|
// Quadrant blocks, rotating. Four of them, so the tick wraps every four
|
||||||
expect(frames[1]).toContain('✢');
|
// frames rather than every six.
|
||||||
expect(frames[2]).toContain('✳');
|
expect(frames[0]).toContain('▖');
|
||||||
expect(frames[3]).toContain('∗');
|
expect(frames[1]).toContain('▘');
|
||||||
expect(frames[4]).toContain('✻');
|
expect(frames[2]).toContain('▝');
|
||||||
expect(frames[5]).toContain('✽');
|
expect(frames[3]).toContain('▗');
|
||||||
expect(new Set(frames).size).toBe(6);
|
expect(frames[4]).toBe(frames[0]);
|
||||||
|
expect(frames[5]).toBe(frames[1]);
|
||||||
|
expect(new Set(frames).size).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects a tier from the environment', () => {
|
it('detects a tier from the environment', () => {
|
||||||
@@ -449,7 +451,7 @@ describe('formatting helpers', () => {
|
|||||||
describe('the approval card', () => {
|
describe('the approval card', () => {
|
||||||
it('draws the dialog above the tail, with its digits', () => {
|
it('draws the dialog above the tail, with its digits', () => {
|
||||||
const text = frameLines(render(fixture(), 100, 30)).join('\n');
|
const text = frameLines(render(fixture(), 100, 30)).join('\n');
|
||||||
expect(text).toContain('⚠ requests: Bash(git push origin main)');
|
expect(text).toContain('▲ requests: Bash(git push origin main)');
|
||||||
expect(text).toContain('1. Yes');
|
expect(text).toContain('1. Yes');
|
||||||
expect(text).toContain('3. No, tell Claude what to do');
|
expect(text).toContain('3. No, tell Claude what to do');
|
||||||
expect(text).toContain('y approve · n deny · digit chooses');
|
expect(text).toContain('y approve · n deny · digit chooses');
|
||||||
@@ -460,7 +462,7 @@ describe('the approval card', () => {
|
|||||||
it('paints a dialog red and a waiting prompt yellow', () => {
|
it('paints a dialog red and a waiting prompt yellow', () => {
|
||||||
const model = fixture();
|
const model = fixture();
|
||||||
const frame = render(model, 100, 30, { color: true });
|
const frame = render(model, 100, 30, { color: true });
|
||||||
expect(frame).toContain('\x1b[31m ⚠ requests');
|
expect(frame).toContain('\x1b[31m ▲ requests');
|
||||||
|
|
||||||
model.select('bbb2');
|
model.select('bbb2');
|
||||||
const idle = render(model, 100, 30, { color: true });
|
const idle = render(model, 100, 30, { color: true });
|
||||||
@@ -476,10 +478,10 @@ describe('the approval card', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('counts pending prompts in the header badge', () => {
|
it('counts pending prompts in the header badge', () => {
|
||||||
expect(frameLines(render(fixture(), 100, 30))[0]).toContain('⚠ 2');
|
expect(frameLines(render(fixture(), 100, 30))[0]).toContain('▲ 2');
|
||||||
const model = createTuiModel();
|
const model = createTuiModel();
|
||||||
model.replaceSessions([{ sessionId: 'aaa1', name: 'w1', sources: ['live'], status: 'idle' }]);
|
model.replaceSessions([{ sessionId: 'aaa1', name: 'w1', sources: ['live'], status: 'idle' }]);
|
||||||
expect(frameLines(render(model, 100, 30))[0]).not.toContain('⚠');
|
expect(frameLines(render(model, 100, 30))[0]).not.toContain('▲');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -632,6 +634,7 @@ describe('the unicode glyph set is safe to render', () => {
|
|||||||
UNICODE.boxHorizontal,
|
UNICODE.boxHorizontal,
|
||||||
UNICODE.boxVertical,
|
UNICODE.boxVertical,
|
||||||
UNICODE.enter,
|
UNICODE.enter,
|
||||||
|
UNICODE.updown,
|
||||||
UNICODE.separator,
|
UNICODE.separator,
|
||||||
UNICODE.ellipsis,
|
UNICODE.ellipsis,
|
||||||
];
|
];
|
||||||
@@ -650,6 +653,30 @@ describe('the unicode glyph set is safe to render', () => {
|
|||||||
expect(UNICODE.enter).not.toBe('\u23CE');
|
expect(UNICODE.enter).not.toBe('\u23CE');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws only from blocks a plain terminal font actually carries', () => {
|
||||||
|
// The rule, as a CLASS rather than one glyph at a time. Three separate
|
||||||
|
// "why are there boxes" reports came from this list, each fixed alone:
|
||||||
|
// ❯ (U+276F, sparse Dingbats), ⏵ (U+23F5) and ⏎ (U+23CE, both Misc
|
||||||
|
// Technical). The same font drew Box Drawing, Block Elements, Geometric
|
||||||
|
// Shapes and Latin-1 perfectly, so those are what the set may use.
|
||||||
|
const BANNED: Array<[number, number, string]> = [
|
||||||
|
[0x2300, 0x23ff, 'Miscellaneous Technical'],
|
||||||
|
[0x2600, 0x26ff, 'Miscellaneous Symbols'],
|
||||||
|
[0x2700, 0x27bf, 'Dingbats'],
|
||||||
|
];
|
||||||
|
// U+2714 is the one Dingbat kept: it was observed rendering on the very
|
||||||
|
// font that failed the others, and it is the list's "done" mark.
|
||||||
|
const ALLOWED = new Set([0x2714]);
|
||||||
|
for (const glyph of every) {
|
||||||
|
for (const char of glyph) {
|
||||||
|
const cp = char.codePointAt(0) ?? 0;
|
||||||
|
if (ALLOWED.has(cp)) continue;
|
||||||
|
const banned = BANNED.find(([lo, hi]) => cp >= lo && cp <= hi);
|
||||||
|
expect({ glyph, block: banned?.[2] ?? null }).toEqual({ glyph, block: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('has no emoji where a text glyph belongs', () => {
|
it('has no emoji where a text glyph belongs', () => {
|
||||||
// U+270B is Wide AND emoji-presentation: it drew at emoji size mid-row.
|
// U+270B is Wide AND emoji-presentation: it drew at emoji size mid-row.
|
||||||
expect(every.join('')).not.toContain('\u270B');
|
expect(every.join('')).not.toContain('\u270B');
|
||||||
|
|||||||
Reference in New Issue
Block a user