mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
feat(cli): add interactive cd command
This commit is contained in:
committed by
Dominic Cooney
parent
241c93026d
commit
c450cff2e1
+1
-1
@@ -215,7 +215,7 @@ cline connect --stop
|
||||
cline connect --stop telegram
|
||||
```
|
||||
|
||||
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cwd <path>`, `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
|
||||
In chat surfaces, connector slash commands include `/help`, `/start`, `/new`, `/clear`, `/whereami`, `/tools`, `/yolo`, `/cd <path>` (also `/cwd <path>`), `/schedule`, `/abort`, and `/exit`. Run `cline connect <adapter> --help` to see the full flag list for any adapter.
|
||||
|
||||
### Schedules
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ The Telegram connector uses the shared connector command parser:
|
||||
- `/whereami` - show thread, cwd, tools, and yolo state
|
||||
- `/tools [on|off|toggle]` - allow or block repo/file/shell tools
|
||||
- `/yolo [on|off|toggle]` - auto-approve tool use
|
||||
- `/cwd <path>` - change working directory
|
||||
- `/cd <path>` or `/cwd <path>` - change working directory
|
||||
- `/schedule create/list/trigger/delete` - manage scheduled workflows
|
||||
- `/abort` - stop the current task
|
||||
- `/exit` - stop the connector
|
||||
|
||||
@@ -1026,7 +1026,7 @@ export async function handleConnectorUserTurn<
|
||||
const { prompt, userImages, userFiles } = await buildUserInputMessage(
|
||||
runtimeInput,
|
||||
input.userInstructionService,
|
||||
{ mode: startRequest.mode },
|
||||
{ mode: startRequest.mode, cwd: startRequest.cwd },
|
||||
);
|
||||
try {
|
||||
await input.client.sendRuntimeSession(
|
||||
|
||||
@@ -1210,6 +1210,7 @@ export async function runCli(): Promise<void> {
|
||||
await runInteractive(config, userInstructionService, resumeSessionId, {
|
||||
initialPrompt: args.prompt,
|
||||
clineApiBaseUrl: initialClineProviderSettings?.baseUrl,
|
||||
explicitSystemPrompt: args.systemPrompt,
|
||||
clineProviderSettings: initialClineProviderSettings,
|
||||
startupTarget,
|
||||
initialNotice,
|
||||
|
||||
@@ -45,6 +45,7 @@ function makeRuntime(): InteractiveChatCommandRuntime {
|
||||
getActiveSessionId: vi.fn(() => "session-1"),
|
||||
resetForNewSession: vi.fn(async () => {}),
|
||||
restartEmpty: vi.fn(async () => {}),
|
||||
changeWorkingDirectory: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -164,6 +165,41 @@ describe("runInteractiveChatCommand", () => {
|
||||
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("changes the runtime working directory before reporting /cd success", async () => {
|
||||
const config = makeConfig();
|
||||
const state = makeState(config);
|
||||
const runtime = makeRuntime();
|
||||
const target = process.cwd();
|
||||
state.cwd = "/tmp";
|
||||
state.workspaceRoot = "/tmp";
|
||||
vi.mocked(runtime.changeWorkingDirectory).mockImplementation(
|
||||
async (next) => {
|
||||
Object.assign(state, next);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runInteractiveChatCommand({
|
||||
prompt: `/cd ${target}`,
|
||||
enabled: true,
|
||||
config,
|
||||
host: chatCommandHost,
|
||||
chatCommandState: state,
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
expect(runtime.changeWorkingDirectory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: target }),
|
||||
);
|
||||
expect(state.cwd).toBe(target);
|
||||
expect(result).toMatchObject({
|
||||
handled: true,
|
||||
turnResult: { commandOutput: expect.stringContaining(`cwd=${target}`) },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns plugin command submit prompts as model input", async () => {
|
||||
const config = makeConfig();
|
||||
const runtime = makeRuntime();
|
||||
|
||||
@@ -22,6 +22,7 @@ export type InteractiveChatCommandRuntime = Pick<
|
||||
| "getActiveSessionId"
|
||||
| "resetForNewSession"
|
||||
| "restartEmpty"
|
||||
| "changeWorkingDirectory"
|
||||
>;
|
||||
|
||||
export type InteractiveChatCommandResult =
|
||||
@@ -75,10 +76,14 @@ export async function runInteractiveChatCommand(input: {
|
||||
autoApproveTools: input.autoApproveAllRef.current,
|
||||
}),
|
||||
setState: async (next) => {
|
||||
input.chatCommandState.enableTools = next.enableTools;
|
||||
input.chatCommandState.autoApproveTools = next.autoApproveTools;
|
||||
input.chatCommandState.cwd = next.cwd;
|
||||
input.chatCommandState.workspaceRoot = next.workspaceRoot;
|
||||
if (
|
||||
next.cwd !== input.chatCommandState.cwd ||
|
||||
next.workspaceRoot !== input.chatCommandState.workspaceRoot
|
||||
) {
|
||||
await input.sessionRuntime.changeWorkingDirectory(next);
|
||||
} else {
|
||||
Object.assign(input.chatCommandState, next);
|
||||
}
|
||||
input.setInteractiveAutoApprove(next.autoApproveTools);
|
||||
},
|
||||
reply: async (text) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
const resolveSystemPromptMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
@@ -47,6 +48,10 @@ vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: resolveSystemPromptMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
@@ -233,10 +238,12 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
resolveSystemPromptMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
resolveSystemPromptMock.mockResolvedValue("rebuilt system prompt");
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
@@ -587,6 +594,107 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("restarts the active session with one working-directory snapshot", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
const state = createChatCommandState(config);
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
explicitSystemPrompt: "custom prompt",
|
||||
chatCommandState: state,
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.changeWorkingDirectory({
|
||||
...state,
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
});
|
||||
|
||||
expect(resolveSystemPromptMock).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/next-project",
|
||||
explicitSystemPrompt: "custom prompt",
|
||||
providerId: "anthropic",
|
||||
mode: "act",
|
||||
});
|
||||
expect(config).toMatchObject({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
systemPrompt: "rebuilt system prompt",
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
});
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
systemPrompt: "rebuilt system prompt",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(createRuntimeHooksMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/next-project",
|
||||
workspaceRoot: "/tmp/next-project",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores the previous working-directory snapshot when restart fails", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
const state = createChatCommandState(config);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
manager.start.mockRejectedValueOnce(new Error("replacement failed"));
|
||||
|
||||
await expect(
|
||||
runtime.changeWorkingDirectory({
|
||||
...state,
|
||||
cwd: "/tmp/failed-project",
|
||||
workspaceRoot: "/tmp/failed-project",
|
||||
}),
|
||||
).rejects.toThrow("replacement failed");
|
||||
expect(config).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
});
|
||||
expect(state).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(3);
|
||||
expect(manager.start.mock.calls[2]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { basename } from "node:path";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
@@ -32,6 +33,7 @@ import { formatPluginIssueWarnings } from "../../utils/plugin-issues";
|
||||
import { loadInteractiveResumeMessages } from "../../utils/resume";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { markAbortInProgress } from "../active-runtime";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
@@ -98,6 +100,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
config: Config;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
explicitSystemPrompt?: string;
|
||||
resumeSessionId?: string;
|
||||
chatCommandState: ChatCommandState;
|
||||
requestToolApproval: (
|
||||
@@ -131,6 +134,20 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
const createWorkspaceRuntimeHooks = (
|
||||
manager: CliCore,
|
||||
workspace: Pick<ChatCommandState, "cwd" | "workspaceRoot">,
|
||||
): RuntimeHooks =>
|
||||
createRuntimeHooks({
|
||||
verbose: input.config.verbose,
|
||||
yolo: input.config.mode === "yolo",
|
||||
cwd: workspace.cwd,
|
||||
workspaceRoot: workspace.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.ingestHookEvent(payload);
|
||||
},
|
||||
});
|
||||
|
||||
const clearActiveSession = (): void => {
|
||||
activeSessionId = "";
|
||||
setActiveCliSession(undefined);
|
||||
@@ -188,15 +205,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
throw new Error("interactive runtime shutdown requested");
|
||||
}
|
||||
sessionManager = manager;
|
||||
runtimeHooks = createRuntimeHooks({
|
||||
verbose: input.config.verbose,
|
||||
yolo: input.config.mode === "yolo",
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
dispatchHookEvent: async (payload) => {
|
||||
await manager.ingestHookEvent(payload);
|
||||
},
|
||||
});
|
||||
runtimeHooks = createWorkspaceRuntimeHooks(manager, input.chatCommandState);
|
||||
unsubscribeAgent = subscribeToAgentEvents(manager, input.onAgentEvent);
|
||||
unsubscribePendingPrompts = subscribeToPendingPromptEvents(manager, {
|
||||
onPendingPrompts: input.onPendingPrompts,
|
||||
@@ -450,14 +459,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages: MessageWithMetadata[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
initialCompactionState?: SessionCompactionState,
|
||||
options?: { preserveSessionId?: boolean },
|
||||
options?: { preserveSessionId?: boolean; sessionId?: string },
|
||||
): Promise<void> => {
|
||||
// Config-only restarts (model/mode/account changes) continue the same
|
||||
// conversation, so they must keep the session id — otherwise each
|
||||
// restart mints a new session history entry for the same conversation.
|
||||
const reuseSessionId = options?.preserveSessionId
|
||||
? activeSessionId || undefined
|
||||
: undefined;
|
||||
const reuseSessionId =
|
||||
options?.sessionId ??
|
||||
(options?.preserveSessionId ? activeSessionId || undefined : undefined);
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupError = undefined;
|
||||
@@ -520,6 +529,99 @@ export function createInteractiveSessionRuntime(input: {
|
||||
);
|
||||
};
|
||||
|
||||
const changeWorkingDirectory = async (
|
||||
next: ChatCommandState,
|
||||
): Promise<void> => {
|
||||
await ensureReady();
|
||||
const manager = sessionManager;
|
||||
if (!manager) {
|
||||
throw new Error("interactive session manager is unavailable");
|
||||
}
|
||||
const sourceSessionId = activeSessionId;
|
||||
|
||||
const [{ messages, status }, compactionState, systemPrompt] =
|
||||
await Promise.all([
|
||||
readCurrentMessages(),
|
||||
readCurrentCompactionState(),
|
||||
resolveSystemPrompt({
|
||||
cwd: next.cwd,
|
||||
explicitSystemPrompt: input.explicitSystemPrompt,
|
||||
providerId: input.config.providerId,
|
||||
mode: input.config.mode,
|
||||
}),
|
||||
]);
|
||||
if (status !== "read" || activeSessionId !== sourceSessionId) {
|
||||
throw new Error("Working directory changed concurrently. Try /cd again.");
|
||||
}
|
||||
|
||||
const previousState = { ...input.chatCommandState };
|
||||
const previousSessionId = activeSessionId;
|
||||
const previousConfig = {
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: input.config.workspaceRoot,
|
||||
systemPrompt: input.config.systemPrompt,
|
||||
extensionContext: input.config.extensionContext,
|
||||
};
|
||||
const previousRuntimeHooks = runtimeHooks;
|
||||
const nextRuntimeHooks = createWorkspaceRuntimeHooks(manager, next);
|
||||
const projectedMessages = compactionState
|
||||
? projectSessionCompactionState(compactionState, messages)
|
||||
: undefined;
|
||||
const initialCompactionState = projectedMessages
|
||||
? createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: projectedMessages,
|
||||
systemPrompt: compactionState?.system_prompt,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// The directory becomes effective as one snapshot for the replacement
|
||||
// session. A concurrent ensureReady() waits on the restart barrier.
|
||||
Object.assign(input.chatCommandState, next);
|
||||
input.config.cwd = next.cwd;
|
||||
input.config.workspaceRoot = next.workspaceRoot;
|
||||
input.config.systemPrompt = systemPrompt;
|
||||
if (input.config.extensionContext?.workspace) {
|
||||
input.config.extensionContext = {
|
||||
...input.config.extensionContext,
|
||||
workspace: {
|
||||
...input.config.extensionContext.workspace,
|
||||
rootPath: next.workspaceRoot,
|
||||
cwd: next.cwd,
|
||||
workspaceName: basename(next.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
runtimeHooks = nextRuntimeHooks;
|
||||
|
||||
try {
|
||||
await restartWithMessages(messages, undefined, initialCompactionState, {
|
||||
preserveSessionId: true,
|
||||
});
|
||||
} catch (error) {
|
||||
Object.assign(input.chatCommandState, previousState);
|
||||
input.config.cwd = previousConfig.cwd;
|
||||
input.config.workspaceRoot = previousConfig.workspaceRoot;
|
||||
input.config.systemPrompt = previousConfig.systemPrompt;
|
||||
input.config.extensionContext = previousConfig.extensionContext;
|
||||
runtimeHooks = previousRuntimeHooks;
|
||||
await nextRuntimeHooks.shutdown().catch(() => {});
|
||||
try {
|
||||
await restartWithMessages(messages, undefined, initialCompactionState, {
|
||||
sessionId: previousSessionId || undefined,
|
||||
});
|
||||
} catch (recoveryError) {
|
||||
throw new AggregateError(
|
||||
[error, recoveryError],
|
||||
"Working directory change failed, and the previous session could not be restored.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await previousRuntimeHooks?.shutdown().catch(() => {});
|
||||
};
|
||||
|
||||
const updateCurrentSessionConnection = async (
|
||||
update: SessionConnectionUpdate,
|
||||
): Promise<void> => {
|
||||
@@ -919,6 +1021,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
changeWorkingDirectory,
|
||||
updateCurrentSessionConnection,
|
||||
resumeSession,
|
||||
forkCurrentSession,
|
||||
|
||||
@@ -51,6 +51,21 @@ describe("buildUserInputMessage", () => {
|
||||
expect(result.userImages).toEqual([]);
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
|
||||
it("resolves relative file mentions from the configured working directory", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cli-prompt-cwd-"));
|
||||
const filePath = join(dir, "notes.md");
|
||||
writeFileSync(filePath, "# Notes\n");
|
||||
|
||||
const result = await buildUserInputMessage(
|
||||
"summarize @./notes.md",
|
||||
undefined,
|
||||
{ cwd: dir },
|
||||
);
|
||||
|
||||
expect(result.prompt).toBe("summarize [file: notes.md]");
|
||||
expect(result.userFiles).toEqual([filePath]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSystemPrompt workspace metadata", () => {
|
||||
|
||||
@@ -74,11 +74,11 @@ function extractFileMentions(
|
||||
return matches;
|
||||
}
|
||||
|
||||
function resolveMentionPath(filePath: string): string {
|
||||
function resolveMentionPath(filePath: string, cwd: string): string {
|
||||
if (filePath.startsWith("~/")) {
|
||||
return resolve(homedir(), filePath.slice(2));
|
||||
}
|
||||
return resolve(filePath);
|
||||
return resolve(cwd, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +104,7 @@ export function shouldExpandSkillSlashCommands(mode?: string): boolean {
|
||||
export async function buildUserInputMessage(
|
||||
rawPrompt: string,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
options?: { mode?: string },
|
||||
options?: { mode?: string; cwd?: string },
|
||||
): Promise<{
|
||||
prompt: string;
|
||||
userImages: string[];
|
||||
@@ -154,7 +154,10 @@ export async function buildUserInputMessage(
|
||||
|
||||
for (const mention of fileMentions) {
|
||||
try {
|
||||
const resolvedPath = resolveMentionPath(mention.path);
|
||||
const resolvedPath = resolveMentionPath(
|
||||
mention.path,
|
||||
options?.cwd ?? process.cwd(),
|
||||
);
|
||||
const stats = statSync(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
throw new Error(`Path is not a file: ${resolvedPath}`);
|
||||
|
||||
@@ -281,6 +281,7 @@ export async function runAgent(
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(prompt, userInstructionService, {
|
||||
mode: config.mode,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
const started = await sessionManager.start({
|
||||
source: SessionSource.CLI,
|
||||
|
||||
@@ -186,6 +186,7 @@ export async function runInteractive(
|
||||
initialPrompt?: string;
|
||||
initialNotice?: CliMigrationNotice;
|
||||
onInitialNoticeShown?: (notice: CliMigrationNotice) => void | Promise<void>;
|
||||
explicitSystemPrompt?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
assertInteractivePreflight(config);
|
||||
@@ -287,6 +288,7 @@ export async function runInteractive(
|
||||
config,
|
||||
providerSettingsManager,
|
||||
userInstructionService,
|
||||
explicitSystemPrompt: options?.explicitSystemPrompt,
|
||||
resumeSessionId,
|
||||
chatCommandState,
|
||||
requestToolApproval,
|
||||
@@ -634,6 +636,7 @@ export async function runInteractive(
|
||||
userFiles,
|
||||
} = await buildUserInputMessage(input, userInstructionService, {
|
||||
mode,
|
||||
cwd: config.cwd,
|
||||
});
|
||||
const mergedUserImages = [
|
||||
...(attachments?.userImages ?? []),
|
||||
|
||||
@@ -82,6 +82,7 @@ export async function runZen(
|
||||
// Zen runs in yolo mode, whose preset has no skills tool — skill
|
||||
// commands must keep expanding textually.
|
||||
mode: "yolo",
|
||||
cwd: config.cwd,
|
||||
});
|
||||
|
||||
const startRequest: ChatStartSessionRequest = {
|
||||
|
||||
@@ -316,4 +316,26 @@ describe("slash command registry", () => {
|
||||
getVisibleSystemSlashCommands(registry).map((command) => command.name),
|
||||
).toContain("account");
|
||||
});
|
||||
|
||||
it("exposes cd as a runtime command", () => {
|
||||
const registry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
{
|
||||
name: "cd",
|
||||
instructions: "/cd <directory>",
|
||||
description: "Change the working directory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveSlashCommand(registry, "cd")).toMatchObject({
|
||||
source: "runtime",
|
||||
execution: "runtime",
|
||||
visible: true,
|
||||
selectable: true,
|
||||
});
|
||||
expect(
|
||||
getVisibleSystemSlashCommands(registry).map((command) => command.name),
|
||||
).toContain("cd");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,7 @@ const TUI_LOCAL_COMMANDS: Array<{
|
||||
|
||||
const SYSTEM_COMMAND_ORDER = [
|
||||
"settings",
|
||||
"cd",
|
||||
"model",
|
||||
"theme",
|
||||
"account",
|
||||
@@ -130,7 +131,7 @@ const SYSTEM_COMMAND_ORDER = [
|
||||
"history",
|
||||
"help",
|
||||
"quit",
|
||||
] satisfies ReadonlyArray<LocalSlashCommandName | "team">;
|
||||
] satisfies ReadonlyArray<LocalSlashCommandName | "cd" | "team">;
|
||||
|
||||
const SYSTEM_COMMAND_PRIORITY = new Map<string, number>(
|
||||
SYSTEM_COMMAND_ORDER.map((name, index) => [name, index]),
|
||||
|
||||
@@ -133,6 +133,12 @@ const HELP_ROWS: HelpRow[] = [
|
||||
key: "/theme",
|
||||
desc: "Change color theme",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-cd",
|
||||
key: "/cd <directory>",
|
||||
desc: "Change the working directory",
|
||||
},
|
||||
{
|
||||
kind: "entry",
|
||||
id: "c-mcp",
|
||||
|
||||
@@ -123,6 +123,11 @@ export function listInteractiveSlashCommands(
|
||||
instructions: "",
|
||||
description: "Modify agent configuration",
|
||||
},
|
||||
{
|
||||
name: "cd",
|
||||
instructions: "/cd <directory>",
|
||||
description: "Change the working directory",
|
||||
},
|
||||
{
|
||||
name: "mcp",
|
||||
instructions: "",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createChatCommandHost,
|
||||
@@ -199,6 +202,65 @@ describe("chat commands", () => {
|
||||
expect(reply).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("changes directories with both /cd and the existing /cwd spelling", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-"));
|
||||
const target = join(root, "project with spaces");
|
||||
mkdirSync(target);
|
||||
|
||||
for (const command of [
|
||||
`/cd "project with spaces"`,
|
||||
`/cwd "project with spaces"`,
|
||||
]) {
|
||||
const state = {
|
||||
enableTools: true,
|
||||
autoApproveTools: false,
|
||||
cwd: root,
|
||||
workspaceRoot: root,
|
||||
};
|
||||
const setState = vi.fn(async (next) => Object.assign(state, next));
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
expect(
|
||||
await maybeHandleChatCommand(command, {
|
||||
enabled: true,
|
||||
getState: () => state,
|
||||
setState,
|
||||
reply,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(state.cwd).toBe(target);
|
||||
expect(setState).toHaveBeenCalledOnce();
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`cwd=${target}`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the working directory unchanged when /cd is not a directory", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-chat-cd-invalid-"));
|
||||
writeFileSync(join(root, "file.txt"), "not a directory");
|
||||
const setState = vi.fn(async () => undefined);
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
expect(
|
||||
await maybeHandleChatCommand("/cd file.txt", {
|
||||
enabled: true,
|
||||
getState: () => ({
|
||||
enableTools: true,
|
||||
autoApproveTools: false,
|
||||
cwd: root,
|
||||
workspaceRoot: root,
|
||||
}),
|
||||
setState,
|
||||
reply,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(setState).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith(
|
||||
`invalid directory: ${join(root, "file.txt")}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("shows usage for /team with no arguments", async () => {
|
||||
const reply = vi.fn(async () => undefined);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { resolveWorkspaceRoot } from "./helpers";
|
||||
|
||||
@@ -216,6 +217,22 @@ function tokenizeArgs(input: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resolveChatCommandDirectory(cwd: string, args: string[]): string {
|
||||
const rawPath = args.join(" ").trim();
|
||||
const unquotedPath =
|
||||
(rawPath.startsWith('"') && rawPath.endsWith('"')) ||
|
||||
(rawPath.startsWith("'") && rawPath.endsWith("'"))
|
||||
? rawPath.slice(1, -1)
|
||||
: rawPath;
|
||||
if (unquotedPath === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (unquotedPath.startsWith("~/")) {
|
||||
return resolve(homedir(), unquotedPath.slice(2));
|
||||
}
|
||||
return resolve(cwd, unquotedPath);
|
||||
}
|
||||
|
||||
function parseFlagValues(tokens: string[]): {
|
||||
positionals: string[];
|
||||
flags: Record<string, string>;
|
||||
@@ -282,7 +299,7 @@ function formatHelp(state: ChatCommandState): string {
|
||||
"/whereami - show thread, cwd, tools, and yolo state",
|
||||
"/tools [on|off|toggle] - allow repo/file/shell tools",
|
||||
"/yolo [on|off|toggle] - auto-approve tool use",
|
||||
"/cwd <path> - change working directory",
|
||||
"/cd <path> (or /cwd <path>) - change working directory",
|
||||
"/schedule create/list/trigger/delete - manage scheduled workflows",
|
||||
"/abort - stop the current task",
|
||||
"/mute [target] - ignore this thread or target until /unmute",
|
||||
@@ -398,7 +415,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
},
|
||||
})
|
||||
.register("command", {
|
||||
names: ["/cwd"],
|
||||
names: ["/cd", "/cwd"],
|
||||
run: async ({ args, state }, context) => {
|
||||
const rawPath = args.join(" ").trim();
|
||||
if (!rawPath) {
|
||||
@@ -407,7 +424,7 @@ function createDefaultChatCommandHost(): ChatCommandHost {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const nextCwd = resolve(state.cwd, rawPath);
|
||||
const nextCwd = resolveChatCommandDirectory(state.cwd, args);
|
||||
const fileStat = await stat(nextCwd).catch(() => undefined);
|
||||
if (!fileStat?.isDirectory()) {
|
||||
await context.reply(`invalid directory: ${nextCwd}`);
|
||||
|
||||
Reference in New Issue
Block a user