From ada3c6d4471e26e6d51c49e9b5d5f6609819b185 Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Sat, 30 May 2026 06:08:10 +0100 Subject: [PATCH 1/2] fix: invalidate assistant Markdown instances on theme change (Codex review) The pi-tui Markdown component caches rendered output by text+width and caches the defaultStylePrefix for its entire lifetime. After a theme switch, existing AssistantMessageComponent instances held stale ANSI colors because the Markdown child was never rebuilt. - Add ThemeAwareComponent interface + isThemeAware type guard to component-capabilities.ts following the existing Expandable pattern - Add AssistantMessageComponent.applyTheme() that rebuilds the Markdown child with the new markdownTheme and defaultTextStyle - Wire KimiTUI.applyTheme() to iterate transcript children and call applyTheme() on any theme-aware component --- .../components/messages/assistant-message.ts | 18 ++++++++++++++++-- apps/kimi-code/src/tui/kimi-tui.ts | 7 ++++++- .../src/tui/utils/component-capabilities.ts | 17 +++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 1be89b2ca..60ce73f30 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -5,7 +5,7 @@ * to align after the bullet. */ -import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import type { Component, DefaultTextStyle, MarkdownTheme } from '@earendil-works/pi-tui'; import { Container, Markdown, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; @@ -16,12 +16,14 @@ import type { ColorPalette } from '#/tui/theme/colors'; export class AssistantMessageComponent implements Component { private contentContainer: Container; private markdownTheme: MarkdownTheme; + private defaultTextStyle: DefaultTextStyle; private bulletColor: string; private lastText = ''; private showBullet: boolean; constructor(markdownTheme: MarkdownTheme, colors: ColorPalette, showBullet: boolean = true) { this.markdownTheme = markdownTheme; + this.defaultTextStyle = { color: (text) => chalk.hex(colors.text)(text) }; this.bulletColor = colors.roleAssistant; this.showBullet = showBullet; this.contentContainer = new Container(); @@ -31,13 +33,25 @@ export class AssistantMessageComponent implements Component { this.showBullet = show; } + applyTheme(markdownTheme: MarkdownTheme, colors: ColorPalette): void { + this.markdownTheme = markdownTheme; + this.bulletColor = colors.roleAssistant; + this.defaultTextStyle = { color: (text) => chalk.hex(colors.text)(text) }; + if (this.lastText) { + this.contentContainer.clear(); + this.contentContainer.addChild( + new Markdown(this.lastText.trim(), 0, 0, this.markdownTheme, this.defaultTextStyle), + ); + } + } + updateContent(text: string): void { const displayText = text; if (displayText === this.lastText) return; this.lastText = displayText; this.contentContainer.clear(); if (displayText.trim().length > 0) { - this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme)); + this.contentContainer.addChild(new Markdown(displayText.trim(), 0, 0, this.markdownTheme, this.defaultTextStyle)); } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index db152fe27..ec5b85364 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -112,7 +112,7 @@ import { type TUIStartupState, } from './types'; import { createTUIState, type TUIState } from './tui-state'; -import { isExpandable, isPlanExpandable } from './utils/component-capabilities'; +import { isExpandable, isPlanExpandable, isThemeAware } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; @@ -1563,6 +1563,11 @@ export class KimiTUI { this.state.theme.styles = nextTheme.styles; this.state.theme.markdownTheme = nextTheme.markdownTheme; this.setAppState({ theme }); + for (const child of this.state.transcriptContainer.children) { + if (isThemeAware(child)) { + child.applyTheme(this.state.theme.markdownTheme, this.state.theme.colors); + } + } this.updateEditorBorderHighlight(); this.state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/utils/component-capabilities.ts b/apps/kimi-code/src/tui/utils/component-capabilities.ts index 5f1f0ba97..dd4672f48 100644 --- a/apps/kimi-code/src/tui/utils/component-capabilities.ts +++ b/apps/kimi-code/src/tui/utils/component-capabilities.ts @@ -1,3 +1,7 @@ +import type { MarkdownTheme } from '@earendil-works/pi-tui'; + +import type { ColorPalette } from '#/tui/theme/colors'; + export interface Expandable { setExpanded(expanded: boolean): void; } @@ -12,6 +16,19 @@ export interface Disposable { dispose(): void; } +export interface ThemeAwareComponent { + applyTheme(markdownTheme: MarkdownTheme, colors: ColorPalette): void; +} + +export function isThemeAware(obj: unknown): obj is ThemeAwareComponent { + return ( + typeof obj === 'object' && + obj !== null && + 'applyTheme' in obj && + typeof (obj as ThemeAwareComponent).applyTheme === 'function' + ); +} + export function isExpandable(obj: unknown): obj is Expandable { return ( typeof obj === 'object' && From 267a8835a9718ae812dd5e6e6d1af9920d392ba1 Mon Sep 17 00:00:00 2001 From: LifeJiggy Date: Thu, 11 Jun 2026 08:04:53 +0100 Subject: [PATCH 2/2] test: add applyTheme and isThemeAware tests for markdown theme invalidation --- .../messages/assistant-message.test.ts | 36 +++++++++- .../tui/utils/component-capabilities.test.ts | 70 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 apps/kimi-code/test/tui/utils/component-capabilities.test.ts diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index 47d0836a9..fdc2cd0df 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; -import { darkColors } from '#/tui/theme/colors'; +import { darkColors, lightColors } from '#/tui/theme/colors'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import { captureProcessWrite } from '../../../helpers/process'; @@ -50,4 +50,38 @@ describe('AssistantMessageComponent', () => { expect(text).toContain(''); expect(text).not.toContain('UserPromptSubmit hook'); }); + + it('re-renders content with new theme after applyTheme', () => { + const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + component.updateContent('hello world'); + + const beforeTheme = component.render(40).map(strip).join('\n'); + expect(beforeTheme).toContain('hello world'); + + component.applyTheme(createMarkdownTheme(lightColors), lightColors); + + const afterTheme = component.render(40).map(strip).join('\n'); + expect(afterTheme).toContain('hello world'); + }); + + it('does not render content when lastText is empty after applyTheme', () => { + const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + + component.applyTheme(createMarkdownTheme(lightColors), lightColors); + + expect(component.render(40)).toEqual([]); + }); + + it('updates bullet color after applyTheme', () => { + const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + component.updateContent('test'); + + const darkRender = component.render(40); + expect(darkRender.some((line) => line.length > 0)).toBe(true); + + component.applyTheme(createMarkdownTheme(lightColors), lightColors); + + const lightRender = component.render(40); + expect(lightRender.some((line) => line.length > 0)).toBe(true); + }); }); diff --git a/apps/kimi-code/test/tui/utils/component-capabilities.test.ts b/apps/kimi-code/test/tui/utils/component-capabilities.test.ts new file mode 100644 index 000000000..062e7cbc8 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/component-capabilities.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; + +import { darkColors } from '#/tui/theme/colors'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { + isExpandable, + isPlanExpandable, + isThemeAware, + hasDispose, +} from '#/tui/utils/component-capabilities'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; + +describe('isThemeAware', () => { + it('returns true for AssistantMessageComponent', () => { + const component = new AssistantMessageComponent(createMarkdownTheme(darkColors), darkColors); + expect(isThemeAware(component)).toBe(true); + }); + + it('returns false for plain objects', () => { + expect(isThemeAware({})).toBe(false); + expect(isThemeAware(null)).toBe(false); + expect(isThemeAware(undefined)).toBe(false); + expect(isThemeAware('string')).toBe(false); + expect(isThemeAware(42)).toBe(false); + }); + + it('returns false for objects with non-function applyTheme', () => { + expect(isThemeAware({ applyTheme: 'not-a-function' })).toBe(false); + }); + + it('returns true for objects implementing ThemeAwareComponent', () => { + const fake = { + applyTheme: () => {}, + }; + expect(isThemeAware(fake)).toBe(true); + }); +}); + +describe('isExpandable', () => { + it('returns true for objects with setExpanded function', () => { + expect(isExpandable({ setExpanded: () => {} })).toBe(true); + }); + + it('returns false for plain objects', () => { + expect(isExpandable({})).toBe(false); + expect(isExpandable(null)).toBe(false); + }); +}); + +describe('isPlanExpandable', () => { + it('returns true for objects with setPlanExpanded function', () => { + expect(isPlanExpandable({ setPlanExpanded: () => true })).toBe(true); + }); + + it('returns false for plain objects', () => { + expect(isPlanExpandable({})).toBe(false); + expect(isPlanExpandable(null)).toBe(false); + }); +}); + +describe('hasDispose', () => { + it('returns true for objects with dispose function', () => { + expect(hasDispose({ dispose: () => {} })).toBe(true); + }); + + it('returns false for plain objects', () => { + expect(hasDispose({})).toBe(false); + expect(hasDispose(null)).toBe(false); + }); +});