SLOPSHOPPER

Super Sidebar

Live agent family and task panel for Claude Desktop Code, plus a read-only local Codex session dashboard

newpaneguardcommandtimeragents
★ 155v0.2.0Apache-2.0updated 2026-10-22Zafer-Liu/claude-super-sidebar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · super-sidebar
│ ┃ 状态 ✕ › fix the failing auth test and add an audit log call │ ┃ 代理 · 1 [ 刷新 ] │ ┃ 0 运行中 · ⏺ Read(src/auth.ts) │ ┃ 仅展示已观测数据;缺失值不代表零。 ⎿ Read 6 lines │ ┃ 会话状态读取失败,保留上次观测。 ⏺ Update(src/auth.ts) │ ┃ ┌──────────────────────────────────────────┐ ⎿ Added 2 lines, removed 1 line │ ┃ │主代理 [ 收起 ]│ ⏺ Bash(bun test) │ ┃ │✓ 已完成 │ ⎿ 3 pass, 1 fail │ ┃ │当前耗时 42 秒 · 9 工具调用 │ │ ┃ │app │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │模式: 主会话 │ │ ┃ │角色: lead · 协调者 │ ✻ Worked for 42s · done 4:20 PM │ ┃ │模型: 暂无数据 │ │ ┃ │Token 用量: 98,880 · 会话累计(含缓存) │ › /super-sidebar │ ┃ │当前上下文: 当前会话未提供 │ ⎿ super-sidebar: 状态面板已打开。 │ ┃ └──────────────────────────────────────────┘ │ ┃ 任务流 · 0/0 │ ┃ 本轮已观测步骤 · 连线表示观测顺序 │ ┃ 等待本轮工作步骤 │ ┃ ┌──────────────────────────────────────────┐ │ ┃ │偏好设置 │ │ ┃ │语言[ EN ] │ │ ┃ │容器配色[ 白色 · 已选择 ][ 米色 ][ 黑色 ] │ │ ┃ │版本信息 [ 展开 ]│ │ ┃ └──────────────────────────────────────────┘ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · 状态
代理 · 1 [ 刷新 ] 0 运行中 · 仅展示已观测数据;缺失值不代表零。 会话状态读取失败,保留上次观测。 ┌──────────────────────────────────────────────────────────┐ │主代理 [ 收起 ]│ │✓ 已完成 │ │当前耗时 42 秒 · 9 工具调用 │ │app │ │模式: 主会话 │ │角色: lead · 协调者 │ │模型: 暂无数据 │ │Token 用量: 98,880 · 会话累计(含缓存) │ │当前上下文: 当前会话未提供 │ └──────────────────────────────────────────────────────────┘ 任务流 · 0/0 本轮已观测步骤 · 连线表示观测顺序 等待本轮工作步骤 ┌──────────────────────────────────────────────────────────┐ │偏好设置 │ │语言[ EN ] │ │容器配色[ 白色 · 已选择 ][ 米色 ][ 黑色 ] │ │版本信息 [ 展开 ]│ └──────────────────────────────────────────────────────────┘
README

Claude Super Sidebar — native agent visibility and task flow

<strong>See your agents. Follow the work.</strong><br /> A native Claude pane and a local Codex session panel.

<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue" alt="License: Apache-2.0" /></a> <img src="https://img.shields.io/badge/version-0.2.0-6689cd" alt="Version 0.2.0" /> <img src="https://img.shields.io/badge/TypeScript-3178c6?logo=typescript&amp;logoColor=white" alt="TypeScript" /> <img src="https://img.shields.io/badge/Claude-supported-32936b" alt="Claude supported" /> <img src="https://img.shields.io/badge/Codex-local_panel-32936b" alt="Codex local panel supported" />

<strong>English</strong> · <a href="README.zh-CN.md">简体中文</a>

<a href="#preview">Preview</a> · <a href="#installation">Installation</a> · <a href="#usage">Usage</a> · <a href="#codex-configuration">Codex</a> · <a href="#development">Development</a> · <a href="#audit-and-validation">Validation</a>

Overview

Claude Super Sidebar brings the main agent, subagent relationships, execution details, and the current conversation’s work steps into one panel. Claude Code uses a native plugin pane; standalone Codex CLI/App uses a read-only local session dashboard. Neither requires a frontend build.

The repository and product are Claude Super Sidebar (claude-super-sidebar). The plugin installs as super-sidebar, with a native pane titled 状态 · Status. The host reserves the claude- prefix, so a plugin ID cannot start with claude-; the product name stays with the repository and this README. The Codex page is titled 状态 status, and its team section is 团队面板 Team panel. Each adapter uses its host's own data source; task summaries require no additional model call.

IntegrationData source and UICurrent validation
Claude Desktop CodeLifecycle hooks and session.messages() → native paneHistorical portable checks passed; latest isolation regressions have not been run; current native engine validation and Desktop reload remain unverified
Codex CLI / AppLocal sessions/rollout-*.jsonl → read-only browser panelHistorical adapter/HTTP checks passed; latest isolation regressions have not been run; real local App session and page verified

Preview

Single-column overview of agent relationships, stage-grouped task flow, and preferences

Illustrative layout with sample data, not a runtime screenshot. The single column shows agent relationships, a stage-grouped task flow, and a preferences container. Container colors (white, beige, black) apply to the cards only; the surrounding page background stays unchanged. Actual rendering differs between the Claude native pane and Codex browser panel.

Features

  • Agent relationships — main agent and subagents in a unified tree, with directional connectors and details that expand in place.
  • Execution status — colored running, waiting, completed, and failed states. Claude shows active elapsed time; Codex shows recorded time without inferring process liveness.
  • Agent details — model, mode, role, observed token usage, available context usage, elapsed time, and tool-call count.
  • Conversation task flow — Both adapters group observed operations into work stages, use concise action titles, and show bounded operation details on expansion. Results remain separate from the stage description; failures and missing results are preserved.
  • Per-request scope — each new user request clears the previous flow; tool results preserve the current round. Up to 30 recent steps are shown.
  • Plan display — Claude extracts observable plan text. Codex displays successfully submitted update_plan steps; failed updates preserve the previous plan.
  • Execution and verification — “Operations completed” only describes tool execution. Explicit verification conclusions retain their conversation evidence and are labeled as assistant-reported; an exit code of zero never proves product behavior.
  • Container colors — white, beige, and black apply to cards and preferences; the surrounding page background stays unchanged. Authored controls support Chinese and English.
  • Local parsing — deriving the flow requires no additional LLM call.

Requirements

The following requirements apply to the Claude plugin. The Codex dashboard only requires Node.js 22.18+ and locally persisted Codex sessions.

  • A Claude Desktop Code host that exposes the native hook-module and pane APIs.
  • The matching Claude Code CLI for plugin installation.
  • A stable local copy of this repository.

Developed against Claude Code engine 2.1.299, using an early-access plugin API. The host must support hook modules, claude-code imports, and native ui.render panes. Availability may differ between distributions and versions.

Installation

Claude Desktop Code

Add the marketplace straight from GitHub, then install the plugin:

claude plugin marketplace add Zafer-Liu/claude-super-sidebar
claude plugin install super-sidebar@super-sidebar

Prefer a local clone? Run this from the repository root instead:

claude plugin install super-sidebar --marketplace . --scope user

Verify that super-sidebar@super-sidebar has scope user and enabled: true:

claude plugin list --json

If it is installed but disabled:

claude plugin enable super-sidebar@super-sidebar

Start a new Code session to load the plugin. User scope makes it available across projects for the current user. The GitHub marketplace installs a cached copy, so the clone can live anywhere; a local-path install reads from the source folder, so keep the repository in place for that route.

If claude is not on PATH, invoke your host’s matching Claude Code executable by its full path. Project overrides and organization policies may affect plugin loading.

Usage

These controls apply to the Claude native plugin. See Codex configuration for the local dashboard and conversation binding.

The plugin attempts to open 状态 · Status on session startup. Type /super-sidebar in the conversation input to reopen it.

SectionWhat it shows
Agent relationshipsMain agent, subagents, parent–child connections, and expandable execution details
Task FlowWork steps grouped into stages, with concise titles, status, operation details, and separate verification conclusions
PreferencesLanguage, container colors (white / beige / black), and a collapsible version block (plugin version and implementation revision)

Use 展开 / 收起 to expand or collapse an agent. Use 刷新 to refresh immediately; the pane also refreshes every two seconds.

Task-flow arrows indicate chronological execution. A new user request starts a fresh flow automatically, without task-management controls.

Codex configuration

Codex model in a Claude host

If your Claude Desktop deployment offers a Codex model, follow the Claude installation above and select that model in the host’s model picker. No Codex-specific configuration file is required: the Claude host supplies session events and the native pane API.

Claude Super Sidebar does not add models or configure model providers.

Standalone Codex CLI or App

The read-only local session dashboard supports standalone Codex. From this repository:

npm run codex -- --cwd "/absolute/path/to/your/project"

Open the local panel, or ask Codex App to open that URL in its browser sidebar. No npm install, API key, or Codex configuration changes are required. Without --cwd, the panel observes the process working directory.

