fix(tui): start a session straight into it, and drop two unsafe glyphs

Two reports from the same beta screenshot.

Starting a session left the user on the dashboard next to the row they had just
asked for, which reads as the create having silently failed. Starting a session
is a request to WORK in it, so the terminal now goes there as soon as the pane
exists, and the CLI booting is worth watching. If the pane is slow the notice
says so and the row is left selected, exactly as the resume path does.

The footer's `↵` was drawing as an empty box: `⏎` (U+23CE) has poor font
coverage, on the same terminal that renders `·`, `─`, `│`, `○`, `▶` and `✔`
perfectly. It is now U+21B5, from the Arrows block every monospace font ships.

`✋` (U+270B) was worse than a coverage problem: it is East Asian WIDE, so the
renderer, which addresses cells by column, was reserving two cells for it. The
golden frames had the age column shifted a space left to match, which is how
long that had been wrong. It is now `!`, and the frames align correctly.

A test walks the whole unicode glyph set and fails on any entry wider than one
cell, so a glyph that shifts the layout cannot be added again. The comment on
the table spells out both bars a glyph has to clear, because the tier check
answers neither: it asks whether the LOCALE is UTF-8, which says nothing about
whether a font has the glyph or how wide it draws.
This commit is contained in:
Codeman maintainer
2026-08-22 14:13:58 +02:00
parent 95a1f540b5
commit 7b1150ca4f
3 changed files with 89 additions and 10 deletions
+16 -1
View File
@@ -2297,8 +2297,23 @@ class TuiApp {
});
// The row appears with the next resync; remember which one to select.
this.pendingSelectId = result.sessionId;
this.message('info', `started ${mode} in ${caseName}`);
this.notice(`starting ${mode} in ${caseName}…`);
this.paint(true);
await this.refresh();
// Starting a session is a request to WORK in it, so the terminal goes
// there as soon as the pane exists. Leaving the user on the dashboard
// next to a session they just asked for reads as the create having
// silently failed (reported from the beta as "it doesn't go into it
// directly"), and the pane is worth watching while the CLI boots.
const fresh = await this.awaitResumedRow(result.sessionId);
if (!fresh) {
this.message('info', `started ${mode} in ${caseName}; its pane is still starting — press ⏎ on the new row`);
this.paint(true);
return;
}
this.pendingSelectId = null;
await this.attachToSession(fresh);
return;
} catch (error) {
this.message('err', `could not start a session in ${caseName}: ${getErrorMessage(error)}`);
}
+19 -2
View File
@@ -123,10 +123,27 @@ export interface TuiGlyphSet {
ellipsis: string;
}
/**
* ⚠️ Every glyph here must clear TWO bars that are easy to miss, and both were
* failed at once by the first version of this table.
*
* WIDTH: the renderer addresses cells by column, so a glyph the terminal draws
* two cells wide shifts everything after it. `east_asian_width` W or F is
* therefore disqualifying. `✋` (U+270B) was Wide, and being an emoji is also
* why fonts render it at emoji size in the middle of a text row.
*
* 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
* whether a given codepoint has a glyph. A beta tester's font drew `·`, `─`,
* `│`, `○`, `▶` and `✔` perfectly while drawing `⏎` (U+23CE) as an empty box.
* Prefer Latin-1, Arrows (U+2190–21FF), Box Drawing, Block Elements and
* Geometric Shapes, which every monospace font ships; treat Dingbats,
* Miscellaneous Symbols and anything with emoji presentation as suspect.
*/
const UNICODE_GLYPHS: TuiGlyphSet = {
blockedPermission: '⚠',
blockedQuestion: '⚠',
waiting: '✋',
waiting: '!',
working: ['·', '✢', '✳', '∗', '✻', '✽'],
idle: '○',
recent: '✔',
@@ -139,7 +156,7 @@ const UNICODE_GLYPHS: TuiGlyphSet = {
boxBottomRight: '┘',
boxHorizontal: '─',
boxVertical: '│',
enter: '⏎',
enter: '↵',
updown: '↑↓',
separator: '·',
ellipsis: '…',
+54 -7
View File
@@ -7,7 +7,7 @@
* NO_COLOR leaves nothing but cursor addressing behind.
*/
import { describe, it, expect } from 'vitest';
import { stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js';
import { charWidth, stripStyles, toDisplayLines, visibleWidth } from '../../src/tui/tui-ansi.js';
import { composerMove, createComposer } from '../../src/tui/tui-composer.js';
import { computeLayout, needsBanner } from '../../src/tui/tui-layout.js';
import { createTuiModel, type TuiModelStore } from '../../src/tui/tui-model.js';
@@ -18,6 +18,7 @@ import {
formatElapsed,
formatPlanUsage,
formatTokens,
glyphsFor,
renderFrame,
rowLabel,
type TuiRenderOptions,
@@ -130,7 +131,7 @@ describe('renderFrame structure', () => {
expect(frameLines(render(fixture(), 100, 30))).toEqual([
' 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',
' 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',
" WORKING ───────────────────────────│ 2. Yes, don't ask again",
' 3 w1-codeman ✻ 17m 45.2k│ 3. No, tell Claude what to do',
@@ -157,7 +158,7 @@ describe('renderFrame structure', () => {
' │',
' │',
' │',
' ↑↓ select · ⏎ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?',
' ↑↓ select · ↵ attach · 1-9 jump · y/n answer · p prompt · n new · x kill · / search · g digest · ?',
]);
});
@@ -165,7 +166,7 @@ describe('renderFrame structure', () => {
expect(frameLines(render(fixture(), 44, 20))).toEqual([
' codeman ⚠ 2 tnode · v1.19.0 · 4 sessions',
' NEEDS YOU ─────────────────────────────────',
' 1 w6-docs ✋ 11m',
' 1 w6-docs ! 11m',
' /home/dev/docs',
'▶ 2 w4-api-refactor ⚠ 2m',
' /home/dev/api · 12.3k',
@@ -182,7 +183,7 @@ describe('renderFrame structure', () => {
'',
'',
'',
' ↑↓ select · ⏎ attach · 1-9 jump · y/n answe',
' ↑↓ select · ↵ attach · 1-9 jump · y/n answe',
]);
});
@@ -224,7 +225,7 @@ describe('color', () => {
const frame = render(model, 100, 30, { color: true });
expect(frame).toContain('\x1b[32m✻');
expect(frame).toContain('\x1b[31m⚠');
expect(frame).toContain('\x1b[33m✋');
expect(frame).toContain('\x1b[33m!');
expect(frame).toContain('\x1b[1mcodeman');
});
@@ -463,7 +464,7 @@ describe('the approval card', () => {
model.select('bbb2');
const idle = render(model, 100, 30, { color: true });
expect(idle).toContain('\x1b[33m ✋');
expect(idle).toContain('\x1b[33m !');
expect(idle).toContain('p to reply');
});
@@ -608,3 +609,49 @@ describe('formatPlanUsage', () => {
expect(formatPlanUsage({})).toBe('');
});
});
describe('the unicode glyph set is safe to render', () => {
// Two failures this pins, both found on one beta tester's terminal:
// a double-width glyph shifting every cell after it, and a codepoint their
// font had no glyph for at all.
const UNICODE = glyphsFor('unicode');
const every = [
UNICODE.blockedPermission,
UNICODE.blockedQuestion,
UNICODE.waiting,
...UNICODE.working,
UNICODE.idle,
UNICODE.recent,
UNICODE.cursor,
UNICODE.rule,
UNICODE.divider,
UNICODE.boxTopLeft,
UNICODE.boxTopRight,
UNICODE.boxBottomLeft,
UNICODE.boxBottomRight,
UNICODE.boxHorizontal,
UNICODE.boxVertical,
UNICODE.enter,
UNICODE.separator,
UNICODE.ellipsis,
];
it('has no double-width glyph, which would shift every cell after it', () => {
for (const glyph of every) {
for (const char of glyph) {
expect({ glyph, width: charWidth(char.codePointAt(0) ?? 0) }).toEqual({ glyph, width: 1 });
}
}
});
it('uses the arrow-block return symbol, not the one fonts lack', () => {
// U+23CE rendered as an empty box on a font that drew everything else here.
expect(UNICODE.enter).toBe('\u21B5');
expect(UNICODE.enter).not.toBe('\u23CE');
});
it('has no emoji where a text glyph belongs', () => {
// U+270B is Wide AND emoji-presentation: it drew at emoji size mid-row.
expect(every.join('')).not.toContain('\u270B');
});
});