Skip to content
Merged
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -62,6 +63,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** | 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

| Platform | Status |
Expand Down
17 changes: 16 additions & 1 deletion src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -27,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("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)
Expand All @@ -45,6 +47,19 @@ 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
.setValue(this.plugin.settings.continueLastConversation)
.onChange(async (value) => {
this.plugin.settings.continueLastConversation = value;
await this.plugin.saveSettings();
})
);
}

private isClaudeSidebarOpen(): boolean {
Expand Down
74 changes: 59 additions & 15 deletions src/terminal/terminal-process.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,105 @@
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";
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,
});
Expand All @@ -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"],
Expand Down Expand Up @@ -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");
}
}

Expand Down
15 changes: 13 additions & 2 deletions src/view/terminal-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
},
Expand Down
47 changes: 27 additions & 20 deletions tests/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) ✅

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand All @@ -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
Expand Down
Loading