node codex/server.mjs --cwd "/path/to/project" --port 4318 --codex-home "/path/to/codex/data"

The panel incrementally reads local JSONL files under CODEX_HOME/sessions (default ~/.codex/sessions) every two seconds. Each panel stays bound to one conversation and its descendants, showing the latest turn's 30 most recent work steps, successfully submitted plan, and observed usage. Summaries come from public progress messages; wrapper names such as exec / js are hidden, with human-readable operation titles as fallbacks. The session picker, project path, task-flow explanation and refresh-mechanism footer are omitted. Stop with Ctrl+C.

Starting from Codex uses CODEX_THREAD_ID automatically. Alternatively pass --thread "full-thread-id" or open http://127.0.0.1:4317/?thread=full-thread-id. Without a binding, the panel initially pins the most recent local conversation and never switches as other conversations change.

npm run codex -- --cwd "/absolute/path/to/project" --thread "full-thread-id"

When the server is already running, open another conversation with its own ?thread=... URL. The URL binding overrides the server default; all conversations remain limited to the project path chosen at server startup.

This is a companion browser UI, not a Claude ui.render plugin inside Codex, and it does not drive conversations. The local rollout format is not a stable public API. Compatibility was checked with Codex CLI 0.162.1 and this machine's App session records. Ephemeral, remote, archived, and unpersisted sessions are excluded; abnormal exits can leave the last observed state. See Codex adapter details.

How it works

Claude Code session
  ├─ Agent / turn / tool events ──→ Agent status and usage
  └─ Conversation transcript ─────→ Stage-grouped work steps
                                      │
                              Session-scoped state
                                      │
                            Native Status pane (single column)
                            ├─ Agent relationships
                            ├─ Task flow (stages, operations, verification)
                            └─ Preferences (container colors, version)

The manifest loads hooks/register.tsx through hooks/hooks.json. Session startup registers the command and refresh timer; lifecycle hooks observe execution; session.messages() supplies the task-flow parser. Native components and SVG connectors render the pane.

Development

For a compatible interactive CLI, load the repository explicitly:

claude --plugin-dir .

For Desktop hot reload, invoke the bundled /plugin-authoring skill and accept the host’s hot-reload prompt. Edits reload at the end of the turn.

Validate the manifest and hook modules with the matching CLI:

claude plugin validate .
PathResponsibility
.claude-plugin/Plugin manifest and local marketplace
hooks/register.tsxLifecycle hooks, command, and refresh
hooks/pane.tsxNative pane and connectors
hooks/steps.tsConversation-to-flow parser
hooks/model.tsState and agent-tree helpers
types/index.d.tsPlugin state contract
codex/Read-only Codex adapter, local server, browser UI and tests
verification/Historical captures and inspection notes

Audit and validation

Historical check before the latest isolation fixes: npm test 32 passed, 0 failed. The current isolation audit added regression cases without running tests/builds; see current audit. Reproduce without installing packages:

npm run test:claude  # 9 tests: actual model/parser sources, mocked lifecycle host
npm run test:codex   # 23 tests: parsing, incremental reads, scope, HTTP/CLI
npm test            # All portable tests

This audit fixed premature activity clearing during overlapping Claude tools, stale native pane test labels, and a Codex project-scope bypass after a rollout file replacement. See the audit report and verification evidence.

Codex has real local session and browser evidence. Claude's portable logic regressions pass, but no runnable compatible Claude Code CLI was found for this audit: native claude plugin validate, claude plugin test, and Desktop loading were not rerun. The covered checks pass; this does not establish error-free behavior across both hosts and all versions. Historical installation results and screenshots do not replace current native-host validation.

Limitations

  • Claude plan extraction has logic regression coverage but remains unverified in the current native pane. Codex plan updates have success/failure result coverage.
  • Main-agent context usage comes from the host. Unavailable subagent metrics are shown as unavailable; token totals reflect observed completed turns, including cache tokens.
  • Steps reflect the available transcript and progress-message grouping, rather than a dependency graph or task scheduler.
  • Claude installation and enablement have historical evidence only; the current code still requires validation, loading, and fresh-session auto-opening checks in a compatible host.
  • The Claude plugin does not aggregate cross-session history. Codex resolves explicitly bound conversations and descendants before the recent-session cap; unbound discovery is limited to 100 sessions; the API returns only that conversation and its descendants. Missing records show a waiting state without falling back to another conversation. Terminal fallbacks do not reproduce the native visual pane.
  • Codex usage reflects recorded cumulative tokens and the last request's input tokens. Last input size is not live context occupancy. Unknown fields are ignored; malformed lines are skipped with a notice.

The Codex rollout format is not a stable public API; compatibility is limited to tested local versions. This panel does not provide multi-user authentication or remote monitoring.

Contributing

Issues and pull requests are welcome. For rendering problems, include the host and engine version, reproduction steps, and a screenshot with sensitive information removed. Keep native UI behavior and transcript-derived status changes within the host API contract.

License

Licensed under the Apache License 2.0.


Created by Zafer_Liu.

