From 073efef75cab40d486db2e28253c7a9536e4ef1d Mon Sep 17 00:00:00 2001 From: blamouche Date: Wed, 20 May 2026 10:12:26 +0200 Subject: [PATCH] Run multiple runtimes in parallel via session tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the sidebar panel from a single-process view into a tabbed multi-session view: each tab is an independent CliSession with its own PTY process and xterm terminal. A New session / + control opens a session from any configured runtime; multiple sessions of the same runtime are allowed and labels are disambiguated. Stop/Restart/Clear and the @file/ @folder buttons act on the active tab; a single ResizeObserver refits only the visible terminal and re-fits on tab activation. Automations now spawn their own session tab for the declared runtime (or the default when none is declared), await CLI readiness, then send the prompt — replacing the old skip-if-not-running/runtime-mismatch logic. Add settings autoCloseAutomationSessions and maxConcurrentSessions; the Automations modal "Run now" button is always enabled. Extract pure helpers to session-utils.ts (resolveRuntimeForAutomation, runtimeMatches, nextSessionLabel, canOpenSession) with unit tests. Update README and the generated example automation. Bump to 0.2.9 (manifest, versions.json, package.json, prompt-hub version). Co-Authored-By: Claude Opus 4.7 (1M context) --- .prompt-hub/memory.md | 8 + .prompt-hub/releases.md | 7 + ...-20260520-095705-parallel-runtimes-tabs.md | 41 + .prompt-hub/version.md | 2 +- README.md | 45 +- automations-modal.ts | 15 +- main.js | 944 +++++++++------ main.ts | 1036 +++++++++++------ manifest.json | 2 +- package.json | 4 +- session-utils.ts | 64 + styles.css | 125 ++ tests/session-utils.test.ts | 86 ++ versions.json | 3 +- 14 files changed, 1631 insertions(+), 751 deletions(-) create mode 100644 .prompt-hub/todo/todo-20260520-095705-parallel-runtimes-tabs.md create mode 100644 session-utils.ts create mode 100644 tests/session-utils.test.ts diff --git a/.prompt-hub/memory.md b/.prompt-hub/memory.md index bb0b7cc..c29e6dc 100644 --- a/.prompt-hub/memory.md +++ b/.prompt-hub/memory.md @@ -76,3 +76,11 @@ | 2026-05-20 00:18:00 CEST | agent | Updated README intro paragraph to describe the Automations feature (scheduled/manual prompt files + exportable per-run history) alongside runtime/mention features. Docs only — no code or bundle change; manifest left at 0.2.7. Bumped prompt-hub version 0.2.7 → 0.2.8. | `README.md`, `.prompt-hub/version.md`, `.prompt-hub/releases.md` | success | Commit and push to `feature/automation`. | | 2026-05-20 00:22:00 CEST | agent | Released 0.2.8 (first release shipping Automations). Aligned manifest/versions to 0.2.8, fast-forward merged `feature/automation` into `main` (8efbce2..55a6b72), pushed main, created+pushed annotated tag `0.2.8`. Release workflow run 26128878146 succeeded and created a DRAFT GitHub Release with 5 assets (any-ai-cli-0.2.8.zip, main.js, manifest.json, styles.css, versions.json). Awaiting user decision to publish the draft. | `manifest.json`, `versions.json`, `.prompt-hub/releases.md`, `git merge`, `git tag 0.2.8`, `gh run watch` | success | Publish the draft release if user confirms. | | 2026-05-20 00:24:00 CEST | agent | User confirmed publish; ran `gh release edit 0.2.8 --draft=false`. Release 0.2.8 is now public at https://github.com/blamouche/obsidian-any-ai-code/releases/tag/0.2.8. | `gh release edit` | success | Release live. | + +## 2026-05-20 10:15 — agent (Claude Opus 4.7) +- Action : implémenté les sessions runtime parallèles à onglets (feature majeure). +- Détails : nouveau `session-utils.ts` (fonctions pures + tests), classe `CliSession` + `createSessionTerminal()` dans `main.ts`, refactor complet de `ClaudeCliView` (sessions[], onglets, startSession/closeSession/setActiveSession, onData/onExit/Codex par session), réécriture de `triggerAutomation` (+`activateViewAndStartSession`) pour spawn un nouvel onglet par run, settings `autoCloseAutomationSessions`/`maxConcurrentSessions`, `automations-modal.ts` "Run now" toujours actif, CSS onglets, README + exemple d'automatisation mis à jour. +- Fichiers : main.ts, session-utils.ts (new), tests/session-utils.test.ts (new), automations-modal.ts, styles.css, package.json, manifest.json, versions.json, README.md, .prompt-hub/{version,releases}.md. +- Validation : `npm run lint` OK, `npm test` 70/70 OK, `npm run build` OK, `tsc --noEmit` sans erreur sur les fichiers du projet (erreurs résiduelles uniquement dans node_modules/vitest). +- Version : 0.2.8 -> 0.2.9. +- Statut : success. Étape suivante : commit + push, puis test manuel dans Obsidian (smoke test du plan). diff --git a/.prompt-hub/releases.md b/.prompt-hub/releases.md index d6ac01c..c04ee85 100644 --- a/.prompt-hub/releases.md +++ b/.prompt-hub/releases.md @@ -1,5 +1,12 @@ # Releases +## 0.2.9 - 2026-05-20 +- **Parallel runtime sessions in tabs.** The sidebar panel now hosts multiple independent sessions, each with its own PTY process and xterm terminal, shown in a tab bar. A `New session` button / `+` control opens a session from any configured runtime; multiple sessions of the same runtime are allowed and disambiguated (`Claude`, `Claude (2)`, …). Switching tabs refits the visible terminal; the per-tab `×` closes a session. `Stop` / `Restart` / `Clear` / `@active file` / `@active folder` now act on the active tab. +- **Automations spawn their own session.** Each automation run (scheduled or manual) opens a fresh session tab for the declared `runtime` (or the default runtime when none is declared), waits for the CLI to boot, then sends the prompt. Replaces the old "skip if no CLI running / runtime mismatch" behaviour. Unknown declared runtimes are skipped and logged. +- New settings: **Auto-close automation sessions** (default on — closes an automation tab when its process exits) and **Max concurrent sessions** (default 8, `0` = unlimited) to guard against runaway spawns. Removed the obsolete "Auto-restart on runtime switch" toggle (the sidebar runtime dropdown is replaced by the tab bar). +- Extracted pure helpers to `session-utils.ts` (`resolveRuntimeForAutomation`, `runtimeMatches`, `nextSessionLabel`, `canOpenSession`) with unit tests; the **Run now** button in the Automations modal is always enabled. +- Updated README and the generated example automation to document the tabbed/parallel model. Aligned `manifest.json`, `versions.json`, and `package.json` to 0.2.9. + ## 0.2.8 - 2026-05-20 - **First release shipping the Automations feature.** Merged `feature/automation` into `main`: schedule reusable prompts from a vault folder (per-file `interval`/`cron` frontmatter), fire them into the running CLI automatically or manually from the Automations modal, with a per-run history (capped ring buffer) you can clear or export to a date-stamped markdown file. Includes the "Create example automation" settings button that writes a fully documented hello-world file. - Fixes folded into this release: send `\r` (not `\n`) so prompts submit; split body/Enter into two writes so Codex submits too; export creates a fresh date-and-time-stamped file each time. diff --git a/.prompt-hub/todo/todo-20260520-095705-parallel-runtimes-tabs.md b/.prompt-hub/todo/todo-20260520-095705-parallel-runtimes-tabs.md new file mode 100644 index 0000000..76064d0 --- /dev/null +++ b/.prompt-hub/todo/todo-20260520-095705-parallel-runtimes-tabs.md @@ -0,0 +1,41 @@ +# Tâche : faire tourner plusieurs runtimes en parallèle (sessions à onglets) + +Date : 2026-05-20 09:57:05 +Slug : parallel-runtimes-tabs + +## Objectif +Permettre l'exécution de plusieurs runtimes simultanément via une UX à onglets dans le +panneau latéral. Un onglet = une session indépendante (process PTY + terminal xterm propres). + +## Décisions validées avec l'utilisateur +- UX : onglets dans un seul panneau, bouton « + » pour lancer une session. +- Plusieurs sessions du même runtime autorisées. +- Automatisation : ouvre TOUJOURS un nouvel onglet avec le runtime déclaré (ou runtime par + défaut si non déclaré), puis envoie le prompt à cette session. + +## Plan d'exécution +1. `session-utils.ts` (fonctions pures) + `tests/session-utils.test.ts` + lint glob. +2. Champs settings `autoCloseAutomationSessions`, `maxConcurrentSessions` + sanitizing. +3. Classe `CliSession` + helper `createSessionTerminal()`. +4. Refactor `ClaudeCliView` : sessions[], startSession/closeSession/setActiveSession + onData/onExit/Codex par session. +5. Reconstruire `onOpen()` : barre d'onglets, menu « + », hôte terminaux, recâblage boutons, autoStart -> 1 session. +6. setActiveSession fit/resize à l'activation + observer unique. +7. Auto-close sessions automatisation + plafond maxConcurrentSessions. +8. `activateViewAndStartSession` + réécriture `triggerAutomation`. +9. `automations-modal.ts` : « Run now » toujours actif. +10. Lignes UI settings + CSS onglets. + +## Statut +Terminé (implémentation). Test manuel Obsidian restant côté utilisateur. + +## Review +- Implémentation conforme au plan validé : sessions à onglets, plusieurs sessions par + runtime, automatisation -> nouvel onglet (runtime déclaré ou défaut), auto-close + plafond. +- Fonctions de décision isolées dans `session-utils.ts` et couvertes par 15 tests unitaires. +- Pièges xterm gérés : un seul ResizeObserver ciblant la session visible, refit/resize à + l'activation d'onglet (pas de fit sur terminal `display:none`), reset + NO_COLOR Codex par session. +- Validation : lint OK, 70 tests OK, build esbuild OK, `tsc --noEmit` propre sur le code projet. +- Version 0.2.8 -> 0.2.9 (manifest, versions.json, package.json, version.md alignés). +- Limites / suivi : le champ `autoRestartOnRuntimeSwitch` est conservé dans les settings pour + éviter une migration mais n'a plus d'effet (nettoyage possible plus tard). Smoke test manuel + dans Obsidian (ouverture multi-onglets, bascule, fermeture, automatisation) à réaliser. diff --git a/.prompt-hub/version.md b/.prompt-hub/version.md index a45be46..d81f1c3 100644 --- a/.prompt-hub/version.md +++ b/.prompt-hub/version.md @@ -1 +1 @@ -0.2.8 +0.2.9 \ No newline at end of file diff --git a/README.md b/README.md index 050d7e6..45c72ad 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,17 @@ Run any local AI coding CLI — Claude, Codex, or your own — inside a right sidebar terminal in Obsidian. -The plugin embeds a real PTY-backed terminal in the sidebar and lets you declare an unlimited list of CLI runtimes from settings (each with a display name and a launch command). Pick one from a dropdown to start it in your active vault folder, switch between them on the fly, and inject the active file or folder as a mention with one click. You can also schedule reusable prompts: drop markdown files in a vault folder and the plugin fires them into the running CLI on an interval or cron — or run them on demand from the Automations panel, with a per-run history you can export. +The plugin embeds a real PTY-backed terminal in the sidebar and lets you declare an unlimited list of CLI runtimes from settings (each with a display name and a launch command). Open as many sessions as you like — each runs in its own tab with its own process, so several runtimes (or several instances of the same one) run in parallel in a single panel. Launch sessions in your active vault folder, switch tabs on the fly, and inject the active file or folder as a mention with one click. You can also schedule reusable prompts: drop markdown files in a vault folder and the plugin fires each one into its own fresh session tab on an interval or cron — or run them on demand from the Automations panel, with a per-run history you can export. ## Features - Dedicated sidebar view with an embedded `xterm` terminal. -- **Customizable runtime list** — declare any number of CLI runtimes from settings (Claude and Codex are pre-populated; add Aider, custom wrappers, anything on `PATH`) and switch between them via a sidebar dropdown. -- One-click `@active file` and `@active folder` buttons that insert the current note path (or its parent folder) as a mention in the running CLI's stdin. -- **Automations** — drop markdown files (prompt + frontmatter schedule) in a vault folder and have them fired into the running CLI on an `interval` or `cron`, or run them manually from a modal with a per-run history log. -- Process controls in the toolbar: `Start`, `Stop`, `Restart`, `Clear`. -- Launches the selected runtime in the **current active vault folder** so the AI sees your notes as the working tree. +- **Parallel sessions in tabs** — run multiple runtimes (or multiple instances of the same one) side by side, each in its own tab with its own process. Switch tabs to bring a session to the front; background sessions keep running. A `+` control opens a new session from any configured runtime. +- **Customizable runtime list** — declare any number of CLI runtimes from settings (Claude and Codex are pre-populated; add Aider, custom wrappers, anything on `PATH`). +- One-click `@active file` and `@active folder` buttons that insert the current note path (or its parent folder) as a mention in the active session's stdin. +- **Automations** — drop markdown files (prompt + frontmatter schedule) in a vault folder and have each fired into its own new session tab on an `interval` or `cron`, or run them manually from a modal with a per-run history log. +- Process controls in the toolbar act on the active tab: `New session`, `Stop`, `Restart`, `Clear`. +- Launches each session in the **current active vault folder** so the AI sees your notes as the working tree. - Resilient PTY stack with multi-tier fallbacks (`node-pty` → Python PTY bridge → direct pipe → `script`) so it works on macOS, Linux, and Windows. - Visible runtime status (`Status: ...`) and clear error reporting in the panel. @@ -83,17 +84,17 @@ The community-store auto-install and the release zip both ship only the three ca ## Usage 1. Click the bot ribbon icon, or run the command palette entry **`Open panel`**, to reveal the panel on the right. -2. Use the runtime dropdown on the first toolbar row to pick which CLI to launch (Claude, Codex, or any custom entry you added). -3. Click `Start` to launch the selected runtime in the active vault folder. -4. While the CLI is running: +2. Click `New session` (or the `+` at the end of the tab bar) and pick a runtime to launch it in the active vault folder. With auto-start enabled, the default runtime opens automatically as the first session. +3. Open more sessions the same way — each gets its own tab and process, so runtimes run in parallel. Tabs of the same runtime are disambiguated (`Claude`, `Claude (2)`, …). +4. Click a tab to bring its session to the front; the `×` on a tab closes that session (and kills its process). A status dot shows whether each session is running. +5. The toolbar acts on the active tab: - Click `@active file` or `@active folder` (second toolbar row) to insert the current note path or its parent folder as a mention. - - Click `Restart` to relaunch, `Stop` to terminate, `Clear` to wipe the terminal output. -5. Switching the dropdown to another runtime while a process is running automatically restarts it on the new CLI (configurable in settings). + - Click `Restart` to relaunch the active session's runtime in place, `Stop` to terminate it, `Clear` to wipe its terminal output. 6. Click `Automations` (second toolbar row) to open the Automations modal: run any prompt manually with **Run now**, or browse the **History** tab to see what fired and when. ## Automations -Automations let you store reusable prompts as markdown files in your vault and have the plugin send them to the running CLI either on a schedule (`interval` or `cron`) or manually from the **Automations** modal. +Automations let you store reusable prompts as markdown files in your vault. When an automation fires (on a schedule or manually), the plugin opens a **new session tab** for the target runtime and sends the prompt to it — so scheduled runs execute in parallel without disturbing your other sessions. ### Setup @@ -113,7 +114,7 @@ name: Daily summary # optional, defaults to the filename enabled: true # optional, defaults to true interval: 60 # minutes — exclusive with `cron` # cron: "0 9 * * 1-5" # standard 5-field cron — exclusive with `interval` -runtime: claude # optional — only fire if this runtime id or display name is running +runtime: claude # optional — runtime id or display name to spawn; omit to use the default runtime appendNewline: true # optional, defaults to true (adds Enter so the CLI executes the prompt) --- @@ -123,27 +124,29 @@ Summarize my notes from the last 24h and propose three priorities for today. Rules: - Exactly one of `interval` or `cron` must be set. `interval` is in whole minutes (>= 1). `cron` uses standard 5-field syntax (`cron-parser`). -- `enabled: false` keeps the entry visible in the modal but skips scheduling and disables the **Run now** button. +- `enabled: false` keeps the entry visible in the modal but skips scheduling (you can still trigger it with **Run now**). - The prompt is everything after the closing `---` (trimmed). -- If the configured `runtime` does not match the currently running CLI, the run is skipped and logged in History. -- If no CLI is running when a scheduled run is due, the run is skipped (the plugin never auto-starts a CLI for you). +- The `runtime` field selects which runtime to spawn, matched by id or display name. If it names a runtime that is not configured, the run is skipped and logged in History. +- If `runtime` is omitted, the automation spawns the **default runtime** (set in plugin settings). +- Each run opens its own session tab. With **Auto-close automation sessions** enabled (default), the tab closes when the process exits so tabs don't accumulate. ### Manual runs and history The **Automations** toolbar button opens a modal with two tabs: -- **Automations** — list of parsed entries with schedule, last run, next run, status badge, and a **Run now** button per row (disabled when no CLI is running). Parse errors are shown at the top with file paths and reasons. +- **Automations** — list of parsed entries with schedule, last run, next run, status badge, and a **Run now** button per row (always enabled — it opens a new session tab and sends the prompt). Parse errors are shown at the top with file paths and reasons. - **History** — chronological log of every fired run (or skip / error), capped at 200 entries by default. You can **Clear history** or **Export as markdown** to create a snapshot note in the vault. ## Plugin Settings General: -- **Default runtime** — which configured runtime is selected when the panel opens (and used by auto-start). -- **Auto-start** — start the default runtime automatically when the panel opens. -- **Auto-restart on runtime switch** — when you change the runtime from the sidebar dropdown while a process is running, restart it immediately to apply the new selection. +- **Default runtime** — which configured runtime opens as the first session on auto-start, and which automations use when they declare no `runtime`. +- **Auto-start** — open the default runtime as a session automatically when the panel opens. +- **Auto-close automation sessions** — when an automation-spawned session's process exits, close its tab automatically so tabs don't pile up (default on). +- **Max concurrent sessions** — cap the number of session tabs that can run at once (`0` = unlimited). Protects against runaway automation spawns. -Runtimes section (the customizable list of CLIs shown in the sidebar dropdown): +Runtimes section (the customizable list of CLIs available from the new-session menu): - Each entry holds a display name and a launch command. Examples: - `Claude` → `claude` diff --git a/automations-modal.ts b/automations-modal.ts index a21f553..9c84613 100644 --- a/automations-modal.ts +++ b/automations-modal.ts @@ -114,8 +114,6 @@ export class AutomationsModal extends Modal { return; } - const runnable = this.isAnyViewRunning(); - const table = host.createEl("table", { cls: "any-ai-cli-am-table" }); const thead = table.createEl("thead").createEl("tr"); for (const label of ["Name", "Schedule", "Last run", "Next run", "Status", "Actions"]) { @@ -150,10 +148,7 @@ export class AutomationsModal extends Modal { const actionsCell = tr.createEl("td", { cls: "any-ai-cli-am-actions" }); const runBtn = actionsCell.createEl("button", { text: "Run now" }); - runBtn.disabled = !runnable; - if (!runnable) { - runBtn.setAttribute("title", "Start a CLI in the sidebar panel first."); - } + runBtn.setAttribute("title", "Open a new session tab and send this prompt."); runBtn.addEventListener("click", () => { void this.plugin.triggerAutomation(entry, "manual"); }); @@ -213,14 +208,6 @@ export class AutomationsModal extends Modal { } } - private isAnyViewRunning(): boolean { - const leaves = this.app.workspace.getLeavesOfType("claude-cli-view"); - return leaves.some((leaf) => { - const view = leaf.view as unknown as { isProcessRunning?: () => boolean }; - return typeof view?.isProcessRunning === "function" && view.isProcessRunning(); - }); - } - private async exportHistory(history: AutomationRunRecord[]): Promise { const now = new Date(); const lines: string[] = []; diff --git a/main.js b/main.js index 9d3b9db..3929d52 100644 --- a/main.js +++ b/main.js @@ -9150,6 +9150,7 @@ __export(main_exports, { module.exports = __toCommonJS(main_exports); var import_obsidian2 = require("obsidian"); var import_child_process = require("child_process"); +var import_node_crypto2 = require("node:crypto"); var fs2 = __toESM(require("fs")); var os2 = __toESM(require("os")); var path = __toESM(require("path")); @@ -18550,6 +18551,44 @@ function detectNodeExecutable(configuredValue, platform, env, existsSync2, pathA return "node"; } +// session-utils.ts +function runtimeMatches(runtime, target) { + const wanted = target.trim().toLowerCase(); + if (!wanted) { + return false; + } + if (runtime.id.trim().toLowerCase() === wanted) { + return true; + } + return runtime.name.trim().toLowerCase() === wanted; +} +function resolveRuntimeForAutomation(runtimes, declared, defaultId) { + var _a5, _b, _c2; + const wanted = (declared != null ? declared : "").trim(); + if (wanted) { + return (_a5 = runtimes.find((runtime) => runtimeMatches(runtime, wanted))) != null ? _a5 : null; + } + return (_c2 = (_b = runtimes.find((runtime) => runtime.id === defaultId)) != null ? _b : runtimes[0]) != null ? _c2 : null; +} +function nextSessionLabel(existingLabels, runtimeName) { + const base = runtimeName.trim() || "(Unnamed)"; + const taken = new Set(existingLabels); + if (!taken.has(base)) { + return base; + } + let counter = 2; + while (taken.has(`${base} (${counter})`)) { + counter += 1; + } + return `${base} (${counter})`; +} +function canOpenSession(currentCount, max) { + if (!Number.isFinite(max) || max <= 0) { + return true; + } + return currentCount < max; +} + // automation.ts var import_cron_parser = __toESM(require_dist()); var FRONTMATTER_RE = /^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/; @@ -18821,7 +18860,6 @@ var AutomationsModal = class extends import_obsidian.Modal { }); return; } - const runnable = this.isAnyViewRunning(); const table = host.createEl("table", { cls: "any-ai-cli-am-table" }); const thead = table.createEl("thead").createEl("tr"); for (const label of ["Name", "Schedule", "Last run", "Next run", "Status", "Actions"]) { @@ -18850,10 +18888,7 @@ var AutomationsModal = class extends import_obsidian.Modal { } const actionsCell = tr2.createEl("td", { cls: "any-ai-cli-am-actions" }); const runBtn = actionsCell.createEl("button", { text: "Run now" }); - runBtn.disabled = !runnable; - if (!runnable) { - runBtn.setAttribute("title", "Start a CLI in the sidebar panel first."); - } + runBtn.setAttribute("title", "Open a new session tab and send this prompt."); runBtn.addEventListener("click", () => { void this.plugin.triggerAutomation(entry, "manual"); }); @@ -18904,13 +18939,6 @@ var AutomationsModal = class extends import_obsidian.Modal { } } } - isAnyViewRunning() { - const leaves = this.app.workspace.getLeavesOfType("claude-cli-view"); - return leaves.some((leaf) => { - const view = leaf.view; - return typeof (view == null ? void 0 : view.isProcessRunning) === "function" && view.isProcessRunning(); - }); - } async exportHistory(history) { const now = /* @__PURE__ */ new Date(); const lines = []; @@ -18979,7 +19007,9 @@ var DEFAULT_SETTINGS = { automationsFolder: "", automationsLastRun: {}, automationsHistory: [], - automationsHistoryLimit: 200 + automationsHistoryLimit: 200, + autoCloseAutomationSessions: true, + maxConcurrentSessions: 8 }; var AUTOMATION_TICK_MS = 3e4; var EXAMPLE_AUTOMATION_CONTENT = `--- @@ -19018,10 +19048,10 @@ interval: 60 # cron: "0 8 1 * *" # 08:00 on the 1st of each month # runtime (string, optional) -# Only fire when this runtime is the one currently running, matched -# by its id OR its display name (case-insensitive). Remove the line -# to send to whichever runtime is active. Skipped runs are logged -# in the History tab. +# Which runtime to spawn for this automation, matched by its id OR its +# display name (case-insensitive). Each run opens its own session tab. +# Remove the line to use the default runtime (set in plugin settings). +# Runs naming an unconfigured runtime are skipped and logged in History. runtime: Claude # appendNewline (true | false, optional, default true) @@ -19035,134 +19065,78 @@ Say hello and tell me the current date and time. function cloneDefaultRuntimes() { return DEFAULT_RUNTIMES.map((runtime) => ({ ...runtime })); } -var ClaudeCliView = class extends import_obsidian2.ItemView { - constructor(leaf, plugin) { - super(leaf); - this.terminal = null; - this.fitAddon = null; - this.processHandle = null; - this.terminalHostEl = null; - this.resizeObserver = null; - this.statusEl = null; - this.runtimeSelect = null; - this.runningRuntimeId = null; - this.pendingStartRuntimeId = null; - this.plugin = plugin; - } - getViewType() { - return VIEW_TYPE_CLAUDE; - } - getDisplayText() { - return "Any AI CLI"; - } - getIcon() { - return "bot"; - } - onOpen() { - this.contentEl.empty(); - this.contentEl.addClass("claude-cli-view"); - const toolbarEl = this.contentEl.createDiv({ cls: "claude-cli-toolbar" }); - const primaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); - const runtimePickerEl = primaryRowEl.createDiv({ cls: "claude-cli-runtime-picker" }); - const runtimeIconEl = runtimePickerEl.createSpan({ cls: "claude-cli-runtime-picker-icon" }); - (0, import_obsidian2.setIcon)(runtimeIconEl, "terminal"); - this.runtimeSelect = runtimePickerEl.createEl("select", { cls: "claude-cli-runtime-select" }); - this.runtimeSelect.setAttribute("aria-label", "Select runtime"); - this.refreshRuntimeSelect(); - const runtimeChevronEl = runtimePickerEl.createSpan({ cls: "claude-cli-runtime-picker-chevron" }); - (0, import_obsidian2.setIcon)(runtimeChevronEl, "chevron-down"); - const startBtn = primaryRowEl.createEl("button", { text: "Start" }); - const stopBtn = primaryRowEl.createEl("button", { text: "Stop" }); - const restartBtn = primaryRowEl.createEl("button", { text: "Restart" }); - const clearBtn = primaryRowEl.createEl("button", { text: "Clear" }); - this.setButtonIcon(startBtn, "play", "Start"); - this.setButtonIcon(stopBtn, "square", "Stop"); - this.setButtonIcon(restartBtn, "refresh-cw", "Restart"); - this.setButtonIcon(clearBtn, "eraser", "Clear"); - startBtn.addClass("claude-cli-btn-primary"); - stopBtn.addClass("claude-cli-btn-danger"); - const secondaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); - const mentionBtn = secondaryRowEl.createEl("button", { text: "@active file" }); - const folderMentionBtn = secondaryRowEl.createEl("button", { text: "@active folder" }); - const automationsBtn = secondaryRowEl.createEl("button", { text: "Automations" }); - this.setButtonIcon(mentionBtn, "file-plus", "@active file"); - this.setButtonIcon(folderMentionBtn, "folder-plus", "@active folder"); - this.setButtonIcon(automationsBtn, "calendar-clock", "Automations"); - mentionBtn.addClass("claude-cli-btn-info"); - folderMentionBtn.addClass("claude-cli-btn-info"); - automationsBtn.addClass("claude-cli-btn-info"); - this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" }); - startBtn.addEventListener("click", () => this.startClaudeProcess()); - stopBtn.addEventListener("click", () => this.stopClaudeProcess()); - restartBtn.addEventListener("click", () => this.restartClaudeProcess()); - clearBtn.addEventListener("click", () => { - var _a5; - return (_a5 = this.terminal) == null ? void 0 : _a5.clear(); - }); - mentionBtn.addEventListener("click", () => this.insertActiveFileMention()); - folderMentionBtn.addEventListener("click", () => this.insertActiveFolderMention()); - automationsBtn.addEventListener("click", () => { - new AutomationsModal(this.app, this.plugin).open(); - }); - this.runtimeSelect.addEventListener("change", () => { - if (this.runtimeSelect) { - this.setRuntime(this.runtimeSelect.value); - } - }); - this.terminalHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminal" }); - this.terminal = new Dl({ - cursorBlink: true, - convertEol: true, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", - fontSize: 13, - scrollback: 3e3, - theme: { - background: "#0f1115", - foreground: "#e6e6e6" - } - }); - this.fitAddon = new o(); - this.terminal.loadAddon(this.fitAddon); - this.terminal.open(this.terminalHostEl); - this.fitAddon.fit(); - this.writeSystemLine("CLI panel ready."); - this.terminal.onData((data) => { - var _a5; - (_a5 = this.processHandle) == null ? void 0 : _a5.write(data); - }); - this.resizeObserver = new ResizeObserver(() => { - var _a5, _b, _c2; - (_a5 = this.fitAddon) == null ? void 0 : _a5.fit(); - if (this.processHandle && this.terminal) { - (_c2 = (_b = this.processHandle).resize) == null ? void 0 : _c2.call( - _b, - Math.max(20, this.terminal.cols || 120), - Math.max(10, this.terminal.rows || 30) - ); - } - }); - this.resizeObserver.observe(this.contentEl); - if (this.plugin.settings.autoStart) { - this.startClaudeProcess(); - } else { - this.writeSystemLine(`Auto-start is disabled. Click start to launch ${this.getRuntimeLabel()}.`); - this.setStatus("Idle"); +var SESSION_READY_FALLBACK_MS = 1200; +function createSessionTerminal() { + const terminal = new Dl({ + cursorBlink: true, + convertEol: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", + fontSize: 13, + scrollback: 3e3, + theme: { + background: "#0f1115", + foreground: "#e6e6e6" } - return Promise.resolve(); + }); + const fitAddon = new o(); + terminal.loadAddon(fitAddon); + return { terminal, fitAddon }; +} +var CliSession = class { + constructor(params) { + this.processHandle = null; + this.status = "Idle"; + this.pendingRestart = false; + this.ready = false; + this.resolveReady = null; + this.readyTimer = null; + this.id = params.id; + this.runtimeId = params.runtimeId; + this.label = params.label; + this.origin = params.origin; + this.terminal = params.terminal; + this.fitAddon = params.fitAddon; + this.hostEl = params.hostEl; + this.resetReady(); } - isProcessRunning() { - return this.processHandle !== null && this.runningRuntimeId !== null; + /** Arm a fresh readiness promise for a (re)spawn. */ + resetReady() { + this.ready = false; + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + this.readyTimer = null; + } + this.whenReady = new Promise((resolve2) => { + this.resolveReady = resolve2; + }); } - getRunningRuntimeId() { - return this.runningRuntimeId; + markReady() { + var _a5; + if (this.ready) { + return; + } + this.ready = true; + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + this.readyTimer = null; + } + (_a5 = this.resolveReady) == null ? void 0 : _a5.call(this); + this.resolveReady = null; } - matchesRuntime(target) { - if (!this.runningRuntimeId) return false; - if (this.runningRuntimeId === target) return true; - const runtime = this.plugin.settings.runtimes.find((r) => r.id === this.runningRuntimeId); - return runtime ? runtime.name.trim().toLowerCase() === target.trim().toLowerCase() : false; + armReadyFallback(delayMs) { + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + } + this.readyTimer = activeWindow.setTimeout(() => this.markReady(), delayMs); } - sendAutomationPrompt(text, submitWithEnter) { + isRunning() { + return this.processHandle !== null; + } + writeSystemLine(message) { + this.terminal.write("\r\x1B[2K"); + this.terminal.writeln(message); + } + sendPrompt(text, submitWithEnter) { if (!this.processHandle) { throw new Error("CLI process is not running"); } @@ -19178,92 +19152,312 @@ var ClaudeCliView = class extends import_obsidian2.ItemView { } this.writeSystemLine(`[Automation prompt injected]`); } - refreshRuntimeSelect() { - if (!this.runtimeSelect) { - return; + dispose() { + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + this.readyTimer = null; } - this.runtimeSelect.empty(); - const runtimes = this.plugin.settings.runtimes; - if (runtimes.length === 0) { - const placeholder = this.runtimeSelect.createEl("option", { text: "No runtime configured" }); - placeholder.value = ""; - this.runtimeSelect.value = ""; - this.runtimeSelect.disabled = true; - return; + try { + this.terminal.dispose(); + } catch (e) { } - this.runtimeSelect.disabled = false; - for (const runtime of runtimes) { - const opt = this.runtimeSelect.createEl("option", { text: runtime.name || "(Unnamed)" }); - opt.value = runtime.id; + this.hostEl.remove(); + } +}; +var ClaudeCliView = class extends import_obsidian2.ItemView { + constructor(leaf, plugin) { + super(leaf); + this.sessions = []; + this.activeSessionId = null; + this.tabBarEl = null; + this.terminalsHostEl = null; + this.emptyHintEl = null; + this.statusEl = null; + this.resizeObserver = null; + this.stopBtn = null; + this.restartBtn = null; + this.clearBtn = null; + this.plugin = plugin; + } + getViewType() { + return VIEW_TYPE_CLAUDE; + } + getDisplayText() { + return "Any AI CLI"; + } + getIcon() { + return "bot"; + } + onOpen() { + this.contentEl.empty(); + this.contentEl.addClass("claude-cli-view"); + this.tabBarEl = this.contentEl.createDiv({ cls: "claude-cli-tabbar" }); + const toolbarEl = this.contentEl.createDiv({ cls: "claude-cli-toolbar" }); + const primaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); + const newBtn = primaryRowEl.createEl("button", { text: "New session" }); + const stopBtn = primaryRowEl.createEl("button", { text: "Stop" }); + const restartBtn = primaryRowEl.createEl("button", { text: "Restart" }); + const clearBtn = primaryRowEl.createEl("button", { text: "Clear" }); + this.setButtonIcon(newBtn, "plus", "New session"); + this.setButtonIcon(stopBtn, "square", "Stop"); + this.setButtonIcon(restartBtn, "refresh-cw", "Restart"); + this.setButtonIcon(clearBtn, "eraser", "Clear"); + newBtn.addClass("claude-cli-btn-primary"); + stopBtn.addClass("claude-cli-btn-danger"); + this.stopBtn = stopBtn; + this.restartBtn = restartBtn; + this.clearBtn = clearBtn; + const secondaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); + const mentionBtn = secondaryRowEl.createEl("button", { text: "@active file" }); + const folderMentionBtn = secondaryRowEl.createEl("button", { text: "@active folder" }); + const automationsBtn = secondaryRowEl.createEl("button", { text: "Automations" }); + this.setButtonIcon(mentionBtn, "file-plus", "@active file"); + this.setButtonIcon(folderMentionBtn, "folder-plus", "@active folder"); + this.setButtonIcon(automationsBtn, "calendar-clock", "Automations"); + mentionBtn.addClass("claude-cli-btn-info"); + folderMentionBtn.addClass("claude-cli-btn-info"); + automationsBtn.addClass("claude-cli-btn-info"); + this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" }); + newBtn.addEventListener("click", (evt) => this.openNewSessionMenu(evt)); + stopBtn.addEventListener("click", () => this.stopActiveSession()); + restartBtn.addEventListener("click", () => this.restartActiveSession()); + clearBtn.addEventListener("click", () => { + var _a5; + return (_a5 = this.getActiveSession()) == null ? void 0 : _a5.terminal.clear(); + }); + mentionBtn.addEventListener("click", () => this.insertActiveFileMention()); + folderMentionBtn.addEventListener("click", () => this.insertActiveFolderMention()); + automationsBtn.addEventListener("click", () => { + new AutomationsModal(this.app, this.plugin).open(); + }); + this.terminalsHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminals" }); + this.emptyHintEl = this.terminalsHostEl.createDiv({ cls: "claude-cli-empty-hint" }); + this.emptyHintEl.setText("No session running. Use the + button to launch a runtime."); + this.resizeObserver = new ResizeObserver(() => { + var _a5, _b; + const session = this.getActiveSession(); + if (!session) { + return; + } + session.fitAddon.fit(); + if (session.processHandle) { + (_b = (_a5 = session.processHandle).resize) == null ? void 0 : _b.call( + _a5, + Math.max(20, session.terminal.cols || 120), + Math.max(10, session.terminal.rows || 30) + ); + } + }); + this.resizeObserver.observe(this.contentEl); + this.renderTabBar(); + this.updateEmptyState(); + this.updateToolbarState(); + if (this.plugin.settings.autoStart) { + const runtime = this.getSelectedRuntime(); + if (runtime) { + this.startSession({ runtimeId: runtime.id, origin: "manual" }); + } else { + this.setStatus("No runtime configured. Add one in plugin settings."); + } + } else { + this.setStatus("Idle"); } - const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId); - this.runtimeSelect.value = validSelection ? this.plugin.settings.selectedRuntimeId : runtimes[0].id; + return Promise.resolve(); } onClose() { var _a5, _b; - this.stopClaudeProcess(); - (_a5 = this.resizeObserver) == null ? void 0 : _a5.disconnect(); + for (const session of this.sessions) { + try { + (_a5 = session.processHandle) == null ? void 0 : _a5.kill("SIGTERM"); + } catch (e) { + } + session.dispose(); + } + this.sessions = []; + this.activeSessionId = null; + (_b = this.resizeObserver) == null ? void 0 : _b.disconnect(); this.resizeObserver = null; - (_b = this.terminal) == null ? void 0 : _b.dispose(); - this.terminal = null; - this.fitAddon = null; this.statusEl = null; + this.tabBarEl = null; + this.terminalsHostEl = null; + this.emptyHintEl = null; return Promise.resolve(); } - startClaudeProcess(runtimeIdOverride) { + getActiveSession() { + if (!this.activeSessionId) { + return null; + } + return this.findSession(this.activeSessionId); + } + findSession(id) { var _a5; - if (!this.terminal) { - return; + return (_a5 = this.sessions.find((s15) => s15.id === id)) != null ? _a5 : null; + } + isProcessRunning() { + return this.sessions.some((s15) => s15.isRunning()); + } + startSession(params) { + if (!this.terminalsHostEl) { + return null; } - const targetRuntime = runtimeIdOverride ? this.plugin.settings.runtimes.find((r) => r.id === runtimeIdOverride) : this.getSelectedRuntime(); - if (!targetRuntime) { - const message = "No runtime configured. Add one in plugin settings."; - this.writeSystemLine(`[${message}]`); + const runtime = this.plugin.settings.runtimes.find((r) => r.id === params.runtimeId); + if (!runtime) { + const message = "Runtime not configured. Add one in plugin settings."; this.setStatus(message); new import_obsidian2.Notice(message, 6e3); - return; + return null; } - const targetLabel = targetRuntime.name || "(Unnamed runtime)"; - if (this.processHandle) { - if (this.runningRuntimeId === targetRuntime.id) { - this.writeSystemLine(`[${targetLabel} process is already running]`); - this.setStatus("Already running"); - } else { - this.pendingStartRuntimeId = targetRuntime.id; - this.writeSystemLine(`[Switch requested: ${targetLabel}. Stopping current process first...]`); - this.stopClaudeProcess(true); - } - return; + if (!canOpenSession(this.sessions.length, this.plugin.settings.maxConcurrentSessions)) { + const message = `Session limit reached (${this.plugin.settings.maxConcurrentSessions}). Close a tab first.`; + this.setStatus(message); + new import_obsidian2.Notice(message, 6e3); + return null; } - const command = (targetRuntime.command || "").trim(); + const command = (runtime.command || "").trim(); if (!command) { - const message = `Runtime "${targetLabel}" has an empty command. Set one in plugin settings.`; - this.writeSystemLine(`[${message}]`); + const message = `Runtime "${runtime.name || "(Unnamed)"}" has an empty command. Set one in plugin settings.`; this.setStatus(message); new import_obsidian2.Notice(message, 6e3); + return null; + } + const hostEl = this.terminalsHostEl.createDiv({ cls: "claude-cli-terminal" }); + const { terminal, fitAddon } = createSessionTerminal(); + terminal.open(hostEl); + fitAddon.fit(); + const label = nextSessionLabel( + this.sessions.map((s15) => s15.label), + runtime.name || "(Unnamed)" + ); + const session = new CliSession({ + id: (0, import_node_crypto2.randomUUID)(), + runtimeId: runtime.id, + label, + origin: params.origin, + terminal, + fitAddon, + hostEl + }); + this.sessions.push(session); + terminal.onData((data) => { + var _a5; + return (_a5 = session.processHandle) == null ? void 0 : _a5.write(data); + }); + session.writeSystemLine(`CLI session ready (${label}).`); + this.setActiveSession(session.id); + this.spawnIntoSession(session, runtime); + this.renderTabBar(); + this.updateToolbarState(); + return session; + } + closeSession(id) { + var _a5, _b, _c2; + const index = this.sessions.findIndex((s15) => s15.id === id); + if (index < 0) { return; } + const session = this.sessions[index]; + try { + (_a5 = session.processHandle) == null ? void 0 : _a5.kill("SIGTERM"); + } catch (e) { + } + session.processHandle = null; + session.markReady(); + session.dispose(); + this.sessions.splice(index, 1); + if (this.activeSessionId === id) { + this.activeSessionId = null; + const next = (_c2 = (_b = this.sessions[index]) != null ? _b : this.sessions[index - 1]) != null ? _c2 : null; + if (next) { + this.setActiveSession(next.id); + } + } + this.renderTabBar(); + this.updateEmptyState(); + this.updateToolbarState(); + if (!this.getActiveSession()) { + this.setStatus("Idle"); + } + } + setActiveSession(id) { + var _a5, _b; + const session = this.findSession(id); + if (!session) { + return; + } + this.activeSessionId = id; + for (const s15 of this.sessions) { + s15.hostEl.toggleClass("is-hidden", s15.id !== id); + } + this.updateEmptyState(); + this.renderTabBar(); + session.fitAddon.fit(); + (_b = (_a5 = session.processHandle) == null ? void 0 : _a5.resize) == null ? void 0 : _b.call( + _a5, + Math.max(20, session.terminal.cols || 120), + Math.max(10, session.terminal.rows || 30) + ); + session.terminal.focus(); + this.setStatus(session.status); + this.updateToolbarState(); + } + sendAutomationPromptTo(sessionId, text, submitWithEnter) { + const session = this.findSession(sessionId); + if (!session) { + throw new Error("Session no longer exists"); + } + session.sendPrompt(text, submitWithEnter); + } + // Called by the plugin when the configured runtimes change in settings. + refreshRuntimeSelect() { + this.renderTabBar(); + } + spawnIntoSession(session, runtime) { + const label = session.label; + const command = (runtime.command || "").trim(); + if (!command) { + const message = `Runtime "${runtime.name || "(Unnamed)"}" has an empty command. Set one in plugin settings.`; + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } + session.markReady(); + return false; + } const codexLike = isCodexLikeCommand(command); if (codexLike) { - this.resetTerminalDisplay(); + session.terminal.reset(); + session.fitAddon.fit(); + } + session.resetReady(); + session.writeSystemLine(`[Starting: ${command}]`); + session.status = `Starting in vault folder (${process.platform})...`; + if (this.activeSessionId === session.id) { + this.setStatus(session.status); } - this.writeSystemLine(`[Starting: ${command}]`); - this.setStatus(`Starting in vault folder (${process.platform})...`); try { const vaultPath = getVaultBasePath(this.app); if (!vaultPath) { - const message = `Unable to resolve current vault path. ${targetLabel} was not started.`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); + const message = `Unable to resolve current vault path. ${label} was not started.`; + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } new import_obsidian2.Notice(message, 6e3); - return; + session.markReady(); + return false; } if (!fs2.existsSync(vaultPath)) { const message = `Vault path does not exist: ${vaultPath}`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } new import_obsidian2.Notice(message, 6e3); - return; + session.markReady(); + return false; } const shellEnv = getShellEnv(); if (codexLike) { @@ -19275,60 +19469,183 @@ var ClaudeCliView = class extends import_obsidian2.ItemView { command, cwd: vaultPath, env: shellEnv, - cols: Math.max(20, this.terminal.cols || 120), - rows: Math.max(10, this.terminal.rows || 30), + cols: Math.max(20, session.terminal.cols || 120), + rows: Math.max(10, session.terminal.rows || 30), nodeExecutable: this.plugin.settings.nodeExecutable, pluginDir: this.plugin.manifest.dir, vaultPath }); - this.processHandle = makeProxyAdapter(helperHandle); - this.runningRuntimeId = targetRuntime.id; + session.processHandle = makeProxyAdapter(helperHandle); } catch (error) { const message = `Failed to start process: ${error.message}`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); - new import_obsidian2.Notice(message, 7e3); - this.processHandle = null; - this.runningRuntimeId = null; - return; - } - this.setStatus("Running"); - this.processHandle.onData((data) => { - var _a6; - (_a6 = this.terminal) == null ? void 0 : _a6.write(data); - }); - this.processHandle.onExit((exitCode, signal) => { - const message = `Process exited (code=${exitCode}, signal=${signal})`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); - this.processHandle = null; - this.runningRuntimeId = null; - const nextRuntimeId = this.pendingStartRuntimeId; - this.pendingStartRuntimeId = null; - if (nextRuntimeId) { - void this.startClaudeProcess(nextRuntimeId); + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); } + new import_obsidian2.Notice(message, 7e3); + session.processHandle = null; + session.markReady(); + return false; + } + session.status = "Running"; + if (this.activeSessionId === session.id) { + this.setStatus("Running"); + } + session.armReadyFallback(SESSION_READY_FALLBACK_MS); + session.processHandle.onData((data) => { + session.terminal.write(data); + session.markReady(); }); - (_a5 = this.fitAddon) == null ? void 0 : _a5.fit(); + session.processHandle.onExit((exitCode, signal) => { + session.processHandle = null; + session.markReady(); + if (session.pendingRestart) { + session.pendingRestart = false; + const current = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId); + if (current) { + this.spawnIntoSession(session, current); + this.renderTabBar(); + this.updateToolbarState(); + return; + } + } + const message = `Process exited (code=${exitCode}, signal=${signal})`; + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } + if (session.origin === "automation" && this.plugin.settings.autoCloseAutomationSessions) { + this.closeSession(session.id); + return; + } + this.renderTabBar(); + this.updateToolbarState(); + }); + if (this.activeSessionId === session.id) { + session.fitAddon.fit(); + } + return true; } - stopClaudeProcess(preservePendingStart = false) { - if (!this.processHandle) { + stopActiveSession() { + const session = this.getActiveSession(); + if (!session || !session.processHandle) { return; } - if (!preservePendingStart) { - this.pendingStartRuntimeId = null; - } - this.writeSystemLine(`[Stopping ${this.getRunningRuntimeLabel()} process...]`); + session.writeSystemLine(`[Stopping ${session.label} process...]`); + session.status = "Stopping..."; this.setStatus("Stopping..."); try { - this.processHandle.kill("SIGTERM"); + session.processHandle.kill("SIGTERM"); } catch (error) { const message = `Failed to stop process: ${error.message}`; - this.writeSystemLine(`[${message}]`); + session.writeSystemLine(`[${message}]`); + session.status = message; this.setStatus(message); new import_obsidian2.Notice(message, 6e3); } } + restartActiveSession() { + const session = this.getActiveSession(); + if (!session) { + return; + } + const runtime = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId); + if (!runtime) { + new import_obsidian2.Notice("Runtime is no longer configured.", 6e3); + return; + } + if (session.processHandle) { + session.pendingRestart = true; + session.writeSystemLine(`[Restart requested: ${session.label}]`); + session.status = "Restarting..."; + this.setStatus("Restarting..."); + try { + session.processHandle.kill("SIGTERM"); + } catch (e) { + session.pendingRestart = false; + } + return; + } + this.spawnIntoSession(session, runtime); + this.renderTabBar(); + this.updateToolbarState(); + } + openNewSessionMenu(evt) { + const runtimes = this.plugin.settings.runtimes; + if (runtimes.length === 0) { + new import_obsidian2.Notice("No runtime configured. Add one in plugin settings.", 6e3); + return; + } + if (runtimes.length === 1) { + this.startSession({ runtimeId: runtimes[0].id, origin: "manual" }); + return; + } + const menu = new import_obsidian2.Menu(); + for (const runtime of runtimes) { + menu.addItem( + (item) => item.setTitle(runtime.name || "(Unnamed)").setIcon("terminal").onClick(() => { + this.startSession({ runtimeId: runtime.id, origin: "manual" }); + }) + ); + } + menu.showAtMouseEvent(evt); + } + renderTabBar() { + if (!this.tabBarEl) { + return; + } + this.tabBarEl.empty(); + for (const session of this.sessions) { + const tabEl = this.tabBarEl.createDiv({ + cls: `claude-cli-tab${session.id === this.activeSessionId ? " is-active" : ""}` + }); + const dotCls = session.isRunning() ? "is-running" : "is-stopped"; + tabEl.createSpan({ cls: `claude-cli-tab-dot ${dotCls}` }); + if (session.origin === "automation") { + const autoIcon = tabEl.createSpan({ cls: "claude-cli-tab-auto" }); + (0, import_obsidian2.setIcon)(autoIcon, "calendar-clock"); + } + tabEl.createSpan({ text: session.label, cls: "claude-cli-tab-label" }); + const closeEl = tabEl.createSpan({ cls: "claude-cli-tab-close" }); + (0, import_obsidian2.setIcon)(closeEl, "x"); + closeEl.setAttribute("aria-label", "Close session"); + closeEl.addEventListener("click", (evt) => { + evt.stopPropagation(); + this.closeSession(session.id); + }); + tabEl.addEventListener("click", () => { + if (session.id !== this.activeSessionId) { + this.setActiveSession(session.id); + } + }); + } + const newTabEl = this.tabBarEl.createDiv({ cls: "claude-cli-tab-new" }); + (0, import_obsidian2.setIcon)(newTabEl, "plus"); + newTabEl.setAttribute("aria-label", "New session"); + newTabEl.addEventListener("click", (evt) => this.openNewSessionMenu(evt)); + } + updateEmptyState() { + if (!this.emptyHintEl) { + return; + } + this.emptyHintEl.toggleClass("is-hidden", this.sessions.length > 0); + } + updateToolbarState() { + var _a5; + const session = this.getActiveSession(); + const running = (_a5 = session == null ? void 0 : session.isRunning()) != null ? _a5 : false; + if (this.stopBtn) { + this.stopBtn.disabled = !running; + } + if (this.restartBtn) { + this.restartBtn.disabled = !session; + } + if (this.clearBtn) { + this.clearBtn.disabled = !session; + } + } setStatus(message) { var _a5; (_a5 = this.statusEl) == null ? void 0 : _a5.setText(`Status: ${message}`); @@ -19338,121 +19655,46 @@ var ClaudeCliView = class extends import_obsidian2.ItemView { const { runtimes, selectedRuntimeId } = this.plugin.settings; return (_b = (_a5 = runtimes.find((r) => r.id === selectedRuntimeId)) != null ? _a5 : runtimes[0]) != null ? _b : null; } - getRuntimeLabel(runtimeId) { - var _a5; - if (runtimeId) { - const match = this.plugin.settings.runtimes.find((r) => r.id === runtimeId); - return (match == null ? void 0 : match.name) || "(Unnamed runtime)"; - } - return ((_a5 = this.getSelectedRuntime()) == null ? void 0 : _a5.name) || "(No runtime)"; - } - getRunningRuntimeLabel() { - if (!this.runningRuntimeId) { - return this.getRuntimeLabel(); - } - return this.getRuntimeLabel(this.runningRuntimeId); - } - restartClaudeProcess() { - const target = this.getSelectedRuntime(); - if (!target) { - void this.startClaudeProcess(); - return; - } - if (!this.processHandle) { - void this.startClaudeProcess(target.id); - return; - } - this.pendingStartRuntimeId = target.id; - this.writeSystemLine(`[Restart requested: ${target.name}]`); - this.stopClaudeProcess(true); - } - resetTerminalDisplay() { - var _a5; - if (!this.terminal) { - return; - } - this.terminal.reset(); - (_a5 = this.fitAddon) == null ? void 0 : _a5.fit(); - } - setRuntime(runtimeId) { - if (!runtimeId) { - return; - } - const exists = this.plugin.settings.runtimes.some((r) => r.id === runtimeId); - if (!exists) { - this.refreshRuntimeSelect(); - return; - } - if (this.plugin.settings.selectedRuntimeId === runtimeId) { - return; - } - this.plugin.settings.selectedRuntimeId = runtimeId; - void this.plugin.saveSettings(); - this.refreshRuntimeSelect(); - const selectedLabel = this.getRuntimeLabel(); - this.writeSystemLine(`[Runtime selected: ${selectedLabel}]`); - if (this.processHandle) { - if (this.plugin.settings.autoRestartOnRuntimeSwitch) { - this.setStatus(`Restarting to ${selectedLabel}...`); - this.restartClaudeProcess(); - return; - } - this.setStatus(`${selectedLabel} selected (restart to apply)`); - return; - } - this.setStatus(`${selectedLabel} selected`); - } insertActiveFileMention() { - var _a5; + const session = this.getActiveSession(); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { const message = "No active file detected."; - this.writeSystemLine(`[${message}]`); this.setStatus(message); new import_obsidian2.Notice(message, 4e3); return; } - if (!this.processHandle) { - const message = `${this.getRuntimeLabel()} process is not running. Start it before inserting a file mention.`; - this.writeSystemLine(`[${message}]`); + if (!session || !session.processHandle) { + const message = "No running session. Start one before inserting a file mention."; this.setStatus(message); new import_obsidian2.Notice(message, 5e3); return; } const mention = formatActiveFileMention(activeFile.path); - this.processHandle.write(mention); - (_a5 = this.terminal) == null ? void 0 : _a5.focus(); + session.processHandle.write(mention); + session.terminal.focus(); this.setStatus(`Inserted ${mention.trim()}`); } insertActiveFolderMention() { - var _a5; + const session = this.getActiveSession(); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { const message = "No active file detected."; - this.writeSystemLine(`[${message}]`); this.setStatus(message); new import_obsidian2.Notice(message, 4e3); return; } - if (!this.processHandle) { - const message = `${this.getRuntimeLabel()} process is not running. Start it before inserting a folder mention.`; - this.writeSystemLine(`[${message}]`); + if (!session || !session.processHandle) { + const message = "No running session. Start one before inserting a folder mention."; this.setStatus(message); new import_obsidian2.Notice(message, 5e3); return; } const mention = formatActiveFolderMention(activeFile.path); - this.processHandle.write(mention); - (_a5 = this.terminal) == null ? void 0 : _a5.focus(); + session.processHandle.write(mention); + session.terminal.focus(); this.setStatus(`Inserted ${mention.trim()}`); } - writeSystemLine(message) { - if (!this.terminal) { - return; - } - this.terminal.write("\r\x1B[2K"); - this.terminal.writeln(message); - } setButtonIcon(buttonEl, iconName, label) { buttonEl.empty(); buttonEl.addClass("claude-cli-btn"); @@ -19596,33 +19838,39 @@ var ClaudeCliPlugin = class extends import_obsidian2.Plugin { name: entry.name, source }; - const view = this.findRunnableView(); - if (!view) { - const reason = "No CLI is running"; + const runtime = resolveRuntimeForAutomation( + this.settings.runtimes, + entry.runtime, + this.settings.selectedRuntimeId + ); + if (!runtime) { + const reason = entry.runtime ? `Runtime "${entry.runtime}" not configured` : "No runtime configured"; this.recordHistory({ ...baseRecord, status: "skipped", reason }); if (source === "manual") { new import_obsidian2.Notice(`Automation "${entry.name}" skipped \u2014 ${reason.toLowerCase()}.`, 5e3); } return; } - if (entry.runtime) { - const runtimeMatches = view.matchesRuntime(entry.runtime); - if (!runtimeMatches) { - const reason = `Runtime mismatch (expected "${entry.runtime}")`; - this.recordHistory({ ...baseRecord, status: "skipped", reason }); - if (source === "manual") { - new import_obsidian2.Notice(`Automation "${entry.name}" skipped \u2014 ${reason.toLowerCase()}.`, 5e3); - } - return; + const started = await this.activateViewAndStartSession({ + runtimeId: runtime.id, + origin: "automation" + }); + if (!started) { + const reason = "Could not open a session"; + this.recordHistory({ ...baseRecord, status: "error", reason }); + if (source === "manual") { + new import_obsidian2.Notice(`Automation "${entry.name}" failed \u2014 ${reason.toLowerCase()}.`, 5e3); } + return; } + const { view, session } = started; try { - view.sendAutomationPrompt(entry.body, entry.appendNewline); - const runtimeId = view.getRunningRuntimeId(); + await session.whenReady; + view.sendAutomationPromptTo(session.id, entry.body, entry.appendNewline); this.recordHistory({ ...baseRecord, status: "ran", - runtimeId, + runtimeId: session.runtimeId, promptPreview: buildPromptPreview(entry.body) }); this.settings.automationsLastRun[entry.path] = now; @@ -19639,15 +19887,18 @@ var ClaudeCliPlugin = class extends import_obsidian2.Plugin { new import_obsidian2.Notice(`Automation "${entry.name}" failed: ${err.message}`, 6e3); } } - findRunnableView() { - const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE); - for (const leaf of leaves) { - const view = leaf.view; - if (view instanceof ClaudeCliView && view.isProcessRunning()) { - return view; - } + async activateViewAndStartSession(params) { + await this.activateView(); + const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE)[0]; + const view = leaf == null ? void 0 : leaf.view; + if (!(view instanceof ClaudeCliView)) { + return null; } - return null; + const session = view.startSession(params); + if (!session) { + return null; + } + return { view, session }; } recordHistory(record) { this.settings.automationsHistory = pushHistory( @@ -19710,7 +19961,9 @@ var ClaudeCliPlugin = class extends import_obsidian2.Plugin { automationsFolder: typeof raw.automationsFolder === "string" ? raw.automationsFolder : DEFAULT_SETTINGS.automationsFolder, automationsLastRun: sanitizeLastRun(raw.automationsLastRun), automationsHistory: sanitizeHistory(raw.automationsHistory), - automationsHistoryLimit: typeof raw.automationsHistoryLimit === "number" && Number.isInteger(raw.automationsHistoryLimit) && raw.automationsHistoryLimit > 0 ? raw.automationsHistoryLimit : DEFAULT_SETTINGS.automationsHistoryLimit + automationsHistoryLimit: typeof raw.automationsHistoryLimit === "number" && Number.isInteger(raw.automationsHistoryLimit) && raw.automationsHistoryLimit > 0 ? raw.automationsHistoryLimit : DEFAULT_SETTINGS.automationsHistoryLimit, + autoCloseAutomationSessions: typeof raw.autoCloseAutomationSessions === "boolean" ? raw.autoCloseAutomationSessions : DEFAULT_SETTINGS.autoCloseAutomationSessions, + maxConcurrentSessions: typeof raw.maxConcurrentSessions === "number" && Number.isInteger(raw.maxConcurrentSessions) && raw.maxConcurrentSessions >= 0 ? raw.maxConcurrentSessions : DEFAULT_SETTINGS.maxConcurrentSessions }; } async saveSettings() { @@ -19762,15 +20015,22 @@ var ClaudeCliSettingTab = class extends import_obsidian2.PluginSettingTab { await this.plugin.saveSettings(); }) ); - new import_obsidian2.Setting(containerEl).setName("Auto-restart on runtime switch").setDesc("Automatically restart the running process when changing the runtime from the sidebar dropdown.").addToggle( - (toggle) => toggle.setValue(this.plugin.settings.autoRestartOnRuntimeSwitch).onChange(async (value) => { - this.plugin.settings.autoRestartOnRuntimeSwitch = value; + new import_obsidian2.Setting(containerEl).setName("Auto-close automation sessions").setDesc("When an automation-spawned session's process exits, close its tab automatically so tabs don't pile up.").addToggle( + (toggle) => toggle.setValue(this.plugin.settings.autoCloseAutomationSessions).onChange(async (value) => { + this.plugin.settings.autoCloseAutomationSessions = value; + await this.plugin.saveSettings(); + }) + ); + new import_obsidian2.Setting(containerEl).setName("Max concurrent sessions").setDesc("Maximum number of session tabs that can run at once (0 = unlimited). Protects against runaway automation spawns.").addText( + (text) => text.setPlaceholder("8").setValue(String(this.plugin.settings.maxConcurrentSessions)).onChange(async (value) => { + const parsed = Number.parseInt(value.trim(), 10); + this.plugin.settings.maxConcurrentSessions = Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_SETTINGS.maxConcurrentSessions; await this.plugin.saveSettings(); }) ); new import_obsidian2.Setting(containerEl).setName("Runtimes").setHeading(); containerEl.createEl("p", { - text: "Configure the runtimes that show up in the sidebar dropdown. Each entry needs a display name and a launch command. Add as many as you want.", + text: "Configure the runtimes available from the sidebar new-session menu. Each entry needs a display name and a launch command. Add as many as you want.", cls: "setting-item-description" }); const runtimesListEl = containerEl.createDiv({ cls: "claude-cli-runtimes-list" }); diff --git a/main.ts b/main.ts index a51d3f1..f31b337 100644 --- a/main.ts +++ b/main.ts @@ -1,5 +1,6 @@ -import { App, FileSystemAdapter, ItemView, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder, WorkspaceLeaf, parseYaml, setIcon } from "obsidian"; +import { App, FileSystemAdapter, ItemView, Menu, Notice, Plugin, PluginSettingTab, Setting, TFile, TFolder, WorkspaceLeaf, parseYaml, setIcon } from "obsidian"; import { spawn, type ChildProcess } from "child_process"; +import { randomUUID } from "node:crypto"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -16,6 +17,11 @@ import { resolvePluginDir as resolvePluginDirWithVault, type CliRuntimeConfig } from "./runtime-utils"; +import { + canOpenSession, + nextSessionLabel, + resolveRuntimeForAutomation +} from "./session-utils"; import { buildPromptPreview, computeNextRun, @@ -54,6 +60,8 @@ interface ClaudeCliPluginSettings { automationsLastRun: Record; automationsHistory: AutomationRunRecord[]; automationsHistoryLimit: number; + autoCloseAutomationSessions: boolean; + maxConcurrentSessions: number; } const DEFAULT_SETTINGS: ClaudeCliPluginSettings = { @@ -65,7 +73,9 @@ const DEFAULT_SETTINGS: ClaudeCliPluginSettings = { automationsFolder: "", automationsLastRun: {}, automationsHistory: [], - automationsHistoryLimit: 200 + automationsHistoryLimit: 200, + autoCloseAutomationSessions: true, + maxConcurrentSessions: 8 }; const AUTOMATION_TICK_MS = 30_000; @@ -106,10 +116,10 @@ interval: 60 # cron: "0 8 1 * *" # 08:00 on the 1st of each month # runtime (string, optional) -# Only fire when this runtime is the one currently running, matched -# by its id OR its display name (case-insensitive). Remove the line -# to send to whichever runtime is active. Skipped runs are logged -# in the History tab. +# Which runtime to spawn for this automation, matched by its id OR its +# display name (case-insensitive). Each run opens its own session tab. +# Remove the line to use the default runtime (set in plugin settings). +# Runs naming an unconfigured runtime are skipped and logged in History. runtime: Claude # appendNewline (true | false, optional, default true) @@ -133,150 +143,112 @@ interface ProcessAdapter { onExit(callback: (exitCode: number, signal: string) => void): void; } -class ClaudeCliView extends ItemView { - private plugin: ClaudeCliPlugin; - private terminal: Terminal | null = null; - private fitAddon: FitAddon | null = null; - private processHandle: ProcessAdapter | null = null; - private terminalHostEl: HTMLDivElement | null = null; - private resizeObserver: ResizeObserver | null = null; - private statusEl: HTMLDivElement | null = null; - private runtimeSelect: HTMLSelectElement | null = null; - private runningRuntimeId: string | null = null; - private pendingStartRuntimeId: string | null = null; +type SessionOrigin = "manual" | "automation"; - constructor(leaf: WorkspaceLeaf, plugin: ClaudeCliPlugin) { - super(leaf); - this.plugin = plugin; - } +/** How long to wait for the spawned CLI to emit output before considering a + * freshly started session "ready" to receive an automation prompt. */ +const SESSION_READY_FALLBACK_MS = 1200; - getViewType(): string { - return VIEW_TYPE_CLAUDE; - } - - getDisplayText(): string { - return "Any AI CLI"; - } - - getIcon(): string { - return "bot"; - } - - onOpen(): Promise { - this.contentEl.empty(); - this.contentEl.addClass("claude-cli-view"); - - const toolbarEl = this.contentEl.createDiv({ cls: "claude-cli-toolbar" }); - - const primaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); - const runtimePickerEl = primaryRowEl.createDiv({ cls: "claude-cli-runtime-picker" }); - const runtimeIconEl = runtimePickerEl.createSpan({ cls: "claude-cli-runtime-picker-icon" }); - setIcon(runtimeIconEl, "terminal"); - this.runtimeSelect = runtimePickerEl.createEl("select", { cls: "claude-cli-runtime-select" }); - this.runtimeSelect.setAttribute("aria-label", "Select runtime"); - this.refreshRuntimeSelect(); - const runtimeChevronEl = runtimePickerEl.createSpan({ cls: "claude-cli-runtime-picker-chevron" }); - setIcon(runtimeChevronEl, "chevron-down"); - const startBtn = primaryRowEl.createEl("button", { text: "Start" }); - const stopBtn = primaryRowEl.createEl("button", { text: "Stop" }); - const restartBtn = primaryRowEl.createEl("button", { text: "Restart" }); - const clearBtn = primaryRowEl.createEl("button", { text: "Clear" }); - this.setButtonIcon(startBtn, "play", "Start"); - this.setButtonIcon(stopBtn, "square", "Stop"); - this.setButtonIcon(restartBtn, "refresh-cw", "Restart"); - this.setButtonIcon(clearBtn, "eraser", "Clear"); - startBtn.addClass("claude-cli-btn-primary"); - stopBtn.addClass("claude-cli-btn-danger"); - - const secondaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); - const mentionBtn = secondaryRowEl.createEl("button", { text: "@active file" }); - const folderMentionBtn = secondaryRowEl.createEl("button", { text: "@active folder" }); - const automationsBtn = secondaryRowEl.createEl("button", { text: "Automations" }); - this.setButtonIcon(mentionBtn, "file-plus", "@active file"); - this.setButtonIcon(folderMentionBtn, "folder-plus", "@active folder"); - this.setButtonIcon(automationsBtn, "calendar-clock", "Automations"); - mentionBtn.addClass("claude-cli-btn-info"); - folderMentionBtn.addClass("claude-cli-btn-info"); - automationsBtn.addClass("claude-cli-btn-info"); - - this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" }); - - startBtn.addEventListener("click", () => this.startClaudeProcess()); - stopBtn.addEventListener("click", () => this.stopClaudeProcess()); - restartBtn.addEventListener("click", () => this.restartClaudeProcess()); - clearBtn.addEventListener("click", () => this.terminal?.clear()); - mentionBtn.addEventListener("click", () => this.insertActiveFileMention()); - folderMentionBtn.addEventListener("click", () => this.insertActiveFolderMention()); - automationsBtn.addEventListener("click", () => { - new AutomationsModal(this.app, this.plugin).open(); - }); - this.runtimeSelect.addEventListener("change", () => { - if (this.runtimeSelect) { - this.setRuntime(this.runtimeSelect.value); - } - }); - - this.terminalHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminal" }); - - this.terminal = new Terminal({ - cursorBlink: true, - convertEol: true, - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", - fontSize: 13, - scrollback: 3000, - theme: { - background: "#0f1115", - foreground: "#e6e6e6" - } - }); - - this.fitAddon = new FitAddon(); - this.terminal.loadAddon(this.fitAddon); - this.terminal.open(this.terminalHostEl); - this.fitAddon.fit(); - this.writeSystemLine("CLI panel ready."); - - this.terminal.onData((data) => { - this.processHandle?.write(data); - }); - - this.resizeObserver = new ResizeObserver(() => { - this.fitAddon?.fit(); - if (this.processHandle && this.terminal) { - this.processHandle.resize?.( - Math.max(20, this.terminal.cols || 120), - Math.max(10, this.terminal.rows || 30) - ); - } - }); - this.resizeObserver.observe(this.contentEl); - - if (this.plugin.settings.autoStart) { - this.startClaudeProcess(); - } else { - this.writeSystemLine(`Auto-start is disabled. Click start to launch ${this.getRuntimeLabel()}.`); - this.setStatus("Idle"); +function createSessionTerminal(): { terminal: Terminal; fitAddon: FitAddon } { + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace", + fontSize: 13, + scrollback: 3000, + theme: { + background: "#0f1115", + foreground: "#e6e6e6" } + }); + const fitAddon = new FitAddon(); + terminal.loadAddon(fitAddon); + return { terminal, fitAddon }; +} - return Promise.resolve(); +/** + * One independent CLI session: its own PTY process and its own xterm terminal + * rendered into a dedicated host element. The view owns a list of these and + * shows one at a time via tabs. + */ +class CliSession { + readonly id: string; + runtimeId: string; + label: string; + origin: SessionOrigin; + readonly terminal: Terminal; + readonly fitAddon: FitAddon; + readonly hostEl: HTMLDivElement; + processHandle: ProcessAdapter | null = null; + status = "Idle"; + pendingRestart = false; + + private ready = false; + whenReady!: Promise; + private resolveReady: (() => void) | null = null; + private readyTimer: number | null = null; + + constructor(params: { + id: string; + runtimeId: string; + label: string; + origin: SessionOrigin; + terminal: Terminal; + fitAddon: FitAddon; + hostEl: HTMLDivElement; + }) { + this.id = params.id; + this.runtimeId = params.runtimeId; + this.label = params.label; + this.origin = params.origin; + this.terminal = params.terminal; + this.fitAddon = params.fitAddon; + this.hostEl = params.hostEl; + this.resetReady(); } - isProcessRunning(): boolean { - return this.processHandle !== null && this.runningRuntimeId !== null; + /** Arm a fresh readiness promise for a (re)spawn. */ + resetReady(): void { + this.ready = false; + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + this.readyTimer = null; + } + this.whenReady = new Promise((resolve) => { + this.resolveReady = resolve; + }); } - getRunningRuntimeId(): string | null { - return this.runningRuntimeId; + markReady(): void { + if (this.ready) { + return; + } + this.ready = true; + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + this.readyTimer = null; + } + this.resolveReady?.(); + this.resolveReady = null; } - matchesRuntime(target: string): boolean { - if (!this.runningRuntimeId) return false; - if (this.runningRuntimeId === target) return true; - const runtime = this.plugin.settings.runtimes.find((r) => r.id === this.runningRuntimeId); - return runtime ? runtime.name.trim().toLowerCase() === target.trim().toLowerCase() : false; + armReadyFallback(delayMs: number): void { + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + } + this.readyTimer = activeWindow.setTimeout(() => this.markReady(), delayMs); } - sendAutomationPrompt(text: string, submitWithEnter: boolean): void { + isRunning(): boolean { + return this.processHandle !== null; + } + + writeSystemLine(message: string): void { + this.terminal.write("\r"); + this.terminal.writeln(message); + } + + sendPrompt(text: string, submitWithEnter: boolean): void { if (!this.processHandle) { throw new Error("CLI process is not running"); } @@ -299,102 +271,337 @@ class ClaudeCliView extends ItemView { this.writeSystemLine(`[Automation prompt injected]`); } - refreshRuntimeSelect(): void { - if (!this.runtimeSelect) { - return; + dispose(): void { + if (this.readyTimer !== null) { + activeWindow.clearTimeout(this.readyTimer); + this.readyTimer = null; } - this.runtimeSelect.empty(); - const runtimes = this.plugin.settings.runtimes; - if (runtimes.length === 0) { - const placeholder = this.runtimeSelect.createEl("option", { text: "No runtime configured" }); - placeholder.value = ""; - this.runtimeSelect.value = ""; - this.runtimeSelect.disabled = true; - return; + try { + this.terminal.dispose(); + } catch { + /* ignore disposal errors */ } - this.runtimeSelect.disabled = false; - for (const runtime of runtimes) { - const opt = this.runtimeSelect.createEl("option", { text: runtime.name || "(Unnamed)" }); - opt.value = runtime.id; - } - const validSelection = runtimes.some((r) => r.id === this.plugin.settings.selectedRuntimeId); - this.runtimeSelect.value = validSelection - ? this.plugin.settings.selectedRuntimeId - : runtimes[0].id; + this.hostEl.remove(); + } +} + +class ClaudeCliView extends ItemView { + private plugin: ClaudeCliPlugin; + private sessions: CliSession[] = []; + private activeSessionId: string | null = null; + private tabBarEl: HTMLDivElement | null = null; + private terminalsHostEl: HTMLDivElement | null = null; + private emptyHintEl: HTMLDivElement | null = null; + private statusEl: HTMLDivElement | null = null; + private resizeObserver: ResizeObserver | null = null; + private stopBtn: HTMLButtonElement | null = null; + private restartBtn: HTMLButtonElement | null = null; + private clearBtn: HTMLButtonElement | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: ClaudeCliPlugin) { + super(leaf); + this.plugin = plugin; } - onClose(): Promise { - this.stopClaudeProcess(); - this.resizeObserver?.disconnect(); - this.resizeObserver = null; - this.terminal?.dispose(); - this.terminal = null; - this.fitAddon = null; - this.statusEl = null; + getViewType(): string { + return VIEW_TYPE_CLAUDE; + } + + getDisplayText(): string { + return "Any AI CLI"; + } + + getIcon(): string { + return "bot"; + } + + onOpen(): Promise { + this.contentEl.empty(); + this.contentEl.addClass("claude-cli-view"); + + this.tabBarEl = this.contentEl.createDiv({ cls: "claude-cli-tabbar" }); + + const toolbarEl = this.contentEl.createDiv({ cls: "claude-cli-toolbar" }); + const primaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); + const newBtn = primaryRowEl.createEl("button", { text: "New session" }); + const stopBtn = primaryRowEl.createEl("button", { text: "Stop" }); + const restartBtn = primaryRowEl.createEl("button", { text: "Restart" }); + const clearBtn = primaryRowEl.createEl("button", { text: "Clear" }); + this.setButtonIcon(newBtn, "plus", "New session"); + this.setButtonIcon(stopBtn, "square", "Stop"); + this.setButtonIcon(restartBtn, "refresh-cw", "Restart"); + this.setButtonIcon(clearBtn, "eraser", "Clear"); + newBtn.addClass("claude-cli-btn-primary"); + stopBtn.addClass("claude-cli-btn-danger"); + this.stopBtn = stopBtn; + this.restartBtn = restartBtn; + this.clearBtn = clearBtn; + + const secondaryRowEl = toolbarEl.createDiv({ cls: "claude-cli-toolbar-row" }); + const mentionBtn = secondaryRowEl.createEl("button", { text: "@active file" }); + const folderMentionBtn = secondaryRowEl.createEl("button", { text: "@active folder" }); + const automationsBtn = secondaryRowEl.createEl("button", { text: "Automations" }); + this.setButtonIcon(mentionBtn, "file-plus", "@active file"); + this.setButtonIcon(folderMentionBtn, "folder-plus", "@active folder"); + this.setButtonIcon(automationsBtn, "calendar-clock", "Automations"); + mentionBtn.addClass("claude-cli-btn-info"); + folderMentionBtn.addClass("claude-cli-btn-info"); + automationsBtn.addClass("claude-cli-btn-info"); + + this.statusEl = this.contentEl.createDiv({ cls: "claude-cli-status" }); + + newBtn.addEventListener("click", (evt) => this.openNewSessionMenu(evt)); + stopBtn.addEventListener("click", () => this.stopActiveSession()); + restartBtn.addEventListener("click", () => this.restartActiveSession()); + clearBtn.addEventListener("click", () => this.getActiveSession()?.terminal.clear()); + mentionBtn.addEventListener("click", () => this.insertActiveFileMention()); + folderMentionBtn.addEventListener("click", () => this.insertActiveFolderMention()); + automationsBtn.addEventListener("click", () => { + new AutomationsModal(this.app, this.plugin).open(); + }); + + this.terminalsHostEl = this.contentEl.createDiv({ cls: "claude-cli-terminals" }); + this.emptyHintEl = this.terminalsHostEl.createDiv({ cls: "claude-cli-empty-hint" }); + this.emptyHintEl.setText("No session running. Use the + button to launch a runtime."); + + this.resizeObserver = new ResizeObserver(() => { + const session = this.getActiveSession(); + if (!session) { + return; + } + session.fitAddon.fit(); + if (session.processHandle) { + session.processHandle.resize?.( + Math.max(20, session.terminal.cols || 120), + Math.max(10, session.terminal.rows || 30) + ); + } + }); + this.resizeObserver.observe(this.contentEl); + + this.renderTabBar(); + this.updateEmptyState(); + this.updateToolbarState(); + + if (this.plugin.settings.autoStart) { + const runtime = this.getSelectedRuntime(); + if (runtime) { + this.startSession({ runtimeId: runtime.id, origin: "manual" }); + } else { + this.setStatus("No runtime configured. Add one in plugin settings."); + } + } else { + this.setStatus("Idle"); + } + return Promise.resolve(); } - startClaudeProcess(runtimeIdOverride?: string): void { - if (!this.terminal) { - return; - } - const targetRuntime = runtimeIdOverride - ? this.plugin.settings.runtimes.find((r) => r.id === runtimeIdOverride) - : this.getSelectedRuntime(); - if (!targetRuntime) { - const message = "No runtime configured. Add one in plugin settings."; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); - new Notice(message, 6000); - return; - } - - const targetLabel = targetRuntime.name || "(Unnamed runtime)"; - - if (this.processHandle) { - if (this.runningRuntimeId === targetRuntime.id) { - this.writeSystemLine(`[${targetLabel} process is already running]`); - this.setStatus("Already running"); - } else { - this.pendingStartRuntimeId = targetRuntime.id; - this.writeSystemLine(`[Switch requested: ${targetLabel}. Stopping current process first...]`); - this.stopClaudeProcess(true); + onClose(): Promise { + for (const session of this.sessions) { + try { + session.processHandle?.kill("SIGTERM"); + } catch { + /* ignore */ } - return; + session.dispose(); } + this.sessions = []; + this.activeSessionId = null; + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.statusEl = null; + this.tabBarEl = null; + this.terminalsHostEl = null; + this.emptyHintEl = null; + return Promise.resolve(); + } - const command = (targetRuntime.command || "").trim(); - if (!command) { - const message = `Runtime "${targetLabel}" has an empty command. Set one in plugin settings.`; - this.writeSystemLine(`[${message}]`); + getActiveSession(): CliSession | null { + if (!this.activeSessionId) { + return null; + } + return this.findSession(this.activeSessionId); + } + + findSession(id: string): CliSession | null { + return this.sessions.find((s) => s.id === id) ?? null; + } + + isProcessRunning(): boolean { + return this.sessions.some((s) => s.isRunning()); + } + + startSession(params: { runtimeId: string; origin: SessionOrigin }): CliSession | null { + if (!this.terminalsHostEl) { + return null; + } + const runtime = this.plugin.settings.runtimes.find((r) => r.id === params.runtimeId); + if (!runtime) { + const message = "Runtime not configured. Add one in plugin settings."; this.setStatus(message); new Notice(message, 6000); + return null; + } + if (!canOpenSession(this.sessions.length, this.plugin.settings.maxConcurrentSessions)) { + const message = `Session limit reached (${this.plugin.settings.maxConcurrentSessions}). Close a tab first.`; + this.setStatus(message); + new Notice(message, 6000); + return null; + } + const command = (runtime.command || "").trim(); + if (!command) { + const message = `Runtime "${runtime.name || "(Unnamed)"}" has an empty command. Set one in plugin settings.`; + this.setStatus(message); + new Notice(message, 6000); + return null; + } + + const hostEl = this.terminalsHostEl.createDiv({ cls: "claude-cli-terminal" }); + const { terminal, fitAddon } = createSessionTerminal(); + terminal.open(hostEl); + fitAddon.fit(); + const label = nextSessionLabel( + this.sessions.map((s) => s.label), + runtime.name || "(Unnamed)" + ); + const session = new CliSession({ + id: randomUUID(), + runtimeId: runtime.id, + label, + origin: params.origin, + terminal, + fitAddon, + hostEl + }); + this.sessions.push(session); + terminal.onData((data) => session.processHandle?.write(data)); + session.writeSystemLine(`CLI session ready (${label}).`); + + // Activate the new tab before spawning so the terminal is visible and sized. + this.setActiveSession(session.id); + this.spawnIntoSession(session, runtime); + this.renderTabBar(); + this.updateToolbarState(); + return session; + } + + closeSession(id: string): void { + const index = this.sessions.findIndex((s) => s.id === id); + if (index < 0) { return; } + const session = this.sessions[index]; + try { + session.processHandle?.kill("SIGTERM"); + } catch { + /* ignore */ + } + session.processHandle = null; + session.markReady(); + session.dispose(); + this.sessions.splice(index, 1); + if (this.activeSessionId === id) { + this.activeSessionId = null; + const next = this.sessions[index] ?? this.sessions[index - 1] ?? null; + if (next) { + this.setActiveSession(next.id); + } + } + this.renderTabBar(); + this.updateEmptyState(); + this.updateToolbarState(); + if (!this.getActiveSession()) { + this.setStatus("Idle"); + } + } + + setActiveSession(id: string): void { + const session = this.findSession(id); + if (!session) { + return; + } + this.activeSessionId = id; + for (const s of this.sessions) { + s.hostEl.toggleClass("is-hidden", s.id !== id); + } + this.updateEmptyState(); + this.renderTabBar(); + // A hidden terminal cannot lay out; refit + resize now that it is visible, + // otherwise xterm output degrades to letter-per-line. + session.fitAddon.fit(); + session.processHandle?.resize?.( + Math.max(20, session.terminal.cols || 120), + Math.max(10, session.terminal.rows || 30) + ); + session.terminal.focus(); + this.setStatus(session.status); + this.updateToolbarState(); + } + + sendAutomationPromptTo(sessionId: string, text: string, submitWithEnter: boolean): void { + const session = this.findSession(sessionId); + if (!session) { + throw new Error("Session no longer exists"); + } + session.sendPrompt(text, submitWithEnter); + } + + // Called by the plugin when the configured runtimes change in settings. + refreshRuntimeSelect(): void { + this.renderTabBar(); + } + + private spawnIntoSession(session: CliSession, runtime: CliRuntimeConfig): boolean { + const label = session.label; + const command = (runtime.command || "").trim(); + if (!command) { + const message = `Runtime "${runtime.name || "(Unnamed)"}" has an empty command. Set one in plugin settings.`; + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } + session.markReady(); + return false; + } const codexLike = isCodexLikeCommand(command); if (codexLike) { - this.resetTerminalDisplay(); + session.terminal.reset(); + session.fitAddon.fit(); } - this.writeSystemLine(`[Starting: ${command}]`); - this.setStatus(`Starting in vault folder (${process.platform})...`); + session.resetReady(); + session.writeSystemLine(`[Starting: ${command}]`); + session.status = `Starting in vault folder (${process.platform})...`; + if (this.activeSessionId === session.id) { + this.setStatus(session.status); + } try { const vaultPath = getVaultBasePath(this.app); if (!vaultPath) { - const message = `Unable to resolve current vault path. ${targetLabel} was not started.`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); + const message = `Unable to resolve current vault path. ${label} was not started.`; + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } new Notice(message, 6000); - return; + session.markReady(); + return false; } if (!fs.existsSync(vaultPath)) { const message = `Vault path does not exist: ${vaultPath}`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } new Notice(message, 6000); - return; + session.markReady(); + return false; } const shellEnv = getShellEnv(); @@ -409,202 +616,247 @@ class ClaudeCliView extends ItemView { command, cwd: vaultPath, env: shellEnv, - cols: Math.max(20, this.terminal.cols || 120), - rows: Math.max(10, this.terminal.rows || 30), + cols: Math.max(20, session.terminal.cols || 120), + rows: Math.max(10, session.terminal.rows || 30), nodeExecutable: this.plugin.settings.nodeExecutable, pluginDir: this.plugin.manifest.dir, vaultPath }); - this.processHandle = makeProxyAdapter(helperHandle); - this.runningRuntimeId = targetRuntime.id; + session.processHandle = makeProxyAdapter(helperHandle); } catch (error) { const message = `Failed to start process: ${(error as Error).message}`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); - new Notice(message, 7000); - this.processHandle = null; - this.runningRuntimeId = null; - return; - } - this.setStatus("Running"); - - this.processHandle.onData((data: string) => { - this.terminal?.write(data); - }); - - this.processHandle.onExit((exitCode, signal) => { - const message = `Process exited (code=${exitCode}, signal=${signal})`; - this.writeSystemLine(`[${message}]`); - this.setStatus(message); - this.processHandle = null; - this.runningRuntimeId = null; - const nextRuntimeId = this.pendingStartRuntimeId; - this.pendingStartRuntimeId = null; - if (nextRuntimeId) { - void this.startClaudeProcess(nextRuntimeId); + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); } + new Notice(message, 7000); + session.processHandle = null; + session.markReady(); + return false; + } + + session.status = "Running"; + if (this.activeSessionId === session.id) { + this.setStatus("Running"); + } + session.armReadyFallback(SESSION_READY_FALLBACK_MS); + + session.processHandle.onData((data: string) => { + session.terminal.write(data); + session.markReady(); }); - this.fitAddon?.fit(); + session.processHandle.onExit((exitCode, signal) => { + session.processHandle = null; + session.markReady(); + if (session.pendingRestart) { + session.pendingRestart = false; + const current = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId); + if (current) { + this.spawnIntoSession(session, current); + this.renderTabBar(); + this.updateToolbarState(); + return; + } + } + const message = `Process exited (code=${exitCode}, signal=${signal})`; + session.writeSystemLine(`[${message}]`); + session.status = message; + if (this.activeSessionId === session.id) { + this.setStatus(message); + } + if (session.origin === "automation" && this.plugin.settings.autoCloseAutomationSessions) { + this.closeSession(session.id); + return; + } + this.renderTabBar(); + this.updateToolbarState(); + }); + + if (this.activeSessionId === session.id) { + session.fitAddon.fit(); + } + return true; } - stopClaudeProcess(preservePendingStart = false): void { - if (!this.processHandle) { + private stopActiveSession(): void { + const session = this.getActiveSession(); + if (!session || !session.processHandle) { return; } - if (!preservePendingStart) { - this.pendingStartRuntimeId = null; - } - - this.writeSystemLine(`[Stopping ${this.getRunningRuntimeLabel()} process...]`); + session.writeSystemLine(`[Stopping ${session.label} process...]`); + session.status = "Stopping..."; this.setStatus("Stopping..."); try { - this.processHandle.kill("SIGTERM"); + session.processHandle.kill("SIGTERM"); } catch (error) { const message = `Failed to stop process: ${(error as Error).message}`; - this.writeSystemLine(`[${message}]`); + session.writeSystemLine(`[${message}]`); + session.status = message; this.setStatus(message); new Notice(message, 6000); } } + private restartActiveSession(): void { + const session = this.getActiveSession(); + if (!session) { + return; + } + const runtime = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId); + if (!runtime) { + new Notice("Runtime is no longer configured.", 6000); + return; + } + if (session.processHandle) { + session.pendingRestart = true; + session.writeSystemLine(`[Restart requested: ${session.label}]`); + session.status = "Restarting..."; + this.setStatus("Restarting..."); + try { + session.processHandle.kill("SIGTERM"); + } catch { + session.pendingRestart = false; + } + return; + } + this.spawnIntoSession(session, runtime); + this.renderTabBar(); + this.updateToolbarState(); + } + + private openNewSessionMenu(evt: MouseEvent): void { + const runtimes = this.plugin.settings.runtimes; + if (runtimes.length === 0) { + new Notice("No runtime configured. Add one in plugin settings.", 6000); + return; + } + if (runtimes.length === 1) { + this.startSession({ runtimeId: runtimes[0].id, origin: "manual" }); + return; + } + const menu = new Menu(); + for (const runtime of runtimes) { + menu.addItem((item) => + item + .setTitle(runtime.name || "(Unnamed)") + .setIcon("terminal") + .onClick(() => { + this.startSession({ runtimeId: runtime.id, origin: "manual" }); + }) + ); + } + menu.showAtMouseEvent(evt); + } + + private renderTabBar(): void { + if (!this.tabBarEl) { + return; + } + this.tabBarEl.empty(); + for (const session of this.sessions) { + const tabEl = this.tabBarEl.createDiv({ + cls: `claude-cli-tab${session.id === this.activeSessionId ? " is-active" : ""}` + }); + const dotCls = session.isRunning() ? "is-running" : "is-stopped"; + tabEl.createSpan({ cls: `claude-cli-tab-dot ${dotCls}` }); + if (session.origin === "automation") { + const autoIcon = tabEl.createSpan({ cls: "claude-cli-tab-auto" }); + setIcon(autoIcon, "calendar-clock"); + } + tabEl.createSpan({ text: session.label, cls: "claude-cli-tab-label" }); + const closeEl = tabEl.createSpan({ cls: "claude-cli-tab-close" }); + setIcon(closeEl, "x"); + closeEl.setAttribute("aria-label", "Close session"); + closeEl.addEventListener("click", (evt) => { + evt.stopPropagation(); + this.closeSession(session.id); + }); + tabEl.addEventListener("click", () => { + if (session.id !== this.activeSessionId) { + this.setActiveSession(session.id); + } + }); + } + const newTabEl = this.tabBarEl.createDiv({ cls: "claude-cli-tab-new" }); + setIcon(newTabEl, "plus"); + newTabEl.setAttribute("aria-label", "New session"); + newTabEl.addEventListener("click", (evt) => this.openNewSessionMenu(evt)); + } + + private updateEmptyState(): void { + if (!this.emptyHintEl) { + return; + } + this.emptyHintEl.toggleClass("is-hidden", this.sessions.length > 0); + } + + private updateToolbarState(): void { + const session = this.getActiveSession(); + const running = session?.isRunning() ?? false; + if (this.stopBtn) { + this.stopBtn.disabled = !running; + } + if (this.restartBtn) { + this.restartBtn.disabled = !session; + } + if (this.clearBtn) { + this.clearBtn.disabled = !session; + } + } + private setStatus(message: string): void { this.statusEl?.setText(`Status: ${message}`); } private getSelectedRuntime(): CliRuntimeConfig | null { const { runtimes, selectedRuntimeId } = this.plugin.settings; - return ( - runtimes.find((r) => r.id === selectedRuntimeId) ?? - runtimes[0] ?? - null - ); - } - - private getRuntimeLabel(runtimeId?: string): string { - if (runtimeId) { - const match = this.plugin.settings.runtimes.find((r) => r.id === runtimeId); - return match?.name || "(Unnamed runtime)"; - } - return this.getSelectedRuntime()?.name || "(No runtime)"; - } - - private getRunningRuntimeLabel(): string { - if (!this.runningRuntimeId) { - return this.getRuntimeLabel(); - } - return this.getRuntimeLabel(this.runningRuntimeId); - } - - private restartClaudeProcess(): void { - const target = this.getSelectedRuntime(); - if (!target) { - void this.startClaudeProcess(); - return; - } - if (!this.processHandle) { - void this.startClaudeProcess(target.id); - return; - } - this.pendingStartRuntimeId = target.id; - this.writeSystemLine(`[Restart requested: ${target.name}]`); - this.stopClaudeProcess(true); - } - - private resetTerminalDisplay(): void { - if (!this.terminal) { - return; - } - - this.terminal.reset(); - this.fitAddon?.fit(); - } - - private setRuntime(runtimeId: string): void { - if (!runtimeId) { - return; - } - const exists = this.plugin.settings.runtimes.some((r) => r.id === runtimeId); - if (!exists) { - this.refreshRuntimeSelect(); - return; - } - if (this.plugin.settings.selectedRuntimeId === runtimeId) { - return; - } - - this.plugin.settings.selectedRuntimeId = runtimeId; - void this.plugin.saveSettings(); - this.refreshRuntimeSelect(); - - const selectedLabel = this.getRuntimeLabel(); - this.writeSystemLine(`[Runtime selected: ${selectedLabel}]`); - if (this.processHandle) { - if (this.plugin.settings.autoRestartOnRuntimeSwitch) { - this.setStatus(`Restarting to ${selectedLabel}...`); - this.restartClaudeProcess(); - return; - } - this.setStatus(`${selectedLabel} selected (restart to apply)`); - return; - } - this.setStatus(`${selectedLabel} selected`); + return runtimes.find((r) => r.id === selectedRuntimeId) ?? runtimes[0] ?? null; } private insertActiveFileMention(): void { + const session = this.getActiveSession(); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { const message = "No active file detected."; - this.writeSystemLine(`[${message}]`); this.setStatus(message); new Notice(message, 4000); return; } - if (!this.processHandle) { - const message = `${this.getRuntimeLabel()} process is not running. Start it before inserting a file mention.`; - this.writeSystemLine(`[${message}]`); + if (!session || !session.processHandle) { + const message = "No running session. Start one before inserting a file mention."; this.setStatus(message); new Notice(message, 5000); return; } - const mention = formatActiveFileMention(activeFile.path); - this.processHandle.write(mention); - this.terminal?.focus(); + session.processHandle.write(mention); + session.terminal.focus(); this.setStatus(`Inserted ${mention.trim()}`); } private insertActiveFolderMention(): void { + const session = this.getActiveSession(); const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { const message = "No active file detected."; - this.writeSystemLine(`[${message}]`); this.setStatus(message); new Notice(message, 4000); return; } - if (!this.processHandle) { - const message = `${this.getRuntimeLabel()} process is not running. Start it before inserting a folder mention.`; - this.writeSystemLine(`[${message}]`); + if (!session || !session.processHandle) { + const message = "No running session. Start one before inserting a folder mention."; this.setStatus(message); new Notice(message, 5000); return; } - const mention = formatActiveFolderMention(activeFile.path); - this.processHandle.write(mention); - this.terminal?.focus(); + session.processHandle.write(mention); + session.terminal.focus(); this.setStatus(`Inserted ${mention.trim()}`); } - private writeSystemLine(message: string): void { - if (!this.terminal) { - return; - } - this.terminal.write("\r\u001b[2K"); - this.terminal.writeln(message); - } - private setButtonIcon(buttonEl: HTMLButtonElement, iconName: string, label: string): void { buttonEl.empty(); buttonEl.addClass("claude-cli-btn"); @@ -771,9 +1023,18 @@ export default class ClaudeCliPlugin extends Plugin { source } as const; - const view = this.findRunnableView(); - if (!view) { - const reason = "No CLI is running"; + // Each automation run spawns its own session/tab so runs execute in + // parallel. Resolve the runtime by the declared name/id, or fall back to + // the default runtime when none is declared. + const runtime = resolveRuntimeForAutomation( + this.settings.runtimes, + entry.runtime, + this.settings.selectedRuntimeId + ); + if (!runtime) { + const reason = entry.runtime + ? `Runtime "${entry.runtime}" not configured` + : "No runtime configured"; this.recordHistory({ ...baseRecord, status: "skipped", reason }); if (source === "manual") { new Notice(`Automation "${entry.name}" skipped — ${reason.toLowerCase()}.`, 5000); @@ -781,25 +1042,29 @@ export default class ClaudeCliPlugin extends Plugin { return; } - if (entry.runtime) { - const runtimeMatches = view.matchesRuntime(entry.runtime); - if (!runtimeMatches) { - const reason = `Runtime mismatch (expected "${entry.runtime}")`; - this.recordHistory({ ...baseRecord, status: "skipped", reason }); - if (source === "manual") { - new Notice(`Automation "${entry.name}" skipped — ${reason.toLowerCase()}.`, 5000); - } - return; + const started = await this.activateViewAndStartSession({ + runtimeId: runtime.id, + origin: "automation" + }); + if (!started) { + const reason = "Could not open a session"; + this.recordHistory({ ...baseRecord, status: "error", reason }); + if (source === "manual") { + new Notice(`Automation "${entry.name}" failed — ${reason.toLowerCase()}.`, 5000); } + return; } + const { view, session } = started; try { - view.sendAutomationPrompt(entry.body, entry.appendNewline); - const runtimeId = view.getRunningRuntimeId(); + // Wait until the freshly spawned CLI has booted (first output or a short + // fallback) so the prompt lands in its input box, not before it exists. + await session.whenReady; + view.sendAutomationPromptTo(session.id, entry.body, entry.appendNewline); this.recordHistory({ ...baseRecord, status: "ran", - runtimeId, + runtimeId: session.runtimeId, promptPreview: buildPromptPreview(entry.body) }); this.settings.automationsLastRun[entry.path] = now; @@ -817,15 +1082,21 @@ export default class ClaudeCliPlugin extends Plugin { } } - private findRunnableView(): ClaudeCliView | null { - const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE); - for (const leaf of leaves) { - const view = leaf.view; - if (view instanceof ClaudeCliView && view.isProcessRunning()) { - return view; - } + private async activateViewAndStartSession(params: { + runtimeId: string; + origin: SessionOrigin; + }): Promise<{ view: ClaudeCliView; session: CliSession } | null> { + await this.activateView(); + const leaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDE)[0]; + const view = leaf?.view; + if (!(view instanceof ClaudeCliView)) { + return null; } - return null; + const session = view.startSession(params); + if (!session) { + return null; + } + return { view, session }; } recordHistory(record: AutomationRunRecord): void { @@ -908,7 +1179,17 @@ export default class ClaudeCliPlugin extends Plugin { Number.isInteger(raw.automationsHistoryLimit) && raw.automationsHistoryLimit > 0 ? raw.automationsHistoryLimit - : DEFAULT_SETTINGS.automationsHistoryLimit + : DEFAULT_SETTINGS.automationsHistoryLimit, + autoCloseAutomationSessions: + typeof raw.autoCloseAutomationSessions === "boolean" + ? raw.autoCloseAutomationSessions + : DEFAULT_SETTINGS.autoCloseAutomationSessions, + maxConcurrentSessions: + typeof raw.maxConcurrentSessions === "number" && + Number.isInteger(raw.maxConcurrentSessions) && + raw.maxConcurrentSessions >= 0 + ? raw.maxConcurrentSessions + : DEFAULT_SETTINGS.maxConcurrentSessions }; } @@ -978,20 +1259,37 @@ class ClaudeCliSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName("Auto-restart on runtime switch") - .setDesc("Automatically restart the running process when changing the runtime from the sidebar dropdown.") + .setName("Auto-close automation sessions") + .setDesc("When an automation-spawned session's process exits, close its tab automatically so tabs don't pile up.") .addToggle((toggle) => toggle - .setValue(this.plugin.settings.autoRestartOnRuntimeSwitch) + .setValue(this.plugin.settings.autoCloseAutomationSessions) .onChange(async (value) => { - this.plugin.settings.autoRestartOnRuntimeSwitch = value; + this.plugin.settings.autoCloseAutomationSessions = value; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Max concurrent sessions") + .setDesc("Maximum number of session tabs that can run at once (0 = unlimited). Protects against runaway automation spawns.") + .addText((text) => + text + .setPlaceholder("8") + .setValue(String(this.plugin.settings.maxConcurrentSessions)) + .onChange(async (value) => { + const parsed = Number.parseInt(value.trim(), 10); + this.plugin.settings.maxConcurrentSessions = + Number.isInteger(parsed) && parsed >= 0 + ? parsed + : DEFAULT_SETTINGS.maxConcurrentSessions; await this.plugin.saveSettings(); }) ); new Setting(containerEl).setName("Runtimes").setHeading(); containerEl.createEl("p", { - text: "Configure the runtimes that show up in the sidebar dropdown. Each entry needs a display name and a launch command. Add as many as you want.", + text: "Configure the runtimes available from the sidebar new-session menu. Each entry needs a display name and a launch command. Add as many as you want.", cls: "setting-item-description" }); diff --git a/manifest.json b/manifest.json index 7071da8..eb9fa2b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "any-ai-cli", "name": "Any AI CLI", - "version": "0.2.8", + "version": "0.2.9", "minAppVersion": "1.7.2", "description": "Run an AI assistant CLI like Claude from a right sidebar terminal panel, with a customizable runtime list.", "author": "Benoit Lamouche", diff --git a/package.json b/package.json index bb8840c..cc0ef97 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "any-ai-cli", - "version": "0.1.47", + "version": "0.2.9", "description": "Run an AI assistant CLI like Claude from a right sidebar terminal panel, with a customizable runtime list.", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "node esbuild.config.mjs production", - "lint": "eslint main.ts runtime-utils.ts \"tests/**/*.ts\"", + "lint": "eslint main.ts runtime-utils.ts session-utils.ts \"tests/**/*.ts\"", "test": "vitest run", "test:watch": "vitest" }, diff --git a/session-utils.ts b/session-utils.ts new file mode 100644 index 0000000..43930be --- /dev/null +++ b/session-utils.ts @@ -0,0 +1,64 @@ +import type { CliRuntimeConfig } from "./runtime-utils"; + +/** + * True when `target` (an automation's declared runtime) refers to `runtime`, + * matched either by id or by display name (case-insensitive). Mirrors the rule + * the sidebar view used before sessions existed. + */ +export function runtimeMatches(runtime: CliRuntimeConfig, target: string): boolean { + const wanted = target.trim().toLowerCase(); + if (!wanted) { + return false; + } + if (runtime.id.trim().toLowerCase() === wanted) { + return true; + } + return runtime.name.trim().toLowerCase() === wanted; +} + +/** + * Resolve which runtime an automation should spawn. + * - If `declared` is set, find the runtime matching it (id or name); returns + * `null` when none matches (the automation is then skipped). + * - If `declared` is empty/null, fall back to the default runtime + * (`defaultId`), then to the first configured runtime. + */ +export function resolveRuntimeForAutomation( + runtimes: CliRuntimeConfig[], + declared: string | null | undefined, + defaultId: string +): CliRuntimeConfig | null { + const wanted = (declared ?? "").trim(); + if (wanted) { + return runtimes.find((runtime) => runtimeMatches(runtime, wanted)) ?? null; + } + return runtimes.find((runtime) => runtime.id === defaultId) ?? runtimes[0] ?? null; +} + +/** + * Build a tab label for a new session, disambiguating duplicates of the same + * runtime: "Claude", "Claude (2)", "Claude (3)", ... + */ +export function nextSessionLabel(existingLabels: string[], runtimeName: string): string { + const base = runtimeName.trim() || "(Unnamed)"; + const taken = new Set(existingLabels); + if (!taken.has(base)) { + return base; + } + let counter = 2; + while (taken.has(`${base} (${counter})`)) { + counter += 1; + } + return `${base} (${counter})`; +} + +/** + * Whether another session may be opened given the current count and the + * configured cap. `max <= 0` means unlimited. + */ +export function canOpenSession(currentCount: number, max: number): boolean { + if (!Number.isFinite(max) || max <= 0) { + return true; + } + return currentCount < max; +} diff --git a/styles.css b/styles.css index 4386139..2812320 100644 --- a/styles.css +++ b/styles.css @@ -249,6 +249,127 @@ font-size: 12px; } +/* ----- Session tab bar ----- */ +.claude-cli-tabbar { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: nowrap; + overflow-x: auto; + scrollbar-width: thin; +} + +.claude-cli-tab { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px 6px 0 0; + background: var(--background-secondary); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + white-space: nowrap; + max-width: 200px; +} + +.claude-cli-tab:hover { + background: var(--interactive-hover); + color: var(--text-normal); +} + +.claude-cli-tab.is-active { + background: var(--background-primary); + color: var(--text-normal); + border-bottom-color: var(--interactive-accent); +} + +.claude-cli-tab-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.claude-cli-tab-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; +} + +.claude-cli-tab-dot.is-running { + background: var(--color-green, #3fb950); +} + +.claude-cli-tab-dot.is-stopped { + background: var(--text-faint); +} + +.claude-cli-tab-auto { + display: inline-flex; + align-items: center; + opacity: 0.7; +} + +.claude-cli-tab-auto svg { + width: 12px; + height: 12px; +} + +.claude-cli-tab-close { + display: inline-flex; + align-items: center; + border-radius: 4px; + opacity: 0.6; +} + +.claude-cli-tab-close:hover { + opacity: 1; + background: var(--background-modifier-hover); +} + +.claude-cli-tab-close svg { + width: 13px; + height: 13px; +} + +.claude-cli-tab-new { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 6px; + color: var(--text-muted); + cursor: pointer; + flex: 0 0 auto; +} + +.claude-cli-tab-new:hover { + background: var(--interactive-hover); + color: var(--text-normal); +} + +/* ----- Terminals container ----- */ +.claude-cli-terminals { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + position: relative; +} + +.claude-cli-empty-hint { + color: var(--text-muted); + font-size: 13px; + padding: 16px; + text-align: center; +} + +.claude-cli-empty-hint.is-hidden { + display: none; +} + .claude-cli-terminal { flex: 1; min-height: 220px; @@ -257,6 +378,10 @@ overflow: hidden; } +.claude-cli-terminal.is-hidden { + display: none; +} + .claude-cli-terminal .xterm { position: relative; height: 100%; diff --git a/tests/session-utils.test.ts b/tests/session-utils.test.ts new file mode 100644 index 0000000..db9fcb8 --- /dev/null +++ b/tests/session-utils.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { + canOpenSession, + nextSessionLabel, + resolveRuntimeForAutomation, + runtimeMatches +} from "../session-utils"; +import type { CliRuntimeConfig } from "../runtime-utils"; + +const runtimes: CliRuntimeConfig[] = [ + { id: "claude", name: "Claude", command: "claude" }, + { id: "codex", name: "Codex", command: "codex" }, + { id: "abc123", name: "My Gemini", command: "gemini" } +]; + +describe("runtimeMatches", () => { + it("matches by id", () => { + expect(runtimeMatches(runtimes[0], "claude")).toBe(true); + }); + + it("matches by name case-insensitively", () => { + expect(runtimeMatches(runtimes[2], "my gemini")).toBe(true); + }); + + it("returns false on no match or empty target", () => { + expect(runtimeMatches(runtimes[0], "codex")).toBe(false); + expect(runtimeMatches(runtimes[0], " ")).toBe(false); + }); +}); + +describe("resolveRuntimeForAutomation", () => { + it("resolves a declared runtime by name", () => { + expect(resolveRuntimeForAutomation(runtimes, "Codex", "claude")?.id).toBe("codex"); + }); + + it("resolves a declared runtime by id", () => { + expect(resolveRuntimeForAutomation(runtimes, "abc123", "claude")?.id).toBe("abc123"); + }); + + it("returns null when a declared runtime is unknown", () => { + expect(resolveRuntimeForAutomation(runtimes, "Nope", "claude")).toBeNull(); + }); + + it("falls back to the default runtime when none is declared", () => { + expect(resolveRuntimeForAutomation(runtimes, null, "codex")?.id).toBe("codex"); + expect(resolveRuntimeForAutomation(runtimes, "", "abc123")?.id).toBe("abc123"); + }); + + it("falls back to the first runtime when the default id is invalid", () => { + expect(resolveRuntimeForAutomation(runtimes, null, "missing")?.id).toBe("claude"); + }); + + it("returns null when there are no runtimes", () => { + expect(resolveRuntimeForAutomation([], null, "claude")).toBeNull(); + }); +}); + +describe("nextSessionLabel", () => { + it("uses the runtime name when free", () => { + expect(nextSessionLabel([], "Claude")).toBe("Claude"); + }); + + it("disambiguates duplicates", () => { + expect(nextSessionLabel(["Claude"], "Claude")).toBe("Claude (2)"); + expect(nextSessionLabel(["Claude", "Claude (2)"], "Claude")).toBe("Claude (3)"); + }); + + it("falls back to (Unnamed) for blank names", () => { + expect(nextSessionLabel([], " ")).toBe("(Unnamed)"); + }); +}); + +describe("canOpenSession", () => { + it("allows when under the cap", () => { + expect(canOpenSession(2, 8)).toBe(true); + }); + + it("blocks at the cap", () => { + expect(canOpenSession(8, 8)).toBe(false); + }); + + it("treats 0 or negative as unlimited", () => { + expect(canOpenSession(100, 0)).toBe(true); + expect(canOpenSession(100, -1)).toBe(true); + }); +}); diff --git a/versions.json b/versions.json index 686889c..3006b20 100644 --- a/versions.json +++ b/versions.json @@ -18,5 +18,6 @@ "0.1.45": "1.7.2", "0.1.46": "1.7.2", "0.1.47": "1.7.2", - "0.2.8": "1.7.2" + "0.2.8": "1.7.2", + "0.2.9": "1.7.2" }