From df6fed1905982be08a007fe7cd527909aaac2b15 Mon Sep 17 00:00:00 2001 From: go-bananas-wwj Date: Tue, 21 Jul 2026 13:25:54 +0000 Subject: [PATCH 1/2] feat(tui): add /titleon and /titleoff to toggle session title in the footer Add two built-in slash commands that toggle whether the footer status bar shows the current session title after the cwd and git branch badge: - /titleon: show the session title in the footer (session-only) - /titleoff: hide it again (default) The toggle lives in AppState (not persisted to tui.toml): it resets on session switch / new / fork / logout, but survives /reload within the same session. The title is capped at 40 display columns with truncateToWidth so CJK titles cannot crowd the status bar, and renames via /title update the footer live through the existing sessionTitle state. Refs #2018 --- .changeset/title-footer-toggle.md | 5 + apps/kimi-code/src/tui/commands/dispatch.ts | 10 ++ apps/kimi-code/src/tui/commands/index.ts | 8 +- apps/kimi-code/src/tui/commands/registry.ts | 14 ++ apps/kimi-code/src/tui/commands/session.ts | 34 ++++ .../src/tui/components/chrome/footer.ts | 6 + .../src/tui/controllers/auth-flow.ts | 2 + apps/kimi-code/src/tui/kimi-tui.ts | 6 +- apps/kimi-code/src/tui/types.ts | 3 + .../test/tui/commands/registry.test.ts | 2 + .../test/tui/commands/session.test.ts | 64 ++++++++ .../test/tui/components/chrome/footer.test.ts | 146 ++++++++++++++++++ .../tui/components/chrome/welcome.test.ts | 1 + .../test/tui/create-tui-state.test.ts | 1 + .../kimi-code/test/tui/message-replay.test.ts | 33 ++++ docs/en/reference/slash-commands.md | 2 + docs/zh/reference/slash-commands.md | 2 + 17 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 .changeset/title-footer-toggle.md create mode 100644 apps/kimi-code/test/tui/commands/session.test.ts diff --git a/.changeset/title-footer-toggle.md b/.changeset/title-footer-toggle.md new file mode 100644 index 0000000000..139a270d6b --- /dev/null +++ b/.changeset/title-footer-toggle.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /titleon and /titleoff slash commands to toggle showing the current session title in the footer after the git branch. The toggle is session-only: it is not persisted to tui.toml and resets when switching to another session or starting a new one. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..ce0b53ad00 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -51,6 +51,8 @@ import { handleForkCommand, handleInitCommand, handleTitleCommand, + handleTitleOffCommand, + handleTitleOnCommand, } from './session'; import { handleSwarmCommand } from './swarm'; import { handleUndoCommand } from './undo'; @@ -89,6 +91,8 @@ export { handleForkCommand, handleInitCommand, handleTitleCommand, + handleTitleOffCommand, + handleTitleOnCommand, } from './session'; export { handleUndoCommand } from './undo'; export { handleWebCommand } from './web'; @@ -330,6 +334,12 @@ async function handleBuiltInSlashCommand( case 'title': await handleTitleCommand(host, args); return; + case 'titleon': + handleTitleOnCommand(host); + return; + case 'titleoff': + handleTitleOffCommand(host); + return; case 'yolo': await handleYoloCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 7449dba9bc..bf630a7877 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -28,7 +28,13 @@ export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand, parseGoalCommand } from './goal'; export { goalArgumentCompletions } from './registry'; -export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; +export { + handleForkCommand, + handleInitCommand, + handleTitleCommand, + handleTitleOffCommand, + handleTitleOnCommand, +} from './session'; export { handleUndoCommand } from './undo'; export { handleWebCommand } from './web'; export { diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..4da4ce7ebf 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -318,6 +318,20 @@ export const BUILTIN_SLASH_COMMANDS = [ argumentHint: '', availability: 'always', }, + { + name: 'titleon', + aliases: [], + description: 'Show session title in the footer (this session only)', + priority: 60, + availability: 'always', + }, + { + name: 'titleoff', + aliases: [], + description: 'Hide session title from the footer', + priority: 60, + availability: 'always', + }, { name: 'usage', aliases: [], diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 2f0870db0d..f9f48dac50 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -46,6 +46,40 @@ export async function handleTitleCommand(host: SlashCommandHost, args: string): host.showStatus(`Session title set to: ${newTitle}`); } +// Narrowed host for the /titleon and /titleoff footer toggles (mirrors the +// UpdatePreferenceHost pattern in config.ts) so tests can use a plain fake. +type SessionTitleFooterHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'showSessionTitleInFooter' + >; + }; + setAppState(patch: Pick<SlashCommandHost['state']['appState'], 'showSessionTitleInFooter'>): void; + showStatus(msg: string, color?: string): void; + track: SlashCommandHost['track']; +}; + +export function handleTitleOnCommand(host: SessionTitleFooterHost): void { + if (host.state.appState.showSessionTitleInFooter) { + host.showStatus('Session title is already shown in the footer.'); + return; + } + host.setAppState({ showSessionTitleInFooter: true }); + host.track('session_title_footer_changed', { visible: true }); + host.showStatus('Session title now shown in the footer (this session only).'); +} + +export function handleTitleOffCommand(host: SessionTitleFooterHost): void { + if (!host.state.appState.showSessionTitleInFooter) { + host.showStatus('Session title is already hidden from the footer.'); + return; + } + host.setAppState({ showSessionTitleInFooter: false }); + host.track('session_title_footer_changed', { visible: false }); + host.showStatus('Session title hidden from the footer.'); +} + export async function handleForkCommand(host: SlashCommandHost, args: string): Promise<void> { void args; const session = host.session; diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..c5cd818a0c 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -30,6 +30,7 @@ import { } from '#/utils/usage/usage-format'; const MAX_CWD_SEGMENTS = 3; +const MAX_FOOTER_TITLE_LENGTH = 40; const GOAL_TIMER_INTERVAL_MS = 1_000; // Toolbar tips — rotates every 10s. Most tips are short and pair up (two @@ -308,6 +309,11 @@ export class FooterComponent implements Component { left.push(formatFooterGitBadge(git, colors)); } + if (state.showSessionTitleInFooter && state.sessionTitle) { + const title = truncateToWidth(state.sessionTitle, MAX_FOOTER_TITLE_LENGTH, '…'); + left.push(chalk.hex(colors.textDim)(title)); + } + const leftLine = left.join(' '); const leftWidth = visibleWidth(leftLine); diff --git a/apps/kimi-code/src/tui/controllers/auth-flow.ts b/apps/kimi-code/src/tui/controllers/auth-flow.ts index a7acc77ce8..2400c11543 100644 --- a/apps/kimi-code/src/tui/controllers/auth-flow.ts +++ b/apps/kimi-code/src/tui/controllers/auth-flow.ts @@ -60,6 +60,7 @@ export class AuthFlowController { maxContextTokens: 0, contextUsage: 0, sessionTitle: null, + showSessionTitleInFooter: false, }); this.host.appendStartupNotice(OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE); this.host.setStartupReady(); @@ -110,6 +111,7 @@ export class AuthFlowController { sessionId: '', model: '', sessionTitle: null, + showSessionTitleInFooter: false, }); await this.host.refreshSkillCommands(); await this.host.refreshPluginCommands(); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 464c7237aa..e8fae66337 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -233,6 +233,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { availableModels: {}, availableProviders: {}, sessionTitle: null, + showSessionTitleInFooter: false, goal: null, mcpServersSummary: null, banner: undefined, @@ -1725,6 +1726,8 @@ export class KimiTUI { async switchToSession(session: Session, statusMessage: string): Promise<void> { this.resetSessionRuntime(); await this.setSession(session); + // /titleon is session-only: a session switch (including /fork) resets it. + this.setAppState({ showSessionTitleInFooter: false }); await this.syncRuntimeState(session); this.updateTerminalTitle(); try { @@ -1797,7 +1800,8 @@ export class KimiTUI { this.resetSessionRuntime(); await this.setSession(session); - this.setAppState({ sessionId: session.id }); + // /titleon is session-only: a new session resets it. + this.setAppState({ sessionId: session.id, showSessionTitleInFooter: false }); try { await this.activateRuntime(); await this.syncRuntimeState(session); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..f7bad5487f 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -55,6 +55,9 @@ export interface AppState { availableModels: Record<string, ModelAlias>; availableProviders: Record<string, ProviderConfig>; sessionTitle: string | null; + /** Session-only toggle (/titleon, /titleoff): show the session title in the + * footer after the git badge. Not persisted; resets on session switch. */ + showSessionTitleInFooter: boolean; /** Current goal snapshot for the footer badge; null/undefined when no active goal. */ goal?: GoalSnapshot | null; mcpServersSummary: string | null; diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index bc4c5894fc..8e10c713c7 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -171,6 +171,8 @@ describe('built-in slash command registry', () => { 'status', 'theme', 'title', + 'titleoff', + 'titleon', 'undo', 'usage', 'version', diff --git a/apps/kimi-code/test/tui/commands/session.test.ts b/apps/kimi-code/test/tui/commands/session.test.ts new file mode 100644 index 0000000000..61234a7f0e --- /dev/null +++ b/apps/kimi-code/test/tui/commands/session.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { handleTitleOffCommand, handleTitleOnCommand } from '#/tui/commands/session'; + +function fakeHost(showSessionTitleInFooter: boolean) { + return { + state: { + appState: { showSessionTitleInFooter }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + track: vi.fn(), + }; +} + +describe('/titleon', () => { + it('enables the footer session title for the current session only', () => { + const host = fakeHost(false); + + handleTitleOnCommand(host); + + expect(host.setAppState).toHaveBeenCalledWith({ showSessionTitleInFooter: true }); + expect(host.track).toHaveBeenCalledWith('session_title_footer_changed', { visible: true }); + expect(host.showStatus).toHaveBeenCalledWith( + 'Session title now shown in the footer (this session only).', + ); + }); + + it('is idempotent when the title is already shown', () => { + const host = fakeHost(true); + + handleTitleOnCommand(host); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.track).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith( + 'Session title is already shown in the footer.', + ); + }); +}); + +describe('/titleoff', () => { + it('disables the footer session title', () => { + const host = fakeHost(true); + + handleTitleOffCommand(host); + + expect(host.setAppState).toHaveBeenCalledWith({ showSessionTitleInFooter: false }); + expect(host.track).toHaveBeenCalledWith('session_title_footer_changed', { visible: false }); + expect(host.showStatus).toHaveBeenCalledWith('Session title hidden from the footer.'); + }); + + it('is idempotent when the title is already hidden', () => { + const host = fakeHost(false); + + handleTitleOffCommand(host); + + expect(host.setAppState).not.toHaveBeenCalled(); + expect(host.track).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith( + 'Session title is already hidden from the footer.', + ); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 2fe6f3e52e..e571b32ea5 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -1,4 +1,8 @@ import chalk from 'chalk'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { FooterComponent } from '#/tui/components/chrome/footer'; @@ -38,6 +42,7 @@ const appState: AppState = { additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, + showSessionTitleInFooter: false, model: 'kimi-k2', permissionMode: 'manual', thinkingEffort: 'off', @@ -187,3 +192,144 @@ describe('FooterComponent displayName override', () => { expect(footer.render(120).join('\n')).not.toContain('Remote Name'); }); }); + +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +function stripAnsi(text: string): string { + return text.replaceAll(ANSI_PATTERN, ''); +} + +describe('FooterComponent session title', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + }); + + it('hides the session title when the toggle is off', () => { + const state: AppState = { + ...appState, + sessionTitle: 'My session', + showSessionTitleInFooter: false, + }; + const footer = new FooterComponent(state); + + expect(stripAnsi(footer.render(120).join('\n'))).not.toContain('My session'); + }); + + it('shows the session title after the git badge when the toggle is on', () => { + const state: AppState = { + ...appState, + sessionTitle: 'My session', + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const rendered = stripAnsi(footer.render(120).join('\n')); + expect(rendered).toContain('My session'); + // Line 1 joins items with two spaces: cwd, then title (no git repo here). + const line1 = rendered.split('\n')[0] ?? ''; + const cwdIndex = line1.indexOf('/tmp/project'); + const titleIndex = line1.indexOf('My session'); + expect(cwdIndex).toBeGreaterThanOrEqual(0); + expect(titleIndex).toBeGreaterThan(cwdIndex); + }); + + it('renders no empty segment when the toggle is on but the title is null', () => { + const withToggle: AppState = { + ...appState, + sessionTitle: null, + showSessionTitleInFooter: true, + }; + const withoutToggle: AppState = { + ...appState, + sessionTitle: null, + showSessionTitleInFooter: false, + }; + + expect(new FooterComponent(withToggle).render(120).join('\n')).toBe( + new FooterComponent(withoutToggle).render(120).join('\n'), + ); + }); + + it('renders the title right after the git branch badge', () => { + const repoDir = mkdtempSync(join(tmpdir(), 'kimi-footer-title-')); + try { + execFileSync('git', ['init', '-b', 'trunk', repoDir]); + const state: AppState = { + ...appState, + workDir: repoDir, + sessionTitle: 'My session', + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const line1 = stripAnsi(footer.render(160).join('\n')).split('\n')[0] ?? ''; + const branchIndex = line1.indexOf('trunk'); + const titleIndex = line1.indexOf('My session'); + expect(branchIndex).toBeGreaterThanOrEqual(0); + expect(titleIndex).toBeGreaterThan(branchIndex); + } finally { + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('renders a title of exactly 40 columns without an ellipsis', () => { + const state: AppState = { + ...appState, + sessionTitle: 'a'.repeat(40), + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const rendered = stripAnsi(footer.render(200).join('\n')); + expect(rendered).toContain('a'.repeat(40)); + expect(rendered).not.toContain('…'); + }); + + it('truncates a 41-column title to width 40 with an ellipsis', () => { + const state: AppState = { + ...appState, + sessionTitle: 'a'.repeat(41), + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const rendered = stripAnsi(footer.render(200).join('\n')); + // Width-aware: 39 columns of text + the 1-column ellipsis. + expect(rendered).toContain(`${'a'.repeat(39)}…`); + expect(rendered).not.toContain('a'.repeat(40)); + }); + + it('caps wide titles by display width, not character count', () => { + const state: AppState = { + ...appState, + sessionTitle: 'a'.repeat(50), + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const rendered = stripAnsi(footer.render(200).join('\n')); + expect(rendered).toContain(`${'a'.repeat(39)}…`); + expect(rendered).not.toContain('a'.repeat(40)); + }); + + it('caps CJK titles by display width (2 columns per character)', () => { + const state: AppState = { + ...appState, + // 25 CJK characters = 50 columns, over the 40-column cap. + sessionTitle: '汉'.repeat(25), + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const rendered = stripAnsi(footer.render(200).join('\n')); + // 19 CJK chars (38 columns) + the 1-column ellipsis = 39 columns. + expect(rendered).toContain(`${'汉'.repeat(19)}…`); + expect(rendered).not.toContain('汉'.repeat(20)); + }); +}); diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index bc1b754fb6..f81ee24301 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -15,6 +15,7 @@ const appState: AppState = { additionalDirs: [], sessionId: 'ses-1', sessionTitle: null, + showSessionTitleInFooter: false, model: 'kimi-k2', permissionMode: 'manual', thinkingEffort: 'off', diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf0702..434af4df60 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -30,6 +30,7 @@ function fakeInitialAppState(): AppState { availableModels: {}, availableProviders: {}, sessionTitle: null, + showSessionTitleInFooter: false, mcpServersSummary: null, }; } diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 20d825ce04..bb9903d661 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -16,6 +16,7 @@ import { describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; +import type { AppState } from '#/tui/types'; import { AgentGroupComponent } from '#/tui/components/messages/agent-group'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { StepSummaryComponent } from '#/tui/components/messages/step-summary'; @@ -41,7 +42,10 @@ interface ReplayDriver { readonly streamingUI: StreamingUIController; readonly sessionEventHandler: SessionEventHandler; init(): Promise<boolean>; + setAppState(patch: Partial<AppState>): void; switchToSession(session: Session, statusMessage: string): Promise<void>; + reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void>; + createNewSession(): Promise<void>; } function makeStartupInput(): KimiTUIStartupInput { @@ -1311,3 +1315,32 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('final text 4'); }); }); + +describe('session title footer toggle lifecycle', () => { + it('resets showSessionTitleInFooter on session switch', async () => { + const driver = await replayIntoDriver([]); + driver.setAppState({ showSessionTitleInFooter: true }); + + await driver.switchToSession(makeSession([]), 'Switched session.'); + + expect(driver.state.appState.showSessionTitleInFooter).toBe(false); + }); + + it('resets showSessionTitleInFooter on a new session', async () => { + const driver = await replayIntoDriver([]); + driver.setAppState({ showSessionTitleInFooter: true }); + + await driver.createNewSession(); + + expect(driver.state.appState.showSessionTitleInFooter).toBe(false); + }); + + it('preserves showSessionTitleInFooter across /reload (same-session reload)', async () => { + const driver = await replayIntoDriver([]); + driver.setAppState({ showSessionTitleInFooter: true }); + + await driver.reloadCurrentSessionView(makeSession([]), 'Session reloaded.'); + + expect(driver.state.appState.showSessionTitleInFooter).toBe(true); + }); +}); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 75c8033564..ec1c37a615 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -31,6 +31,8 @@ Some commands are only available in the idle state. Executing these commands whi | `/tasks` | `/task` | Browse the background task list | Yes | | `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No | | `/title [<text>]` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes | +| `/titleon` | — | Show the current session title in the footer after the git branch (session-only, not persisted) | Yes | +| `/titleoff` | — | Hide the session title from the footer | Yes | | `/compact [<instruction>]` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No | | `/undo [<count>]` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone | No | | `/reload` | — | Reload the current session and apply the latest `config.toml` settings (providers, models, etc.) and `tui.toml` UI preferences, without restarting the CLI | No | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index ec4d2b79dc..6a197a8f62 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -31,6 +31,8 @@ | `/tasks` | `/task` | 浏览后台任务列表 | 是 | | `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 | | `/title [<text>]` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 | +| `/titleon` | — | 在页脚 git 分支后显示当前会话标题(仅当前会话生效,不持久化) | 是 | +| `/titleoff` | — | 在页脚隐藏会话标题 | 是 | | `/compact [<instruction>]` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 | | `/undo [<count>]` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销 | 否 | | `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 | From 2b0b0ffe11955f7e024e342f414e14be96e6718f Mon Sep 17 00:00:00 2001 From: go-bananas-wwj <go-bananas-wwj@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:46:58 +0000 Subject: [PATCH 2/2] fix(tui): collapse multiline session titles to one line in the footer --- .../src/tui/components/chrome/footer.ts | 10 +++++-- .../test/tui/components/chrome/footer.test.ts | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index c5cd818a0c..876f32833f 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -310,8 +310,14 @@ export class FooterComponent implements Component { } if (state.showSessionTitleInFooter && state.sessionTitle) { - const title = truncateToWidth(state.sessionTitle, MAX_FOOTER_TITLE_LENGTH, '…'); - left.push(chalk.hex(colors.textDim)(title)); + // /title accepts pasted multiline text; keep the footer on one line + // (same collapse as the session picker's singleLine). + const title = state.sessionTitle.replaceAll(/\s+/g, ' ').trim(); + if (title.length > 0) { + left.push( + chalk.hex(colors.textDim)(truncateToWidth(title, MAX_FOOTER_TITLE_LENGTH, '…')), + ); + } } const leftLine = left.join(' '); diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index e571b32ea5..33a8f80e18 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -221,6 +221,34 @@ describe('FooterComponent session title', () => { expect(stripAnsi(footer.render(120).join('\n'))).not.toContain('My session'); }); + it('collapses a multiline session title to a single line', () => { + const state: AppState = { + ...appState, + sessionTitle: 'first line\nsecond line', + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const rendered = footer.render(120); + const line1 = stripAnsi(rendered[0] ?? ''); + expect(line1).toContain('first line second line'); + // The newline must not leak into later footer lines either. + expect(stripAnsi(rendered.join('\n'))).not.toContain('\nsecond line'); + }); + + it('collapses tabs and runs of whitespace in the session title', () => { + const state: AppState = { + ...appState, + sessionTitle: 'my\t session title', + showSessionTitleInFooter: true, + }; + const footer = new FooterComponent(state); + + const line1 = stripAnsi(footer.render(120).join('\n')).split('\n')[0] ?? ''; + expect(line1).toContain('my session title'); + expect(line1).not.toContain('\t'); + }); + it('shows the session title after the git badge when the toggle is on', () => { const state: AppState = { ...appState,