Source 7 files
hooks/register.tsx 213 lines
1import { atom, read, update } from 'claude-code';
2import type { Register, EngineInterface } from 'claude-code';
3import { initial, mergeMembers } from './model';
4import { drawPanel } from './pane';
5import { conversationSteps, proseTitle, operationTitle, operationSubject, verification } from './steps';
6import { loadPreferences, paneTitle, t } from './i18n';
7
8const panel = atom({ plugin: 'super-sidebar', key: 'panel' } as const, initial);
9const pane = 'super-sidebar';
10const preferences = atom({ plugin: 'super-sidebar', key: 'preferences' } as const, { language: 'zh', theme: 'white', revision: 0, loaded: false } as import('../types').Preferences);
11const observe = async (action: () => Promise<unknown>) => {
12  try { await action(); } catch { /* Panel failures must never affect execution. */ }
13};
14
15// Runtime coordination uses session IDs; the host engine object cannot escape into collections.
16const refreshing = new Set<string>();
17const activeTools = new Map<string, Map<string, Map<symbol, string>>>();
18const refresh = async ($: EngineInterface) => {
19    const sessionId = await $.session.id();
20    if (refreshing.has(sessionId)) return;
21    refreshing.add(sessionId);
22    try {
23      const agents = await $.agent.list();
24      const messages = await $.session.messages();
25      const steps = conversationSteps(messages);
26      const cwd = await $.session.cwd();
27      const model = await $.session.model();
28      const usage = await $.session.usage();
29      if (await $.session.id() !== sessionId) return;
30      await update($, panel, p => p.sessionId !== sessionId ? p : ({ ...p, members: mergeMembers(p.members, agents, Date.now()),
31        tasks: p.liveTurnId ? p.tasks : steps, taskNotice: undefined,
32        project: cwd.split(/[\\/]/).filter(Boolean).pop() || cwd, mainModel: model,
33        mainContextTokens: usage.context.tokens, mainStartedAt: p.mainStartedAt ?? Date.now(),
34        error: '', updatedAt: Date.now() }));
35    } catch {
36      await observe(() => update($, panel, p => ({ ...p, error: '会话状态读取失败,保留上次观测。' })));
37    } finally { refreshing.delete(sessionId); }
38  };
39export const register: Register = on => {
40  on('session.start', async ($, e, next) => {
41    await observe(async () => {
42      const sessionId = await $.session.id();
43      await update($, panel, p => {
44        if (p.sessionId === sessionId) return p;
45        activeTools.delete(sessionId);
46        return { ...initial, members: [], tasks: [], sessionId };
47      });
48      await loadPreferences($);
49      const pref = await read($, preferences);
50      const language = pref.sessionId === sessionId ? pref.language : 'zh';
51      await $.command.register({ name: 'super-sidebar', description: t(language, 'command') });
52      await update($, panel, p => ({ ...p, project: e.cwd.split(/[\\/]/).filter(Boolean).pop() || e.cwd }));
53      await refresh($);
54      $.clock.every(2000, () => { void refresh($); });
55      await $.ui.open({ id: pane, title: paneTitle(language) });
56    });
57    return next(e);
58  });
59  on('command.run', { command: 'super-sidebar' }, async $ => {
60    await refresh($);
61    await loadPreferences($);
62    const sessionId = await $.session.id();
63    const pref = await read($, preferences);
64    const language = pref.sessionId === sessionId ? pref.language : 'zh';
65    const placed = await $.ui.open({ id: pane, title: paneTitle(language) });
66    return { text: t(language, placed.isPlaced ? 'opened' : 'unplaced') };
67  });
68  on('agent.spawn', async ($, e, next) => {
69    const sessionId = await $.session.id().catch(() => undefined);
70    const result = await next(e);
71    await observe(async () => {
72      await refresh($);
73      if (result.agentId) await update($, panel, p => p.sessionId !== sessionId ? p : ({ ...p, members: p.members.map(m => m.id === result.agentId
74        ? { ...m, model: result.model, mode: e.isTeammate ? '团队成员' : e.background ? '后台子代理' : '子代理', role: e.isTeammate ? 'teammate' : e.subagentType || m.type } : m) }));
75    });
76    return result;
77  });
78  on('turn.step', async function* ($, e, next) {
79    if (e.agentId) return yield* next(e);
80    const sessionId = await $.session.id();
81    const id = `response:${e.turnId}:${e.index}`;
82    let answer = '', lastPaint = 0;
83    let requested: Array<{ title: string; generated: boolean }> = [];
84    const paint = async (finished = false, hasTools = false, failed = false) => observe(async () => {
85      await update($, panel, p => {
86        if (p.sessionId !== sessionId || p.liveTurnId !== e.turnId) return p;
87        const previous = p.tasks.find(task => task.id === id);
88        const stage = { ...previous, id, kind: 'stage' as const, source: 'panel' as const,
89          subject: proseTitle(answer) || operationSubject(requested.length ? requested : previous?.operations ?? []),
90          titleGenerated: !answer.trim() && requested.every(label => label.generated),
91          summary: answer.slice(0, 2400),
92          status: failed ? 'failed' : finished && !hasTools ? 'completed' : 'in_progress',
93          operations: previous?.operations ?? [], operationCount: previous?.operationCount ?? 0,
94          verification: finished ? verification(answer) : { status: 'unknown' as const },
95        };
96        return { ...p, tasks: [...p.tasks.filter(task => task.id !== id), stage].slice(-30) };
97      });
98    });
99    await paint();
100    const stream = next(e);
101    try {
102      while (true) {
103        const item = await stream.next();
104        if (item.done) {
105          answer = item.value.answer;
106          requested = item.value.toolUses.map(use => operationTitle({ tool: use.name, tool_use_id: '',
107            input: use.input && typeof use.input === 'object' ? use.input as Record<string, unknown> : {} }));
108          await paint(true, item.value.toolUses.length > 0);
109          return item.value;
110        }
111        // Forward the stream unchanged; only visible text is observed.
112        const chunk = item.value;
113        yield chunk;
114        if (chunk.kind === 'text') {
115          answer = (answer + chunk.text).slice(0, 12000);
116          if (Date.now() - lastPaint >= 250) { lastPaint = Date.now(); await paint(); }
117        }
118      }
119    } catch (error) {
120      await paint(true, false, true);
121      throw error;
122    } finally { await stream.return(undefined as never); }
123  });
124  on('turn.start', async ($, e, next) => {
125    await observe(async () => {
126      await update($, panel, p => e.agentId ? ({ ...p, members: p.members.map(m => m.id === e.agentId
127        ? { ...m, activeStartedAt: Date.now(), currentDurationMs: undefined } : m) })
128        : ({ ...p, mainStatus: 'running', mainActiveStartedAt: Date.now(), mainCurrentDurationMs: undefined, liveTurnId: e.turnId, tasks: [] }));
129      await refresh($);
130    });
131    return next(e);
132  });
133  on('turn.complete', async ($, e, next) => {
134    await observe(async () => {
135      const sessionId = await $.session.id();
136      await refresh($);
137      const status = e.reason === 'error' || e.reason === 'refusal' ? 'failed' : e.isAborted ? 'killed' : 'completed';
138      const recent = status === 'failed' ? '执行失败' : status === 'killed' ? '已停止' : '已完成';
139      const tokens = e.usage ? e.usage.input_tokens + e.usage.output_tokens + e.usage.cache_read_input_tokens + e.usage.cache_creation_input_tokens : undefined;
140      await update($, panel, p => p.sessionId !== sessionId ? p : e.agentId ? ({
141        ...p, members: p.members.map(m => m.id === e.agentId ? {
142          ...m, status: m.type === 'teammate' && status === 'completed' ? 'idle' : status,
143          recent, tool: undefined, endedAt: Date.now(), activeStartedAt: undefined, currentDurationMs: e.durationMs,
144          tokens: tokens === undefined ? m.tokens : (m.tokens ?? 0) + tokens,
145          durationMs: (m.durationMs ?? 0) + e.durationMs,
146        } : m),
147      }) : ({ ...p, mainStatus: status, mainRecent: recent, mainTool: undefined, tasks: p.tasks.map(task => task.status !== 'in_progress' ? task : ({ ...task, status: status === 'completed' ? 'unknown' : status })), mainActiveStartedAt: undefined, mainCurrentDurationMs: e.durationMs,
148        mainTokens: tokens === undefined ? p.mainTokens : (p.mainTokens ?? 0) + tokens,
149        mainDurationMs: (p.mainDurationMs ?? 0) + e.durationMs }));
150    });
151    return next(e);
152  });
153  on('tool.call', async ($, e, next) => {
154    const sessionId = await $.session.id().catch(() => undefined);
155    if (!sessionId) return next(e);
156    const name = String(e.tool), agentId = e.agentId;
157    const args = e as unknown as Record<string, unknown>;
158    const activity = typeof args.description === 'string' ? args.description : `正在执行 ${name}`;
159    const scope = agentId || 'main', invocation = Symbol(name);
160    const engineTools = activeTools.get(sessionId) ?? new Map<string, Map<symbol, string>>();
161    const agentTools = engineTools.get(scope) ?? new Map<symbol, string>();
162    activeTools.set(sessionId, engineTools); engineTools.set(scope, agentTools);
163    agentTools.set(invocation, activity);
164    let stageId = '';
165    if (!agentId) await observe(async () => {
166      const input = { ...args };
167      const label = operationTitle({ tool: name, tool_use_id: e.tool_use_id, input });
168      await update($, panel, p => {
169        if (p.sessionId !== sessionId) return p;
170        const stage = p.tasks.at(-1);
171        if (!stage || !p.liveTurnId) return p;
172        stageId = stage.id;
173        const operations = [...(stage.operations ?? []).filter(op => op.id !== e.tool_use_id),
174          { id: e.tool_use_id, title: label.title, status: 'in_progress' }];
175        return { ...p, tasks: p.tasks.map(task => task.id !== stageId ? task : { ...task,
176          subject: task.summary?.trim() ? task.subject : operationSubject(operations),
177          titleGenerated: task.summary?.trim() ? task.titleGenerated : task.titleGenerated !== false && label.generated,
178          status: 'in_progress', operations: operations.slice(0, 40), operationCount: (task.operationCount ?? 0) + 1 }) };
179      });
180    });
181    // Observation failures must never block or replace the user's tool execution.
182    try {
183      await update($, panel, p => p.sessionId !== sessionId ? p : agentId ? ({ ...p, members: p.members.map(m => m.id === agentId
184        ? { ...m, tool: activity, calls: m.calls + 1 } : m) }) : ({ ...p, mainRecent: `正在执行 ${name}`, mainTool: activity, mainCalls: (p.mainCalls ?? 0) + 1 }));
185    } catch { /* Keep the tool path independent of the panel. */ }
186    let toolStatus = 'failed';
187    try {
188      const result = await next(e);
189      toolStatus = result.isError || 'deny' in result ? 'failed' : 'completed';
190      return result;
191    } finally {
192      if (stageId) await observe(async () => {
193        await update($, panel, p => p.sessionId !== sessionId ? p : { ...p, tasks: p.tasks.map(task => {
194          if (task.id !== stageId) return task;
195          const operations = (task.operations ?? []).map(op => op.id === e.tool_use_id ? { ...op, status: toolStatus } : op);
196          return { ...task, operations, status: operations.some(op => op.status === 'failed') ? 'failed'
197            : operations.some(op => op.status === 'in_progress') ? 'in_progress' : 'completed' };
198        }) });
199      });
200      agentTools.delete(invocation);
201      const latest = [...agentTools.values()].at(-1);
202      if (!agentTools.size) engineTools.delete(scope);
203      if (!engineTools.size && activeTools.get(sessionId) === engineTools) activeTools.delete(sessionId);
204      try {
205      await update($, panel, p => p.sessionId !== sessionId ? p : agentId ? ({ ...p, members: p.members.map(m => m.id === agentId
206        ? { ...m, tool: latest } : m) }) : ({ ...p, mainTool: latest }));
207      if (await $.session.id() === sessionId) await refresh($);
208      } catch { /* Display failures must never replace a tool outcome. */ }
209    }
210  });
211  on('ui.render', { component: 'Pane', requestId: pane }, ($, e) => drawPanel($, e, () => refresh($)));
212};
213
hooks/model.ts 132 lines
1import type { Member, Panel, Task } from '../types';
2import { linkPath } from './connector';
3
4export const initial: Panel = {
5  members: [], tasks: [], selected: 'main', filter: 'all', view: 'tree',
6  mainStatus: 'idle', mainRecent: '等待活动', project: '', error: '', updatedAt: 0,
7};
8export const label = (status: string) => ({
9  running: '运行中', pending: '待开始', waiting: '等待中', idle: '空闲',
10  completed: '已完成', failed: '失败', killed: '已停止', in_progress: '进行中',
11}[status] ?? status);
12export const color = (status: string) => ({
13  running: '#26b47c', in_progress: '#26b47c', failed: '#e16d78',
14  killed: '#b48a56', completed: '#6386ec', waiting: '#b48a56',
15}[status] ?? '#8994a5');
16export const text = (value: unknown, limit = 160): string =>
17  typeof value === 'string' ? value.replace(/[\r\n\t]+/g, ' ').slice(0, limit) : '';
18export const record = (value: unknown): Record<string, unknown> =>
19  value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
20
21export function mergeMembers(old: Member[], live: Array<{
22  id: string; name?: string; description: string; type: string; status: string; parentId?: string;
23}>, now: number): Member[] {
24  const map = new Map(old.map(m => [m.id, m]));
25  for (const agent of live) {
26    const previous = map.get(agent.id);
27    // Display name: task description first (e.g. "Implement contract data core"),
28    // then the agent's addressable name, then its type. A bare type like
29    // "general-purpose" is identical across agents and useless as a title.
30    const display = agent.description?.trim() || agent.name || agent.type;
31    map.set(agent.id, {
32      ...previous, id: agent.id, name: display,
33      description: agent.description, type: agent.type, parentId: agent.parentId,
34      status: agent.status, observedAt: previous?.observedAt ?? now,
35      calls: previous?.calls ?? 0, recent: previous?.recent ?? '',
36      endedAt: ['completed', 'failed', 'killed'].includes(agent.status)
37        ? previous?.endedAt ?? now : undefined,
38    });
39  }
40  // Missing from a later roster is not proof of completion. Keep the last observation.
41  return [...map.values()].slice(-200);
42}
43
44export function collectTasks(existing: Task[], tool: string, input: unknown, output: unknown): Task[] {
45  const args = record(input);
46  const map = new Map(existing.map(t => [t.id, t]));
47
48  const result = record(output);
49  if (tool === 'TaskList' && Array.isArray(result.tasks)) {
50    return [...existing.filter(t => t.source === 'panel'), ...result.tasks.flatMap(raw => {
51      const t = record(raw);
52      return typeof t.id === 'string' && typeof t.subject === 'string'
53        ? [{ id: t.id, subject: t.subject, status: text(t.status) || 'pending', owner: text(t.owner) || undefined,
54          blockedBy: Array.isArray(t.blockedBy) ? t.blockedBy.filter((id): id is string => typeof id === 'string') : [] }] : [];
55    })];
56  }
57  if (tool === 'TaskCreate') {
58    const t = record(result.task);
59    if (typeof t.id === 'string') map.set(t.id, {
60      id: t.id, subject: text(t.subject || args.subject), status: 'pending',
61      summary: text(t.summary || args.summary || args.description),
62    });
63  }
64  if (tool === 'TaskUpdate' && result.success === true && typeof args.taskId === 'string') {
65    const previous = map.get(args.taskId);
66    if (args.status === 'deleted') map.delete(args.taskId);
67    else map.set(args.taskId, {
68      id: args.taskId, subject: text(args.subject) || previous?.subject || `任务 ${args.taskId}`,
69      status: text(args.status) || previous?.status || 'pending',
70      owner: typeof args.owner === 'string' ? args.owner : previous?.owner,
71      summary: text(args.summary) || previous?.summary,
72      blockedBy: [...new Set([...(previous?.blockedBy ?? []),
73        ...(Array.isArray(args.addBlockedBy) ? args.addBlockedBy.filter((id): id is string => typeof id === 'string') : [])])],
74    });
75  }
76  if (tool === 'TodoWrite' && Array.isArray(result.newTodos)) {
77    for (const id of map.keys()) if (id.startsWith('todo:')) map.delete(id);
78    result.newTodos.forEach((raw, i) => {
79      const t = record(raw);
80      map.set(`todo:${i}`, { id: `todo:${i}`, subject: text(t.content), status: text(t.status) });
81    });
82  }
83  return [...map.values()].slice(-300);
84}
85
86const escape = (s: string) => s.replace(/[&<>"']/g, c => ({
87  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
88}[c]!));
89const short = (s: string, n: number) => s.length > n ? s.slice(0, n - 1) + '…' : s;
90
91// Compact depth-first tree: stable sibling order, explicit cycle and orphan handling.
92export function treeRows(members: Member[]): Array<{ member: Member; depth: number }> {
93  const ids = new Set(members.map(m => m.id));
94  const rows: Array<{ member: Member; depth: number }> = [];
95  const seen = new Set<string>();
96  const walk = (m: Member, depth: number) => {
97    if (seen.has(m.id)) return;
98    seen.add(m.id); rows.push({ member: m, depth });
99    for (const child of members) if (child.parentId === m.id) walk(child, depth + 1);
100  };
101  for (const m of members) if (!m.parentId || !ids.has(m.parentId)) walk(m, 1);
102  for (const m of members) if (!seen.has(m.id)) walk(m, 1);
103  return rows;
104}
105
106export function diagram(panel: Panel): string {
107  const rows = treeRows(panel.members);
108  const width = 520, height = 142 + rows.length * 122;
109  const positions = new Map<string, { x: number; y: number }>([['main', { x: 20, y: 16 }]]);
110  rows.forEach(({ member, depth }, i) => positions.set(member.id, { x: 20 + Math.min(depth, 5) * 22, y: 142 + i * 122 }));
111  const cards = [{ id: 'main', name: '主代理', description: panel.project || '当前会话',
112    status: panel.mainStatus, recent: panel.mainRecent, type: 'lead', calls: 0, observedAt: 0 }, ...rows.map(r => r.member)];
113  const lines = rows.map(({ member }) => {
114    const at = positions.get(member.id)!;
115    const parent = positions.get(member.parentId || 'main') || positions.get('main')!;
116    return `<path d="M${parent.x + 10} ${parent.y + 106} V${at.y - 12} ${linkPath(parent.x + 10, at.y - 12, at.x, at.y + 52)}" fill="none" stroke="#7890c2" stroke-opacity=".55" stroke-width="1.5" stroke-dasharray="4 5"/>`;
117  }).join('');
118  const nodes = cards.map(m => {
119    const p = positions.get(m.id)!, w = width - p.x - 18;
120    const selected = panel.selected === m.id;
121    return `<g><title>${escape(m.description + ' — ' + label(m.status))}</title>
122      <rect x="${p.x}" y="${p.y}" width="${w}" height="106" rx="12" fill="${selected ? '#edf3ff' : '#f7f9fd'}" stroke="${selected ? '#5680ec' : '#cbd5e6'}" stroke-width="${selected ? 2 : 1}"/>
123      <circle cx="${p.x + 18}" cy="${p.y + 23}" r="4" fill="${color(m.status)}"/>
124      <text x="${p.x + 30}" y="${p.y + 28}" font-size="15" font-weight="600" fill="#24334e">${escape(short(m.name, 30))}</text>
125      <text x="${p.x + w - 16}" y="${p.y + 28}" text-anchor="end" font-size="12" fill="${color(m.status)}">${escape(label(m.status))}</text>
126      <text x="${p.x + 16}" y="${p.y + 56}" font-size="13" fill="#41516b">${escape(short(m.description, 29))}</text>
127      <path d="M${p.x} ${p.y + 73} H${p.x + w}" stroke="#dce3f0"/>
128      <text x="${p.x + 16}" y="${p.y + 94}" font-size="12" fill="#71809a">${escape(short(m.tool || m.recent || '暂无活动记录', 35))}</text></g>`;
129  }).join('');
130  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img"><style>text{font-family:system-ui,"Microsoft YaHei",sans-serif}</style>${lines}${nodes}</svg>`;
131}
132
hooks/pane.tsx 226 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, RenderInput } from 'claude-code';
3import { initial, treeRows } from './model';
4import { summaryBody } from './steps';
5import type { Theme } from '../types';
6import { activityLabel, generatedLabel, operationLabel, statusLabel, taskStatusLabel, verificationLabel, switchLanguage, switchTheme, t, taskTitle } from './i18n';
7
8const panel = atom({ plugin: 'super-sidebar', key: 'panel' } as const, initial);
9const preferences = atom({ plugin: 'super-sidebar', key: 'preferences' } as const, { language: 'zh', theme: 'white', revision: 0, loaded: false } as import('../types').Preferences);
10const palettes = {
11  white: { background: '#ffffff', ink: '#292d35', muted: '#606570', border: '#b5bac3', connector: '#767c88',
12    running: '#23733e', completed: '#255fbb', failed: '#b42332', waiting: '#805800' },
13  beige: { background: '#f5efe3', ink: '#342f28', muted: '#696052', border: '#b7ac98', connector: '#81745f',
14    running: '#326b37', completed: '#305da3', failed: '#a82b32', waiting: '#795600' },
15  black: { background: '#191a1d', ink: '#e6e6e1', muted: '#aaaab2', border: '#575961', connector: '#9597a2',
16    running: '#7bd99a', completed: '#8bb9ff', failed: '#ff939d', waiting: '#eac772' },
17} as const;
18const themes: Theme[] = ['white', 'beige', 'black'];
19const statusColor = (status: string, palette: typeof palettes[Theme]) => {
20  if (status === 'running' || status === 'in_progress') return palette.running;
21  if (status === 'completed') return palette.completed;
22  if (status === 'failed') return palette.failed;
23  if (status === 'waiting' || status === 'pending' || status === 'killed') return palette.waiting;
24  return palette.ink;
25};
26const elbowArrow = (color: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="48" height="28" viewBox="0 0 48 28"><path d="M8 0 V12 H36 V25 M32 21 L36 25 L40 21" fill="none" stroke="${color}" stroke-width="1"/></svg>`;
27const stepArrow = (color: string) => `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12 0 V21 M8 17 L12 21 L16 17" fill="none" stroke="${color}" stroke-width="1"/></svg>`;
28const preview = (value: string) => {
29  const compact = value.replace(/\s+/g, ' ').trim();
30  return compact.length > 180 ? `${compact.slice(0, 179)}…` : compact;
31};
32const marker = (status: string) => status === 'completed' ? '✓' : status === 'failed' ? '×'
33  : status === 'running' || status === 'in_progress' ? '●' : '○';
34
35// A short content hash of the loaded modules, so a hot reload that changes the
36// code is visible as a different revision. Read once per module load.
37async function implementationRevision($: EngineInterface): Promise<string> {
38  const files = ['hooks/pane.tsx', 'hooks/steps.ts', 'hooks/i18n.ts', 'hooks/register.tsx', 'hooks/model.ts'];
39  let hash = 0;
40  for (const file of files) {
41    const text = await $.fs.read(`${$.plugin.root}/${file}`).catch(() => '');
42    for (let i = 0; i < text.length; i++) hash = (Math.imul(hash, 31) + text.charCodeAt(i)) | 0;
43  }
44  return (hash >>> 0).toString(36);
45}
46async function pluginVersion($: EngineInterface): Promise<string> {
47  const raw = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '');
48  const match = /"version"\s*:\s*"([^"]+)"/.exec(raw);
49  return match?.[1] ?? '';
50}
51
52export async function drawPanel($: EngineInterface, e: RenderInput, refresh: () => Promise<void>) {
53  const { Box, Text, Button, Markdown } = $.ui.resolve(e);
54  // MarkdownProps exposes no color/style; rich text keeps the native host renderer.
55  const Svg = e.surface === 'desktop' ? $.ui.resolve(e).Svg : undefined;
56  const p = await read($, panel);
57  const pref = await read($, preferences);
58  const sessionId = await $.session.id();
59  const lang = pref.sessionId === sessionId ? pref.language : 'zh';
60  const theme = pref.sessionId === sessionId && (pref.theme === 'beige' || pref.theme === 'black') ? pref.theme : 'white';
61  const palette = palettes[theme];
62  const buttonVariant = theme === 'black' ? 'primary' as const : 'secondary' as const;
63  const tr = (key: Parameters<typeof t>[1]) => t(lang, key);
64  // Provenance distinguishes generated operation titles from supplied prose.
65  const messages = await $.session.messages().catch(() => []);
66  const version = await pluginVersion($);
67  const revision = await implementationRevision($);
68  if (p.sessionId !== sessionId || await $.session.id() !== sessionId) return <Box />;
69  const toggle = async (id: string) => {
70    if (await $.session.id() !== sessionId) return;
71    await update($, panel, current => current.sessionId !== sessionId ? current
72      : { ...current, selected: current.selected === id ? '' : id });
73  };
74  const rows = [{ member: {
75    id: 'main', name: tr('main'), description: p.project, status: p.mainStatus,
76    mode: '主会话', role: 'lead · 协调者', model: p.mainModel, tokens: p.mainTokens,
77    observedAt: p.mainStartedAt ?? Date.now(), recent: p.mainRecent, calls: p.mainCalls ?? 0,
78    activeStartedAt: p.mainActiveStartedAt, currentDurationMs: p.mainCurrentDurationMs,
79    endedAt: undefined as number | undefined, durationMs: p.mainDurationMs, tool: p.mainTool,
80  }, depth: 0 }, ...treeRows(p.members)];
81  // Plan identity is explicit; ordinary prose never promotes a stage to a plan.
82  const plans = p.tasks.filter(task => task.kind === 'plan');
83  const planIds = new Set(plans.map(task => task.id));
84  const tasks = p.tasks.filter(task => !planIds.has(task.id) && Boolean(task.subject.trim()));
85  const running = rows.filter(({ member }) => member.status === 'running').length;
86  return <Box flexDirection="column" width="100%" gap="lg" padding="sm">
87    <Box flexDirection="column" gap="sm">
88      <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm" flexWrap="wrap">
89        <Text bold>{tr('agents')} · {rows.length}</Text>
90        <Box flexDirection="row" gap="xs" alignItems="center">
91          <Button key="refresh" label={tr('refresh')} variant={buttonVariant} onPress={refresh} />
92        </Box>
93      </Box>
94      <Text dimColor>{running} {statusLabel(lang, 'running')} · {tr('observedOnly')}</Text>
95      {p.error && <Text color={palette.waiting}>{generatedLabel(lang, p.error)}</Text>}
96      {rows.map(({ member: m, depth }) => {
97        const expanded = p.selected === m.id;
98        const active = m.status === 'running';
99        const terminal = ['completed', 'failed', 'killed'].includes(m.status);
100        const elapsed = active ? Date.now() - (m.activeStartedAt ?? m.observedAt)
101          : (m.currentDurationMs ?? m.durationMs ?? ((m.endedAt ?? m.observedAt) - m.observedAt));
102        const seconds = Math.max(0, Math.floor(elapsed / 1000));
103        const parent = rows.find(row => row.member.id === ('parentId' in m ? m.parentId : undefined))?.member.name || tr('main');
104        const activity = m.tool ? activityLabel(lang, m.tool, messages)
105          : active ? tr('working') : generatedLabel(lang, m.recent) || tr('noActivity');
106        return <Box key={`branch:${m.id}`} flexDirection="column" gap={0} marginLeft={Math.min(depth, 4) * 2} minWidth={0}>
107          {depth > 0 && <Box flexDirection="row" gap="xs" alignItems="center">
108            {Svg ? <Svg source={elbowArrow('#818791')} alt={tr('parent')} width={48} height={28} />
109              : <Text dimColor>└─↓</Text>}
110          </Box>}
111          <Box key={`card:${m.id}`} flexShrink={1} minWidth={0} flexDirection="column" gap="sm"
112            padding="sm" borderStyle="single" borderColor={palette.border} backgroundColor={palette.background}>
113            <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
114              <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} gap="xs">
115                <Text color={palette.ink} bold>{m.name}</Text>
116                <Text color={statusColor(m.status, palette)}>{marker(m.status)} {statusLabel(lang, m.status)}</Text>
117              </Box>
118              <Button key={`expand:${m.id}`} variant={buttonVariant} label={expanded ? tr('collapse') : tr('expand')} onPress={() => toggle(m.id)} />
119            </Box>
120            <Text color={palette.muted}>{tr('elapsed')} {seconds} {tr('seconds')} · {m.calls ?? 0} {tr('calls')}</Text>
121            {!terminal && <Text color={palette.muted}>{tr('activity')}: {activity}</Text>}
122            {expanded && m.description && <Text color={palette.ink}>{m.description}</Text>}
123            {expanded && <Box key={`details:${m.id}`} flexDirection="column" gap="xs">
124              {depth > 0 && <Text color={palette.muted}>{tr('parent')}: {parent}</Text>}
125              <Text color={palette.ink}>{tr('mode')}: {generatedLabel(lang, m.mode || '子代理(加载前启动)')}</Text>
126              <Text color={palette.ink}>{tr('role')}: {generatedLabel(lang, m.role || ('type' in m ? m.type : 'lead'))}</Text>
127              <Text color={palette.ink}>{tr('model')}: {m.model || tr('unavailable')}</Text>
128              <Text color={palette.ink}>{tr('tokens')}: {m.tokens === undefined ? tr('tokenUnavailable') : `${m.tokens.toLocaleString()} · ${tr('tokenCumulative')}`}</Text>
129              <Text color={palette.ink}>{tr('context')}: {m.id === 'main'
130                ? (p.mainContextTokens !== undefined ? `${p.mainContextTokens.toLocaleString()} tokens` : tr('contextUnavailable'))
131                : tr('contextUnavailable')}</Text>
132            </Box>}
133          </Box>
134        </Box>;
135      })}
136    </Box>
137    <Box flexDirection="column" gap="sm">
138      <Text bold>{tr('tasks')} · {tasks.filter(task => task.status === 'completed').length}/{tasks.length}{p.mainStatus === 'running' ? ` · ${statusLabel(lang, 'in_progress')}` : ''}</Text>
139      <Text dimColor>{tr('stepOrder')}</Text>
140      {plans.map(plan => {
141        const expanded = p.selected === `plan:${plan.id}`;
142        const body = plan.summary === t('zh', 'noPlan') ? tr('noPlan') : plan.summary;
143        return <Box key={`plan:${plan.id}`} flexDirection="column" gap="sm" padding="sm" borderStyle="single" borderColor={palette.border} backgroundColor={palette.background} minWidth={0}>
144          <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
145            <Box flexDirection="column" gap="xs" flexShrink={1} minWidth={0}>
146              <Text color={palette.ink} bold>{tr('plan')}</Text>
147              <Text color={statusColor(plan.status, palette)}>{taskStatusLabel(lang, plan.status)}</Text>
148            </Box>
149            <Button key={`expand-plan:${plan.id}`} variant={buttonVariant} label={expanded ? tr('collapse') : tr('expand')}
150              onPress={() => toggle(`plan:${plan.id}`)} />
151          </Box>
152          {expanded && body && <Markdown text={body} />}
153          {expanded && (plan.operations?.length ?? 0) > 0 && <Box flexDirection="column" gap="xs">
154            <Text color={palette.muted}>{plan.operationCount ?? plan.operations?.length} {tr('operations')}</Text>
155            {plan.operations!.slice(0, 40).map(operation => <Text key={operation.id} color={palette.ink}>
156              {operationLabel(lang, operation, messages)} · {taskStatusLabel(lang, operation.status)}
157            </Text>)}
158          </Box>}
159        </Box>;
160      })}
161      {tasks.length === 0 ? (p.mainStatus !== 'running' && <Text dimColor>{tr('emptyTasks')}</Text>) : tasks.slice(0, 30).map((task, index) => {
162        const expanded = p.selected === `task:${task.id}`;
163        const summary = summaryBody(task.summary ?? '');
164        return <Box key={`task-flow:${task.id}`} flexDirection="column" gap={0} minWidth={0}>
165          {index > 0 && <Box paddingLeft="sm">
166            {Svg ? <Svg source={stepArrow('#818791')} alt={tr('stepOrder')} width={24} height={24} />
167              : <Text dimColor>↓</Text>}
168          </Box>}
169          <Box key={`task:${task.id}`} flexDirection="column" padding="sm" gap="sm" borderStyle="single" borderColor={palette.border} backgroundColor={palette.background} minWidth={0}>
170            <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
171              <Box flexDirection="column" flexGrow={1} flexShrink={1} minWidth={0} gap="xs">
172                <Text color={palette.ink} bold>{index + 1}. {taskTitle(lang, task, messages)}</Text>
173                <Text color={statusColor(task.status, palette)}>{marker(task.status)} {task.status === 'completed' && !task.operationCount ? tr('replyComplete') : taskStatusLabel(lang, task.status)}</Text>
174              </Box>
175              {((task.operations?.length ?? 0) > 0 || Boolean(task.summary && task.summary.trim().length > 180) || Boolean(task.verification?.evidence)) && <Button key={`expand-task:${task.id}`} variant={buttonVariant} label={expanded ? tr('collapse') : tr('expand')}
176                onPress={() => toggle(`task:${task.id}`)} />}
177            </Box>
178            {(expanded ? task.summary : summary) && (expanded ? <Markdown text={task.summary!} /> : <Text color={palette.muted}>{preview(summary)}</Text>)}
179            {(task.operations?.length ?? 0) > 0 && <Box flexDirection="column" gap="xs">
180              {task.operations!.slice(0, expanded ? 40 : 3).map(operation => <Text key={operation.id} color={statusColor(operation.status, palette)}>
181                {marker(operation.status)} {operationLabel(lang, operation, messages)} · {taskStatusLabel(lang, operation.status)}
182              </Text>)}
183              {(task.operationCount ?? 0) > (expanded ? 40 : 3) && <Text color={palette.muted}>{task.operationCount} {tr('operations')}</Text>}
184            </Box>}
185            {task.kind === 'stage' && (expanded || task.verification?.status && task.verification.status !== 'unknown') && <Text color={palette.muted}>
186              {task.verification?.status && task.verification.status !== 'unknown' ? `${tr('verification')}: ` : ''}{verificationLabel(lang, task.verification?.status ?? 'unknown')}
187              {task.verification?.source === 'assistant' ? ` · ${tr('assistantReported')}` : ''}
188            </Text>}
189            {expanded && task.verification?.evidence && <Text color={palette.muted}>{task.verification.evidence}</Text>}
190
191          </Box>
192        </Box>;
193      })}
194    </Box>
195    <Box flexDirection="column" gap="sm" borderStyle="single" borderColor={palette.border} backgroundColor={palette.background} padding="sm">
196      <Text color={palette.ink} bold>{tr('preferences')}</Text>
197      <Box flexDirection="row" gap="sm" alignItems="center" flexWrap="wrap">
198        <Text color={palette.ink}>{tr('language')}</Text>
199        <Button key="language" variant={buttonVariant} label={lang === 'zh' ? 'EN' : '中文'}
200          onPress={() => switchLanguage($, sessionId, lang === 'zh' ? 'en' : 'zh')} />
201      </Box>
202      <Box flexDirection="row" gap="xs" alignItems="center" flexWrap="wrap">
203        <Text color={palette.ink}>{tr('theme')}</Text>
204        {themes.map(value => {
205          const selected = value === theme;
206          const label = `${tr(value)}${selected ? ` · ${tr('selected')}` : ''}`;
207          return <Button key={`theme:${value}`} label={label} variant={selected ? 'primary' : buttonVariant}
208            onPress={() => switchTheme($, sessionId, value)} />;
209        })}
210      </Box>
211      <Box flexDirection="column" gap="xs">
212        <Box flexDirection="row" justifyContent="space-between" alignItems="center" gap="sm">
213          <Text color={palette.muted}>{tr('version')}</Text>
214          <Button key="expand-version" variant={buttonVariant}
215            label={p.selected === 'version' ? tr('collapse') : tr('expand')}
216            onPress={() => toggle('version')} />
217        </Box>
218        {p.selected === 'version' && <Box flexDirection="column" gap="xs">
219          <Text color={palette.ink}>{tr('pluginVersion')}: {version || tr('unavailable')}</Text>
220          <Text color={palette.ink}>{tr('revision')}: {revision}</Text>
221        </Box>}
222      </Box>
223    </Box>
224  </Box>;
225}
226
hooks/steps.ts 103 lines
1import type { SessionMessage, ToolUseSummary } from 'claude-code';
2import type { Task } from '../types';
3
4const bounded = (value: string, limit = 60) => {
5  const points = Array.from(value);
6  return points.length > limit ? points.slice(0, limit - 1).join('') + '…' : value;
7};
8const plain = (value: string) => value.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
9  .replace(/^[\s#>*\-]+/gm, '').replace(/[*_`]/g, '').replace(/\s+/g, ' ').trim();
10const stripIntent = (value: string) => value.replace(/^(?:(?:I\s+(?:will|shall)|I['’]ll|I['’]m\s+going\s+to)\s+|我(?:会|将|打算|准备)\s*)/i, '');
11export function proseTitle(prose: string): string {
12  const value = stripIntent(plain(prose));
13  return bounded(value.split(/[。!?!?]|\.(?=\s|$)/)[0]?.split(/[,,;;]/)[0]?.trim() || '', 42);
14}
15const toolName = (use: ToolUseSummary) => use.tool.replace(/^.*__/, '');
16export function operationTitle(use: ToolUseSummary): { title: string; generated: boolean } {
17  const supplied = [use.input.description, use.input.title, use.input.subject]
18    .find(value => typeof value === 'string' && value.trim());
19  if (typeof supplied === 'string') return { title: proseTitle(supplied), generated: false };
20  const names: Record<string, string> = {
21    Read: '读取文件', Edit: '修改文件', Write: '写入文件', Grep: '搜索代码', Glob: '查找文件',
22    Bash: '执行命令', Agent: '执行子代理任务', EnterPlanMode: '制定工作计划', ExitPlanMode: '提交工作计划',
23    Snapshot: '检查界面显示', Screenshot: '检查界面显示', PowerShell: '执行系统操作',
24    WebFetch: '读取网页', WebSearch: '搜索资料', Skill: '应用技能',
25    ReadMcpResourceTool: '读取资源', SendMessage: '跟进代理任务',
26    preview_inspect: '检查界面样式', preview_console_logs: '检查运行日志',
27    preview_logs: '检查服务日志', preview_network: '检查网络请求',
28    get_layout: '检查侧栏布局', get_window_layout: '检查窗口布局',
29    Click: '操作界面控件', click: '操作界面控件', mouse_click: '操作界面控件',
30    preview_click: '操作界面控件', browser_click: '操作界面控件',
31    Scroll: '查看界面内容', mouse_scroll: '查看界面内容',
32    Windows: '检查或调整窗口', Type: '填写界面内容',
33    preview_snapshot: '检查界面内容', browser_snapshot: '检查界面内容',
34    preview_screenshot: '检查界面显示', screenshot: '检查界面显示',
35  };
36  // Never surface commands, results, or arbitrary tool identifiers as titles.
37  const target = use.input.file_path || use.input.path || (toolName(use) === 'Skill' ? use.input.skill : undefined);
38  const file = typeof target === 'string' ? target.split(/[\\/]/).filter(Boolean).pop() : '';
39  return { title: bounded(`${names[toolName(use)] || '处理工作步骤'}${file ? `:${file}` : ''}`), generated: true };
40}
41export function operationSubject(operations: Array<{ title: string }>): string {
42  const titles = [...new Set(operations.map(operation => operation.title).filter(Boolean))];
43  return titles.slice(0, 2).join(' · ');
44}
45export function summaryBody(prose: string): string {
46  const text = prose.trim();
47  const end = /[。!?!?]|\.(?=\s|$)/.exec(text);
48  const first = end ? text.slice(0, end.index + end[0].length) : text;
49  // Keep a substantive first sentence; omit it only when the title carries it all.
50  return plain(first).replace(/[。!?!?.]$/, '') === proseTitle(text)
51    ? text.slice(first.length).trim() : text;
52}
53const shortResult = (prose: string) => Array.from(prose).length <= 240
54  && !/[\n]\s*(?:[-*#]|\d+[.)])|\b(summary|overall|recap|all tasks)\b|总结|综上|全部|所有/i.test(prose)
55  && prose.split(/[。!?!?]|\.(?=\s|$)/).filter(value => value.trim()).length <= 2
56  && /\b(passed|failed|skipped|completed|finished|updated|fixed|found|confirmed|verified)\b|已(?:完成|修改|更新|修复|确认|找到)|通过|失败|跳过/i.test(prose);
57export function verification(prose: string): NonNullable<Task['verification']> {
58  const unknown = { status: 'unknown' as const };
59  if (!shortResult(prose) || Array.from(prose).length > 200
60    || !/\b(test(?:s|ing)?|checks?|build|lint|typecheck|validation|verification)\b|验证|检查|测试|构建/i.test(prose)
61    || /\b(?:will|shall|would|should|could|may|might|plan|not|never|no|without|but|however|and|or)\b|未(?:验证|测试|检查|运行|完成|通过|成功)|没有|尚未|不能|无法|不会|不(?:确定|清楚|一致|通过|成功)|将|准备|计划|但是|但|可能|如果/i.test(prose)) return unknown;
62  const passed = /\b(passed|passing|succeeded)\b|通过|验证成功|检查成功|测试成功|构建成功/i.test(prose);
63  const failed = /\b(failed|failing)\b|失败/i.test(prose);
64  const skipped = /\b(skipped)\b|跳过/i.test(prose);
65  if (Number(passed) + Number(failed) + Number(skipped) !== 1) return unknown;
66  return { status: passed ? 'passed' : failed ? 'failed' : 'skipped', evidence: prose, source: 'assistant' };
67}
68// Transcript fallback; live responses use turn.step IDs in register.tsx.
69export function conversationSteps(messages: SessionMessage[]): Task[] {
70  const steps: Task[] = [];
71  let planText = '';
72  for (const [index, message] of messages.entries()) {
73    if (message.role === 'user') {
74      if (!message.toolResults?.length && message.text.trim()) { steps.length = 0; planText = ''; }
75      continue;
76    }
77    const prose = message.text.trim();
78    const uses = message.toolUses ?? [];
79    if (!prose && !uses.length) continue;
80    const planUse = uses.find(use => toolName(use) === 'ExitPlanMode');
81    const labels = uses.map(operationTitle);
82    const fallback = { title: operationSubject(labels), generated: labels.every(label => label.generated) };
83    const operations = uses.map((use, i) => ({
84      id: use.tool_use_id || `${index}:${i}`, title: operationTitle(use).title,
85      status: use.isError ? 'failed' : use.text === undefined && use.result === undefined ? 'in_progress' : 'completed',
86    }));
87    for (const use of uses) {
88      if (toolName(use) === 'Write' && typeof use.input.file_path === 'string'
89        && /[\\/]plans[\\/].*\.md$/.test(use.input.file_path) && typeof use.input.content === 'string' && !use.isError) planText = use.input.content;
90    }
91    steps.push({ id: `conversation:${index}`, kind: planUse ? 'plan' : 'stage',
92      subject: planUse ? '工作计划 · Plan' : proseTitle(prose) || fallback.title,
93      titleGenerated: Boolean(planUse) || (!prose && fallback.generated),
94      summary: planUse ? (typeof planUse.input.plan === 'string' ? planUse.input.plan : planText || prose) : prose.slice(0, 2400),
95      status: operations.some(op => op.status === 'failed') ? 'failed'
96        : operations.some(op => op.status === 'in_progress') ? 'in_progress' : 'completed',
97      source: 'panel', operations: operations.slice(0, 40), operationCount: operations.length,
98      verification: verification(prose),
99    });
100  }
101  return steps.slice(-30);
102}
103
hooks/i18n.ts 151 lines
1import { atom, read, update } from 'claude-code';
2import type { EngineInterface, SessionMessage } from 'claude-code';
3import type { Language, Preferences, Task, Theme } from '../types';
4
5export const preferences = atom({ plugin: 'super-sidebar', key: 'preferences' } as const,
6  { language: 'zh', theme: 'white', revision: 0, loaded: false } as Preferences);
7
8const words = {
9  preferences: ['偏好设置', 'Preferences'], language: ['语言', 'Language'], theme: ['容器配色', 'Container color'],
10  white: ['白色', 'White'], beige: ['米色', 'Beige'], black: ['黑色', 'Black'],
11  selected: ['已选择', 'Selected'],
12  agents: ['代理', 'Agents'], tasks: ['任务流', 'Task Flow'], refresh: ['刷新', 'Refresh'],
13  expand: ['展开', 'Expand'], collapse: ['收起', 'Collapse'], main: ['主代理', 'Main agent'],
14  mode: ['模式', 'Mode'], role: ['角色', 'Role'], model: ['模型', 'Model'],
15  tokens: ['Token 用量', 'Token usage'], context: ['当前上下文', 'Current context'],
16  tokenCumulative: ['会话累计(含缓存)', 'session total (incl. cache)'],
17  tokenUnavailable: ['暂无已完成回合统计', 'No completed-turn usage observed'],
18  contextUnavailable: ['当前会话未提供', 'Not provided for this session'],
19  elapsed: ['当前耗时', 'Elapsed'], calls: ['工具调用', 'tool calls'], seconds: ['秒', 's'],
20  parent: ['上级', 'Parent'], unavailable: ['暂无数据', 'Unavailable'],
21  working: ['正在处理当前任务', 'Working on the current task'], activity: ['活动', 'Activity'],
22  noActivity: ['暂无活动记录', 'No activity observed'],
23  stepOrder: ['本轮已观测步骤 · 连线表示观测顺序', 'Observed steps for this request · lines show observed order'],
24  emptyTasks: ['等待本轮工作步骤', 'Waiting for steps in this request'], plan: ['工作计划', 'Plan'],
25  operationComplete: ['操作完成', 'Operations complete'], operations: ['操作', 'operations'],
26  replyComplete: ['回复完成', 'Response complete'],
27  moreOperations: ['其余操作已省略', 'Additional operations omitted'],
28  verification: ['验证', 'Verification'], assistantReported: ['对话报告', 'Assistant-reported'],
29  verificationUnknown: ['未记录验证结论', 'No verification conclusion recorded'], verificationPassed: ['通过', 'Passed'],
30  verificationFailed: ['失败', 'Failed'], verificationSkipped: ['跳过', 'Skipped'],
31  noPlan: ['计划正文尚未在对话中观测到。', 'Plan content has not been observed in the conversation yet.'],
32  version: ['版本信息', 'Version'], pluginVersion: ['插件版本', 'Plugin version'], revision: ['实现修订', 'Implementation revision'],
33  observedOnly: ['仅展示已观测数据;缺失值不代表零。', 'Observed data only; missing values do not mean zero.'],
34  command: ['打开实时团队任务与代理关系面板', 'Open live agents and task flow'],
35  opened: ['状态面板已打开。', 'Status pane opened.'],
36  unplaced: ['面板已注册,等待客户端提供绘制位置。', 'Pane registered; waiting for the client to provide a placement.'],
37} as const;
38export const t = (lang: Language, key: keyof typeof words): string => words[key][lang === 'zh' ? 0 : 1];
39export const paneTitle = (lang: Language) => lang === 'zh' ? '状态' : 'Status';
40const statuses: Record<string, readonly [string, string]> = {
41  running: ['运行中', 'Running'], pending: ['待开始', 'Pending'], waiting: ['等待中', 'Waiting'],
42  idle: ['空闲', 'Idle'], completed: ['已完成', 'Completed'], failed: ['失败', 'Failed'],
43  killed: ['已停止', 'Stopped'], in_progress: ['进行中', 'In progress'],
44};
45export const statusLabel = (lang: Language, status: string) => statuses[status]?.[lang === 'zh' ? 0 : 1] ?? status;
46const generated: Record<string, string> = {
47  '主会话': 'Main session', 'lead · 协调者': 'lead · Coordinator',
48  '团队成员': 'Teammate', '后台子代理': 'Background subagent', '子代理': 'Subagent',
49  '子代理(加载前启动)': 'Subagent (started before loading)',
50  '等待活动': 'Waiting for activity', '执行失败': 'Execution failed', '已停止': 'Stopped', '已完成': 'Completed',
51  '会话状态读取失败,保留上次观测。': 'Could not read session state; keeping the last observation.',
52};
53export const generatedLabel = (lang: Language, value: string) => lang === 'en' ? generated[value] ?? value : value;
54export function activityLabel(lang: Language, value: string, messages: SessionMessage[]): string {
55  if (lang === 'zh' || !value.startsWith('正在执行 ')) return generatedLabel(lang, value);
56  const tool = value.slice('正在执行 '.length);
57  const uses = messages.flatMap(message => message.toolUses ?? []);
58  if (uses.some(use => use.input.description === value)) return value;
59  return uses.some(use => use.tool === tool) ? `Running ${tool}` : value;
60}
61
62const operations: Record<string, string> = {
63  '应用技能': 'Apply skill', '读取资源': 'Read resource', '跟进代理任务': 'Follow up agent task',
64  '检查界面样式': 'Inspect interface styles', '检查运行日志': 'Inspect runtime logs',
65  '检查服务日志': 'Inspect server logs', '检查网络请求': 'Inspect network requests',
66  '读取文件': 'Read file', '修改文件': 'Edit file', '写入文件': 'Write file', '搜索代码': 'Search code',
67  '查找文件': 'Find files', '执行命令': 'Run command', '执行子代理任务': 'Run subagent task',
68  '制定工作计划': 'Prepare plan', '提交工作计划': 'Submit plan', '检查界面显示': 'Inspect interface',
69  '执行系统操作': 'Run system operation', '读取网页': 'Read webpage', '搜索资料': 'Search the web',
70  '检查侧栏布局': 'Inspect sidebar layout', '检查窗口布局': 'Inspect window layout',
71  '操作界面控件': 'Use interface control', '查看界面内容': 'View interface content',
72  '检查或调整窗口': 'Inspect or adjust window', '填写界面内容': 'Fill interface content',
73  '检查界面内容': 'Inspect interface content', '处理工作步骤': 'Perform work step',
74};
75function translateOperation(lang: Language, title: string): string {
76  if (lang === 'zh') return title;
77  const split = title.indexOf(':');
78  const operation = split < 0 ? title : title.slice(0, split);
79  return operations[operation] ? operations[operation] + (split < 0 ? '' : `: ${title.slice(split + 1)}`) : title;
80}
81export function taskTitle(lang: Language, task: Task, _messages: SessionMessage[]): string {
82  if (task.kind === 'plan') return t(lang, 'plan');
83  return task.titleGenerated === true ? task.subject.split(' · ').map(title => translateOperation(lang, title)).join(' · ') : task.subject;
84}
85export function operationLabel(lang: Language, operation: { id: string; title: string }, messages: SessionMessage[]): string {
86  const use = messages.flatMap((message, index) => (message.toolUses ?? []).map((use, useIndex) => ({
87    use, id: use.tool_use_id || `${index}:${useIndex}`,
88  }))).find(entry => entry.id === operation.id)?.use;
89  // Supplied prose is never translated, even if it matches a fallback label.
90  if (!use || [use.input.description, use.input.title, use.input.subject]
91    .some(value => typeof value === 'string' && value.trim())) return operation.title;
92  return translateOperation(lang, operation.title);
93}
94export const taskStatusLabel = (lang: Language, status: string) =>
95  status === 'completed' ? t(lang, 'operationComplete') : statusLabel(lang, status);
96export const verificationLabel = (lang: Language, status: string) =>
97  t(lang, status === 'passed' ? 'verificationPassed' : status === 'failed' ? 'verificationFailed'
98    : status === 'skipped' ? 'verificationSkipped' : 'verificationUnknown');
99
100// Serialize disk writes per session so rapid toggles cannot persist out of order.
101const saves = new Map<string, Promise<void>>();
102const storageKey = (sessionId: string) => `preferences:${sessionId}`;
103export async function loadPreferences($: EngineInterface): Promise<void> {
104  const sessionId = await $.session.id();
105  const before = await read($, preferences);
106  if (before.sessionId === sessionId && before.loaded) return;
107  if (await $.session.id() !== sessionId) return;
108  const snapshot = await update($, preferences, current => current.sessionId === sessionId
109    ? { ...current, loaded: true }
110    : { sessionId, language: 'zh', theme: 'white', revision: 0, loaded: true });
111  try {
112    // Host declarations confirm store.get/set. Keys keep saved choices session-specific.
113    const saved = await $.store.get(storageKey(sessionId));
114    if (await $.session.id() !== sessionId) return;
115    const savedLanguage = saved && typeof saved === 'object' && 'language' in saved ? saved.language : undefined;
116    const savedTheme = saved && typeof saved === 'object' && 'theme' in saved ? saved.theme : undefined;
117    const language = savedLanguage === 'en' ? 'en' : 'zh';
118    // Older language-only records and invalid themes use the explicit white default.
119    const theme = savedTheme === 'beige' || savedTheme === 'black' ? savedTheme : 'white';
120    await update($, preferences, current => current.sessionId !== sessionId || current.revision !== snapshot.revision
121      ? current : { ...current, language, theme });
122  } catch { /* Preferences must never interfere with execution. */ }
123}
124async function switchPreferences($: EngineInterface, sessionId: string, choice: Partial<Pick<Preferences, 'language' | 'theme'>>): Promise<void> {
125  if (await $.session.id() !== sessionId) return;
126  const current = await update($, preferences, value => value.sessionId !== sessionId ? value
127    : { ...value, ...choice, revision: value.revision + 1 });
128  if (current.sessionId !== sessionId || await $.session.id() !== sessionId) return;
129  const save = (saves.get(sessionId) ?? Promise.resolve()).then(async () => {
130    if (await $.session.id() !== sessionId) return;
131    // Read inside the queue: even callbacks queued in reverse order retain the newest pair.
132    const latest = await read($, preferences);
133    if (latest.sessionId !== sessionId || await $.session.id() !== sessionId) return;
134    await $.store.set(storageKey(sessionId), { language: latest.language, theme: latest.theme ?? 'white' });
135  }).catch(() => { /* Keep the in-session choice if saving fails. */ });
136  saves.set(sessionId, save);
137  await save;
138  if (saves.get(sessionId) === save) saves.delete(sessionId);
139  if (await $.session.id() === sessionId) {
140    const latest = await read($, preferences);
141    if (latest.sessionId === sessionId) await $.ui.open({ id: 'super-sidebar', title: paneTitle(latest.language) });
142  }
143}
144
145export async function switchLanguage($: EngineInterface, sessionId: string, language: Language): Promise<void> {
146  await switchPreferences($, sessionId, { language });
147}
148export async function switchTheme($: EngineInterface, sessionId: string, theme: Theme): Promise<void> {
149  await switchPreferences($, sessionId, { theme });
150}
151
hooks/connector.ts 5 lines
1export function linkPath(x1: number, y1: number, x2: number, y2: number): string {
2  const k = Math.min(96, Math.max(24, (y2 - y1) * 0.5));
3  return `M ${x1} ${y1} C ${x1} ${y1 + k}, ${x2} ${y2 - k}, ${x2} ${y2}`;
4}
5
types/index.d.ts 77 lines
1export type Language = 'zh' | 'en';
2export type Theme = 'white' | 'beige' | 'black';
3export type Preferences = {
4  sessionId?: string;
5  language: Language;
6  theme: Theme;
7  revision: number;
8  loaded: boolean;
9};
10export type Member = {
11  id: string;
12  name: string;
13  description: string;
14  type: string;
15  parentId?: string;
16  status: string;
17  observedAt: number;
18  endedAt?: number;
19  activeStartedAt?: number;
20  currentDurationMs?: number;
21  model?: string;
22  mode?: string;
23  role?: string;
24  tokens?: number;
25  durationMs?: number;
26  tool?: string;
27  calls: number;
28  recent: string;
29};
30export type Task = {
31  id: string;
32  subject: string;
33  status: string;
34  owner?: string;
35  blockedBy?: string[];
36  source?: 'panel' | 'host';
37  summary?: string;
38  kind?: 'stage' | 'plan';
39  operations?: Array<{ id: string; title: string; status: string }>;
40  operationCount?: number;
41  verification?: {
42    status: 'unknown' | 'passed' | 'failed' | 'skipped';
43    evidence?: string;
44    source?: 'assistant';
45  };
46  titleGenerated?: boolean;
47};
48export type Panel = {
49  sessionId?: string;
50  members: Member[];
51  tasks: Task[];
52  selected: string;
53  filter: string;
54  view: 'tree' | 'list';
55  mainStatus: string;
56  mainRecent: string;
57  project: string;
58  error: string;
59  updatedAt: number;
60  mainModel?: string;
61  mainTokens?: number;
62  mainContextTokens?: number;
63  mainStartedAt?: number;
64  mainDurationMs?: number;
65  mainActiveStartedAt?: number;
66  mainCurrentDurationMs?: number;
67  mainCalls?: number;
68  mainTool?: string;
69  taskNotice?: string;
70  liveTurnId?: string;
71};
72declare module 'claude-code' {
73  interface PluginState {
74    'super-sidebar': { panel: Panel; preferences: Preferences };
75  }
76}
77