feat: add tips banner below welcome panel on startup (#655)
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* 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.
This commit is contained in:
liruifengv 2026-06-11 22:20:31 +08:00 committed by GitHub
parent 0927f79883
commit 1e2e679693
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 728 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Display a tips banner below the welcome panel on startup.

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ coverage/
.kimi-stash-dir
plugins/cdn/
superpowers
.worktrees/

View file

@ -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`;

View file

@ -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<BannerState | null> {
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;
}
}
}

View file

@ -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;
}
}

View file

@ -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<void> {
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<boolean> {
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();

View file

@ -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 {

View file

@ -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();
});
});

View file

@ -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('✦ '));
});
});

View file

@ -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, {