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

<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&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>
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.
| Integration | Data source and UI | Current validation |
|---|---|---|
| Claude Desktop Code | Lifecycle hooks and session.messages() → native pane | Historical portable checks passed; latest isolation regressions have not been run; current native engine validation and Desktop reload remain unverified |
| Codex CLI / App | Local sessions/rollout-*.jsonl → read-only browser panel | Historical adapter/HTTP checks passed; latest isolation regressions have not been run; real local App session and page verified |
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.
update_plan steps; failed updates preserve the previous plan.The following requirements apply to the Claude plugin. The Codex dashboard only requires Node.js 22.18+ and locally persisted Codex sessions.
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.
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.
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.
| Section | What it shows |
|---|---|
| Agent relationships | Main agent, subagents, parent–child connections, and expandable execution details |
| Task Flow | Work steps grouped into stages, with concise titles, status, operation details, and separate verification conclusions |
| Preferences | Language, 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.
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.
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.
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.
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 .
| Path | Responsibility |
|---|---|
| .claude-plugin/ | Plugin manifest and local marketplace |
| hooks/register.tsx | Lifecycle hooks, command, and refresh |
| hooks/pane.tsx | Native pane and connectors |
| hooks/steps.ts | Conversation-to-flow parser |
| hooks/model.ts | State and agent-tree helpers |
| types/index.d.ts | Plugin state contract |
| codex/ | Read-only Codex adapter, local server, browser UI and tests |
| verification/ | Historical captures and inspection notes |
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.
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.
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.
Licensed under the Apache License 2.0.
Created by Zafer_Liu.
hooks/register.tsx 213 lines1import { 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};
213hooks/model.ts 132 lines1import 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 '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
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}
132hooks/pane.tsx 226 lines1import { 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}
226hooks/steps.ts 103 lines1import 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}
103hooks/i18n.ts 151 lines1import { 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}
151hooks/connector.ts 5 lines1export 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}
5types/index.d.ts 77 lines1export 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