From a0668f35eaad4f0bc9f50b78be54a8b70a95c370 Mon Sep 17 00:00:00 2001 From: Mira Date: Thu, 13 Aug 2026 08:29:10 +0000 Subject: [PATCH 1/2] fix(tui): keep banner main text readable with long tags on narrow terminals The banner layout inlines the tag and wraps the main text into the remaining width. Remote banner configs can set a full-sentence tag (e.g. the 38-char K3 thinking-effort banner), which on narrow terminals leaves the main text only a few columns, so it wraps into a ragged, hard-broken column ("balan/ce", "capab/ility"). When the inline tag would leave the main text fewer than 16 columns, render the tag on its own line and give the main text and subtext the full width, aligned with the tag text. Short tags stay inline; tags wider than the terminal are still dropped as before. --- .../src/tui/components/chrome/banner.ts | 31 ++++++++++--- .../test/tui/components/chrome/banner.test.ts | 44 +++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts index 58b6faa5838..1ecf4af2c28 100644 --- a/apps/kimi-code/src/tui/components/chrome/banner.ts +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -6,6 +6,14 @@ import type { BannerState } from '#/tui/types'; const PREFIX_STAR = '✦'; const PADDING = ' '; +/** + * Minimum column count the main text gets next to an inline tag. A long tag + * (e.g. a full sentence from the remote banner config) can fit on the line + * yet leave only a sliver for the main text, which then wraps into a narrow, + * hard-broken column. When that would happen the tag moves onto its own line + * and the main text uses (nearly) the full width instead. + */ +const MIN_INLINE_MAIN_TEXT_WIDTH = 16; export class BannerComponent implements Component { constructor(private readonly state: BannerState) {} @@ -30,14 +38,22 @@ export class BannerComponent implements Component { const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; const tagWidth = visibleWidth(tagDisplay); const showTag = tagWidth > 0 && tagWidth < width; + // Hanging indent aligning with the tag text (right after "✦ "). + const hangingWidth = visibleWidth(PREFIX_STAR + PADDING); + // If the inline tag would squeeze the main text into too narrow a column, + // render the tag on its own line and give the main text the full width. + const tagOnOwnLine = showTag && width - tagWidth < MIN_INLINE_MAIN_TEXT_WIDTH; + const inlineTag = showTag && !tagOnOwnLine; // Body lines (continuations of the main text) indent to match the first - // line's main-text column, which starts right after the tag display. - const bodyIndent = showTag ? ' '.repeat(tagWidth) : ''; + // line's main-text column, which starts right after the tag display. When + // the tag is on its own line, the main text aligns with the tag text. + const bodyIndent = inlineTag ? ' '.repeat(tagWidth) : tagOnOwnLine ? ' '.repeat(hangingWidth) : ''; // Descriptive subtext lines (the second line in the design) start at the // column after the leading star + space, aligning with the tag text itself. - const descIndent = showTag ? ' '.repeat(visibleWidth(PREFIX_STAR + PADDING)) : ''; - const bodyContentWidth = width - (showTag ? tagWidth : 0); - const descContentWidth = width - (showTag ? visibleWidth(PREFIX_STAR + PADDING) : 0); + const descIndent = showTag ? ' '.repeat(hangingWidth) : ''; + const bodyContentWidth = + width - (inlineTag ? tagWidth : tagOnOwnLine ? hangingWidth : 0); + const descContentWidth = width - (showTag ? hangingWidth : 0); if (bodyContentWidth <= 0) { return ['']; @@ -47,11 +63,14 @@ export class BannerComponent implements Component { const subSegments = this.state.subText ? this.state.subText.split('\n') : []; const result: string[] = []; + if (tagOnOwnLine) { + result.push(tagStyled); + } for (let i = 0; i < mainSegments.length; i++) { const wrapped = wrapTextWithAnsi(mainSegments[i]!, bodyContentWidth); for (let j = 0; j < wrapped.length; j++) { const boldLine = main(wrapped[j]!); - if (i === 0 && j === 0 && showTag) { + if (i === 0 && j === 0 && inlineTag) { result.push(tagDisplay + boldLine); } else { result.push(bodyIndent + boldLine); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts index aecf815d98d..1d2d5034a2a 100644 --- a/apps/kimi-code/test/tui/components/chrome/banner.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -182,7 +182,7 @@ describe('BannerComponent', () => { }); it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { - const width = 20; + const width = 24; const lines = new BannerComponent( makeBannerState({ tag: 'New:', @@ -196,9 +196,47 @@ describe('BannerComponent', () => { expect(lines[0]).toContain('✦ New:'); const firstLine = lines[0]!; const mainTextStart = visibleWidth(firstLine.slice(0, firstLine.indexOf('Line 1'))); - const continuationLine = lines.find((line) => line.includes('lot of'))!; - expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('lot of')))).toBe(mainTextStart); + const continuationLine = lines.find((line) => line.includes('of content'))!; + expect(visibleWidth(continuationLine.slice(0, continuationLine.indexOf('of content')))).toBe(mainTextStart); const subLine = lines.find((line) => line.includes('Sub text'))!; expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); }); + + it('moves a long tag onto its own line so the main text keeps a usable width', () => { + // Regression: remote banner configs can set a full-sentence tag. Inline it + // would leave the main text only a few columns, which hard-breaks words. + const width = 50; + const lines = new BannerComponent( + makeBannerState({ + tag: 'Use Kimi K3 with High thinking effort', + mainText: '- for the best balance between token spend and capability', + subText: 'Run /model to switch to K3 and set thinking effort to High', + }), + ).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + // The tag occupies the first line alone; no main text is squeezed next to it. + expect(lines[0]).toContain('✦ Use Kimi K3 with High thinking effort'); + expect(lines[0]).not.toContain('- for'); + // Words stay intact (no mid-word hard breaks like "balan"/"ce"). + const joined = lines.join('\n'); + for (const word of ['balance', 'between', 'capability', 'thinking', 'effort']) { + expect(joined).toContain(word); + } + // Main text and subtext align with the tag text (right after "✦ "). + const mainLine = lines.find((line) => line.includes('- for'))!; + expect(visibleWidth(mainLine.slice(0, mainLine.indexOf('- for')))).toBe(visibleWidth('✦ ')); + const subLine = lines.find((line) => line.includes('Run /model'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Run /model')))).toBe(visibleWidth('✦ ')); + }); + + it('keeps a short tag inline when the remaining width is enough', () => { + const width = 40; + const lines = new BannerComponent( + makeBannerState({ tag: 'Tip:', mainText: 'Use /help to list commands.' }), + ).render(width); + expect(lines[0]).toContain('✦ Tip:'); + expect(lines[0]).toContain('Use /help'); + }); }); From f8c50c5b743850c1536cb9156e4af3644d3eda25 Mon Sep 17 00:00:00 2001 From: Mira Date: Thu, 13 Aug 2026 09:54:53 +0000 Subject: [PATCH 2/2] chore: add changeset for banner narrow-terminal fix --- .changeset/fix-banner-long-tag-narrow-terminal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-banner-long-tag-narrow-terminal.md diff --git a/.changeset/fix-banner-long-tag-narrow-terminal.md b/.changeset/fix-banner-long-tag-narrow-terminal.md new file mode 100644 index 00000000000..24607f482e3 --- /dev/null +++ b/.changeset/fix-banner-long-tag-narrow-terminal.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix startup banner text wrapping on narrow terminals.