From 1e2e679693af2fc97826078aa671555a3a900349 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Thu, 11 Jun 2026 22:20:31 +0800 Subject: [PATCH] feat: add tips banner below welcome panel on startup (#655) * chore: ignore .worktrees directory * feat: add tips banner below welcome panel on startup - Fetch active/fallback tips from the configured CDN with a 3s timeout. - Filter tips by semver, client version, and date range. - Render the banner directly below the welcome panel on startup/resume. - Support tag, multi-line text, subtext, automatic wrapping, and narrow-terminal safety. * refactor(tui): use theme color methods in banner component Replace raw chalk calls with currentTheme helpers: tag uses boldFg('primary'), main text uses boldFg('textStrong'), and subtext uses fg('textDim') without stacking the dim modifier on the already dim shade. Strengthen tests to assert the exact themed ANSI output. --- .changeset/tips-banner.md | 5 + .gitignore | 1 + apps/kimi-code/src/constant/app.ts | 1 + .../src/tui/banner/banner-provider.ts | 146 ++++++++++++ .../src/tui/components/chrome/banner.ts | 76 ++++++ apps/kimi-code/src/tui/kimi-tui.ts | 36 +++ apps/kimi-code/src/tui/types.ts | 8 + .../test/tui/banner/banner-provider.test.ts | 217 ++++++++++++++++++ .../test/tui/components/chrome/banner.test.ts | 193 ++++++++++++++++ .../test/tui/kimi-tui-startup.test.ts | 45 ++++ 10 files changed, 728 insertions(+) create mode 100644 .changeset/tips-banner.md create mode 100644 apps/kimi-code/src/tui/banner/banner-provider.ts create mode 100644 apps/kimi-code/src/tui/components/chrome/banner.ts create mode 100644 apps/kimi-code/test/tui/banner/banner-provider.test.ts create mode 100644 apps/kimi-code/test/tui/components/chrome/banner.test.ts diff --git a/.changeset/tips-banner.md b/.changeset/tips-banner.md new file mode 100644 index 000000000..2e47cc18a --- /dev/null +++ b/.changeset/tips-banner.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Display a tips banner below the welcome panel on startup. diff --git a/.gitignore b/.gitignore index 3fdbd2d60..691c675b7 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ coverage/ .kimi-stash-dir plugins/cdn/ superpowers +.worktrees/ diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index 535ec138e..623f7e715 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -45,6 +45,7 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; // CDN source of truth: all version checks and native install scripts pull from here. export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code'; export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`; +export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; diff --git a/apps/kimi-code/src/tui/banner/banner-provider.ts b/apps/kimi-code/src/tui/banner/banner-provider.ts new file mode 100644 index 000000000..c9707e2f7 --- /dev/null +++ b/apps/kimi-code/src/tui/banner/banner-provider.ts @@ -0,0 +1,146 @@ +import { gte, valid } from 'semver'; + +import { KIMI_CODE_TIPS_BANNER_URL } from '#/constant/app'; +import type { BannerState } from '#/tui/types'; + +interface TipsBannerFallbackItem { + enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_min_version?: string | null; +} + +interface TipsBannerJson { + banner_enabled?: boolean; + banner_title?: string | null; + banner_maintext?: string; + banner_subtext?: string | null; + banner_start_time?: string | null; + banner_end_time?: string | null; + banner_min_version?: string | null; + banner_fallback_enabled?: boolean; + banner_fallback_list?: unknown[]; +} + +function normalizeTag(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeUtcDate(value: string): string { + if (value.endsWith('Z')) return value; + if (/[+-]\d{2}:\d{2}$/.test(value)) return value; + return `${value}Z`; +} + +function parseDate(value: unknown): Date | null { + if (typeof value !== 'string' || value.length === 0) return null; + const normalized = normalizeUtcDate(value); + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; +} + +function isWithinWindow(start: Date | null, end: Date | null, now: Date): boolean { + if (start !== null && now < start) return false; + if (end !== null && now > end) return false; + return true; +} + +function meetsMinVersion(minVersion: unknown, clientVersion: string): boolean { + if (minVersion === undefined || minVersion === null) return true; + if (typeof minVersion !== 'string' || minVersion.length === 0) return true; + const min = valid(minVersion); + const current = valid(clientVersion); + if (min === null || current === null) return false; + return gte(current, min); +} + +function pickActiveBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, +): BannerState | null { + if (json.banner_enabled !== true) return null; + if (!meetsMinVersion(json.banner_min_version, clientVersion)) return null; + const start = parseDate(json.banner_start_time); + const end = parseDate(json.banner_end_time); + if (!isWithinWindow(start, end, now)) return null; + const mainText = normalizeText(json.banner_maintext); + if (mainText === null) return null; + return { + tag: normalizeTag(json.banner_title), + mainText, + subText: normalizeText(json.banner_subtext), + }; +} + +function pickFallbackBanner( + json: TipsBannerJson, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + if (json.banner_fallback_enabled !== true) return null; + const list = Array.isArray(json.banner_fallback_list) ? json.banner_fallback_list : []; + const candidates: BannerState[] = []; + for (const raw of list) { + if (typeof raw !== 'object' || raw === null) continue; + const item = raw as TipsBannerFallbackItem; + if (item.enabled !== true) continue; + if (!meetsMinVersion(item.banner_min_version, clientVersion)) continue; + const mainText = normalizeText(item.banner_maintext); + if (mainText === null) continue; + candidates.push({ + tag: normalizeTag(item.banner_title), + mainText, + subText: normalizeText(item.banner_subtext), + }); + } + if (candidates.length === 0) return null; + const index = Math.floor(random() * candidates.length); + return candidates[index]!; +} + +export function selectBannerState( + json: unknown, + clientVersion: string, + now: Date, + random: () => number, +): BannerState | null { + const typed = typeof json === 'object' && json !== null ? (json as TipsBannerJson) : {}; + return ( + pickActiveBanner(typed, clientVersion, now) ?? + pickFallbackBanner(typed, clientVersion, now, random) + ); +} + +export class BannerProvider { + constructor( + private readonly clientVersion: string, + private readonly url: string = KIMI_CODE_TIPS_BANNER_URL, + ) {} + + async load(fetchImpl: typeof fetch = fetch): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 3000); + const response = await fetchImpl(this.url, { signal: controller.signal }); + clearTimeout(timeout); + if (!response.ok) return null; + const json = await response.json(); + return selectBannerState(json, this.clientVersion, new Date(), Math.random); + } catch { + return null; + } + } +} diff --git a/apps/kimi-code/src/tui/components/chrome/banner.ts b/apps/kimi-code/src/tui/components/chrome/banner.ts new file mode 100644 index 000000000..265f0b02e --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/banner.ts @@ -0,0 +1,76 @@ +import type { Component } from '@earendil-works/pi-tui'; +import { visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui'; + +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const PREFIX_STAR = '✦'; +const PADDING = ' '; + +export class BannerComponent implements Component { + constructor(private readonly state: BannerState) {} + + invalidate(): void {} + + render(width: number): string[] { + const main = (s: string): string => currentTheme.boldFg('textStrong', s); + const dim = (s: string): string => currentTheme.fg('textDim', s); + + // Render nothing but the trailing blank if the terminal cannot hold a + // single visible column. + if (width < 1) { + return ['']; + } + + const tagText = this.state.tag ?? ''; + // Do not add a colon/tag suffix here; the caller-provided tag includes its + // own punctuation/separator. + const tagLabel = tagText.length > 0 ? `${PREFIX_STAR} ${tagText}` : ''; + const tagStyled = tagLabel.length > 0 ? currentTheme.boldFg('primary', tagLabel) : ''; + const tagDisplay = tagStyled.length > 0 ? tagStyled + PADDING : ''; + const tagWidth = visibleWidth(tagDisplay); + const showTag = tagWidth > 0 && tagWidth < width; + // 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) : ''; + // 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); + + if (bodyContentWidth <= 0) { + return ['']; + } + + const mainSegments = this.state.mainText.split('\n'); + const subSegments = this.state.subText ? this.state.subText.split('\n') : []; + + const result: string[] = []; + 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) { + result.push(tagDisplay + boldLine); + } else { + result.push(bodyIndent + boldLine); + } + } + } + + for (const sub of subSegments) { + const available = descContentWidth <= 0 ? bodyContentWidth : descContentWidth; + const wrapped = wrapTextWithAnsi(sub, available); + for (const line of wrapped) { + result.push(descIndent + dim(line)); + } + } + + // Add a blank line below the banner so the following transcript content + // (e.g. the input prompt / status messages) is visually separated. + result.push(''); + + return result; + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index dcddb8987..2eb00d2c6 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -97,6 +97,8 @@ import { combineStartupNotice, isOAuthLoginRequiredError } from './utils/startup import { adaptPanelResponse } from './reverse-rpc/approval/adapter'; import { ApprovalController } from './reverse-rpc/approval/controller'; import { createApprovalRequestHandler } from './reverse-rpc/approval/handler'; +import { BannerProvider } from './banner/banner-provider'; +import { BannerComponent } from './components/chrome/banner'; import { registerReverseRPCHandlers } from './reverse-rpc/index'; import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; @@ -183,6 +185,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { sessionTitle: null, goal: null, mcpServersSummary: null, + banner: undefined, }; } @@ -408,12 +411,45 @@ export class KimiTUI { } } + private async loadBanner(): Promise { + const provider = new BannerProvider(this.state.appState.version); + this.state.appState.banner = await provider.load(); + if (this.state.appState.banner !== null) { + this.renderBanner(); + this.state.ui.requestRender(); + } + } + + private renderBanner(): void { + if (this.state.appState.banner === null || this.state.appState.banner === undefined) { + return; + } + if ( + this.state.transcriptContainer.children.some( + (child) => child instanceof BannerComponent, + ) + ) { + return; + } + const welcomeIndex = this.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const banner = new BannerComponent(this.state.appState.banner); + if (welcomeIndex >= 0) { + this.state.transcriptContainer.children.splice(welcomeIndex + 1, 0, banner); + } else { + this.state.transcriptContainer.children.unshift(banner); + } + this.state.transcriptContainer.invalidate(); + } + private async initMainTui(): Promise { const shouldReplayHistory = await this.init(); // Mount only after init() succeeds; see mountFooter(). this.mountFooter(); this.renderWelcome(); + void this.loadBanner(); this.setupAutocomplete(); void this.loadPersistedInputHistory(); this.state.editorContainer.clear(); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 8bf127096..836db9311 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -12,6 +12,12 @@ import type { NotificationsConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; +export interface BannerState { + tag: string | null; + mainText: string; + subText: string | null; +} + export interface AppState { model: string; workDir: string; @@ -38,6 +44,8 @@ export interface AppState { /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ goal?: GoalSnapshot | null; mcpServersSummary: string | null; + /** Optional banner shown below the welcome panel; null means no banner to render. */ + banner?: BannerState | null; } export interface ToolCallBlockData { diff --git a/apps/kimi-code/test/tui/banner/banner-provider.test.ts b/apps/kimi-code/test/tui/banner/banner-provider.test.ts new file mode 100644 index 000000000..a2036ab31 --- /dev/null +++ b/apps/kimi-code/test/tui/banner/banner-provider.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { selectBannerState } from '#/tui/banner/banner-provider'; + +describe('selectBannerState', () => { + const now = new Date('2026-06-15T12:00:00+08:00'); + + it('returns the active banner when enabled and no time window is set', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: 'Active', + banner_subtext: 'Details', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: 'New', mainText: 'Active', subText: 'Details' }); + }); + + it('returns null when the active banner is outside its time window', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'Old', + banner_maintext: 'Expired', + banner_start_time: '2026-05-01T00:00:00+08:00', + banner_end_time: '2026-05-31T00:00:00+08:00', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('filters out the active banner when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'New', + banner_min_version: '0.15.0', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('picks a random enabled fallback when the active banner is not shown', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_title: 'Tip', banner_maintext: 'First' }, + { enabled: true, banner_title: 'Tip', banner_maintext: 'Second' }, + ], + }, + '0.14.0', + now, + () => 0.75, + ); + expect(result).toEqual({ tag: 'Tip', mainText: 'Second', subText: null }); + }); + + it('filters out fallback entries when the client version is too low', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [ + { enabled: true, banner_maintext: 'Old tip' }, + { enabled: true, banner_maintext: 'New tip', banner_min_version: '0.15.0' }, + ], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Old tip', subText: null }); + }); + + it('returns null when no enabled fallback entries exist', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: false, banner_maintext: 'Hidden' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null for malformed input fields', () => { + expect(selectBannerState({ weird: true }, '0.14.0', now, () => 0)).toBeNull(); + }); + + it('falls back to the fallback list when banner_enabled is missing', () => { + const result = selectBannerState( + { + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats an empty tag as null while still showing the banner', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: '', + banner_maintext: 'No tag', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'No tag', subText: null }); + }); + + it('makes the active banner unavailable when mainText is empty', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_title: 'New', + banner_maintext: '', + banner_fallback_enabled: true, + banner_fallback_list: [{ enabled: true, banner_maintext: 'Fallback' }], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Fallback', subText: null }); + }); + + it('treats missing subtext as null', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Main only', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Main only', subText: null }); + }); + + it('treats empty time fields as always valid', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'Always on', + banner_start_time: '', + banner_end_time: null, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'Always on', subText: null }); + }); + + it('falls back to UTC when timestamps have no timezone', () => { + const result = selectBannerState( + { + banner_enabled: true, + banner_maintext: 'UTC fallback', + banner_start_time: '2026-06-15T04:00:00', + banner_end_time: '2026-06-15T20:00:00', + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toEqual({ tag: null, mainText: 'UTC fallback', subText: null }); + }); + + it('returns null when the fallback list is empty', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + banner_fallback_list: [], + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); + + it('returns null when the fallback list is missing', () => { + const result = selectBannerState( + { + banner_enabled: false, + banner_fallback_enabled: true, + }, + '0.14.0', + now, + () => 0, + ); + expect(result).toBeNull(); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/banner.test.ts b/apps/kimi-code/test/tui/components/chrome/banner.test.ts new file mode 100644 index 000000000..04827fb80 --- /dev/null +++ b/apps/kimi-code/test/tui/components/chrome/banner.test.ts @@ -0,0 +1,193 @@ +import chalk from 'chalk'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { visibleWidth } from '@earendil-works/pi-tui'; + +import { BannerComponent } from '#/tui/components/chrome/banner'; +import { currentTheme } from '#/tui/theme'; +import type { BannerState } from '#/tui/types'; + +const banner: BannerState = { + tag: "What's new:", + mainText: 'This is the main banner message for testing purposes.', + subText: 'This is a short subtext line.', +}; + +describe('BannerComponent', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('renders star tag, main text, and subtext', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new:"); + expect(lines[0]).toContain('This is the main banner message'); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[2]).toBe(''); + }); + + it('does not add an extra colon to the tag', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).not.toContain("What's new::"); + }); + + it('renders without a tag when tag is empty', () => { + const lines = new BannerComponent({ tag: null, mainText: 'Hello', subText: null }).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).toContain('Hello'); + expect(lines[1]).toBe(''); + }); + + it('wraps long main text to fit available width', () => { + const width = 30; + const lines = new BannerComponent(banner).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + expect(lines.some((line) => line.includes('…'))).toBe(false); + const mainContentLines = lines.filter((line) => + /main|banner|message|testing|purposes/.test(line), + ); + expect(mainContentLines.length).toBeGreaterThan(1); + }); + + it('wraps long subtext to fit available width', () => { + const width = 30; + const state: BannerState = { + tag: null, + mainText: 'Short', + subText: 'Short subtext line one plus subtext line two for wrapping tests.', + }; + const lines = new BannerComponent(state).render(width); + expect(lines[0]).toContain('Short'); + const subContentLines = lines.filter((line) => + /Short subtext|line one|plus|subtext|line two|for|wrapping|tests/.test(line), + ); + expect(subContentLines.length).toBeGreaterThan(1); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('keeps every line within terminal width on very narrow terminals', () => { + for (const width of [0, 1, 2, 3, 5, 10]) { + const lines = new BannerComponent(banner).render(width); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(Math.max(0, width)); + } + } + }); + + it('shows tag only on the first wrapped line', () => { + const width = 40; + const state: BannerState = { + tag: 'New:', + mainText: 'This is a very long main text line that should wrap automatically.', + subText: null, + }; + const lines = new BannerComponent(state).render(width); + const mainRows = lines.slice(0, -1); + let tagCount = 0; + for (const line of mainRows) { + if (line.includes('✦ New:')) tagCount += 1; + } + expect(tagCount).toBe(1); + expect(mainRows.length).toBeGreaterThan(1); + const firstIndex = lines.findIndex((line) => line.includes('✦ New:')); + expect(firstIndex).toBe(0); + }); + + it('continues main text under the tag column and keeps subtext aligned with the tag text', () => { + const width = 80; + const lines = new BannerComponent(banner).render(width); + const firstLine = lines[0]!; + const mainStartIndex = firstLine.indexOf('This is the main banner message'); + const tagPrefixVisibleWidth = visibleWidth(firstLine.slice(0, mainStartIndex)); + const subLine = lines[1]!; + const subStartIndex = subLine.indexOf('This is a short subtext'); + const subIndentVisibleWidth = visibleWidth(subLine.slice(0, subStartIndex)); + // The subtext starts two columns after the left edge ("✦ "), which aligns + // with the tag text itself rather than the main-text column. + expect(subIndentVisibleWidth).toBe(visibleWidth('✦ ')); + expect(tagPrefixVisibleWidth).toBeGreaterThan(visibleWidth('✦ ')); + }); + + it('drops the tag when it does not fit', () => { + const width = 5; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).not.toContain('✦'); + expect(lines[0]).not.toContain("What's new"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('still renders the tag when it fits', () => { + const width = 40; + const lines = new BannerComponent(banner).render(width); + expect(lines[0]).toContain('✦'); + expect(lines[0]).toContain("What's new:"); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + }); + + it('does not render subtext when empty', () => { + const lines = new BannerComponent({ tag: 'Tip', mainText: 'Use /help', subText: null }).render(80); + expect(lines.length).toBe(2); + expect(lines[0]).toContain('Use /help'); + expect(lines[1]).toBe(''); + }); + + it('supports explicit newlines in main text', () => { + const lines = new BannerComponent({ tag: null, mainText: 'Line 1\nLine 2', subText: null }).render(80); + expect(lines.length).toBe(3); + expect(lines[0]).toContain('Line 1'); + expect(lines[1]).toContain('Line 2'); + expect(lines[2]).toBe(''); + }); + + it('styles tag, main text, and subtext with theme colors', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[0]).toContain(currentTheme.boldFg('primary', "✦ What's new:")); + expect(lines[0]).toContain( + currentTheme.boldFg('textStrong', 'This is the main banner message for testing purposes.'), + ); + expect(lines[1]).toContain(currentTheme.fg('textDim', 'This is a short subtext line.')); + }); + + it('does not stack the dim modifier on top of the textDim color', () => { + const lines = new BannerComponent(banner).render(80); + expect(lines[1]).toContain('This is a short subtext'); + expect(lines[1]).not.toContain(''); + expect(lines[2]).toBe(''); + }); + + it('keeps subsequent main lines indented to the main-text column and subtext aligned with the tag text', () => { + const width = 20; + const lines = new BannerComponent({ + tag: 'New:', + mainText: 'Line 1 with a lot of content', + subText: 'Sub text', + }).render(width); + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + 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 subLine = lines.find((line) => line.includes('Sub text'))!; + expect(visibleWidth(subLine.slice(0, subLine.indexOf('Sub text')))).toBe(visibleWidth('✦ ')); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 7cc5e1996..f207d3914 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -4,6 +4,9 @@ import type { MigrationPlan } from "@moonshot-ai/migration-legacy"; import { log, type GoalSnapshot } from "@moonshot-ai/kimi-code-sdk"; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from "#/tui/kimi-tui"; +import { BannerProvider } from "#/tui/banner/banner-provider"; +import { BannerComponent } from "#/tui/components/chrome/banner"; +import { WelcomeComponent } from "#/tui/components/chrome/welcome"; import { handleLoginCommand, handleLogoutCommand, @@ -920,6 +923,48 @@ describe("KimiTUI startup", () => { expect(uiContainsFooter(driver)).toBe(true); }); + it("renders the banner below the welcome message after it loads", async () => { + const banner = { + tag: "New", + mainText: "Banner main", + subText: null, + }; + const loadSpy = vi + .spyOn(BannerProvider.prototype, "load") + .mockResolvedValue(banner); + const session = makeSession({ id: "ses-target" }); + const harness = makeHarness(session, { + listSessions: vi.fn(async () => [{ id: "ses-target", workDir: "/tmp/proj-a" }]), + }); + const driver = makeDriver( + harness, + makeStartupInput({ session: "ses-target" }), + ) as unknown as MigrateExitDriver; + + await driver.initMainTui(); + + await vi.waitFor(() => { + expect( + driver.state.transcriptContainer.children.some( + (child) => child instanceof BannerComponent, + ), + ).toBe(true); + }); + + // The banner is rendered directly below the welcome panel so it appears + // above later status messages such as MCP server connection summaries. + const welcomeIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof WelcomeComponent, + ); + const bannerIndex = driver.state.transcriptContainer.children.findIndex( + (child) => child instanceof BannerComponent, + ); + expect(welcomeIndex).toBeGreaterThanOrEqual(0); + expect(bannerIndex).toBe(welcomeIndex + 1); + + loadSpy.mockRestore(); + }); + it("resumes a startup session when Windows workdir uses backslashes", async () => { const session = makeSession({ id: "ses-target" }); const harness = makeHarness(session, {