Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
097bcf3
feat: add workspace add-dir support
liruifengv Jun 16, 2026
eca90b2
fix: honor --add-dir for resumed sessions
liruifengv Jun 16, 2026
54ad90f
fix: keep additional dirs AGENTS.md out of default context
liruifengv Jun 16, 2026
4c74481
feat: append /add-dir result as user message
liruifengv Jun 16, 2026
8663f50
docs: add add-dir research and follow-up todos
liruifengv Jun 16, 2026
787c5bb
feat: wrap /add-dir output as local-command-stdout
liruifengv Jun 22, 2026
4635a18
feat: reopen /add-dir completion after accepting a directory
liruifengv Jun 22, 2026
98330a7
feat: reopen file mention completion after accepting a directory
liruifengv Jun 22, 2026
5fb8fd8
feat: show inline argument hints for slash commands
liruifengv Jun 22, 2026
4dfde95
test: remove stale additional-dirs AGENTS.md assertion
liruifengv Jun 22, 2026
47e2bf0
merge: sync with main
liruifengv Jun 22, 2026
c998c34
fix: resolve /add-dir paths against workdir and persist via kaos
liruifengv Jun 22, 2026
f5d37e9
fix: expand ~ in /add-dir paths before resolving
liruifengv Jun 22, 2026
70d49bf
chore: remove add-dir dev docs from the branch
liruifengv Jun 22, 2026
b1a58f6
chore: clarify add-dir changeset for users
liruifengv Jun 22, 2026
6eaa759
docs: document /add-dir, --add-dir, and local.toml
liruifengv Jun 22, 2026
dd61bb7
Merge branch 'main' into feat/add-dir-workspace
liruifengv Jun 22, 2026
84597e7
test: flush records before reading wire in add-dir runtime tests
liruifengv Jun 22, 2026
94f7047
Merge branch 'main' into feat/add-dir-workspace
liruifengv Jun 22, 2026
9d3862e
Merge branch 'main' into feat/add-dir-workspace
liruifengv Jun 22, 2026
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
11 changes: 11 additions & 0 deletions .changeset/add-dir-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Added the ability to add extra workspace directories:

- Use the `/add-dir <path>` command to add extra working directories to the current session, or remember them for the project.
- Use `kimi --add-dir <path>` to add them on startup.
- Project-level local config is now managed in `.kimi-code/local.toml`; we recommend adding it to your `.gitignore`.
5 changes: 5 additions & 0 deletions .changeset/path-completion-reopen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Polish file mention UX.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ coverage/
plugins/cdn/
superpowers
.worktrees/
.kimi-code/local.toml

