Skip to content

Commit 93f2d96

Browse files
committed
Add persistent shell mode to CLI with workspace management
1 parent 66f825c commit 93f2d96

File tree

9 files changed

+909
-74
lines changed

9 files changed

+909
-74
lines changed

cli/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ We've only tested the CLI on Mac and Linux, and are aware that there are some is
3232

3333
## Usage
3434

35+
### Shell Mode
36+
37+
Kilo Code CLI includes a persistent shell mode that allows you to execute shell commands while maintaining directory changes and environment variables across commands. To enter shell mode, press `Shift+!` or type `!` when the input is empty.
38+
39+
#### Features
40+
41+
- **Persistent Session**: Directory changes (`cd`) and environment variable modifications persist for the duration of the shell session
42+
- **Workspace Propagation**: When you exit shell mode, the CLI's working directory is updated to match the shell's current directory, and the AI context is reinitialized for the new workspace
43+
- **Command History**: Navigate through previous commands using the up/down arrow keys
44+
- **Real-time Output**: Command output is displayed immediately in the CLI interface
45+
3546
### Interactive Mode
3647

3748
```bash

cli/src/cli.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { createExtensionService, ExtensionService } from "./services/extension.j
66
import { App } from "./ui/App.js"
77
import { logs } from "./services/logs.js"
88
import { extensionServiceAtom } from "./state/atoms/service.js"
9+
import { addMessageAtom } from "./state/atoms/ui.js"
10+
import { disposeShellSessionAtom } from "./state/atoms/shell.js"
911
import { initializeServiceEffectAtom } from "./state/atoms/effects.js"
1012
import { loadConfigAtom, mappedExtensionStateAtom, providersAtom, saveConfigAtom } from "./state/atoms/config.js"
1113
import { ciExitReasonAtom } from "./state/atoms/ci.js"
@@ -191,6 +193,7 @@ export class CLI {
191193
parallel: this.options.parallel || false,
192194
worktreeBranch: this.options.worktreeBranch || undefined,
193195
noSplash: this.options.noSplash || false,
196+
onWorkspaceChange: (newWorkspace: string) => this.handleWorkspaceChange(newWorkspace),
194197
},
195198
onExit: () => this.dispose(),
196199
}),
@@ -311,6 +314,11 @@ export class CLI {
311314
this.service = null
312315
}
313316

317+
// Dispose shell session
318+
if (this.store) {
319+
this.store.set(disposeShellSessionAtom)
320+
}
321+
314322
// Clear store reference
315323
this.store = null
316324

@@ -407,6 +415,86 @@ export class CLI {
407415
}
408416
}
409417

418+
/**
419+
* Handle workspace changes from shell mode
420+
*/
421+
public async handleWorkspaceChange(newWorkspace: string): Promise<void> {
422+
if (!this.store || !this.service) {
423+
logs.warn("Cannot handle workspace change: service or store not available", "CLI")
424+
return
425+
}
426+
427+
try {
428+
logs.info("Handling workspace change", "CLI", { oldWorkspace: this.options.workspace, newWorkspace })
429+
430+
// Update CLI options
431+
this.options.workspace = newWorkspace
432+
433+
// Show system message about workspace change
434+
const workspaceChangeMessage = {
435+
id: `workspace-change-${Date.now()}`,
436+
type: "system" as const,
437+
ts: Date.now(),
438+
content: `📁 Workspace changed to: ${newWorkspace}\nReinitializing AI context...`,
439+
partial: false,
440+
}
441+
this.store.set(addMessageAtom, workspaceChangeMessage)
442+
443+
// Dispose current extension service
444+
await this.service.dispose()
445+
this.service = null
446+
447+
// Create new extension service with updated workspace
448+
const telemetryService = getTelemetryService()
449+
const identityManager = getIdentityManager()
450+
const identity = identityManager.getIdentity()
451+
452+
const serviceOptions: Parameters<typeof createExtensionService>[0] = {
453+
workspace: newWorkspace,
454+
mode: this.options.mode || "code",
455+
}
456+
457+
if (identity) {
458+
serviceOptions.identity = {
459+
machineId: identity.machineId,
460+
sessionId: identity.sessionId,
461+
cliUserId: identity.cliUserId,
462+
}
463+
}
464+
465+
this.service = createExtensionService(serviceOptions)
466+
467+
// Set service in store
468+
this.store.set(extensionServiceAtom, this.service)
469+
470+
// Reinitialize service through effect atom
471+
await this.store.set(initializeServiceEffectAtom, this.store)
472+
473+
// Re-inject configuration
474+
await this.injectConfigurationToExtension()
475+
476+
// Re-request router models
477+
await this.requestRouterModels()
478+
479+
// Update telemetry context (access private fields directly since there's no public update method)
480+
;(telemetryService as unknown as { currentWorkspace: string }).currentWorkspace = newWorkspace
481+
482+
logs.info("Workspace change handled successfully", "CLI", { newWorkspace })
483+
} catch (error) {
484+
logs.error("Failed to handle workspace change", "CLI", { error, newWorkspace })
485+
486+
// Show error message
487+
const errorMessage = {
488+
id: `workspace-change-error-${Date.now()}`,
489+
type: "error" as const,
490+
ts: Date.now(),
491+
content: `❌ Failed to change workspace: ${error instanceof Error ? error.message : error}`,
492+
partial: false,
493+
}
494+
this.store.set(addMessageAtom, errorMessage)
495+
}
496+
}
497+
410498
/**
411499
* Resume the last conversation from the current workspace
412500
*/
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import { ShellSession } from "../shell/session.js"
3+
import { spawn, ChildProcess } from "child_process"
4+
import { EventEmitter } from "events"
5+
6+
// Define mock types for testing
7+
interface MockStdin {
8+
write: ReturnType<typeof vi.fn>
9+
end: ReturnType<typeof vi.fn>
10+
}
11+
12+
interface MockChildProcess extends EventEmitter {
13+
stdout: EventEmitter
14+
stderr: EventEmitter
15+
stdin: MockStdin
16+
kill: ReturnType<typeof vi.fn>
17+
}
18+
19+
// Mock the default-shell module
20+
vi.mock("default-shell", () => ({
21+
detectDefaultShell: vi.fn(() => "/bin/bash"),
22+
}))
23+
24+
// Mock child_process.spawn
25+
vi.mock("child_process", () => ({
26+
spawn: vi.fn(),
27+
}))
28+
29+
describe("ShellSession", () => {
30+
let mockChildProcess: MockChildProcess
31+
let stdoutEmitter: EventEmitter
32+
let stderrEmitter: EventEmitter
33+
let stdin: MockStdin
34+
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
38+
// Use EventEmitters for stdout/stderr to easily simulate data events
39+
stdoutEmitter = new EventEmitter()
40+
stderrEmitter = new EventEmitter()
41+
42+
stdin = {
43+
write: vi.fn(),
44+
end: vi.fn(),
45+
}
46+
47+
mockChildProcess = new EventEmitter()
48+
mockChildProcess.stdout = stdoutEmitter
49+
mockChildProcess.stderr = stderrEmitter
50+
mockChildProcess.stdin = stdin
51+
mockChildProcess.kill = vi.fn()
52+
53+
vi.mocked(spawn).mockReturnValue(mockChildProcess as unknown as ChildProcess)
54+
})
55+
56+
afterEach(() => {
57+
vi.restoreAllMocks()
58+
})
59+
60+
describe("initialization", () => {
61+
it("should create a new shell session with default shell", async () => {
62+
const session = new ShellSession()
63+
64+
await session.ensureSession("/tmp/test")
65+
66+
expect(spawn).toHaveBeenCalledWith("/bin/bash", [], {
67+
cwd: "/tmp/test",
68+
stdio: ["pipe", "pipe", "pipe"],
69+
env: expect.objectContaining({
70+
PS1: "",
71+
// PROMPT_COMMAND is no longer set in env
72+
}),
73+
detached: false,
74+
})
75+
})
76+
77+
it("should handle shell spawn errors", async () => {
78+
const session = new ShellSession()
79+
80+
// ensureSession doesn't reject on spawn error because spawn error is async
81+
// and ensureSession returns immediately.
82+
// We should verify that the error event is emitted.
83+
84+
const errorPromise = new Promise<void>((resolve, reject) => {
85+
session.on("error", (err) => {
86+
try {
87+
expect(err.message).toBe("Spawn failed")
88+
resolve()
89+
} catch (e) {
90+
reject(e)
91+
}
92+
})
93+
})
94+
95+
await session.ensureSession("/tmp/test")
96+
97+
// Simulate spawn error
98+
mockChildProcess.emit("error", new Error("Spawn failed"))
99+
100+
await errorPromise
101+
})
102+
})
103+
104+
describe("command execution", () => {
105+
let session: ShellSession
106+
107+
beforeEach(async () => {
108+
session = new ShellSession()
109+
await session.ensureSession("/tmp/test")
110+
})
111+
112+
it("should execute commands and parse sentinel output", async () => {
113+
const runPromise = session.run("echo hello")
114+
115+
// Simulate output
116+
stdoutEmitter.emit("data", "__KILO_DONE__:0:/tmp/test\n")
117+
118+
const result = await runPromise
119+
120+
expect(result).toEqual({
121+
stdout: "",
122+
stderr: "",
123+
exitCode: 0,
124+
cwd: "/tmp/test",
125+
})
126+
127+
expect(stdin.write).toHaveBeenCalledWith(
128+
expect.stringContaining("echo hello"),
129+
"utf8",
130+
expect.any(Function),
131+
)
132+
})
133+
134+
it("should handle command output", async () => {
135+
const runPromise = session.run("echo hello")
136+
137+
// Simulate output
138+
stdoutEmitter.emit("data", "hello world\n__KILO_DONE__:0:/tmp/test\n")
139+
140+
const result = await runPromise
141+
142+
expect(result).toEqual({
143+
stdout: "hello world",
144+
stderr: "",
145+
exitCode: 0,
146+
cwd: "/tmp/test",
147+
})
148+
})
149+
150+
it("should handle stderr output", async () => {
151+
const runPromise = session.run("failing command")
152+
153+
// Simulate output
154+
stderrEmitter.emit("data", "error message\n")
155+
stdoutEmitter.emit("data", "__KILO_DONE__:1:/tmp/test\n")
156+
157+
const result = await runPromise
158+
159+
expect(result).toEqual({
160+
stdout: "",
161+
stderr: "error message",
162+
exitCode: 1,
163+
cwd: "/tmp/test",
164+
})
165+
})
166+
167+
it("should timeout long-running commands", async () => {
168+
const session = new ShellSession({ timeout: 50 })
169+
// We need to initialize the session first (which was done in beforeEach but for the other 'session' instance)
170+
// Actually beforeEach initializes 'session'. But here we create a new one.
171+
172+
// Mock spawn again for this new session if needed, but the global mock persists.
173+
await session.ensureSession("/tmp/test")
174+
175+
await expect(session.run("sleep 1")).rejects.toThrow("Command timeout after 50ms")
176+
})
177+
178+
it("should reject concurrent commands", async () => {
179+
const promise1 = session.run("cmd1")
180+
181+
await expect(session.run("cmd2")).rejects.toThrow("Command already in progress")
182+
183+
// Complete the first command
184+
stdoutEmitter.emit("data", "__KILO_DONE__:0:/tmp/test\n")
185+
186+
await promise1
187+
})
188+
})
189+
190+
describe("directory tracking", () => {
191+
it("should track current directory from sentinel", async () => {
192+
const session = new ShellSession()
193+
await session.ensureSession("/tmp/test")
194+
195+
const runPromise = session.run("cd /home/user")
196+
197+
stdoutEmitter.emit("data", "__KILO_DONE__:0:/home/user\n")
198+
199+
await runPromise
200+
201+
expect(session.getCurrentDirectory()).toBe("/home/user")
202+
})
203+
})
204+
205+
describe("lifecycle", () => {
206+
it("should dispose properly", async () => {
207+
const session = new ShellSession()
208+
await session.ensureSession("/tmp/test")
209+
210+
session.dispose()
211+
212+
expect(mockChildProcess.kill).toHaveBeenCalled()
213+
expect(session.isSessionReady()).toBe(false)
214+
})
215+
216+
it("should handle process exit", async () => {
217+
const session = new ShellSession()
218+
await session.ensureSession("/tmp/test")
219+
220+
const runPromise = session.run("test")
221+
222+
mockChildProcess.emit("close", 1, "SIGTERM")
223+
224+
await expect(runPromise).rejects.toThrow("Shell process exited unexpectedly with code 1")
225+
226+
expect(session.isSessionReady()).toBe(false)
227+
})
228+
})
229+
})

0 commit comments

Comments
 (0)