From 799465c29a97015095f86cab06463c63af395973 Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Sun, 11 Jan 2026 23:22:17 +0200 Subject: [PATCH 1/5] feat: add --continue flag support for resuming conversations - Add `continueLastConversation` setting (off by default) with UI toggle - First/only opened Claude Code sidebar gets --continue flag - Refactor TerminalProcess to use dependency injection for testability - Add ClaudeCommandOptions interface for extensible command customization - Add 29 tests for terminal-process.ts (83.84% coverage) - Update test infrastructure to include terminal-process in coverage Co-Authored-By: Claude Opus 4.5 --- src/settings.ts | 15 + src/terminal/terminal-process.ts | 74 +++- src/view/terminal-view.ts | 15 +- tests/ROADMAP.md | 47 ++- tests/terminal/terminal-process.test.ts | 539 ++++++++++++++++++++++++ vitest.config.ts | 4 +- 6 files changed, 655 insertions(+), 39 deletions(-) create mode 100644 tests/terminal/terminal-process.test.ts diff --git a/src/settings.ts b/src/settings.ts index 3379dc8..2c18862 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,10 +4,12 @@ import {VIEW_TYPE} from "./view/terminal-view"; export interface VaultCodeSettings { mcpEnabled: boolean; + continueLastConversation: boolean; } export const DEFAULT_SETTINGS: VaultCodeSettings = { mcpEnabled: true, + continueLastConversation: false, }; export class VaultCodeSettingTab extends PluginSettingTab { @@ -45,6 +47,19 @@ export class VaultCodeSettingTab extends PluginSettingTab { } }) ); + + new Setting(containerEl) + .setName("Continue last conversation") + // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name + .setDesc("The first opened Claude Code sidebar will continue the last conversation") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.continueLastConversation) + .onChange(async (value) => { + this.plugin.settings.continueLastConversation = value; + await this.plugin.saveSettings(); + }) + ); } private isClaudeSidebarOpen(): boolean { diff --git a/src/terminal/terminal-process.ts b/src/terminal/terminal-process.ts index ed02fad..97a1efb 100644 --- a/src/terminal/terminal-process.ts +++ b/src/terminal/terminal-process.ts @@ -1,4 +1,4 @@ -import { spawn, execSync, ChildProcess } from "child_process"; +import { spawn, execSync, ChildProcess, SpawnOptionsWithoutStdio } from "child_process"; import * as path from "path"; import * as fs from "fs"; import * as os from "os"; @@ -6,56 +6,100 @@ import { StringDecoder } from "string_decoder"; import type { Terminal } from "@xterm/xterm"; import { PTY_SCRIPT_B64, WIN_PTY_SCRIPT_B64 } from "./pty-scripts"; +export interface ClaudeCommandOptions { + useIdeFlag: boolean; // --ide for MCP integration + continueSession: boolean; // --continue for resuming last conversation +} + export interface TerminalProcessOptions { cwd: string; cols: number; rows: number; - useIdeFlag: boolean; + claudeOptions: ClaudeCommandOptions; onData: (data: string) => void; onExit: (code: number | null, signal: string | null) => void; onError: (err: Error) => void; } +/** + * Dependencies that can be injected for testing. + */ +export interface TerminalProcessDeps { + platform: typeof process.platform; + spawn: (command: string, args: string[], options: SpawnOptionsWithoutStdio) => ChildProcess; + execSync: (command: string, options: { encoding: "utf8"; timeout: number }) => string; + writeFileSync: (path: string, data: string, options: { mode: number }) => void; + tmpdir: () => string; + env: typeof process.env; +} + +/** + * Default dependencies using real Node.js modules. + */ +export const defaultDeps: TerminalProcessDeps = { + platform: process.platform, + spawn, + execSync, + writeFileSync: fs.writeFileSync, + tmpdir: os.tmpdir, + env: process.env, +}; + export class TerminalProcess { private proc: ChildProcess | null = null; private stdoutDecoder: StringDecoder | null = null; private stderrDecoder: StringDecoder | null = null; - private isWindows: boolean; + private readonly isWindows: boolean; + private readonly deps: TerminalProcessDeps; - constructor() { - this.isWindows = process.platform === "win32"; + constructor(deps: TerminalProcessDeps = defaultDeps) { + this.deps = deps; + this.isWindows = deps.platform === "win32"; + } + + /** + * Build the Claude CLI command string from options. + */ + buildClaudeCommand(options: ClaudeCommandOptions): string { + const args: string[] = []; + if (options.useIdeFlag) args.push("--ide"); + if (options.continueSession) args.push("--continue"); + return args.length > 0 ? `claude ${args.join(" ")}` : "claude"; } start(options: TerminalProcessOptions): void { this.stop(); - const { cwd, cols, rows, useIdeFlag, onData, onExit, onError } = options; + const { cwd, cols, rows, claudeOptions, onData, onExit, onError } = options; const shell = this.isWindows - ? process.env.COMSPEC || "cmd.exe" - : process.env.SHELL || "/bin/bash"; + ? this.deps.env.COMSPEC || "cmd.exe" + : this.deps.env.SHELL || "/bin/bash"; // Decode and write PTY script to temp file const scriptB64 = this.isWindows ? WIN_PTY_SCRIPT_B64 : PTY_SCRIPT_B64; const scriptName = this.isWindows ? "claude_sidebar_win.py" : "claude_sidebar_pty.py"; - const ptyPath = path.join(os.tmpdir(), scriptName); + const ptyPath = path.join(this.deps.tmpdir(), scriptName); // Always write to ensure current version (overwrites stale cached copies) const ptyScript = Buffer.from(scriptB64, "base64").toString("utf-8"); - fs.writeFileSync(ptyPath, ptyScript, { mode: 0o755 }); + this.deps.writeFileSync(ptyPath, ptyScript, { mode: 0o755 }); // Use 'python' on Windows (works with both python.org and Microsoft Store installs) const cmd = this.isWindows ? "python" : "python3"; - const claudeCmd = useIdeFlag ? "claude --ide" : "claude"; + + // Build claude command with optional flags + const claudeCmd = this.buildClaudeCommand(claudeOptions); + const args = this.isWindows ? [ptyPath, String(cols), String(rows), shell] : [ptyPath, String(cols), String(rows), shell, "-lc", `${claudeCmd} || true; exec $SHELL -i`]; // Get PATH from user's login shell (GUI apps don't inherit shell config) - const shellEnv: typeof process.env = { ...process.env, TERM: "xterm-256color" }; + const shellEnv: typeof process.env = { ...this.deps.env, TERM: "xterm-256color" }; if (!this.isWindows) { try { - const shellOutput = execSync(`${shell} -lic 'echo "__PATH__"; echo "$PATH"'`, { + const shellOutput = this.deps.execSync(`${shell} -lic 'echo "__PATH__"; echo "$PATH"'`, { encoding: "utf8", timeout: 2000, }); @@ -69,7 +113,7 @@ export class TerminalProcess { } } - this.proc = spawn(cmd, args, { + this.proc = this.deps.spawn(cmd, args, { cwd, env: shellEnv, stdio: ["pipe", "pipe", "pipe"], @@ -138,7 +182,7 @@ export class TerminalProcess { } get platform(): "win32" | "darwin" | "linux" { - return this.isWindows ? "win32" : (process.platform as "darwin" | "linux"); + return this.isWindows ? "win32" : (this.deps.platform as "darwin" | "linux"); } } diff --git a/src/view/terminal-view.ts b/src/view/terminal-view.ts index 7442469..55d751f 100644 --- a/src/view/terminal-view.ts +++ b/src/view/terminal-view.ts @@ -3,7 +3,7 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { getThemeColors } from "../theme/xterm-theme"; import { injectXtermCSS } from "../theme/xterm-css"; -import { TerminalProcess, connectTerminalToProcess } from "../terminal/terminal-process"; +import { TerminalProcess, connectTerminalToProcess, ClaudeCommandOptions } from "../terminal/terminal-process"; import { ScrollPositionManager } from "./scroll-position-manager"; import type VaultCodePlugin from "../main"; @@ -197,13 +197,24 @@ export class TerminalView extends ItemView { const cols = this.term.cols || 80; const rows = this.term.rows || 24; + // Check if this is the first/only session + // By the time startShell() runs (called from onOpen), our leaf is already in the workspace + // So "first session" means we're the only Claude terminal leaf + const existingLeaves = this.app.workspace.getLeavesOfType(VIEW_TYPE); + const isFirstSession = existingLeaves.length === 1; + + const claudeOptions: ClaudeCommandOptions = { + useIdeFlag: this.plugin.settings.mcpEnabled, + continueSession: this.plugin.settings.continueLastConversation && isFirstSession, + }; + connectTerminalToProcess(this.term, this.termProcess); this.termProcess.start({ cwd, cols, rows, - useIdeFlag: this.plugin.settings.mcpEnabled, + claudeOptions, onData: (data) => { this.term?.write(data); }, diff --git a/tests/ROADMAP.md b/tests/ROADMAP.md index 9c974d6..44e25e8 100644 --- a/tests/ROADMAP.md +++ b/tests/ROADMAP.md @@ -89,19 +89,21 @@ WebSocket MCP server with real WebSocket client testing. - [x] Keepalive - ping/pong mechanism - [x] Server shutdown - graceful close with active connections -### terminal-process.ts ⛔ DEFERRED - -- [ ] Process startup (Unix vs Windows) -- [ ] Stdin/stdout piping with StringDecoder -- [ ] UTF-8 boundary handling -- [ ] Resize sequence encoding/transmission -- [ ] Process termination and cleanup -- [ ] Platform detection - -**Note:** terminal-process.ts cannot be unit tested because: -- Node.js built-in modules (child_process, fs, os) have non-configurable properties -- vi.mock and vi.spyOn cannot override spawn/execSync -- Requires integration testing with real Python PTY scripts +### terminal-process.ts (29 tests) ✅ + +Refactored to use dependency injection for testability. + +- [x] `buildClaudeCommand` - no flags, --ide only, --continue only, both flags +- [x] Constructor - platform detection (Windows, macOS, Linux) +- [x] `start()` - PTY script writing, spawn command (python/python3), shell detection +- [x] `start()` - claude command flags in Unix args (--ide, --continue, both) +- [x] `start()` - PATH extraction on Unix, skipped on Windows +- [x] `start()` - callbacks (onData, onExit, onError) +- [x] `start()` - environment variable usage (SHELL, COMSPEC) +- [x] `write()` - writes to process stdin, safe when not running +- [x] `resize()` - sends resize escape sequence +- [x] `stop()` - kills process with SIGTERM, safe to call when not running +- [x] `isRunning` - false when not started, true when running, false after exit ## Phase 4: P3 - Integration/E2E (Partial) ✅ @@ -153,7 +155,7 @@ These modules are explicitly excluded from test coverage: |--------|--------| | `src/main.ts` | Plugin lifecycle, integration scope | | `src/settings.ts` | Settings UI, integration scope | -| `src/terminal/**` | PTY requires real Node child processes | +| `src/terminal/pty-scripts.ts` | Generated base64 constants, no logic | | `src/resources/**` | Font loading, browser-only | | `src/mcp/mcp-types.ts` | Type definitions only | | `src/theme/xterm-css.ts` | CSS injection, runtime only | @@ -163,12 +165,12 @@ These modules are explicitly excluded from test coverage: | Metric | Value | |--------|-------| -| Test Files | 11 | -| Tests | 221 | -| Statements | 94.11% | -| Branches | 95.23% | -| Functions | 98.43% | -| Lines | 94.11% | +| Test Files | 12 | +| Tests | 250 | +| Statements | 92.84% | +| Branches | 95.19% | +| Functions | 97.26% | +| Lines | 92.84% | ### Coverage by Module @@ -185,12 +187,17 @@ These modules are explicitly excluded from test coverage: | editor-selection-strategy.ts | 100% | | preview-selection-strategy.ts | 100% | | view-manager.ts | 100% | +| terminal-process.ts | 83.84% | **Note on mcp-server.ts (88.53%):** Uncovered lines (468-473, 502-503) are defensive code paths that cannot be triggered with current timing constants (ping timeout=3s, ping interval=5s). The close event already stops keepalive, so the interval never detects zero clients. +**Note on terminal-process.ts (83.84%):** Uncovered lines are the Windows-specific +setTimeout auto-launch code (lines 148-152) and the `connectTerminalToProcess` +helper function (lines 189-214) which requires xterm.js mocking. + ## Running Tests ```bash diff --git a/tests/terminal/terminal-process.test.ts b/tests/terminal/terminal-process.test.ts new file mode 100644 index 0000000..0c8abb1 --- /dev/null +++ b/tests/terminal/terminal-process.test.ts @@ -0,0 +1,539 @@ +import {describe, it, expect, vi, beforeEach} from "vitest"; +import type {ChildProcess} from "child_process"; +import { + TerminalProcess, + TerminalProcessDeps, +} from "../../src/terminal/terminal-process"; + +/** + * Create a mock ChildProcess with controllable streams and events. + */ +function createMockProcess(): ChildProcess & { + _emit: (event: string, ...args: unknown[]) => void; +} { + const eventHandlers: Record void)[]> = {}; + + const mockProcess = { + stdin: {write: vi.fn()}, + stdout: { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!eventHandlers[`stdout:${event}`]) { + eventHandlers[`stdout:${event}`] = []; + } + eventHandlers[`stdout:${event}`].push(handler); + }), + }, + stderr: { + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!eventHandlers[`stderr:${event}`]) { + eventHandlers[`stderr:${event}`] = []; + } + eventHandlers[`stderr:${event}`].push(handler); + }), + }, + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + if (!eventHandlers[event]) { + eventHandlers[event] = []; + } + eventHandlers[event].push(handler); + }), + killed: false, + kill: vi.fn(), + _emit: (event: string, ...args: unknown[]) => { + const handlers = eventHandlers[event] || []; + handlers.forEach((h) => h(...args)); + }, + }; + + return mockProcess as unknown as ChildProcess & { + _emit: (event: string, ...args: unknown[]) => void; + }; +} + +/** + * Create mock dependencies for testing. + */ +function createMockDeps( + overrides: Partial = {} +): TerminalProcessDeps { + return { + platform: "darwin", + spawn: vi.fn().mockReturnValue(createMockProcess()), + execSync: vi.fn().mockReturnValue("__PATH__\n/usr/local/bin:/usr/bin\n"), + writeFileSync: vi.fn(), + tmpdir: vi.fn().mockReturnValue("/tmp"), + env: {SHELL: "/bin/zsh", PATH: "/usr/bin"}, + ...overrides, + }; +} + +describe("TerminalProcess", () => { + describe("buildClaudeCommand", () => { + it("should return plain claude when no options enabled", () => { + const proc = new TerminalProcess(createMockDeps()); + const result = proc.buildClaudeCommand({ + useIdeFlag: false, + continueSession: false, + }); + expect(result).toBe("claude"); + }); + + it("should add --ide flag when useIdeFlag is true", () => { + const proc = new TerminalProcess(createMockDeps()); + const result = proc.buildClaudeCommand({ + useIdeFlag: true, + continueSession: false, + }); + expect(result).toBe("claude --ide"); + }); + + it("should add --continue flag when continueSession is true", () => { + const proc = new TerminalProcess(createMockDeps()); + const result = proc.buildClaudeCommand({ + useIdeFlag: false, + continueSession: true, + }); + expect(result).toBe("claude --continue"); + }); + + it("should add both flags when both options are true", () => { + const proc = new TerminalProcess(createMockDeps()); + const result = proc.buildClaudeCommand({ + useIdeFlag: true, + continueSession: true, + }); + expect(result).toBe("claude --ide --continue"); + }); + }); + + describe("constructor", () => { + it("should detect Windows platform", () => { + const proc = new TerminalProcess(createMockDeps({platform: "win32"})); + expect(proc.platform).toBe("win32"); + }); + + it("should detect macOS platform", () => { + const proc = new TerminalProcess(createMockDeps({platform: "darwin"})); + expect(proc.platform).toBe("darwin"); + }); + + it("should detect Linux platform", () => { + const proc = new TerminalProcess(createMockDeps({platform: "linux"})); + expect(proc.platform).toBe("linux"); + }); + }); + + describe("start", () => { + let mockDeps: TerminalProcessDeps; + let mockProcess: ReturnType; + + beforeEach(() => { + mockProcess = createMockProcess(); + mockDeps = createMockDeps({ + spawn: vi.fn().mockReturnValue(mockProcess), + }); + }); + + it("should write PTY script to temp directory", () => { + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(mockDeps.writeFileSync).toHaveBeenCalledWith( + "/tmp/claude_sidebar_pty.py", + expect.any(String), + {mode: 0o755} + ); + }); + + it("should use Windows PTY script on Windows", () => { + const winDeps = createMockDeps({ + platform: "win32", + spawn: vi.fn().mockReturnValue(mockProcess), + env: {COMSPEC: "cmd.exe"}, + }); + const proc = new TerminalProcess(winDeps); + proc.start({ + cwd: "C:\\test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(winDeps.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("claude_sidebar_win.py"), + expect.any(String), + {mode: 0o755} + ); + }); + + it("should spawn python3 on Unix", () => { + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(mockDeps.spawn).toHaveBeenCalledWith( + "python3", + expect.any(Array), + expect.any(Object) + ); + }); + + it("should spawn python on Windows", () => { + const winDeps = createMockDeps({ + platform: "win32", + spawn: vi.fn().mockReturnValue(mockProcess), + env: {COMSPEC: "cmd.exe"}, + }); + const proc = new TerminalProcess(winDeps); + proc.start({ + cwd: "C:\\test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(winDeps.spawn).toHaveBeenCalledWith( + "python", + expect.any(Array), + expect.any(Object) + ); + }); + + it("should include claude command with --ide flag in Unix args", () => { + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: true, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + const spawnArgs = (mockDeps.spawn as ReturnType).mock + .calls[0][1] as string[]; + const shellCommand = spawnArgs[spawnArgs.length - 1]; + expect(shellCommand).toContain("claude --ide"); + }); + + it("should include claude command with --continue flag in Unix args", () => { + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: true}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + const spawnArgs = (mockDeps.spawn as ReturnType).mock + .calls[0][1] as string[]; + const shellCommand = spawnArgs[spawnArgs.length - 1]; + expect(shellCommand).toContain("claude --continue"); + }); + + it("should include both flags in Unix args when both enabled", () => { + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: true, continueSession: true}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + const spawnArgs = (mockDeps.spawn as ReturnType).mock + .calls[0][1] as string[]; + const shellCommand = spawnArgs[spawnArgs.length - 1]; + expect(shellCommand).toContain("claude --ide --continue"); + }); + + it("should extract PATH from shell on Unix", () => { + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(mockDeps.execSync).toHaveBeenCalledWith( + expect.stringContaining("echo \"__PATH__\""), + {encoding: "utf8", timeout: 2000} + ); + }); + + it("should not extract PATH on Windows", () => { + const winDeps = createMockDeps({ + platform: "win32", + spawn: vi.fn().mockReturnValue(mockProcess), + env: {COMSPEC: "cmd.exe"}, + }); + const proc = new TerminalProcess(winDeps); + proc.start({ + cwd: "C:\\test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(winDeps.execSync).not.toHaveBeenCalled(); + }); + + it("should call onData when process outputs data", () => { + const onData = vi.fn(); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData, + onExit: vi.fn(), + onError: vi.fn(), + }); + + // Simulate stdout data + mockProcess._emit("stdout:data", Buffer.from("hello")); + expect(onData).toHaveBeenCalledWith("hello"); + }); + + it("should call onExit when process exits", () => { + const onExit = vi.fn(); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit, + onError: vi.fn(), + }); + + mockProcess._emit("exit", 0, null); + expect(onExit).toHaveBeenCalledWith(0, null); + }); + + it("should call onError when process errors", () => { + const onError = vi.fn(); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError, + }); + + const error = new Error("spawn failed"); + mockProcess._emit("error", error); + expect(onError).toHaveBeenCalledWith(error); + }); + + it("should use SHELL from environment on Unix", () => { + const customDeps = createMockDeps({ + env: {SHELL: "/bin/fish", PATH: "/usr/bin"}, + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(customDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(customDeps.execSync).toHaveBeenCalledWith( + expect.stringContaining("/bin/fish"), + expect.any(Object) + ); + }); + + it("should default to /bin/bash if SHELL not set", () => { + const customDeps = createMockDeps({ + env: {PATH: "/usr/bin"}, + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(customDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(customDeps.execSync).toHaveBeenCalledWith( + expect.stringContaining("/bin/bash"), + expect.any(Object) + ); + }); + }); + + describe("write", () => { + it("should write data to process stdin", () => { + const mockProcess = createMockProcess(); + const mockDeps = createMockDeps({ + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + proc.write("test input"); + expect(mockProcess.stdin?.write).toHaveBeenCalledWith("test input"); + }); + + it("should not write if process not running", () => { + const proc = new TerminalProcess(createMockDeps()); + // Don't start - process is null + proc.write("test input"); + // Should not throw, just do nothing + }); + }); + + describe("resize", () => { + it("should send resize escape sequence", () => { + const mockProcess = createMockProcess(); + const mockDeps = createMockDeps({ + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + proc.resize(120, 40); + expect(mockProcess.stdin?.write).toHaveBeenCalledWith( + "\x1b]RESIZE;120;40\x07" + ); + }); + }); + + describe("stop", () => { + it("should kill the process", () => { + const mockProcess = createMockProcess(); + const mockDeps = createMockDeps({ + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + proc.stop(); + expect(mockProcess.kill).toHaveBeenCalledWith("SIGTERM"); + }); + + it("should be safe to call when not running", () => { + const proc = new TerminalProcess(createMockDeps()); + expect(() => proc.stop()).not.toThrow(); + }); + }); + + describe("isRunning", () => { + it("should return false when not started", () => { + const proc = new TerminalProcess(createMockDeps()); + expect(proc.isRunning).toBe(false); + }); + + it("should return true when running", () => { + const mockProcess = createMockProcess(); + const mockDeps = createMockDeps({ + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + expect(proc.isRunning).toBe(true); + }); + + it("should return false after process exits", () => { + const mockProcess = createMockProcess(); + const mockDeps = createMockDeps({ + spawn: vi.fn().mockReturnValue(mockProcess), + }); + const proc = new TerminalProcess(mockDeps); + proc.start({ + cwd: "/test", + cols: 80, + rows: 24, + claudeOptions: {useIdeFlag: false, continueSession: false}, + onData: vi.fn(), + onExit: vi.fn(), + onError: vi.fn(), + }); + + // Simulate exit + mockProcess._emit("exit", 0, null); + expect(proc.isRunning).toBe(false); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index b376ac4..4ca94b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ // Plugin lifecycle - integration test scope "src/main.ts", "src/settings.ts", - // PTY - requires real Node child processes (can't mock built-ins) - "src/terminal/**", + // PTY scripts - generated base64 constants, no logic + "src/terminal/pty-scripts.ts", // Font loading - browser-only "src/resources/**", // Type definitions - no logic From 5f562eafa364a85926b77b3c39b74fe54d6c68aa Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Sun, 11 Jan 2026 23:27:14 +0200 Subject: [PATCH 2/5] docs: update settings descriptions with CLI flags Co-Authored-By: Claude Opus 4.5 --- README.md | 7 +++++++ src/settings.ts | 5 ++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6d21a17..5015fa3 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,13 @@ https://github.com/user-attachments/assets/de98439a-8a1f-4a8a-9d02-44027d756462 - Press `Shift+Enter` for multi-line input - Set your own hotkeys in Settings → Hotkeys +### Settings + +| Setting | Description | +|---------|-------------| +| **Send Obsidian context to Claude Code** | Enables MCP integration (`--ide` flag). Sends selection changes to Claude Code via WebSocket. Enables "Send to Claude Code" context menu. | +| **Continue last conversation** | Passes `--continue` flag to first opened sidebar, resuming the previous conversation. | + ## Platform Support | Platform | Status | diff --git a/src/settings.ts b/src/settings.ts index 2c18862..ecad560 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -29,7 +29,7 @@ export class VaultCodeSettingTab extends PluginSettingTab { // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name .setName("Send Obsidian context to Claude Code") // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name - .setDesc("Automatically sends your current text selection to Claude Code so it knows what you're working on. Also enables the \"Send to Claude Code\" command for explicit @-mentions.") + .setDesc("Enables MCP integration (--ide flag). Sends selection changes to Claude Code via WebSocket. Enables \"Send to Claude Code\" context menu.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.mcpEnabled) @@ -50,8 +50,7 @@ export class VaultCodeSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Continue last conversation") - // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name - .setDesc("The first opened Claude Code sidebar will continue the last conversation") + .setDesc("Passes --continue flag to first opened sidebar, resuming the previous conversation.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.continueLastConversation) From c4074cfa7d9ebcbe09b488aefc3f2d8d852f0e0f Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Sun, 11 Jan 2026 23:34:12 +0200 Subject: [PATCH 3/5] docs: improve settings descriptions wording Co-Authored-By: Claude Opus 4.5 --- README.md | 4 ++-- src/settings.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5015fa3..da8ae92 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ https://github.com/user-attachments/assets/de98439a-8a1f-4a8a-9d02-44027d756462 | Setting | Description | |---------|-------------| -| **Send Obsidian context to Claude Code** | Enables MCP integration (`--ide` flag). Sends selection changes to Claude Code via WebSocket. Enables "Send to Claude Code" context menu. | -| **Continue last conversation** | Passes `--continue` flag to first opened sidebar, resuming the previous conversation. | +| **Send Obsidian context to Claude Code** | Send current file and selection changes to Claude Code via IDE MCP integration (`--ide` flag). Enable "Send to Claude Code" context menu. | +| **Continue the last conversation** | Pass `--continue` flag to the first started Claude Code, resuming the previous conversation. | ## Platform Support diff --git a/src/settings.ts b/src/settings.ts index ecad560..33ae4c3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -29,7 +29,7 @@ export class VaultCodeSettingTab extends PluginSettingTab { // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name .setName("Send Obsidian context to Claude Code") // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name - .setDesc("Enables MCP integration (--ide flag). Sends selection changes to Claude Code via WebSocket. Enables \"Send to Claude Code\" context menu.") + .setDesc("Send current file and selection changes to Claude Code via IDE MCP integration (--ide flag). Enable \"Send to Claude Code\" context menu.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.mcpEnabled) @@ -49,8 +49,8 @@ export class VaultCodeSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Continue last conversation") - .setDesc("Passes --continue flag to first opened sidebar, resuming the previous conversation.") + .setName("Continue the last conversation") + .setDesc("Pass --continue flag to the first started Claude Code, resuming the previous conversation.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.continueLastConversation) From 760322900410c4f99a081ef3beee66d7a7e55a27 Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Sun, 11 Jan 2026 23:36:10 +0200 Subject: [PATCH 4/5] chore: add eslint-disable for brand name Co-Authored-By: Claude Opus 4.5 --- src/settings.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/settings.ts b/src/settings.ts index 33ae4c3..26531b3 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -50,6 +50,7 @@ export class VaultCodeSettingTab extends PluginSettingTab { new Setting(containerEl) .setName("Continue the last conversation") + // eslint-disable-next-line obsidianmd/ui/sentence-case -- Claude Code is a brand name .setDesc("Pass --continue flag to the first started Claude Code, resuming the previous conversation.") .addToggle((toggle) => toggle From a71d469daf0dd9ba4f708bbae01f4e0bd217b934 Mon Sep 17 00:00:00 2001 From: Hlib Koval Date: Sun, 11 Jan 2026 23:38:01 +0200 Subject: [PATCH 5/5] docs: add continue conversation feature to README Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index da8ae92..1dd460a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Run [Claude Code](https://claude.com/claude-code) in your Obsidian sidebar with - **Auto-launches Claude** - Claude Code starts automatically - **Multiple tabs** – Run multiple Claude instances side by side - **Obsidian → Claude Code integration** (optional) – Claude Code gets the current context from Obsidian (open file, selected lines) automatically over `/ide` MCP integration (inspired by Claude Code plugin for Intellij IDEA) +- **Continue the last conversation** (optional) – continue working with Claude Code from where you left off automatically ## Requirements