Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tui-auto-session-title.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Generate AI session titles in the CLI, not just in `kimi web`. With the `auto_session_title` experimental flag on, the TUI now asks for a title once a turn completes, so the session name is a short summary instead of the truncated first prompt.
43 changes: 43 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,12 @@ interface SendMessageOptions {
/** How long the one-shot "moved to background" footer hint stays visible. */
const DETACH_HINT_DISPLAY_MS = 4_000;

/** Engine flag id — see `packages/agent-core-v2/src/session/sessionTitle/flag.ts`. */
const AUTO_SESSION_TITLE_FLAG = 'auto_session_title';

/** Generation attempts per session before the TUI stops asking, as in the web client. */
const MAX_SESSION_TITLE_ATTEMPTS = 3;

export class KimiTUI {
readonly harness: KimiHarness;
readonly options: KimiTUIOptions;
Expand All @@ -316,6 +322,8 @@ export class KimiTUI {
private readonly approvalController = new ApprovalController();
private readonly questionController = new QuestionController();
private readonly reverseRpcDisposers: Array<() => void> = [];
/** AI session title bookkeeping, keyed by session id — see maybeGenerateSessionTitle. */
private readonly sessionTitleGeneration = new Map<string, { attempts: number; done: boolean }>();
private skillCommands: readonly KimiSlashCommand[] = [];
readonly skillCommandMap = new Map<string, string>();
private pluginCommands: readonly KimiSlashCommand[] = [];
Expand Down Expand Up @@ -1731,6 +1739,41 @@ export class KimiTUI {

handleTurnEnded(event: TurnEndedEvent): void {
this.staging.handleTurnEnded(event);
this.maybeGenerateSessionTitle(event);
}

/**
* The engine generates an AI session title only when a client asks for one.
* `kimi web` asks every time a turn settles; the TUI never asked, so turning
* "AI session titles" on in /experiments changed nothing here and the title
* stayed the truncated first prompt. Ask on the same trigger and with the
* same policy as the web client: the `first_turn` excerpt, at most
* MAX_SESSION_TITLE_ATTEMPTS tries per session, and no further tries once one
* lands. The rest is already wired — the engine leaves a renamed or already
* generated title alone, and the `session.meta.updated` it publishes reaches
* the status panel and the terminal title through handleSessionMetaChanged.
*/
private maybeGenerateSessionTitle(event: TurnEndedEvent): void {
if (event.reason !== 'completed') return;
if (!this.engineV2) return;
if (!isExperimentalFlagEnabled(AUTO_SESSION_TITLE_FLAG)) return;
const sessionId = this.session?.id;
if (sessionId === undefined) return;
const state = this.sessionTitleGeneration.get(sessionId);
if (state?.done === true) return;
const attempts = state?.attempts ?? 0;
if (attempts >= MAX_SESSION_TITLE_ATTEMPTS) return;
this.sessionTitleGeneration.set(sessionId, { attempts: attempts + 1, done: false });
void this.harness
.generateSessionTitle({ id: sessionId, source: 'first_turn' })
.then((title) => {
if (title === undefined) return;
this.sessionTitleGeneration.set(sessionId, { attempts: 0, done: true });
})
.catch(() => {
// A title is cosmetic: keep the prompt-derived one and let a later
// turn retry while the attempt budget lasts.
});
}

releaseStagingMedia(mediaAttachmentIds: readonly number[]): void {
Expand Down
87 changes: 87 additions & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
import type { SessionReplayRenderer } from '#/tui/controllers/session-replay';
import type { StreamingUIController } from '#/tui/controllers/streaming-ui';
import { setExperimentalFeatures } from '#/tui/commands/experimental-flags';
import { handleFeedbackCommand } from '#/tui/commands/info';
import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text';
import { openUrl } from '#/utils/open-url';
Expand Down Expand Up @@ -8530,3 +8531,89 @@ describe('transcript step and assistant folding', () => {
expect(stripSgr(lastAssistant.render(120).join('\n'))).toContain(`msg-${cycles - 1}`);
});
});
describe('AI session titles', () => {
// The flag snapshot is module state shared by every driver in this file.
afterEach(() => {
setExperimentalFeatures([]);
});

async function makeTitleDriver(
generateSessionTitle: ReturnType<typeof vi.fn>,
flagEnabled = true,
): Promise<MessageDriver> {
const session = makeSession();
const { driver } = await makeDriver(
session,
{
getExperimentalFeatures: vi.fn(async () => [
{ id: 'auto_session_title', enabled: flagEnabled },
]),
generateSessionTitle,
},
{ ...makeStartupInput(), engineV2: true },
);
// The v2 engine starts session-less and creates on first prompt; the turn
// events below stand in for that first prompt.
await driver.setSession(session);
return driver;
}

it('asks the engine to generate a title when a turn completes', async () => {
const generateSessionTitle = vi.fn(async () => 'Wire up the terminal title');
const driver = await makeTitleDriver(generateSessionTitle);

emitTurn(driver, 1);

await vi.waitFor(() => {
expect(generateSessionTitle).toHaveBeenCalledWith({
id: 'ses-1',
source: 'first_turn',
});
});

// A generated title is applied once; later turns must not ask again.
emitTurn(driver, 2);
expect(generateSessionTitle).toHaveBeenCalledTimes(1);
});

it('stays quiet while the experimental flag is off', async () => {
const generateSessionTitle = vi.fn(async () => 'Never requested');
const driver = await makeTitleDriver(generateSessionTitle, false);

emitTurn(driver, 1);

expect(generateSessionTitle).not.toHaveBeenCalled();
});

it('ignores a cancelled turn', async () => {
const generateSessionTitle = vi.fn(async () => 'Never requested');
const driver = await makeTitleDriver(generateSessionTitle);

driver.sessionEventHandler.handleEvent(
{ type: 'turn.started', agentId: 'main', turnId: 1, origin: { kind: 'user' } } as Event,
vi.fn(),
);
driver.sessionEventHandler.handleEvent(
{ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'cancelled' } as Event,
vi.fn(),
);

expect(generateSessionTitle).not.toHaveBeenCalled();
});

it('gives up after three unproductive attempts', async () => {
// `undefined` is what the engine returns when it declines to generate —
// no managed login, or the platform request failed.
const generateSessionTitle = vi.fn(async () => undefined);
const driver = await makeTitleDriver(generateSessionTitle);

for (const turnId of [1, 2, 3, 4]) {
emitTurn(driver, turnId);
await vi.waitFor(() => {
expect(generateSessionTitle).toHaveBeenCalledTimes(Math.min(turnId, 3));
});
}

expect(generateSessionTitle).toHaveBeenCalledTimes(3);
});
});
1 change: 1 addition & 0 deletions docs/en/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Switches that control the behavior of subsystems such as telemetry, background t
| `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI |
| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental [subagent model pool](./config-files.md#subagent-model-pool) in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | Enable the experimental `fork` parameter on the `Agent` and `AgentSwarm` tools, letting the model start a subagent with a snapshot of the calling agent's conversation history instead of an empty context; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE` | Let the managed `chat_title` tool name the session from the conversation instead of keeping the truncated first prompt; requires a managed Kimi Code OAuth login, and the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` |
| `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored |
| `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored |
| `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ kimi
| `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 |
| `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的[subagent 模型池](./config-files.md#subagent-模型池);master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK` | 在 `Agent` 和 `AgentSwarm` 工具上启用实验性的 `fork` 参数,让模型可以以调用方 Agent 对话历史的快照而不是空上下文启动 subagent;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE` | 让托管的 `chat_title` 工具根据对话内容生成会话标题,而不是沿用截断后的第一条提示词;需要托管的 Kimi Code OAuth 登录,master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` |
| `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 |
| `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 |
| `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 |
Expand Down