P3-B: claude-adapter — états fins busy/waiting/idle
Détection de l'état fin d'une session claude et du dialogue en cours, pour savoir « quelle session attend une réponse ». Source primaire = registre ~/.claude/sessions (stable) ; l'écran reconstruit via @xterm/headless TYPE le dialogue (trust/permission/question/plan) et couvre le cas Trust (avant registre). - dép: @xterm/headless ^6.0.0 (aligné sur @xterm/xterm du front). - core/screen-reader.ts: terminal headless persistant par session, alimenté par le flux PTY ; snapshotLines() préserve les espaces (bat le strip ANSI naïf). - core/dialog-classifier.ts: typage pur (texte aplati + regex tolérante inter- versions) + extraction des options numérotées (❯ = sélection). - core/claude-adapter.ts: SessionActivityTracker par session (registre + écran → activity + dialog), évaluation débouncée sur output + poll registre léger, émission sur changement effectif uniquement. - shared: SessionSummary += activity?/waitingFor?/dialog? (optionnels, additif). - pty-manager: tracker instancié pour claude, feed dans handleOutput, summarize enrichi, resize propagé, dispose à l'exit. - web: composant SessionStateBadge (busy/waiting/idle colorés) réutilisable. - tests: screen-reader + classifier sur fixtures réelles S3 (perm-write/ask/ trust), claude-adapter (machine à états). 159 verts.
This commit is contained in:
@@ -27,6 +27,7 @@
|
||||
"@fastify/static": "^8.0.0",
|
||||
"@fastify/websocket": "^11.0.0",
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.13.0",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
90
packages/server/src/core/claude-adapter.ts
Normal file
90
packages/server/src/core/claude-adapter.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// claude-adapter : déduit l'état fin d'une session (busy / waiting / idle) + le dialogue typé.
|
||||
// Source PRIMAIRE = le registre ~/.claude/sessions (status stable inter-versions) ; l'écran reconstruit
|
||||
// (@xterm/headless) sert uniquement à TYPER le dialogue et à couvrir le cas Trust (qui précède le
|
||||
// registre). Un tracker par session vive claude, alimenté par le flux PTY (pas de re-replay du ring).
|
||||
import type { SessionActivity, SessionDialog } from '@arboretum/shared';
|
||||
import { ScreenReader } from './screen-reader.js';
|
||||
import { classifyDialog } from './dialog-classifier.js';
|
||||
import { findByPid } from './session-registry.js';
|
||||
|
||||
const DEBOUNCE_MS = 200; // coalescence des rafales d'output avant ré-évaluation
|
||||
const POLL_MS = 700; // capte les transitions busy↔idle qui n'émettent pas d'output discriminant
|
||||
|
||||
export interface AdapterState {
|
||||
activity: SessionActivity | null;
|
||||
waitingFor: string | null;
|
||||
dialog: SessionDialog | null;
|
||||
}
|
||||
|
||||
const EMPTY: AdapterState = { activity: null, waitingFor: null, dialog: null };
|
||||
|
||||
function sameState(a: AdapterState, b: AdapterState): boolean {
|
||||
return a.activity === b.activity && a.waitingFor === b.waitingFor && JSON.stringify(a.dialog) === JSON.stringify(b.dialog);
|
||||
}
|
||||
|
||||
export class SessionActivityTracker {
|
||||
private readonly reader = new ScreenReader(120, 32);
|
||||
private state: AdapterState = EMPTY;
|
||||
private debounce: NodeJS.Timeout | null = null;
|
||||
private readonly poll: NodeJS.Timeout;
|
||||
private disposed = false;
|
||||
|
||||
constructor(
|
||||
private readonly pid: number,
|
||||
private readonly sessionsDir: string,
|
||||
private readonly onChange: () => void,
|
||||
) {
|
||||
this.poll = setInterval(() => this.evaluate(), POLL_MS);
|
||||
this.poll.unref();
|
||||
}
|
||||
|
||||
feed(chunk: Uint8Array): void {
|
||||
if (this.disposed) return;
|
||||
void this.reader.feed(chunk);
|
||||
if (this.debounce) return;
|
||||
this.debounce = setTimeout(() => {
|
||||
this.debounce = null;
|
||||
this.evaluate();
|
||||
}, DEBOUNCE_MS);
|
||||
this.debounce.unref();
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
if (!this.disposed) this.reader.resize(cols, rows);
|
||||
}
|
||||
|
||||
snapshot(): AdapterState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** Évalue l'état courant (debounce → snapshot fiable). Public pour les tests déterministes. */
|
||||
evaluate(): void {
|
||||
if (this.disposed) return;
|
||||
const reg = findByPid(this.sessionsDir, this.pid);
|
||||
const next = this.derive(reg?.status ?? null, reg?.waitingFor ?? null);
|
||||
if (!sameState(this.state, next)) {
|
||||
this.state = next;
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
private derive(status: 'busy' | 'idle' | 'waiting' | null, waitingFor: string | null): AdapterState {
|
||||
if (status === 'busy') return { activity: 'busy', waitingFor: null, dialog: null };
|
||||
if (status === 'idle') return { activity: 'idle', waitingFor: null, dialog: null };
|
||||
if (status === 'waiting') {
|
||||
const c = classifyDialog(this.reader.snapshotLines());
|
||||
return { activity: 'waiting', waitingFor, dialog: c ? { kind: c.kind, waitingFor, options: c.options } : null };
|
||||
}
|
||||
// Pas (encore) de registre : seul le dialogue Trust le précède (spike S1).
|
||||
const c = classifyDialog(this.reader.snapshotLines());
|
||||
if (c?.kind === 'trust') return { activity: 'waiting', waitingFor: 'trust', dialog: { kind: 'trust', waitingFor: 'trust', options: c.options } };
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
if (this.debounce) clearTimeout(this.debounce);
|
||||
clearInterval(this.poll);
|
||||
this.reader.dispose();
|
||||
}
|
||||
}
|
||||
46
packages/server/src/core/dialog-classifier.ts
Normal file
46
packages/server/src/core/dialog-classifier.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Typage de dialogue à partir de l'écran reconstruit (ScreenReader) — fonctions PURES.
|
||||
// L'écran sert à TYPER le dialogue et extraire ses options ; l'état (waiting vrai/faux) vient du
|
||||
// registre (source primaire). Tolérant aux variations de rendu inter-versions (texte aplati + regex).
|
||||
import type { DialogKind, DialogOption } from '@arboretum/shared';
|
||||
|
||||
export interface ClassifiedDialog {
|
||||
kind: DialogKind;
|
||||
options: DialogOption[];
|
||||
}
|
||||
|
||||
// Option numérotée, éventuellement préfixée du curseur ❯ : « ❯ 1. Yes », « 2) No »…
|
||||
const OPTION_RE = /^\s*(❯)?\s*(\d+)[.)]\s+(.*\S)\s*$/;
|
||||
|
||||
export function parseOptions(lines: string[]): DialogOption[] {
|
||||
const out: DialogOption[] = [];
|
||||
for (const line of lines) {
|
||||
const m = OPTION_RE.exec(line);
|
||||
if (m && m[2] && m[3]) out.push({ n: Number(m[2]), label: m[3].trim(), selected: Boolean(m[1]) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le dialogue typé visible à l'écran, ou null si aucun. `trust` et `question` priment sur
|
||||
* `permission` (un écran AskUserQuestion contient aussi « Esc to cancel »). Un écran numéroté non
|
||||
* typé est traité en `permission` générique (best-effort — le fallback reste le terminal web).
|
||||
*/
|
||||
export function classifyDialog(lines: string[]): ClassifiedDialog | null {
|
||||
const text = lines.join('\n');
|
||||
const flat = text.replace(/\s+/g, '').toLowerCase();
|
||||
const options = parseOptions(lines);
|
||||
|
||||
const isTrust = flat.includes('trust') && (text.includes('❯') || flat.includes('entertoconfirm') || flat.includes('trustthisfolder'));
|
||||
const isQuestion = flat.includes('entertoselect') || flat.includes('tonavigate') || text.includes('↑/↓');
|
||||
const isPermission = flat.includes('doyouwant') || flat.includes('esctocancel') || flat.includes('tabtoamend');
|
||||
const isPlan = flat.includes('readytocode') || flat.includes('wouldyouliketoproceed');
|
||||
|
||||
let kind: DialogKind | null = null;
|
||||
if (isTrust) kind = 'trust';
|
||||
else if (isQuestion) kind = 'question';
|
||||
else if (isPlan) kind = 'plan';
|
||||
else if (isPermission) kind = 'permission';
|
||||
else if (options.length > 0) kind = 'permission';
|
||||
|
||||
return kind ? { kind, options } : null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared'
|
||||
import { RingBuffer } from './ring-buffer.js';
|
||||
import { buildSpawnSpec } from './claude-launcher.js';
|
||||
import { findByPid } from './session-registry.js';
|
||||
import { SessionActivityTracker } from './claude-adapter.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||
@@ -44,6 +45,8 @@ interface ManagedSession {
|
||||
killTimer: NodeJS.Timeout | null;
|
||||
/** ID interne du CLI claude, résolu via le registre après spawn (null pour bash / pas encore prêt). */
|
||||
claudeSessionId: string | null;
|
||||
/** détection d'état fin (busy/waiting/idle + dialogue) ; null pour bash (P3-B). */
|
||||
tracker: SessionActivityTracker | null;
|
||||
}
|
||||
|
||||
export interface PtyManagerEvents {
|
||||
@@ -90,7 +93,14 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
exited: null,
|
||||
killTimer: null,
|
||||
claudeSessionId: null,
|
||||
tracker: null,
|
||||
};
|
||||
// Détection d'état fin (P3-B) : uniquement pour claude (bash n'a pas de registre).
|
||||
if (command === 'claude') {
|
||||
session.tracker = new SessionActivityTracker(proc.pid, this.sessionsDir, () => {
|
||||
if (!session.exited) this.emit('session_update', this.summarize(session));
|
||||
});
|
||||
}
|
||||
this.live.set(id, session);
|
||||
this.db
|
||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at, resumed_from) VALUES (?, ?, ?, ?, ?)')
|
||||
@@ -212,7 +222,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
const hasController = [...s.clients].some((c) => c.controlling);
|
||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
||||
s.clients.add(binding);
|
||||
if (binding.controlling) s.proc.resize(cols, rows);
|
||||
if (binding.controlling) {
|
||||
s.proc.resize(cols, rows);
|
||||
s.tracker?.resize(cols, rows);
|
||||
}
|
||||
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||
binding.sentBytes = 0;
|
||||
@@ -247,6 +260,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s || s.exited || !binding.controlling) return;
|
||||
s.proc.resize(cols, rows);
|
||||
s.tracker?.resize(cols, rows);
|
||||
}
|
||||
|
||||
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
|
||||
@@ -266,6 +280,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
|
||||
private handleOutput(s: ManagedSession, chunk: Buffer): void {
|
||||
s.ring.write(chunk);
|
||||
s.tracker?.feed(chunk);
|
||||
for (const c of s.clients) {
|
||||
if (c.lagging) continue;
|
||||
c.sendOutput(chunk);
|
||||
@@ -302,6 +317,8 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
|
||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||
s.exited = { exitCode, signal };
|
||||
s.tracker?.dispose();
|
||||
s.tracker = null;
|
||||
if (s.killTimer) clearTimeout(s.killTimer);
|
||||
const endedAt = new Date().toISOString();
|
||||
this.db.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?').run(endedAt, exitCode, s.id);
|
||||
@@ -313,6 +330,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
}
|
||||
|
||||
private summarize(s: ManagedSession): SessionSummary {
|
||||
const act = s.tracker?.snapshot();
|
||||
return {
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
@@ -330,7 +348,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
// une managée vivante ne se resume pas (corruption) ; une managée claude morte oui.
|
||||
resumable: !!s.exited && s.command === 'claude' && s.claudeSessionId != null,
|
||||
attachable: !s.exited,
|
||||
registryStatus: null, // P2 : statut fin des managées via claude-adapter (P3-B)
|
||||
// statut brut du registre dérivé de l'activité fine (P3-B) ; null pour bash.
|
||||
registryStatus: act?.activity ?? null,
|
||||
activity: act?.activity ?? null,
|
||||
waitingFor: act?.waitingFor ?? null,
|
||||
dialog: act?.dialog ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
39
packages/server/src/core/screen-reader.ts
Normal file
39
packages/server/src/core/screen-reader.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Reconstruction d'écran via @xterm/headless : terminal headless PERSISTANT par session, alimenté
|
||||
// incrémentalement par le flux PTY. Remplace le strip ANSI naïf (qui « mange les espaces » et casse
|
||||
// la détection des dialogues — verdict S1/S3). Aucune dépendance DOM (usage Node).
|
||||
import { Terminal } from '@xterm/headless';
|
||||
|
||||
export class ScreenReader {
|
||||
private readonly term: Terminal;
|
||||
|
||||
constructor(cols = 120, rows = 40) {
|
||||
this.term = new Terminal({ cols, rows, scrollback: 0, allowProposedApi: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Alimente le terminal avec un chunk PTY brut (la frontière UTF-8 et les séquences ANSI coupées
|
||||
* sont gérées par xterm). La promesse se résout quand le chunk a été parsé (snapshot fiable ensuite).
|
||||
*/
|
||||
feed(chunk: Uint8Array | string): Promise<void> {
|
||||
return new Promise((resolve) => this.term.write(chunk as Uint8Array, resolve));
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
this.term.resize(cols, rows);
|
||||
}
|
||||
|
||||
/** Lignes visibles du viewport courant, espaces préservés (trim à droite uniquement). */
|
||||
snapshotLines(): string[] {
|
||||
const buf = this.term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let y = 0; y < this.term.rows; y++) {
|
||||
const line = buf.getLine(buf.baseY + y);
|
||||
lines.push(line ? line.translateToString(true) : '');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.term.dispose();
|
||||
}
|
||||
}
|
||||
74
packages/server/test/claude-adapter.test.ts
Normal file
74
packages/server/test/claude-adapter.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { SessionActivityTracker } from '../src/core/claude-adapter.js';
|
||||
|
||||
const PID = 999_999;
|
||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
function writeRegistry(dir: string, status: string, waitingFor?: string): void {
|
||||
writeFileSync(
|
||||
join(dir, `${PID}.json`),
|
||||
JSON.stringify({ pid: PID, procStart: '123', sessionId: 'sid', cwd: '/x', status, ...(waitingFor ? { waitingFor } : {}) }),
|
||||
);
|
||||
}
|
||||
|
||||
describe('SessionActivityTracker', () => {
|
||||
let dir: string;
|
||||
let tracker: SessionActivityTracker;
|
||||
let changes: number;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'arb-adapter-'));
|
||||
changes = 0;
|
||||
tracker = new SessionActivityTracker(PID, dir, () => {
|
||||
changes++;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
tracker.dispose();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('registre busy → idle : transitions d’activité + onChange sur changement uniquement', () => {
|
||||
writeRegistry(dir, 'busy');
|
||||
tracker.evaluate();
|
||||
expect(tracker.snapshot().activity).toBe('busy');
|
||||
expect(changes).toBe(1);
|
||||
|
||||
tracker.evaluate(); // rien n'a changé
|
||||
expect(changes).toBe(1);
|
||||
|
||||
writeRegistry(dir, 'idle');
|
||||
tracker.evaluate();
|
||||
expect(tracker.snapshot().activity).toBe('idle');
|
||||
expect(changes).toBe(2);
|
||||
});
|
||||
|
||||
it('registre waiting + écran permission → activity waiting, dialog typé permission', async () => {
|
||||
await tracker['reader'].feed('\x1b[2J\x1b[HDo you want to create x.txt?\r\n❯ 1. Yes\r\n2. No\r\nEsc to cancel · Tab to amend\r\n');
|
||||
await sleep(30);
|
||||
writeRegistry(dir, 'waiting', 'permission prompt');
|
||||
tracker.evaluate();
|
||||
const s = tracker.snapshot();
|
||||
expect(s.activity).toBe('waiting');
|
||||
expect(s.waitingFor).toBe('permission prompt');
|
||||
expect(s.dialog?.kind).toBe('permission');
|
||||
expect(s.dialog?.options.find((o) => o.n === 1)).toMatchObject({ selected: true });
|
||||
});
|
||||
|
||||
it('pas de registre + écran Trust → waiting/trust (le Trust précède le registre)', async () => {
|
||||
await tracker['reader'].feed('\x1b[2J\x1b[HDo you trust the files in this folder?\r\n❯ 1. Yes, I trust this folder\r\n2. No, exit\r\n');
|
||||
await sleep(30);
|
||||
tracker.evaluate();
|
||||
const s = tracker.snapshot();
|
||||
expect(s.activity).toBe('waiting');
|
||||
expect(s.dialog?.kind).toBe('trust');
|
||||
});
|
||||
|
||||
it('pas de registre + pas de dialogue → état vide (inconnu)', () => {
|
||||
tracker.evaluate();
|
||||
expect(tracker.snapshot()).toMatchObject({ activity: null, dialog: null });
|
||||
});
|
||||
});
|
||||
79
packages/server/test/dialog-detection.test.ts
Normal file
79
packages/server/test/dialog-detection.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ScreenReader } from '../src/core/screen-reader.js';
|
||||
import { classifyDialog, parseOptions, type ClassifiedDialog } from '../src/core/dialog-classifier.js';
|
||||
|
||||
const capturesDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', 'spikes', 's3-tui', 'captures');
|
||||
|
||||
/** Rejoue un flux PTY brut par segments (comme l'adapter en prod) et collecte écran+dialogue détecté. */
|
||||
async function replay(fixture: string): Promise<Array<{ dialog: ClassifiedDialog; text: string }>> {
|
||||
const data = readFileSync(join(capturesDir, fixture));
|
||||
const reader = new ScreenReader(120, 40);
|
||||
const seen: Array<{ dialog: ClassifiedDialog; text: string }> = [];
|
||||
const STEP = 512;
|
||||
for (let i = 0; i < data.length; i += STEP) {
|
||||
await reader.feed(data.subarray(i, i + STEP)); // xterm gère les séquences coupées en frontière
|
||||
const lines = reader.snapshotLines();
|
||||
const d = classifyDialog(lines);
|
||||
if (d) seen.push({ dialog: d, text: lines.join('\n') });
|
||||
}
|
||||
reader.dispose();
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('ScreenReader (@xterm/headless) — anti strip-ANSI naïf', () => {
|
||||
it('préserve les espaces du dialogue (« Do you want to create », pas « Doyouwant »)', async () => {
|
||||
const seen = await replay('perm-write2.raw.log');
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.some((s) => /Do you want to create/.test(s.text))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyDialog — unitaire (lignes synthétiques)', () => {
|
||||
it('trust', () => {
|
||||
const d = classifyDialog(['Do you trust the files in this folder?', '❯ 1. Yes, I trust this folder', '2. No, exit']);
|
||||
expect(d?.kind).toBe('trust');
|
||||
expect(d?.options).toHaveLength(2);
|
||||
expect(d?.options[0]).toMatchObject({ n: 1, selected: true });
|
||||
});
|
||||
it('permission', () => {
|
||||
const d = classifyDialog(['Do you want to create s3-edit.txt?', '❯ 1. Yes', '3. No', 'Esc to cancel · Tab to amend']);
|
||||
expect(d?.kind).toBe('permission');
|
||||
});
|
||||
it('question (prime sur permission malgré « Esc to cancel »)', () => {
|
||||
const d = classifyDialog(['☐ Couleur', '❯ 1. Rouge', '2. Bleu', 'Enter to select · ↑/↓ to navigate · Esc to cancel']);
|
||||
expect(d?.kind).toBe('question');
|
||||
});
|
||||
it('aucun dialogue → null', () => {
|
||||
expect(classifyDialog(['just some output', 'no options here'])).toBeNull();
|
||||
});
|
||||
it('parseOptions tolère « ❯ 2) Label » et trim', () => {
|
||||
expect(parseOptions([' ❯ 2) Yes, allow all '])).toEqual([{ n: 2, label: 'Yes, allow all', selected: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('détection sur fixtures réelles S3 (replay headless)', () => {
|
||||
it('perm-write2 → permission avec options Yes/…/No, option 1 sélectionnée', async () => {
|
||||
const perms = (await replay('perm-write2.raw.log')).filter((s) => s.dialog.kind === 'permission');
|
||||
expect(perms.length).toBeGreaterThan(0);
|
||||
const withOpts = perms.find((s) => s.dialog.options.length >= 2);
|
||||
expect(withOpts).toBeDefined();
|
||||
const labels = withOpts!.dialog.options.map((o) => o.label).join(' | ');
|
||||
expect(labels).toMatch(/Yes/);
|
||||
expect(labels).toMatch(/No/);
|
||||
expect(withOpts!.dialog.options.find((o) => o.n === 1)?.selected).toBe(true);
|
||||
});
|
||||
|
||||
it('ask2 → question avec une option « Rouge »', async () => {
|
||||
const qs = (await replay('ask2.raw.log')).filter((s) => s.dialog.kind === 'question');
|
||||
expect(qs.length).toBeGreaterThan(0);
|
||||
expect(qs.some((s) => s.dialog.options.some((o) => /Rouge/.test(o.label)))).toBe(true);
|
||||
});
|
||||
|
||||
it('trust → dialogue trust détecté', async () => {
|
||||
const seen = await replay('trust.raw.log');
|
||||
expect(seen.some((s) => s.dialog.kind === 'trust')).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -68,6 +68,21 @@ export type SessionSource =
|
||||
/** Statut brut tel qu'écrit par le CLI dans ~/.claude/sessions (interprété finement en P3). */
|
||||
export type SessionRegistryStatus = 'busy' | 'idle' | 'waiting';
|
||||
|
||||
// ---- États fins de session (P3-B, claude-adapter) ----
|
||||
/** busy = Claude traite ; waiting = bloqué sur un dialogue ; idle = prêt pour une instruction. */
|
||||
export type SessionActivity = 'busy' | 'waiting' | 'idle';
|
||||
export type DialogKind = 'trust' | 'permission' | 'question' | 'plan';
|
||||
export interface DialogOption {
|
||||
n: number;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
}
|
||||
export interface SessionDialog {
|
||||
kind: DialogKind;
|
||||
waitingFor: string | null;
|
||||
options: DialogOption[];
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
cwd: string;
|
||||
@@ -92,6 +107,12 @@ export interface SessionSummary {
|
||||
attachable: boolean;
|
||||
/** statut brut du registre ~/.claude/sessions (P2) ; interprété finement en P3 (claude-adapter). */
|
||||
registryStatus: SessionRegistryStatus | null;
|
||||
// ---- P3-B : états fins (optionnels, remplis par le claude-adapter) ----
|
||||
/** absent/null = inconnu (bash, démarrage, historique). */
|
||||
activity?: SessionActivity | null;
|
||||
waitingFor?: string | null;
|
||||
/** dialogue typé en cours (présent quand activity === 'waiting'). */
|
||||
dialog?: SessionDialog | null;
|
||||
}
|
||||
|
||||
// ---- Worktrees & repos (P3) ----
|
||||
|
||||
File diff suppressed because one or more lines are too long
33
packages/web/src/components/SessionStateBadge.vue
Normal file
33
packages/web/src/components/SessionStateBadge.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<span v-if="!session.live && session.resumable" class="badge bg-amber-950 text-amber-400">
|
||||
{{ t('sessions.resumable') }}
|
||||
</span>
|
||||
<span v-else-if="!session.live" class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||
<span v-else class="badge gap-1" :class="badgeClass">
|
||||
<span class="inline-block h-1.5 w-1.5 rounded-full" :class="dotClass" />
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import type { SessionSummary } from '@arboretum/shared';
|
||||
|
||||
const props = defineProps<{ session: SessionSummary }>();
|
||||
const { t } = useI18n();
|
||||
|
||||
// activity (P3-B, managées) prime ; registryStatus (P2, découvertes) en repli.
|
||||
const activity = computed(() => props.session.activity ?? props.session.registryStatus ?? null);
|
||||
const label = computed(() => (activity.value ? t(`sessions.registryStatus.${activity.value}`) : t('sessions.live')));
|
||||
const badgeClass = computed(() => ({
|
||||
'bg-amber-950 text-amber-300': activity.value === 'waiting',
|
||||
'bg-sky-950 text-sky-300': activity.value === 'busy',
|
||||
'bg-emerald-950 text-emerald-400': activity.value === 'idle' || activity.value === null,
|
||||
}));
|
||||
const dotClass = computed(() => ({
|
||||
'bg-amber-300 animate-pulse': activity.value === 'waiting',
|
||||
'bg-sky-300 animate-pulse': activity.value === 'busy',
|
||||
'bg-emerald-400': activity.value === 'idle' || activity.value === null,
|
||||
}));
|
||||
</script>
|
||||
@@ -58,12 +58,7 @@
|
||||
<span v-if="s.source === 'discovered'" class="badge bg-sky-950 text-sky-400">
|
||||
{{ t('sessions.sourceDiscovered') }}
|
||||
</span>
|
||||
<span v-if="s.live" class="badge gap-1 bg-emerald-950 text-emerald-400">
|
||||
<span class="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-400" />
|
||||
{{ s.registryStatus ? t(`sessions.registryStatus.${s.registryStatus}`) : t('sessions.live') }}
|
||||
</span>
|
||||
<span v-else-if="s.resumable" class="badge bg-amber-950 text-amber-400">{{ t('sessions.resumable') }}</span>
|
||||
<span v-else class="badge bg-zinc-800 text-zinc-500">{{ t('sessions.statusLabel.exited') }}</span>
|
||||
<SessionStateBadge :session="s" />
|
||||
<span class="ml-auto text-xs text-zinc-500">{{ formatDate(s.createdAt) }}</span>
|
||||
</div>
|
||||
<p v-if="s.title" class="truncate text-sm text-zinc-200" :title="s.title">{{ s.title }}</p>
|
||||
@@ -113,6 +108,7 @@ import { useI18n } from 'vue-i18n';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useSessionsStore } from '../stores/sessions';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher.vue';
|
||||
import SessionStateBadge from '../components/SessionStateBadge.vue';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
Reference in New Issue
Block a user