Dockerfile
docker-compose.yml
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export function createProgram(
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(
new Option(
'--add-dir <dir>',
'Add an additional workspace directory for this session. Can be repeated.',
)
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
.default([]),
)
.addOption(new Option('--yes').hideHelp().default(false))
.addOption(new Option('--auto-approve').hideHelp().default(false))
.option('--plan', 'Start in plan mode.', false);
Expand Down Expand Up @@ -123,6 +131,7 @@ export function createProgram(
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
addDirs: raw['addDir'] as string[],
};

onMain(opts);
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface CLIOptions {
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
skillsDirs: string[];
addDirs?: string[];
}

export interface ValidatedOptions {
Expand Down
17 changes: 14 additions & 3 deletions apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@ async function resolvePromptSession(
`Session "${opts.session}" was created under a different directory.`,
);
}
const session = await harness.resumeSession({ id: opts.session });
const session = await harness.resumeSession({
id: opts.session,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
Expand All @@ -261,7 +264,10 @@ async function resolvePromptSession(
const sessions = await harness.listSessions({ workDir });
const previous = sessions[0];
if (previous !== undefined) {
const session = await harness.resumeSession({ id: previous.id });
const session = await harness.resumeSession({
id: previous.id,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
const status = await session.getStatus();
const restorePermission = await forcePromptPermission(
session,
Expand All @@ -284,7 +290,12 @@ async function resolvePromptSession(
}

const model = requireConfiguredModel(opts.model, defaultModel);
const session = await harness.createSession({ workDir, model, permission: 'auto' });
const session = await harness.createSession({
workDir,
model,
permission: 'auto',
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
});
installHeadlessHandlers(session);
return {
session,
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export async function runShell(
const configMs = Date.now() - configStartedAt;
const tui = new KimiTUI(harness, {
cliOptions: opts,
additionalDirs: opts.addDirs?.length ? opts.addDirs : undefined,
tuiConfig,
version,
workDir,
Expand Down
91 changes: 91 additions & 0 deletions apps/kimi-code/src/tui/commands/add-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import type { SlashCommandHost } from './dispatch';

type AddDirChoice = 'session' | 'remember' | 'cancel';

export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> {
const input = args.trim();
const session = host.session;

if (input.length === 0 || input.toLowerCase() === 'list') {
const additionalDirs = session?.summary?.additionalDirs ?? [];
if (additionalDirs.length === 0) {
host.showStatus('No additional directories configured.');
return;
}
host.showStatus(formatAdditionalDirsStatus(additionalDirs));
return;
}

if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

host.mountEditorReplacement(
new ChoicePickerComponent({
title: `Add directory to workspace: ${input}`,
hint: '↑↓ navigate · Enter confirm · Esc cancel',
options: [
{
value: 'session',
label: 'Yes, for this session',
},
{
value: 'remember',
label: 'Yes, and remember this directory',
},
{
value: 'cancel',
label: 'No',
},
],
onSelect: (value) => {
void handleAddDirChoice(host, session.id, input, value as AddDirChoice);
},
onCancel: () => {
host.restoreEditor();
host.showStatus(`Did not add ${input} as a working directory.`);
},
}),
);
}

function formatAdditionalDirsStatus(additionalDirs: readonly string[]): string {
return ['Additional directories:', ...additionalDirs.map((dir) => ` ${dir}`)].join('\n');
}

async function handleAddDirChoice(
host: SlashCommandHost,
sessionId: string,
path: string,
choice: AddDirChoice,
): Promise<void> {
host.restoreEditor();

if (choice === 'cancel') {
host.showStatus(`Did not add ${path} as a working directory.`);
return;
}

const session = host.session;
if (session === undefined || session.id !== sessionId) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

try {
const result = await session.addAdditionalDir(path, { persist: choice === 'remember' });
host.setAppState({ additionalDirs: result.additionalDirs });
host.refreshSlashCommandAutocomplete();
host.showStatus(
choice === 'remember'
? `Added workspace directory:\n ${path}\n Saved to:\n ${result.configPath}`
: `Added workspace directory:\n ${path}\n For this session only`,
'success',
);
} catch (error) {
host.showError(error instanceof Error ? error.message : String(error));
}
}
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
} from './config';
import { handleGoalCommand } from './goal';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handleAddDirCommand } from './add-dir';
import { parseSlashInput } from './parse';
import { handlePluginsCommand } from './plugins';
import { handleProviderCommand } from './provider';
Expand All @@ -59,6 +60,7 @@ import { handleWebCommand } from './web';

export { handleLoginCommand, handleLogoutCommand } from './auth';
export { handleBtwCommand } from './btw';
export { handleAddDirCommand } from './add-dir';
export {
handleAutoCommand,
handleCompactCommand,
Expand Down Expand Up @@ -247,6 +249,9 @@ async function handleBuiltInSlashCommand(
case 'plugins':
void handlePluginsCommand(host, args);
return;
case 'add-dir':
await handleAddDirCommand(host, args);
return;
case 'experiments':
await showExperimentsPanel(host);
return;
Expand Down
108 changes: 104 additions & 4 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join, relative, resolve } from 'pathe';

import type { AutocompleteItem } from '@earendil-works/pi-tui';

import { completeLeadingArg, type ArgCompletionSpec } from './complete-args';
Expand All @@ -22,6 +26,10 @@ const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'off', description: 'Turn swarm mode off' },
];

const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'list', description: 'Show configured additional workspace directories' },
];

/** Argument autocompletion for the `/goal` command (subcommands). */
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
Expand All @@ -41,6 +49,89 @@ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteIt
return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix);
}

/** Argument autocompletion for the `/add-dir` command. */
export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
if (isPathLikeAddDirArgument(argumentPrefix)) {
return completeAddDirPath(argumentPrefix);
}
return completeLeadingArg(ADD_DIR_ARG_COMPLETIONS, argumentPrefix);
}

function isPathLikeAddDirArgument(argumentPrefix: string): boolean {
return argumentPrefix === '.' || argumentPrefix === '..' || argumentPrefix.startsWith('./') || argumentPrefix.startsWith('../') || argumentPrefix.startsWith('/') || argumentPrefix.startsWith('~');
}

function completeAddDirPath(argumentPrefix: string): AutocompleteItem[] | null {
const normalizedPrefix = argumentPrefix === '~' ? '~/' : argumentPrefix;
const expandedPrefix = expandHomePrefix(normalizedPrefix);
const parentInput = getDirectoryCompletionParentInput(normalizedPrefix, expandedPrefix);
const partialName = normalizedPrefix.endsWith('/') ? '' : basename(expandedPrefix);
const parentDir = resolveDirectoryCompletionParent(parentInput);
let entries;
try {
entries = readdirSync(parentDir, { withFileTypes: true });
} catch {
return null;
}

const items: AutocompleteItem[] = [];
for (const entry of entries) {
if (entry.name === '.' || entry.name === '..' || entry.name.startsWith('.')) continue;
if (partialName.length > 0 && !entry.name.toLowerCase().startsWith(partialName.toLowerCase())) continue;
const absolutePath = join(parentDir, entry.name);
if (!isDirectoryPath(absolutePath, entry.isDirectory(), entry.isSymbolicLink())) continue;
const value = formatDirectoryCompletionValue(normalizedPrefix, parentInput, entry.name);
items.push({
value,
label: `${entry.name}/`,
description: absolutePath,
});
}

return items.length > 0 ? items : null;
}

function expandHomePrefix(argumentPrefix: string): string {
if (argumentPrefix === '~') return homedir();
if (argumentPrefix.startsWith('~/')) return join(homedir(), argumentPrefix.slice(2));
return argumentPrefix;
}

function getDirectoryCompletionParentInput(argumentPrefix: string, expandedPrefix: string): string {
if (argumentPrefix === '/') return '/';
if (argumentPrefix === '~/') return homedir();
if (argumentPrefix.endsWith('/')) return expandedPrefix.slice(0, -1);
return dirname(expandedPrefix);
}

function resolveDirectoryCompletionParent(parentInput: string): string {
if (parentInput === '~') return homedir();
if (parentInput.startsWith('~/')) return join(homedir(), parentInput.slice(2));
return resolve(parentInput);
}

function isDirectoryPath(path: string, isDirectory: boolean, isSymlink: boolean): boolean {
if (isDirectory) return true;
if (!isSymlink) return false;
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}

function formatDirectoryCompletionValue(argumentPrefix: string, parentInput: string, entryName: string): string {
if (argumentPrefix.startsWith('~/')) {
const home = homedir();
const homeRelative = relative(home, parentInput);
return `~${homeRelative.length > 0 ? `/${homeRelative}` : ''}/${entryName}/`;
}
if (argumentPrefix.startsWith('/')) {
return `${join(parentInput, entryName)}/`;
}
return `${join(parentInput, entryName)}/`;
}

export const BUILTIN_SLASH_COMMANDS = [
{
name: 'yolo',
Expand Down Expand Up @@ -82,6 +173,7 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: [],
description: 'Toggle swarm mode or run one task in swarm mode',
priority: 100,
argumentHint: '[on|off] | <task>',
completeArgs: swarmArgumentCompletions,
availability: 'idle-only',
},
Expand Down Expand Up @@ -146,6 +238,15 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
{
name: 'add-dir',
aliases: [],
description: 'Add or list an additional workspace directory',
priority: 60,
availability: 'idle-only',
argumentHint: '[list] | <path>',
completeArgs: addDirArgumentCompletions,
},
{
name: 'experiments',
aliases: ['experimental'],
Expand All @@ -172,16 +273,14 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: [],
description: 'Compact the conversation context',
priority: 80,
argumentHint: '<instruction>',
},
{
name: 'goal',
aliases: [],
description: 'Start or manage an autonomous goal',
priority: 80,
// No argumentHint: the menu description stays as short as every other
// command's. The subcommands (status/pause/resume/cancel/replace) surface in
// the argument autocomplete list once the user types `/goal ` (see
// completeArgs), so they don't need to be spelled out inline.
argumentHint: '[status|pause|resume|cancel|replace|next] | <objective>',
completeArgs: goalArgumentCompletions,
// status / pause / cancel are always available; creation, replacement, and
// resume start (or restart) a turn and so are idle-only.
Expand Down Expand Up @@ -209,6 +308,7 @@ export const BUILTIN_SLASH_COMMANDS = [
aliases: ['rename'],
description: 'Set or show session title',
priority: 60,
argumentHint: '<title>',
availability: 'always',
},
{
Expand Down
Loading
Loading