mirror of
https://github.com/blamouche/obsidian-any-ai-code.git
synced 2026-07-22 06:53:38 +00:00
Activity-aware tab dot + idle auto-close for automation sessions
The session tab status dot now reflects live CLI activity rather than just whether the process is alive: green = a manual session's AI is working, purple = an automation session's AI is working, gray = the CLI is idle (finished its turn) or stopped. CliSession tracks activity continuously — output flowing = working, quiet for ~5s = idle — and notifies the view via onActivityChange to repaint the dot. Add a new opt-in setting "Auto-close automation sessions when idle": closes an automation tab once its CLI goes quiet after the prompt ran, even if the process stays interactive (Claude/Codex). It complements the existing on-exit close (renamed "...on exit"); both are kept. Idle-close is armed only after the prompt is sent and driven by real CLI output, so boot and slow first responses don't close the tab early. Extract the pure tabDotClass helper to session-utils.ts with tests. Update README. Bump to 0.2.11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1746632361
commit
560735b898
13 changed files with 291 additions and 17 deletions
|
|
@ -98,3 +98,11 @@
|
|||
- Détails : tag 0.2.10 sur HEAD, assets any-ai-cli-0.2.10.zip + main.js/manifest.json/styles.css/versions.json, notes "What's new" (sessions parallèles + fix appendNewline).
|
||||
- Nettoyage : retiré le zip du suivi git, ajouté *.zip au .gitignore (commit 0f69356).
|
||||
- Statut : success.
|
||||
|
||||
## 2026-05-20 17:42 — agent (Claude Opus 4.7)
|
||||
- Action : pastille d'onglet par activité (vert=manuel working, violet=automation working, gris=idle/arrêté) + option de fermeture auto des onglets d'automatisation sur inactivité (~5s, OFF par défaut).
|
||||
- Détails : CliSession.activity + noteActivity/markIdle/closeOnIdleArmed, onActivityChange câblé dans startSession (idle-close gardé par origin/running/closeOnIdleArmed/setting), noteActivity dans onData, markIdle dans onExit ; helper pur tabDotClass (session-utils.ts) + tests ; setting autoCloseAutomationSessionsOnIdle ; CSS is-working/is-automation/is-idle ; toggle "on exit" renommé.
|
||||
- Décisions : seuil idle fixe 5s (const ACTIVITY_IDLE_MS), option OFF par défaut, garder les deux réglages.
|
||||
- Validation : lint OK, 74 tests OK, tsc propre code projet, build OK.
|
||||
- Version : 0.2.10 -> 0.2.11.
|
||||
- Statut : success. Suivant : commit/push + zip + release GitHub.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
# Releases
|
||||
|
||||
## 0.2.11 - 2026-05-20
|
||||
- **Activity-aware tab status dot.** Each session tab's dot now reflects live CLI activity instead of just "process alive": **green** = a manual session's AI is working, **purple** = an automation session's AI is working, **gray** = the CLI is idle (finished its turn) or stopped. Activity is tracked continuously — output flowing = working; quiet for ~5s = idle.
|
||||
- **New setting "Auto-close automation sessions when idle"** (off by default). Closes an automation tab once its CLI goes quiet ~5s after the prompt ran, even if the process stays interactive (Claude/Codex). Complements the existing "on exit" close (renamed "Auto-close automation sessions on exit"); both are kept. Idle-close is armed only after the prompt is sent and driven by real CLI output, so boot and slow first responses don't close the tab prematurely.
|
||||
- Extracted the pure `tabDotClass` helper to `session-utils.ts` with unit tests. Updated README. Bump to 0.2.11.
|
||||
|
||||
## 0.2.10 - 2026-05-20
|
||||
- Fixed automation prompts not submitting (the auto-Enter from `appendNewline: true` was lost) on freshly spawned session tabs. A new session was marked "ready" on its first byte of output, so the prompt + Enter were sent while the CLI was still booting and its input box did not yet exist. Readiness now waits for the CLI's output to go quiet (debounced ~800 ms, hard cap 10 s), reproducing the idle-at-prompt conditions under which the delayed `\r` reliably submits.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
# Tâche : pastille d'onglet par activité + fermeture auto sur inactivité
|
||||
|
||||
Date : 2026-05-20 17:34:56
|
||||
Slug : activity-dot-idle-close
|
||||
|
||||
## Objectif
|
||||
- Pastille d'onglet à 3 états : vert = session manuelle qui travaille, violet = automatisation
|
||||
qui travaille, gris = terminé/arrêté. Basé sur un suivi d'activité continu (sortie qui coule
|
||||
vs silence prolongé de 5 s).
|
||||
- Nouvelle option (désactivée par défaut) : fermer un onglet d'automatisation quand l'activité
|
||||
est terminée (inactivité), en plus de la fermeture-sur-sortie existante.
|
||||
|
||||
## Décisions validées
|
||||
- Seuil d'inactivité fixe = 5 s.
|
||||
- Option de fermeture sur inactivité OFF par défaut.
|
||||
- Garder les deux réglages (sortie + inactivité).
|
||||
|
||||
## Plan
|
||||
1. `tabDotClass` pur dans session-utils.ts + tests.
|
||||
2. CliSession : activity, noteActivity/markIdle, closeOnIdleArmed, resets, dispose.
|
||||
3. Vue : onActivityChange (startSession), noteActivity (onData), markIdle (onExit).
|
||||
4. renderTabBar : pastille 3 états via tabDotClass.
|
||||
5. Réglage autoCloseAutomationSessionsOnIdle (champ/défaut/load/UI).
|
||||
6. CSS is-working/is-automation/is-idle.
|
||||
7. README + prompt-hub + version 0.2.11.
|
||||
|
||||
## Statut
|
||||
Terminé (implémentation). Test manuel Obsidian côté utilisateur.
|
||||
|
||||
## Review
|
||||
- Suivi d'activité continu ajouté à CliSession (noteActivity/markIdle), indépendant des timers
|
||||
de readiness. onActivityChange pilote le re-render de la pastille et la fermeture sur inactivité.
|
||||
- Pastille 3 états via fonction pure tabDotClass (4 tests). 74 tests au total OK.
|
||||
- Fermeture sur inactivité gardée par : origin=automation, isRunning, closeOnIdleArmed (posé après
|
||||
envoi du prompt), et setting activé. closeOnIdleArmed empêche la fermeture pendant le boot ;
|
||||
pas d'armement de timer à l'envoi -> pas de fermeture si le CLI tarde à répondre.
|
||||
- Seuil fixe 5s (ACTIVITY_IDLE_MS). Option OFF par défaut. Les deux réglages de fermeture conservés.
|
||||
- lint/tsc/build OK. Version 0.2.11 (manifest/versions.json/package.json/version.md alignés).
|
||||
- Limite : le seuil 5s reste heuristique (une pause de sortie > 5s en pleine tâche peut être vue
|
||||
comme "terminé"). Documenté dans la desc du réglage et le README.
|
||||
|
|
@ -1 +1 @@
|
|||
0.2.10
|
||||
0.2.11
|
||||
|
|
@ -86,7 +86,7 @@ The community-store auto-install and the release zip both ship only the three ca
|
|||
1. Click the bot ribbon icon, or run the command palette entry **`Open panel`**, to reveal the panel on the right.
|
||||
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.
|
||||
4. Click a tab to bring its session to the front; the `×` on a tab closes that session (and kills its process). Each tab has a status dot reflecting activity: **green** = the AI is working, **purple** = an automation's AI is working, **gray** = the CLI is idle (finished its turn) or stopped.
|
||||
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 the active session's runtime in place, `Stop` to terminate it, `Clear` to wipe its terminal output.
|
||||
|
|
@ -128,7 +128,7 @@ Rules:
|
|||
- The prompt is everything after the closing `---` (trimmed).
|
||||
- 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.
|
||||
- Each run opens its own session tab. With **Auto-close automation sessions on exit** enabled (default), the tab closes when the process exits; enable **Auto-close automation sessions when idle** to also close it once the AI goes quiet (for CLIs that stay interactive instead of exiting).
|
||||
|
||||
### Manual runs and history
|
||||
|
||||
|
|
@ -143,7 +143,8 @@ General:
|
|||
|
||||
- **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).
|
||||
- **Auto-close automation sessions on exit** — when an automation-spawned session's process exits, close its tab automatically so tabs don't pile up (default on).
|
||||
- **Auto-close automation sessions when idle** — close an automation tab once its CLI goes quiet for ~5s after the prompt ran (the AI finished its turn), even if the process stays alive (default off). Useful for CLIs like Claude/Codex that stay interactive instead of exiting. A long task that pauses output for over 5s could be closed early, so it's opt-in.
|
||||
- **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 available from the new-session menu):
|
||||
|
|
|
|||
84
main.js
84
main.js
|
|
@ -18588,6 +18588,12 @@ function canOpenSession(currentCount, max) {
|
|||
}
|
||||
return currentCount < max;
|
||||
}
|
||||
function tabDotClass(opts) {
|
||||
if (opts.running && opts.activity === "working") {
|
||||
return opts.origin === "automation" ? "is-automation" : "is-working";
|
||||
}
|
||||
return "is-idle";
|
||||
}
|
||||
|
||||
// automation.ts
|
||||
var import_cron_parser = __toESM(require_dist());
|
||||
|
|
@ -19009,6 +19015,7 @@ var DEFAULT_SETTINGS = {
|
|||
automationsHistory: [],
|
||||
automationsHistoryLimit: 200,
|
||||
autoCloseAutomationSessions: true,
|
||||
autoCloseAutomationSessionsOnIdle: false,
|
||||
maxConcurrentSessions: 8
|
||||
};
|
||||
var AUTOMATION_TICK_MS = 3e4;
|
||||
|
|
@ -19067,6 +19074,7 @@ function cloneDefaultRuntimes() {
|
|||
}
|
||||
var SESSION_READY_QUIET_MS = 800;
|
||||
var SESSION_READY_MAX_MS = 1e4;
|
||||
var ACTIVITY_IDLE_MS = 5e3;
|
||||
function createSessionTerminal() {
|
||||
const terminal = new Dl({
|
||||
cursorBlink: true,
|
||||
|
|
@ -19088,6 +19096,14 @@ var CliSession = class {
|
|||
this.processHandle = null;
|
||||
this.status = "Idle";
|
||||
this.pendingRestart = false;
|
||||
// Live activity: "working" while the CLI emits output, "idle" after it goes
|
||||
// quiet. Drives the tab dot colour and (for automations) idle auto-close.
|
||||
this.activity = "idle";
|
||||
// Set true once an automation prompt has been sent, so idle auto-close only
|
||||
// fires after the automation actually ran (not during boot).
|
||||
this.closeOnIdleArmed = false;
|
||||
this.onActivityChange = null;
|
||||
this.activityTimer = null;
|
||||
this.ready = false;
|
||||
this.resolveReady = null;
|
||||
this.settleTimer = null;
|
||||
|
|
@ -19111,10 +19127,16 @@ var CliSession = class {
|
|||
this.maxWaitTimer = null;
|
||||
}
|
||||
}
|
||||
/** Arm a fresh readiness promise for a (re)spawn. */
|
||||
/** Arm a fresh readiness promise for a (re)spawn, and reset activity state. */
|
||||
resetReady() {
|
||||
this.ready = false;
|
||||
this.clearReadyTimers();
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
this.activityTimer = null;
|
||||
}
|
||||
this.activity = "idle";
|
||||
this.closeOnIdleArmed = false;
|
||||
this.whenReady = new Promise((resolve2) => {
|
||||
this.resolveReady = resolve2;
|
||||
});
|
||||
|
|
@ -19147,6 +19169,38 @@ var CliSession = class {
|
|||
}
|
||||
this.settleTimer = activeWindow.setTimeout(() => this.markReady(), quietMs);
|
||||
}
|
||||
/** Each output chunk marks the session "working" and (re)arms a quiet timer
|
||||
* that flips it back to "idle" after `idleMs` of silence. */
|
||||
noteActivity(idleMs) {
|
||||
var _a5;
|
||||
if (this.activity !== "working") {
|
||||
this.activity = "working";
|
||||
(_a5 = this.onActivityChange) == null ? void 0 : _a5.call(this);
|
||||
}
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
}
|
||||
this.activityTimer = activeWindow.setTimeout(() => {
|
||||
var _a6;
|
||||
this.activityTimer = null;
|
||||
if (this.activity !== "idle") {
|
||||
this.activity = "idle";
|
||||
(_a6 = this.onActivityChange) == null ? void 0 : _a6.call(this);
|
||||
}
|
||||
}, idleMs);
|
||||
}
|
||||
/** Force the idle state immediately (e.g. when the process exits). */
|
||||
markIdle() {
|
||||
var _a5;
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
this.activityTimer = null;
|
||||
}
|
||||
if (this.activity !== "idle") {
|
||||
this.activity = "idle";
|
||||
(_a5 = this.onActivityChange) == null ? void 0 : _a5.call(this);
|
||||
}
|
||||
}
|
||||
isRunning() {
|
||||
return this.processHandle !== null;
|
||||
}
|
||||
|
|
@ -19168,10 +19222,15 @@ var CliSession = class {
|
|||
}
|
||||
}, 120);
|
||||
}
|
||||
this.closeOnIdleArmed = true;
|
||||
this.writeSystemLine(`[Automation prompt injected]`);
|
||||
}
|
||||
dispose() {
|
||||
this.clearReadyTimers();
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
this.activityTimer = null;
|
||||
}
|
||||
try {
|
||||
this.terminal.dispose();
|
||||
} catch (e) {
|
||||
|
|
@ -19357,6 +19416,12 @@ var ClaudeCliView = class extends import_obsidian2.ItemView {
|
|||
var _a5;
|
||||
return (_a5 = session.processHandle) == null ? void 0 : _a5.write(data);
|
||||
});
|
||||
session.onActivityChange = () => {
|
||||
this.renderTabBar();
|
||||
if (session.activity === "idle" && session.origin === "automation" && session.isRunning() && session.closeOnIdleArmed && this.plugin.settings.autoCloseAutomationSessionsOnIdle) {
|
||||
this.closeSession(session.id);
|
||||
}
|
||||
};
|
||||
session.writeSystemLine(`CLI session ready (${label}).`);
|
||||
this.setActiveSession(session.id);
|
||||
this.spawnIntoSession(session, runtime);
|
||||
|
|
@ -19511,10 +19576,12 @@ var ClaudeCliView = class extends import_obsidian2.ItemView {
|
|||
session.processHandle.onData((data) => {
|
||||
session.terminal.write(data);
|
||||
session.noteOutputActivity(SESSION_READY_QUIET_MS);
|
||||
session.noteActivity(ACTIVITY_IDLE_MS);
|
||||
});
|
||||
session.processHandle.onExit((exitCode, signal) => {
|
||||
session.processHandle = null;
|
||||
session.markReady();
|
||||
session.markIdle();
|
||||
if (session.pendingRestart) {
|
||||
session.pendingRestart = false;
|
||||
const current = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId);
|
||||
|
|
@ -19616,7 +19683,11 @@ var ClaudeCliView = class extends import_obsidian2.ItemView {
|
|||
const tabEl = this.tabBarEl.createDiv({
|
||||
cls: `claude-cli-tab${session.id === this.activeSessionId ? " is-active" : ""}`
|
||||
});
|
||||
const dotCls = session.isRunning() ? "is-running" : "is-stopped";
|
||||
const dotCls = tabDotClass({
|
||||
running: session.isRunning(),
|
||||
activity: session.activity,
|
||||
origin: session.origin
|
||||
});
|
||||
tabEl.createSpan({ cls: `claude-cli-tab-dot ${dotCls}` });
|
||||
if (session.origin === "automation") {
|
||||
const autoIcon = tabEl.createSpan({ cls: "claude-cli-tab-auto" });
|
||||
|
|
@ -19978,6 +20049,7 @@ var ClaudeCliPlugin = class extends import_obsidian2.Plugin {
|
|||
automationsHistory: sanitizeHistory(raw.automationsHistory),
|
||||
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,
|
||||
autoCloseAutomationSessionsOnIdle: typeof raw.autoCloseAutomationSessionsOnIdle === "boolean" ? raw.autoCloseAutomationSessionsOnIdle : DEFAULT_SETTINGS.autoCloseAutomationSessionsOnIdle,
|
||||
maxConcurrentSessions: typeof raw.maxConcurrentSessions === "number" && Number.isInteger(raw.maxConcurrentSessions) && raw.maxConcurrentSessions >= 0 ? raw.maxConcurrentSessions : DEFAULT_SETTINGS.maxConcurrentSessions
|
||||
};
|
||||
}
|
||||
|
|
@ -20030,12 +20102,18 @@ var ClaudeCliSettingTab = class extends import_obsidian2.PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
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(
|
||||
new import_obsidian2.Setting(containerEl).setName("Auto-close automation sessions on exit").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("Auto-close automation sessions when idle").setDesc("Close an automation tab once its CLI goes quiet for ~5s after the prompt ran (the AI finished its turn), even if the process stays alive. Off by default \u2014 a long task that pauses output for over 5s could be closed early.").addToggle(
|
||||
(toggle) => toggle.setValue(this.plugin.settings.autoCloseAutomationSessionsOnIdle).onChange(async (value) => {
|
||||
this.plugin.settings.autoCloseAutomationSessionsOnIdle = 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);
|
||||
|
|
|
|||
104
main.ts
104
main.ts
|
|
@ -20,7 +20,8 @@ import {
|
|||
import {
|
||||
canOpenSession,
|
||||
nextSessionLabel,
|
||||
resolveRuntimeForAutomation
|
||||
resolveRuntimeForAutomation,
|
||||
tabDotClass
|
||||
} from "./session-utils";
|
||||
import {
|
||||
buildPromptPreview,
|
||||
|
|
@ -61,6 +62,7 @@ interface ClaudeCliPluginSettings {
|
|||
automationsHistory: AutomationRunRecord[];
|
||||
automationsHistoryLimit: number;
|
||||
autoCloseAutomationSessions: boolean;
|
||||
autoCloseAutomationSessionsOnIdle: boolean;
|
||||
maxConcurrentSessions: number;
|
||||
}
|
||||
|
||||
|
|
@ -75,6 +77,7 @@ const DEFAULT_SETTINGS: ClaudeCliPluginSettings = {
|
|||
automationsHistory: [],
|
||||
automationsHistoryLimit: 200,
|
||||
autoCloseAutomationSessions: true,
|
||||
autoCloseAutomationSessionsOnIdle: false,
|
||||
maxConcurrentSessions: 8
|
||||
};
|
||||
|
||||
|
|
@ -152,6 +155,10 @@ type SessionOrigin = "manual" | "automation";
|
|||
const SESSION_READY_QUIET_MS = 800;
|
||||
const SESSION_READY_MAX_MS = 10000;
|
||||
|
||||
// A session is shown as "working" while its CLI emits output, and flips back to
|
||||
// "idle" once output has been quiet for this long (the AI finished its turn).
|
||||
const ACTIVITY_IDLE_MS = 5000;
|
||||
|
||||
function createSessionTerminal(): { terminal: Terminal; fitAddon: FitAddon } {
|
||||
const terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
|
|
@ -186,6 +193,15 @@ class CliSession {
|
|||
status = "Idle";
|
||||
pendingRestart = false;
|
||||
|
||||
// Live activity: "working" while the CLI emits output, "idle" after it goes
|
||||
// quiet. Drives the tab dot colour and (for automations) idle auto-close.
|
||||
activity: "working" | "idle" = "idle";
|
||||
// Set true once an automation prompt has been sent, so idle auto-close only
|
||||
// fires after the automation actually ran (not during boot).
|
||||
closeOnIdleArmed = false;
|
||||
onActivityChange: (() => void) | null = null;
|
||||
private activityTimer: number | null = null;
|
||||
|
||||
private ready = false;
|
||||
whenReady!: Promise<void>;
|
||||
private resolveReady: (() => void) | null = null;
|
||||
|
|
@ -222,10 +238,16 @@ class CliSession {
|
|||
}
|
||||
}
|
||||
|
||||
/** Arm a fresh readiness promise for a (re)spawn. */
|
||||
/** Arm a fresh readiness promise for a (re)spawn, and reset activity state. */
|
||||
resetReady(): void {
|
||||
this.ready = false;
|
||||
this.clearReadyTimers();
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
this.activityTimer = null;
|
||||
}
|
||||
this.activity = "idle";
|
||||
this.closeOnIdleArmed = false;
|
||||
this.whenReady = new Promise<void>((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
|
@ -261,6 +283,37 @@ class CliSession {
|
|||
this.settleTimer = activeWindow.setTimeout(() => this.markReady(), quietMs);
|
||||
}
|
||||
|
||||
/** Each output chunk marks the session "working" and (re)arms a quiet timer
|
||||
* that flips it back to "idle" after `idleMs` of silence. */
|
||||
noteActivity(idleMs: number): void {
|
||||
if (this.activity !== "working") {
|
||||
this.activity = "working";
|
||||
this.onActivityChange?.();
|
||||
}
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
}
|
||||
this.activityTimer = activeWindow.setTimeout(() => {
|
||||
this.activityTimer = null;
|
||||
if (this.activity !== "idle") {
|
||||
this.activity = "idle";
|
||||
this.onActivityChange?.();
|
||||
}
|
||||
}, idleMs);
|
||||
}
|
||||
|
||||
/** Force the idle state immediately (e.g. when the process exits). */
|
||||
markIdle(): void {
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
this.activityTimer = null;
|
||||
}
|
||||
if (this.activity !== "idle") {
|
||||
this.activity = "idle";
|
||||
this.onActivityChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
isRunning(): boolean {
|
||||
return this.processHandle !== null;
|
||||
}
|
||||
|
|
@ -290,11 +343,19 @@ class CliSession {
|
|||
}
|
||||
}, 120);
|
||||
}
|
||||
// From now on, the next working→idle transition means "automation finished",
|
||||
// which (if enabled) closes the tab. Driven by the CLI's real output, not a
|
||||
// timer here, so a slow-to-respond CLI is not closed prematurely.
|
||||
this.closeOnIdleArmed = true;
|
||||
this.writeSystemLine(`[Automation prompt injected]`);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearReadyTimers();
|
||||
if (this.activityTimer !== null) {
|
||||
activeWindow.clearTimeout(this.activityTimer);
|
||||
this.activityTimer = null;
|
||||
}
|
||||
try {
|
||||
this.terminal.dispose();
|
||||
} catch {
|
||||
|
|
@ -495,6 +556,18 @@ class ClaudeCliView extends ItemView {
|
|||
});
|
||||
this.sessions.push(session);
|
||||
terminal.onData((data) => session.processHandle?.write(data));
|
||||
session.onActivityChange = () => {
|
||||
this.renderTabBar();
|
||||
if (
|
||||
session.activity === "idle" &&
|
||||
session.origin === "automation" &&
|
||||
session.isRunning() &&
|
||||
session.closeOnIdleArmed &&
|
||||
this.plugin.settings.autoCloseAutomationSessionsOnIdle
|
||||
) {
|
||||
this.closeSession(session.id);
|
||||
}
|
||||
};
|
||||
session.writeSystemLine(`CLI session ready (${label}).`);
|
||||
|
||||
// Activate the new tab before spawning so the terminal is visible and sized.
|
||||
|
|
@ -665,11 +738,14 @@ class ClaudeCliView extends ItemView {
|
|||
session.terminal.write(data);
|
||||
// Readiness = first output, then a quiet period (input box rendered).
|
||||
session.noteOutputActivity(SESSION_READY_QUIET_MS);
|
||||
// Live activity for the tab dot + automation idle auto-close.
|
||||
session.noteActivity(ACTIVITY_IDLE_MS);
|
||||
});
|
||||
|
||||
session.processHandle.onExit((exitCode, signal) => {
|
||||
session.processHandle = null;
|
||||
session.markReady();
|
||||
session.markIdle();
|
||||
if (session.pendingRestart) {
|
||||
session.pendingRestart = false;
|
||||
const current = this.plugin.settings.runtimes.find((r) => r.id === session.runtimeId);
|
||||
|
|
@ -779,7 +855,11 @@ class ClaudeCliView extends ItemView {
|
|||
const tabEl = this.tabBarEl.createDiv({
|
||||
cls: `claude-cli-tab${session.id === this.activeSessionId ? " is-active" : ""}`
|
||||
});
|
||||
const dotCls = session.isRunning() ? "is-running" : "is-stopped";
|
||||
const dotCls = tabDotClass({
|
||||
running: session.isRunning(),
|
||||
activity: session.activity,
|
||||
origin: session.origin
|
||||
});
|
||||
tabEl.createSpan({ cls: `claude-cli-tab-dot ${dotCls}` });
|
||||
if (session.origin === "automation") {
|
||||
const autoIcon = tabEl.createSpan({ cls: "claude-cli-tab-auto" });
|
||||
|
|
@ -1204,6 +1284,10 @@ export default class ClaudeCliPlugin extends Plugin {
|
|||
typeof raw.autoCloseAutomationSessions === "boolean"
|
||||
? raw.autoCloseAutomationSessions
|
||||
: DEFAULT_SETTINGS.autoCloseAutomationSessions,
|
||||
autoCloseAutomationSessionsOnIdle:
|
||||
typeof raw.autoCloseAutomationSessionsOnIdle === "boolean"
|
||||
? raw.autoCloseAutomationSessionsOnIdle
|
||||
: DEFAULT_SETTINGS.autoCloseAutomationSessionsOnIdle,
|
||||
maxConcurrentSessions:
|
||||
typeof raw.maxConcurrentSessions === "number" &&
|
||||
Number.isInteger(raw.maxConcurrentSessions) &&
|
||||
|
|
@ -1279,7 +1363,7 @@ class ClaudeCliSettingTab extends PluginSettingTab {
|
|||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Auto-close automation sessions")
|
||||
.setName("Auto-close automation sessions on exit")
|
||||
.setDesc("When an automation-spawned session's process exits, close its tab automatically so tabs don't pile up.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
|
|
@ -1290,6 +1374,18 @@ class ClaudeCliSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Auto-close automation sessions when idle")
|
||||
.setDesc("Close an automation tab once its CLI goes quiet for ~5s after the prompt ran (the AI finished its turn), even if the process stays alive. Off by default — a long task that pauses output for over 5s could be closed early.")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.autoCloseAutomationSessionsOnIdle)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoCloseAutomationSessionsOnIdle = 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.")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "any-ai-cli",
|
||||
"name": "Any AI CLI",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "any-ai-cli",
|
||||
"version": "0.2.10",
|
||||
"version": "0.2.11",
|
||||
"description": "Run an AI assistant CLI like Claude from a right sidebar terminal panel, with a customizable runtime list.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -62,3 +62,20 @@ export function canOpenSession(currentCount: number, max: number): boolean {
|
|||
}
|
||||
return currentCount < max;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS modifier class for a session tab's status dot, from its activity:
|
||||
* - `is-working` — a manual session whose CLI is currently producing output (green)
|
||||
* - `is-automation` — an automation session whose CLI is currently working (purple)
|
||||
* - `is-idle` — the CLI is quiet/finished, or the process has stopped (gray)
|
||||
*/
|
||||
export function tabDotClass(opts: {
|
||||
running: boolean;
|
||||
activity: "working" | "idle";
|
||||
origin: "manual" | "automation";
|
||||
}): "is-working" | "is-automation" | "is-idle" {
|
||||
if (opts.running && opts.activity === "working") {
|
||||
return opts.origin === "automation" ? "is-automation" : "is-working";
|
||||
}
|
||||
return "is-idle";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -297,11 +297,15 @@
|
|||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.claude-cli-tab-dot.is-running {
|
||||
.claude-cli-tab-dot.is-working {
|
||||
background: var(--color-green, #3fb950);
|
||||
}
|
||||
|
||||
.claude-cli-tab-dot.is-stopped {
|
||||
.claude-cli-tab-dot.is-automation {
|
||||
background: var(--color-purple, #a371f7);
|
||||
}
|
||||
|
||||
.claude-cli-tab-dot.is-idle {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ import {
|
|||
canOpenSession,
|
||||
nextSessionLabel,
|
||||
resolveRuntimeForAutomation,
|
||||
runtimeMatches
|
||||
runtimeMatches,
|
||||
tabDotClass
|
||||
} from "../session-utils";
|
||||
import type { CliRuntimeConfig } from "../runtime-utils";
|
||||
|
||||
|
|
@ -84,3 +85,26 @@ describe("canOpenSession", () => {
|
|||
expect(canOpenSession(100, -1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tabDotClass", () => {
|
||||
it("is green for a working manual session", () => {
|
||||
expect(tabDotClass({ running: true, activity: "working", origin: "manual" })).toBe("is-working");
|
||||
});
|
||||
|
||||
it("is purple for a working automation session", () => {
|
||||
expect(tabDotClass({ running: true, activity: "working", origin: "automation" })).toBe(
|
||||
"is-automation"
|
||||
);
|
||||
});
|
||||
|
||||
it("is gray when idle, regardless of origin", () => {
|
||||
expect(tabDotClass({ running: true, activity: "idle", origin: "manual" })).toBe("is-idle");
|
||||
expect(tabDotClass({ running: true, activity: "idle", origin: "automation" })).toBe("is-idle");
|
||||
});
|
||||
|
||||
it("is gray when the process has stopped even if last activity was working", () => {
|
||||
expect(tabDotClass({ running: false, activity: "working", origin: "automation" })).toBe(
|
||||
"is-idle"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -20,5 +20,6 @@
|
|||
"0.1.47": "1.7.2",
|
||||
"0.2.8": "1.7.2",
|
||||
"0.2.9": "1.7.2",
|
||||
"0.2.10": "1.7.2"
|
||||
"0.2.10": "1.7.2",
|
||||
"0.2.11": "1.7.2"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue