From 7b1150ca4f84cc8dc735a95ef57390b753badd0e Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Thu, 20 Aug 2026 01:32:44 +0200 Subject: [PATCH] fix(tui): start a session straight into it, and drop two unsafe glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tui/tui-app.ts | 17 ++++++++++- src/tui/tui-render.ts | 21 +++++++++++-- test/tui/tui-render.test.ts | 61 ++++++++++++++++++++++++++++++++----- 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index cdecf68d..1222199a 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -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)}`); } diff --git a/src/tui/tui-render.ts b/src/tui/tui-render.ts index e393f218..c54f27ca 100644 --- a/src/tui/tui-render.ts +++ b/src/tui/tui-render.ts @@ -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: '…', diff --git a/test/tui/tui-render.test.ts b/test/tui/tui-render.test.ts index 36e07cef..2fa113a7 100644 --- a/test/tui/tui-render.test.ts +++ b/test/tui/tui-render.test.ts @@ -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'); + }); +});