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
7 changes: 7 additions & 0 deletions .changeset/add-dir-workspace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Add /add-dir workspace directory management with session-only and project-remembered additional directories.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ coverage/
plugins/cdn/
superpowers
.worktrees/
.kimi-code/local.toml
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 @@ -71,6 +71,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 @@ -119,6 +127,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 @@ -243,7 +243,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 @@ -267,7 +270,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 @@ -290,7 +296,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 @@ -98,6 +98,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
106 changes: 106 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,106 @@
import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import { ChoicePickerComponent } from '../components/dialogs/choice-picker';
import { nextTranscriptId } from '../utils/transcript-id';
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');
}

function formatAddDirMessage(path: string, choice: AddDirChoice, configPath: string): string {
return choice === 'remember'
? `Added workspace directory:\n ${path}\n Saved to:\n ${configPath}`
: `Added workspace directory:\n ${path}\n For this session only`;
}

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();
const message = formatAddDirMessage(path, choice, result.configPath);
await session.appendUserMessage(message);
host.appendTranscriptEntry({
id: nextTranscriptId(),
kind: 'user',
renderMode: 'plain',
content: message,
});
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 @@ -45,6 +45,7 @@ import {
import { handleGoalCommand } from './goal';
import { handleProviderCommand } from './provider';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handleAddDirCommand } from './add-dir';
import { handlePluginsCommand } from './plugins';
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
import { handleSwarmCommand } from './swarm';
Expand All @@ -66,6 +67,7 @@ export {
handleLogoutCommand,
} from './auth';
export { handleBtwCommand } from './btw';
export { handleAddDirCommand } from './add-dir';
export {
handleAutoCommand,
handleCompactCommand,
Expand Down Expand Up @@ -257,6 +259,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
100 changes: 100 additions & 0 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 @@ -146,6 +237,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 Down
15 changes: 11 additions & 4 deletions apps/kimi-code/src/tui/components/dialogs/choice-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,22 @@ export class ChoicePickerComponent extends Container implements Focusable {

const titleSuffix =
searchable && view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : '';
const hintLines = hint.split(/\r?\n/);
const lines: string[] = [
currentTheme.fg('primary', '─'.repeat(width)),
currentTheme.boldFg('primary', ` ${this.opts.title}`) + titleSuffix,
this.opts.formatHint === undefined
? currentTheme.fg('textMuted', ` ${hint}`)
: this.opts.formatHint(` ${hint}`),
];
for (const hintLine of hintLines) {
lines.push(
this.opts.formatHint === undefined
? currentTheme.fg('textMuted', ` ${hintLine}`)
: this.opts.formatHint(` ${hintLine}`),
);
}
if (this.opts.notice !== undefined) {
lines.push(currentTheme.fg('success', ` ${this.opts.notice}`));
for (const noticeLine of this.opts.notice.split(/\r?\n/)) {
lines.push(currentTheme.fg('success', ` ${noticeLine}`));
}
}
lines.push('');
if (searchable && view.query.length > 0) {
Expand Down
11 changes: 11 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,17 @@ export class CustomEditor extends Editor {
}

super.handleInput(normalized);
this.reopenSlashArgumentCompletionAfterInput(normalized);
}

private reopenSlashArgumentCompletionAfterInput(data: string): void {
if (data !== '/') return;
const { line, col } = this.getCursor();
const textBeforeCursor = this.getLines()[line]?.slice(0, col) ?? '';
if (!textBeforeCursor.startsWith('/')) return;
if (!textBeforeCursor.includes(' ')) return;
(this as unknown as { requestAutocomplete?: (options: { force: boolean; explicitTab: boolean }) => void })
.requestAutocomplete?.({ force: true, explicitTab: false });
}
}

Expand Down
Loading
Loading