Sessions pane: every open session with a status light, step progress and time, plus 5-hour and weekly usage

A Claude Code mod that adds a Sessions pane: one place to see what every open session is doing, how far along it is, which ones are waiting for you, and how much of your 5-hour and weekly usage is left.
| Light | Meaning |
|---|---|
| Blue dot | Working (including background work that is still running) |
| Orange dot | Finished, and you have not looked at it yet |
| Hollow dot | You have seen it |
| Red dot | There is a problem you need to handle |
| Red triangle | A severe problem; the work may have stopped |
%APPDATA%\Claude\claude-code-sessions\;claude:// link through rundll32.exe). git clone https://github.com/KeaneCloud/session-dashboard.git
CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (separate several mods with ;): {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "C:\\path\\to\\session-dashboard"
}
}
To try it once in a terminal instead:
claude --plugin-dir /path/to/session-dashboard
The pane opens automatically with each session. If you close it, press ☰ Sessions above the prompt to open it again.
The pane is in English by default. For Traditional Chinese, set an environment variable in ~/.claude/settings.json:
{
"env": {
"SESSION_DASHBOARD_LANG": "zh-TW"
}
}
All strings live in hooks/i18n.js; to add a language, add a block there with the same keys.
The mod registers a tool, mcp__session-dashboard__progress. When Claude calls it, the step timeline appears in the pane. To make that a habit, add something like this to your ~/.claude/CLAUDE.md:
- **Sessions pane**: when the `mcp__session-dashboard__progress` tool is available (use ToolSearch
if it is not loaded), call it when a task starts, when a step finishes, when you are blocked and
waiting for me, and when the task is done. Pass the full step list every time, at most 5 steps,
a few words per step. status: done / running / warn (a problem I need to handle) / bad (a severe
problem) / todo. Pass an empty list when the task is over.
When Claude has not called the tool, the pane falls back to the checkboxes in checklist.md (or TASKS.md) in the working folder:
- [x] A finished step
- [ ] A step still to do
- [ ] A blocked step BLOCKED: what you need from the user
- [ ] A broken step CRITICAL: what went wrong
Two features call a small model (Haiku), each for a few tokens:
To turn auto-naming off, set AUTO_RENAME to false at the top of hooks/register.js.
Tunable values are constants at the top of hooks/register.js and hooks/lib.js. Restart the app after changing them.
| Constant | Default | Meaning |
|---|---|---|
AUTO_OPEN | true | Open the pane when a session starts |
AUTO_RENAME | true | Name sessions automatically |
RENAME_AFTER_TURNS | 3 | Turn after which a session is named |
TITLE_MAX | 10 | Longest session name, in words |
TASK_LABEL_MAX | 12 | Longest task summary, in words |
TIME_REDRAW_MS | 60 s | How often times and usage are refreshed |
CHANGE_CHECK_MS | 10 s | How often other sessions are checked for changes |
LIGHT (lib.js) | – | Status light colours |
$.store, kept on your machine by Claude Code): each session's name, status and step progress, plus the latest usage reading. This is how the panes of different sessions see each other.checklist.md / TASKS.md in the working folder.claude plugin validate .
claude plugin test .
Tests are in tests/ and use the claude-code/testing kit that ships with Claude Code. .claude-plugin/types/ is generated by Claude Code and is not checked in.
hooks/register.js 804 lines1// session-dashboard: Sessions pane — a timeline of the current session's steps, a list of other sessions with status lights, and usage pinned at the bottom. Above the prompt there is only a button that opens the pane.
2// Step data comes from the plugin's `progress` tool (preferred), falling back to checklist.md / TASKS.md in the working folder.
3// Cross-session data lives in $.store (shared by every session on this machine); each session writes only its own keys, so sessions never overwrite each other.
4// Rendering (ui.render) is read-only; cleanup and publishing happen on the heartbeat or in event hooks.
5import { setLang, t } from './i18n.js'
6import {
7 etaText, estimateRemainingMin, folderName, jumpUrl, liveSessions, mergeProgress, parseTaskSteps,
8 STALE_MS, stepStats, svgTimelineNode, svgThinBar, limitColor, tasksPath, CHECKLIST_FILES, othersSignature, thinBar, titleFromTranscript, usageSegments,
9 desktopRecordMatches, currentStepLabel, splitHidden, COLORS, ago,
10 actionLabel, pushAction, showActivity, summarizeBackground,
11 ROWS, FALLBACK_BUDGET, usageRows, fitSteps, othersRows,
12 LIGHT, statusOf, rightTime, svgOtherLine, svgSectionDivider, svgSessionGap, timelineItems, cleanTitle, nameUnits, wrapByWidth, svgAlertIcon, conversationDigest,
13} from './lib.js'
14
15// ===== Tunables (edit here) =====
16const PANE_ID = 'session-dashboard'
17const KEY_PREFIX = 'session:' // $.store: per-session summary (shown in other sessions' panes)
18const HIDDEN_PREFIX = 'hidden:' // $.store: one entry { at } per hidden session (no whole-object read-modify-write, so sessions cannot overwrite each other)
19const LEGACY_HIDDEN_KEY = 'hidden' // Legacy single-object hidden record; still read, and migrated to per-session keys on the heartbeat
20const USAGE_KEY = 'usage:last' // $.store: latest usage reading (account-wide; used by new sessions that have no reading yet)
21const USAGE_STALE_MS = 5 * 60 * 1000 // When showing another session's reading older than this, label it with the reading time
22const PROGRESS_PREFIX = 'progress:' // $.store: the full step list each session reported through the progress tool
23const HEARTBEAT_MS = 30 * 1000 // How often to publish own state, re-read TASKS.md and prune stale data
24const CHANGE_CHECK_MS = 10 * 1000 // How often to check other sessions for changes (redraw only on change)
25const TIME_REDRAW_MS = 60 * 1000 // How often time labels (~18m, 3m ago) and usage are redrawn (1 minute by design)
26const FOCUS_REFRESH_MS = 10 * 1000 // How often to check Desktop's "last focused" time (drives the finished-unread light)
27const TITLE_REFRESH_MS = 60 * 1000 // How often to re-read the session name (a rename shows up within 60 s)
28const TRANSCRIPT_MAX_BYTES = 4 * 1024 * 1024 // $.fs.read per-file limit is 4 MiB (official Limits table)
29const PROGRESS_TOOL = 'mcp__session-dashboard__progress'
30const ACTIVITY_PUBLISH_MS = 10 * 1000 // Activity is written to the shared store at most once every 10 s
31const AUTO_OPEN = true // Open the pane automatically when a session starts
32const PANE_BUTTON_LABEL = '☰ Sessions' // Label of the open-pane button above the prompt
33// Auto-naming: after turn RENAME_AFTER_TURNS, a small model picks a name of at most TITLE_MAX units, once per session; manually renamed sessions are left alone
34const AUTO_RENAME = true // Set to false to turn auto-naming off
35const RENAME_AFTER_TURNS = 3
36const TITLE_MAX = 10
37const RENAME_MODEL = 'haiku'
38const RENAME_TOOL = 'mcp__ccd_session_mgmt__set_session_title' // Desktop's official rename tool (records the title source as "tool")
39const RENAMED_PREFIX = 'renamed:' // $.store: sessions already auto-named (guarantees a single attempt, no retry on failure)
40const TASK_LABEL_MAX = 12 // Max length of the current-task summary (edit here)
41const NAME_RESERVED_CELLS = 16 // Cells taken on an other-session name line by ✕, the light and the right-hand time (approx.)
42const SECTION_GAP = 3 // Blank lines between the current session and "other sessions" (shrinks first when space is tight)
43
44// Icon and text colour per step status (terminal; Desktop draws timeline dots)
45const STEP_LOOK = {
46 done: { icon: '✓', color: 'green' },
47 running: { icon: '●', color: 'cyan' },
48 warn: { icon: '⚠', color: 'yellow' },
49 bad: { icon: '⛔', color: 'red' },
50 todo: { icon: '○', color: undefined },
51}
52
53// State of this session; reset when the module reloads, refilled by session.start
54const me = {
55 id: '', cwd: '', isWorking: false, workStartedAt: null, finishedAt: null, seenAt: null,
56 activity: null, activityPublishedAt: 0, background: null, firstSeen: null, queuedHinted: false,
57 title: null, desktopId: null, desktopFile: null, titleCheckedAt: 0,
58 lastFocusedAt: null, focusFileMtime: 0, timers: [], titleSource: null, pendingTitle: null, taskLabel: null, lastSignature: null,
59}
60
61// ---------- Reading files ----------
62
63/** Read checklist.md in the working folder (falling back to TASKS.md) and convert it to steps; null if neither exists. */
64async function readTaskSteps($, cwd) {
65 for (const name of CHECKLIST_FILES) {
66 try {
67 return parseTaskSteps(await $.fs.read(tasksPath(cwd, name)))
68 } catch {}
69 }
70 return null
71}
72
73/** Redraw only when other sessions or the hidden list changed; ever-changing values such as the heartbeat time do not count. */
74async function redrawIfChanged($) {
75 const now = await $.clock.now()
76 const keys = await $.store.keys()
77 const sig = othersSignature(await loadOthers($, now), keys.filter((k) => k.startsWith(HIDDEN_PREFIX)))
78 if (me.lastSignature !== null && sig !== me.lastSignature) $.ui.invalidate('ui.render')
79 me.lastSignature = sig
80}
81
82/** Read and parse a JSON file; null on failure. */
83async function readJson($, path) {
84 try {
85 return JSON.parse(await $.fs.read(path))
86 } catch {
87 return null
88 }
89}
90
91/** List all Desktop session record files (%APPDATA%\Claude\claude-code-sessions\<acc>\<org>\local_*.json), newest first. */
92async function listDesktopRecords($) {
93 const appData = await $.env.get('APPDATA')
94 if (!appData) return []
95 const root = appData + '\\Claude\\claude-code-sessions'
96 const files = []
97 try {
98 for (const a of await $.fs.list(root)) {
99 if (a.kind !== 'dir') continue
100 for (const b of await $.fs.list(root + '\\' + a.name)) {
101 if (b.kind !== 'dir') continue
102 const dir = root + '\\' + a.name + '\\' + b.name
103 for (const f of await $.fs.list(dir)) {
104 if (f.kind === 'file' && f.name.startsWith('local_') && f.name.endsWith('.json')) files.push({ path: dir + '\\' + f.name, mtimeMs: f.mtimeMs })
105 }
106 }
107 }
108 } catch {
109 return []
110 }
111 return files.sort((x, y) => y.mtimeMs - x.mtimeMs)
112}
113
114/** Find this session's record in the Desktop app: try the remembered path first, otherwise scan all records newest first and stop at the first match (no cap on how many are scanned). */
115async function findDesktopRecord($) {
116 if (me.desktopFile) {
117 const record = await readJson($, me.desktopFile)
118 if (desktopRecordMatches(record, me.id)) return record
119 me.desktopFile = null
120 }
121 for (const f of await listDesktopRecords($)) {
122 const record = await readJson($, f.path)
123 if (desktopRecordMatches(record, me.id)) {
124 me.desktopFile = f.path
125 return record
126 }
127 }
128 return null
129}
130
131/** Get the name from the transcript (~/.claude/projects/<project>/<id>.jsonl); give up if the file exceeds 4 MiB (never read the whole file). */
132async function transcriptTitle($) {
133 const home = await $.env.get('USERPROFILE')
134 if (!home) return null
135 const projects = home + '\\.claude\\projects'
136 try {
137 for (const d of await $.fs.list(projects)) {
138 if (d.kind !== 'dir') continue
139 const path = projects + '\\' + d.name + '\\' + me.id + '.jsonl'
140 if (!(await $.fs.exists(path))) continue
141 const stat = await $.fs.stat(path)
142 if (stat.size > TRANSCRIPT_MAX_BYTES) return null
143 return titleFromTranscript(await $.fs.read(path))
144 }
145 } catch {
146 return null
147 }
148 return null
149}
150
151/** Refresh the session name, Desktop id and last-focused time: Desktop record → transcript → folder name. At most once per TITLE_REFRESH_MS. */
152async function refreshTitle($, now) {
153 if (now - me.titleCheckedAt < TITLE_REFRESH_MS) return
154 me.titleCheckedAt = now
155 const record = await findDesktopRecord($)
156 if (record) {
157 me.title = typeof record.title === 'string' && record.title.trim() !== '' ? record.title.trim() : null
158 me.desktopId = record.sessionId ?? null
159 me.titleSource = record.titleSource ?? null
160 if (typeof record.lastFocusedAt === 'number') me.lastFocusedAt = record.lastFocusedAt
161 }
162 if (!me.title) me.title = await transcriptTitle($)
163}
164
165/**
166 * Check Desktop's "last focused" time (lastFocusedAt). Measured (2026-10-08): switching to a session writes it to that session's record file within 1 s,
167 * but staying on the same session never updates it again. Re-read only when the file's mtime changed, to avoid reading several hundred KB every 10 s.
168 * Returns whether it changed.
169 */
170async function refreshFocus($) {
171 if (!me.desktopFile) return false
172 try {
173 const stat = await $.fs.stat(me.desktopFile)
174 if (stat.mtimeMs === me.focusFileMtime) return false
175 me.focusFileMtime = stat.mtimeMs
176 const record = await readJson($, me.desktopFile)
177 if (typeof record?.lastFocusedAt === 'number' && record.lastFocusedAt !== me.lastFocusedAt) {
178 me.lastFocusedAt = record.lastFocusedAt
179 return true
180 }
181 } catch {
182 // Unreadable: keep the previous value
183 }
184 return false
185}
186
187/** Current steps of this session: progress-tool data first, otherwise TASKS.md. */
188async function currentSteps($) {
189 const progress = me.id ? await $.store.get(PROGRESS_PREFIX + me.id) : null
190 if (progress && Array.isArray(progress.steps) && progress.steps.length > 0) return { progress, steps: progress.steps, source: 'tool' }
191 const steps = await readTaskSteps($, me.cwd)
192 return { progress: null, steps: steps ?? [], source: steps ? 'tasks' : null }
193}
194
195// ---------- Shared store ----------
196
197/** Write own summary to the shared store for other sessions' panes. */
198async function publish($) {
199 if (!me.id) return // After /clear and before the new id is adopted, do not publish, so no ghost data is written under the old id
200 const now = await $.clock.now()
201 await refreshTitle($, now)
202 const { progress, steps, source } = await currentSteps($)
203 await $.store.set(KEY_PREFIX + me.id, {
204 id: me.id,
205 name: me.title ?? folderName(me.cwd),
206 desktopId: me.desktopId,
207 isWorking: me.isWorking,
208 lastSeen: now,
209 firstSeen: me.firstSeen,
210 workStartedAt: me.workStartedAt,
211 finishedAt: me.finishedAt,
212 lastFocusedAt: me.lastFocusedAt,
213 seenAt: me.seenAt,
214 hasToolSteps: source === 'tool',
215 background: me.background,
216 activity: me.activity ? { startedAt: me.activity.startedAt, current: me.activity.actions[0]?.label ?? null } : null,
217 stats: stepStats(steps),
218 eta: source === 'tool' ? etaText(estimateRemainingMin(progress, now)) : null,
219 stepLabel: currentStepLabel(steps),
220 })
221}
222
223/** Load other sessions (read-only; stale ones are skipped, deletion is left to prune on the heartbeat). */
224async function loadOthers($, now) {
225 const entries = []
226 for (const key of await $.store.keys()) {
227 if (!key.startsWith(KEY_PREFIX)) continue
228 const entry = await $.store.get(key)
229 if (!entry || typeof entry.lastSeen !== 'number' || now - entry.lastSeen > STALE_MS) continue
230 // Old records left behind by this session's own previous ids do not count as "other sessions" either
231 if (entry.id !== me.id && !(me.desktopId && entry.desktopId === me.desktopId)) entries.push(entry)
232 }
233 return liveSessions(entries, now)
234}
235
236/** Load hidden records { [id]: { at } }: per-session keys plus the legacy single-object record. */
237async function loadHidden($) {
238 const merged = { ...((await $.store.get(LEGACY_HIDDEN_KEY)) ?? {}) }
239 for (const key of await $.store.keys()) {
240 if (!key.startsWith(HIDDEN_PREFIX)) continue
241 const h = await $.store.get(key)
242 if (h && typeof h.at === 'number') merged[key.slice(HIDDEN_PREFIX.length)] = h
243 }
244 return merged
245}
246
247/** Heartbeat cleanup: delete stale sessions, drop hidden records that are due to be unhidden, and migrate the legacy hidden record to per-session keys. */
248async function prune($, now) {
249 const all = []
250 for (const key of await $.store.keys()) {
251 if (!key.startsWith(KEY_PREFIX)) continue
252 const entry = await $.store.get(key)
253 if (!entry || typeof entry.lastSeen !== 'number' || now - entry.lastSeen > STALE_MS) await $.store.delete(key)
254 else all.push(entry)
255 }
256 const legacy = await $.store.get(LEGACY_HIDDEN_KEY)
257 if (legacy && typeof legacy === 'object') {
258 for (const [id, h] of Object.entries(legacy)) await $.store.set(HIDDEN_PREFIX + id, h)
259 await $.store.delete(LEGACY_HIDDEN_KEY)
260 }
261 const { unhide } = splitHidden(all, await loadHidden($))
262 for (const id of unhide) await $.store.delete(HIDDEN_PREFIX + id)
263}
264
265/** Heartbeat: publish own state, then prune. */
266async function heartbeat($) {
267 await publish($)
268 await prune($, await $.clock.now())
269}
270
271/**
272 * Whether this is the session currently being looked at: its "last focused" time is the newest of all sessions.
273 * Used at the moment work finishes: if the session was watched to completion, the "finished, unread" light should not turn on.
274 */
275async function isFrontSession($, now) {
276 await refreshFocus($)
277 if (typeof me.lastFocusedAt !== 'number') return false
278 for (const s of await loadOthers($, now)) {
279 if (typeof s.lastFocusedAt === 'number' && s.lastFocusedAt > me.lastFocusedAt) return false
280 }
281 return true
282}
283
284/** The turn has really ended (no background work): record the finish time; if the session is in front, also mark it as seen. */
285async function markFinished($) {
286 const now = await $.clock.now()
287 me.finishedAt = now
288 me.seenAt = (await isFrontSession($, now)) ? now : me.seenAt
289}
290
291/** Set working / idle. */
292async function setWorking($, isWorking) {
293 me.isWorking = isWorking
294 if (isWorking) {
295 me.workStartedAt = await $.clock.now()
296 me.activity = { startedAt: me.workStartedAt, actions: [] }
297 me.background = null
298 me.finishedAt = null
299 } else {
300 me.activity = null
301 }
302}
303
304/** Re-register under the new id after /clear, /resume or /branch. */
305async function adoptSessionId($) {
306 me.id = await $.session.id()
307 me.cwd = await $.session.cwd()
308 me.isWorking = false
309 me.activity = null
310 me.background = null
311 me.finishedAt = null
312 me.seenAt = null
313 me.firstSeen = await $.clock.now()
314 me.titleCheckedAt = 0
315 me.desktopFile = null
316 me.focusFileMtime = 0
317}
318
319/**
320 * Auto-naming: after turn RENAME_AFTER_TURNS, pick a name of at most TITLE_MAX units from the conversation, exactly once (the marker is written before the call, so a failure is not retried).
321 * How: prefer Desktop's official rename tool (the app code confirms the title source is recorded as "tool"); if it cannot be called, wait for the next submitted prompt and
322 * set it via UserPromptSubmit's sessionTitle (works for terminal sessions; whether Desktop honours it is unverified). A manually renamed session (titleSource = user) is left alone.
323 */
324async function maybeRename($) {
325 if (!AUTO_RENAME || !me.id) return
326 if (await $.store.get(RENAMED_PREFIX + me.id)) return
327 if ((await $.session.turns()) < RENAME_AFTER_TURNS) return
328 await $.store.set(RENAMED_PREFIX + me.id, { at: await $.clock.now() })
329 me.titleCheckedAt = 0
330 await refreshTitle($, await $.clock.now())
331 if (me.titleSource === 'user') return
332 const convo = conversationDigest(await $.session.messages()) // Uses the whole session, not just the last few messages
333 let title = ''
334 try {
335 title = await askShortName($, t('renameTask'), convo, TITLE_MAX)
336 } catch {
337 return
338 }
339 if (!title) return
340 try {
341 // A missing tool throws; a rejection (e.g. permissions) returns deny / isError. Both count as failure and take the fallback
342 const res = await $.tool.call({ tool: RENAME_TOOL, session_id: 'self', title })
343 if (res?.deny || res?.isError) throw new Error(String(res.deny ?? res.result ?? 'rejected'))
344 $.ui.log('session-dashboard: renamed to "' + title + '" (Desktop rename tool)', { to: 'debug' })
345 } catch (err) {
346 me.pendingTitle = title
347 $.ui.log('session-dashboard: Desktop rename tool failed (' + String(err) + '), falling back to sessionTitle', { to: 'debug' })
348 }
349 me.title = title
350 await publish($)
351}
352
353/**
354 * Ask the small model for a complete name of at most max units (an English word counts as 1 unit). An over-long name is not truncated; the model is asked once more;
355 * if the retry is still too long, the shorter of the two complete names is used.
356 */
357async function askShortName($, task, prompt, max) {
358 const rule = t('nameRule', max)
359 const ask = async (system, text) => {
360 const reply = await $.model.complete({ model: RENAME_MODEL, maxTokens: 40, system, prompt: text })
361 return reply?.isAnswered ? cleanTitle(reply.text) : ''
362 }
363 const first = await ask(task + rule, prompt)
364 if (!first || nameUnits(first) <= max) return first
365 const second = await ask(t('shortenTask') + rule, t('tooLong', first, nameUnits(first)))
366 return second && nameUnits(second) < nameUnits(first) ? second : first
367}
368
369/** Condense the submitted prompt to at most TASK_LABEL_MAX units, shown in the pane as "what it is doing"; on failure the label stays "working". */
370async function summarizeTask($, prompt) {
371 try {
372 const label = await askShortName($, t('summaryTask'), prompt.slice(0, 1000), TASK_LABEL_MAX)
373 if (label) {
374 me.taskLabel = label
375 $.ui.invalidate('ui.render')
376 }
377 } catch {}
378}
379
380// ---------- Actions ----------
381
382/** Open the pane; asked=true means it was requested explicitly (command or button) and takes keyboard focus. A failed open only shows a toast and never aborts the hook. */
383async function openPane($, asked) {
384 const pane = { id: PANE_ID, title: 'Sessions' }
385 try {
386 const opened = await $.ui.open(asked ? { ...pane, focus: true } : pane)
387 // When the mod opens the pane itself and there is no room, the pane is queued; hint once how to open it manually
388 if (!asked && opened && opened.isPlaced === false && !me.queuedHinted) {
389 me.queuedHinted = true
390 $.ui.toast(t('queuedHint'), { timeoutMs: 8000 })
391 }
392 } catch (err) {
393 if (asked) $.ui.toast(t('paneOpenFailed', String(err)))
394 }
395}
396
397/** Jump to another Desktop session via the Desktop app's own claude:// link, which Windows hands to the app. */
398async function jumpTo($, url, name) {
399 try {
400 await $.process.run(['rundll32.exe', 'url.dll,FileProtocolHandler', url])
401 } catch (err) {
402 $.ui.toast(t('jumpFailed', name, String(err)))
403 }
404}
405
406/** Hide another session (takes effect in every pane) without touching the session itself. One key per session, so sessions cannot overwrite each other. */
407async function hideProject($, id) {
408 await $.store.set(HIDDEN_PREFIX + id, { at: await $.clock.now() })
409 $.ui.invalidate('ui.render')
410}
411
412
413/**
414 * Usage reading (read-only): rateLimits only exists after this session receives its first reply (official typing: "empty … before the first reading").
415 * The quota is account-wide; with no reading of its own, a session uses the latest one in the shared store (saved on session.measure), dropping entries past their reset time.
416 * Returns { list, at, own }.
417 */
418async function readRateLimits($, now) {
419 const { rateLimits } = await $.session.usage()
420 if (Array.isArray(rateLimits) && rateLimits.length > 0) return { list: rateLimits, at: now, own: true }
421 const cached = await $.store.get(USAGE_KEY)
422 const list = Array.isArray(cached?.rateLimits) ? cached.rateLimits : []
423 return { list: list.filter((r) => !r.resetsAt || new Date(r.resetsAt).getTime() > now), at: cached?.at ?? now, own: false }
424}
425
426// ---------- Rendering (read-only) ----------
427
428/** Wrap the Svg in a keyed Box (Svg itself takes no key) so tests and redraws can identify it. */
429function svgBox(els, key, source, alt) {
430 const { Box, Svg } = els
431 return Box({ key, children: [Svg({ source, alt })] })
432}
433
434/**
435 * One timeline segment: a dot with connector lines above and below on the left (Svg on Desktop, characters and "│" in the terminal), a title plus one note line on the right.
436 * Every segment is exactly ROWS.step rows tall, so the lines of adjacent segments meet.
437 */
438function timelineRow(els, isDesktop, item, i) {
439 const { Box, Text } = els
440 const step = item.step
441 const title = step ? step.title : item.text
442 const note = step ? (item.kind === 'running' ? (step.note ?? t('inProgress')) : step.note) : undefined
443 const noteColor = item.kind === 'bad' ? 'red' : item.kind === 'warn' ? 'yellow' : undefined
444 const isMeta = !step // "N steps done" / "N more steps"
445 let marker
446 if (isDesktop && els.Svg) {
447 marker = svgBox(els, 'node-' + i, svgTimelineNode(item.kind, item.label, item.top, item.bottom), title)
448 } else {
449 const icon = { done: '✓', collapsed: '✓', running: '●', warn: '!', bad: '!', todo: '○', more: '┆' }[item.kind] ?? '○'
450 const color = { done: 'green', collapsed: 'green', running: 'cyan', warn: 'yellow', bad: 'red' }[item.kind]
451 marker = Box({ flexDirection: 'column', children: [
452 Text({ color, dimColor: !color, children: [icon] }),
453 Text({ dimColor: true, children: [item.bottom ? '│' : ' '] }),
454 ] })
455 }
456 const text = [Text({
457 bold: item.kind === 'running',
458 color: isMeta && item.kind === 'collapsed' ? 'green' : undefined,
459 dimColor: item.kind === 'todo' || item.kind === 'more',
460 wrap: 'wrap',
461 children: [title],
462 })]
463 if (note) text.push(Text({ key: 'note-' + i, color: noteColor, dimColor: !noteColor, wrap: 'wrap', children: [note] }))
464 return Box({ key: 'step-' + i, flexDirection: 'row', columnGap: 1, alignItems: 'center', children: [marker, Box({ flexDirection: 'column', flexGrow: 1, flexShrink: 1, children: text })] })
465}
466
467/** With no reported steps, show a single line: task summary (the prompt condensed by AI) · elapsed time; no file names or tool actions. */
468function activityRow(els, activity, now) {
469 const elapsed = Math.floor(Math.max(0, now - activity.startedAt) / 60000)
470 return els.Text({ key: 'activity', color: 'cyan', wrap: 'wrap', children: [(me.taskLabel ?? t('working')) + ' · ' + elapsed + 'm'] })
471}
472
473/** Background work is also a single line: the turn ended, but background commands or subagents are still running. */
474function backgroundRow(els) {
475 return els.Text({ key: 'background', color: 'cyan', wrap: 'wrap', children: [(me.taskLabel ?? t('working')) + ' · ' + t('background')] })
476}
477
478/** Current-session block: bold name (remaining time on the right) plus the latest 5 steps. maxRows is the number of rows available to this block. */
479async function currentSection($, els, isDesktop, now, maxRows) {
480 const { Box, Text } = els
481 const { progress, steps, source } = await currentSteps($)
482 const title = me.title ?? folderName(me.cwd)
483 const eta = steps.length === 0 ? '' : source === 'tool' ? etaText(estimateRemainingMin(progress, now)) : t('fromChecklist')
484 const rows = [
485 Box({
486 flexDirection: 'row', justifyContent: 'space-between', columnGap: 1,
487 children: [Text({ bold: true, wrap: 'wrap', children: [title] }), Text({ dimColor: true, children: [eta] })],
488 }),
489 ]
490 if (progress?.task && maxRows >= 6) rows.push(Text({ dimColor: true, wrap: 'wrap', children: [progress.task] }))
491 if (showActivity(me.activity, source === 'tool', now)) rows.push(activityRow(els, me.activity, now))
492 else if (me.background && source !== 'tool') rows.push(backgroundRow(els))
493 else if (steps.length === 0) rows.push(Text({ dimColor: true, children: [t('noSteps')] }))
494 // Timeline: "N steps done" → latest 5 steps → "N more steps", all joined into one line
495 const fit = fitSteps(steps, Math.max(0, maxRows - rows.length))
496 timelineItems(fit).forEach((item, i) => rows.push(timelineRow(els, isDesktop, item, i)))
497 return rows
498}
499
500/** Section divider: a thick solid line with "other sessions" embedded in the middle (Svg on Desktop, ━ in the terminal). */
501function sectionDivider(els, isDesktop, columns) {
502 if (isDesktop && els.Svg) return svgBox(els, 'section-divider', svgSectionDivider(t('othersTitle')), t('othersTitle'))
503 const side = '━'.repeat(Math.max(2, Math.floor(((columns ?? 40) - t('othersTitle').length - 4) / 2)))
504 return els.Text({ key: 'section-divider', dimColor: true, wrap: 'truncate-end', children: [side + ' ' + t('othersTitle') + ' ' + side] })
505}
506
507/** How many cells fit on one line of an other-session name. */
508const nameCells = (columns) => Math.max(8, (columns ?? 40) - NAME_RESERVED_CELLS)
509
510/**
511 * One row per other session (always 2 lines):
512 * Line 1: ⊘ + light + name (click to jump) + time on the right; line 2 (Desktop): progress bar + faint divider. While working, only the bar is drawn, with no text.
513 */
514function otherRow($, els, isDesktop, s, now, columns) {
515 const { Box, Button, Text } = els
516 const stats = s.stats ?? { done: 0, total: 0, pct: 0 }
517 const pct = stats.total > 0 ? stats.pct : null
518 const status = statusOf(s)
519 const url = jumpUrl(s.desktopId)
520 const hide = Button({ key: 'hide-' + s.id, label: '✕', plain: true, dimColor: true, onPress: () => hideProject($, s.id) })
521 // Severe problems use a red triangle icon (⚠ in the terminal); other statuses use a light dot
522 const dot = status === 'bad'
523 ? (isDesktop && els.Svg ? svgBox(els, 'alert-' + s.id, svgAlertIcon(), t('severeAlt')) : Text({ key: 'light-' + s.id, color: 'red', children: ['⚠'] }))
524 : Text({ key: 'light-' + s.id, color: LIGHT[status], children: [status === 'idle' ? '○' : '●'] })
525 // Long names wrap: button labels do not wrap by themselves, so the name is split into stacked buttons and every line stays clickable
526 const nameLines = wrapByWidth(s.name, nameCells(columns))
527 const name = !url
528 ? Text({ key: 'other-' + s.id, bold: true, wrap: 'wrap', children: [s.name] })
529 : Box({ flexDirection: 'column', flexShrink: 1, children: nameLines.map((line, n) =>
530 Button({ key: 'jump-' + s.id + (n === 0 ? '' : '-' + n), label: line, plain: true, onPress: () => jumpTo($, url, s.name) })) })
531 const line1 = Box({
532 flexDirection: 'row', justifyContent: 'space-between', columnGap: 1,
533 children: [
534 Box({ flexDirection: 'row', columnGap: 1, flexShrink: 1, alignItems: 'flex-start', children: [hide, dot, name] }),
535 Text({ dimColor: true, children: [[pct === null ? '' : pct + '%', rightTime(s, now)].filter(Boolean).join(' · ')] }),
536 ],
537 })
538 // The progress bar extends almost to the right edge (same width as the usage bars); the terminal uses a text bar sized to the pane width
539 const line2 = isDesktop && els.Svg
540 ? svgBox(els, 'line2-' + s.id, svgOtherLine(pct), pct === null ? s.name : s.name + ' ' + pct + '%')
541 : Text({ key: 'line2-' + s.id, color: 'cyan', children: [pct === null ? ' ' : thinBar(pct, Math.max(8, (columns ?? 40) - 4))] })
542 return Box({ key: 'proj-' + s.id, flexDirection: 'column', children: [line1, line2] })
543}
544
545/**
546 * Usage (pinned to the bottom of the pane): Session limit / Weekly limit, "used% · resets in 2h", 4px bar; a stale reading from another session is labelled with its reading time.
547 * compact = when the pane is very short, each limit takes 1 line (text only, colour shows the level) so there is still room above.
548 */
549async function usageSection($, els, isDesktop, now, compact = false) {
550 const { Box, Text } = els
551 const reading = await readRateLimits($, now)
552 const segments = usageSegments(reading.list, now)
553 if (segments.length === 0) return [Text({ dimColor: true, children: [t('usageEmpty')] })]
554 const staleTag = !reading.own && now - reading.at > USAGE_STALE_MS ? ' · read ' + ago(reading.at, now) : ''
555 return segments.map((seg, i) => {
556 const fill = limitColor(seg.pct)
557 const termColor = fill === COLORS.bad ? 'red' : fill === COLORS.warn ? 'yellow' : 'cyan'
558 if (compact) {
559 return Box({ key: 'limit-compact-' + seg.label, flexDirection: 'row', justifyContent: 'space-between', children: [
560 Text({ children: [seg.label] }),
561 Text({ color: isDesktop ? fill : termColor, children: [seg.pct + '%' + (seg.reset ? ' · ' + seg.reset : '') + (i === 0 ? staleTag : '')] }),
562 ] })
563 }
564 return Box({
565 key: 'limit-' + seg.label, flexDirection: 'column',
566 children: [
567 Box({ flexDirection: 'row', justifyContent: 'space-between', children: [
568 Text({ children: [seg.label] }),
569 Text({ dimColor: true, children: [seg.pct + '%' + (seg.reset ? ' · ' + seg.reset : '') + (i === 0 ? staleTag : '')] }),
570 ] }),
571 isDesktop && els.Svg
572 ? svgBox(els, 'limit-bar-' + seg.label, svgThinBar(seg.pct, fill), seg.label + ' ' + seg.pct + '%')
573 : Text({ color: termColor, children: [thinBar(seg.pct)] }),
574 ],
575 })
576 })
577}
578
579// ---------- Events ----------
580
581const toolSpec = () => ({
582 name: 'progress',
583 description: t('toolDescription'),
584 inputSchema: {
585 type: 'object',
586 properties: {
587 task: { type: 'string', description: t('toolTask') },
588 steps: {
589 type: 'array',
590 maxItems: 5,
591 description: t('toolSteps'),
592 items: {
593 type: 'object',
594 properties: {
595 title: { type: 'string', description: t('toolStepTitle') },
596 status: { type: 'string', enum: ['done', 'running', 'warn', 'bad', 'todo'] },
597 note: { type: 'string', description: t('toolStepNote') },
598 },
599 required: ['title', 'status'],
600 },
601 },
602 },
603 required: ['steps'],
604 },
605})
606
607export function register(on) {
608 // Open the pane first and push slow work such as the name lookup to the background (looking up the name first blocked session.start for about 1 s)
609 on('session.start', async ($, e, next) => {
610 // UI language: env var SESSION_DASHBOARD_LANG (default English; zh-TW for Traditional Chinese). The name must be a literal.
611 setLang(await $.env.get('SESSION_DASHBOARD_LANG').catch(() => undefined))
612 me.id = await $.session.id()
613 me.cwd = await $.session.cwd()
614 me.isWorking = false
615 me.titleCheckedAt = 0
616 me.firstSeen = me.firstSeen ?? (await $.clock.now())
617 if (AUTO_OPEN) await openPane($, false)
618 me.timers = [
619 // Registering the tool waits until it is actually usable (measured at about 0.9 s), so it also runs in the background and does not block session start
620 $.clock.after(0, async () => {
621 await $.command.register({ name: 'dashboard', description: t('commandDescription'), immediate: true })
622 await $.tool.register(toolSpec())
623 }),
624 $.clock.after(0, () => heartbeat($)),
625 $.clock.every(HEARTBEAT_MS, () => heartbeat($)),
626 $.clock.after(0, () => redrawIfChanged($)),
627 $.clock.every(CHANGE_CHECK_MS, () => redrawIfChanged($)),
628 $.clock.every(TIME_REDRAW_MS, () => $.ui.invalidate('ui.render')),
629 $.clock.every(FOCUS_REFRESH_MS, async () => {
630 if (await refreshFocus($)) await publish($)
631 }),
632 ]
633 return next(e)
634 })
635
636 // The Desktop view attaches later than session.start, and a pane opened before that is not shown, so open it again on attach
637 on('session.attach', async ($, e, next) => {
638 const result = await next(e)
639 if (AUTO_OPEN) await openPane($, false)
640 return result
641 })
642
643 // After /clear, /resume or /branch (session.start does not fire again): re-register under the new id
644 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
645 const result = await next(e)
646 await adoptSessionId($)
647 await heartbeat($)
648 $.ui.invalidate('ui.render')
649 return result
650 })
651
652 // Record every tool action (except the progress tool itself); observe only, never block; write to the store at most once per ACTIVITY_PUBLISH_MS
653 on('tool.call', async ($, e, next) => {
654 if (me.activity && e.tool !== PROGRESS_TOOL) {
655 const now = await $.clock.now()
656 me.activity = { ...me.activity, actions: pushAction(me.activity.actions, actionLabel(e), now) }
657 if (now - me.activityPublishedAt >= ACTIVITY_PUBLISH_MS) {
658 me.activityPublishedAt = now
659 await publish($)
660 }
661 $.ui.invalidate('ui.render')
662 }
663 return next(e)
664 })
665
666 // Claude reports step progress
667 on('tool.call', { tool: PROGRESS_TOOL }, async ($, e) => {
668 const now = await $.clock.now()
669 const prev = await $.store.get(PROGRESS_PREFIX + me.id)
670 const progress = mergeProgress(prev, { task: e.task, steps: e.steps }, now)
671 if (progress.steps.length === 0) await $.store.delete(PROGRESS_PREFIX + me.id)
672 else await $.store.set(PROGRESS_PREFIX + me.id, progress)
673 await publish($)
674 $.ui.invalidate('ui.render')
675 const stats = stepStats(progress.steps)
676 return { result: progress.steps.length === 0 ? t('progressCleared') : t('progressUpdated', stats.done, stats.total) }
677 })
678
679 // Read the background task list when a turn ends: Claude ends the turn after pushing work to the background, which must not count as idle (official docs: tell "done" apart from "waiting on background work")
680 on('classic.Stop', async ($, e, next) => {
681 me.background = summarizeBackground(e.background_tasks)
682 if (me.background) me.finishedAt = null
683 else if (!me.isWorking && me.finishedAt === null) await markFinished($)
684 await publish($)
685 $.ui.invalidate('ui.render')
686 return next(e)
687 })
688
689 on('turn.start', async ($, e, next) => {
690 await setWorking($, true)
691 await publish($)
692 $.ui.invalidate('ui.render')
693 return next(e)
694 })
695
696 on('turn.complete', async ($, e, next) => {
697 await setWorking($, false)
698 if (!me.background) await markFinished($)
699 await publish($)
700 $.ui.invalidate('ui.render')
701 $.clock.after(0, () => maybeRename($)) // Runs in the background so it does not delay the end of the turn
702 return next(e)
703 })
704
705 // Fallback when the rename tool cannot be called: attach sessionTitle on the next submitted prompt
706 on('classic.UserPromptSubmit', async ($, e, next) => {
707 const result = await next(e)
708 me.taskLabel = null
709 if (typeof e.prompt === 'string' && e.prompt.trim() !== '') $.clock.after(0, () => summarizeTask($, e.prompt))
710 if (!me.pendingTitle) return result
711 const sessionTitle = me.pendingTitle
712 me.pendingTitle = null
713 return { ...result, sessionTitle }
714 })
715
716 // When usage changes, save a copy for new sessions that have no reading yet (account-wide)
717 on('session.measure', async ($, e, next) => {
718 if (Array.isArray(e.rateLimits) && e.rateLimits.length > 0) {
719 await $.store.set(USAGE_KEY, { rateLimits: e.rateLimits, at: await $.clock.now() })
720 }
721 // No immediate redraw: usage is refreshed together with the per-minute redraw (by design)
722 return next(e)
723 })
724
725 // End: delete own data. On /clear and /resume the process keeps running, so stop publishing under the old id and wait for classic.SessionStart to adopt the new one;
726 // on a real exit, cancel the timers
727 on('session.end', async ($, e, next) => {
728 if (me.id) {
729 await $.store.delete(KEY_PREFIX + me.id)
730 await $.store.delete(PROGRESS_PREFIX + me.id)
731 }
732 me.id = ''
733 if (e.reason !== 'clear' && e.reason !== 'resume') {
734 for (const t of me.timers) t?.cancel?.()
735 me.timers = []
736 }
737 return next(e)
738 })
739
740 // The terminal uses /dashboard; Desktop's / menu does not list mod commands, so the button above the prompt is used instead
741 on('command.run', { command: 'dashboard' }, async ($) => {
742 await openPane($, true)
743 return {}
744 })
745
746 // Above the prompt: only the open-pane button; whatever later mods (e.g. next-steps) render goes below it
747 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
748 if (e.props.hasSurvey) return next(e)
749 const below = await next(e)
750 const { Box, Button } = $.ui.resolve(e)
751 // The button is an explicit action, so the pane opens at any width and on Desktop
752 const open = Button({ key: 'open-sessions', label: PANE_BUTTON_LABEL, plain: true, dimColor: true, onPress: () => openPane($, true) })
753 const button = open // An untracked session comes back when the user opens it, so no "show hidden" button is needed
754 return below ? Box({ flexDirection: 'column', children: [button, below] }) : button
755 })
756
757 // Sessions pane (single page): compute available rows → usage (pinned to the bottom) → other sessions get their guaranteed rows → the rest goes to the current session's latest 5 steps
758 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
759 if (e.requestId !== PANE_ID) return next(e)
760 const els = $.ui.resolve(e)
761 const { Box, Text } = els
762 const isDesktop = e.surface !== 'terminal'
763 const now = await $.clock.now()
764 const budget = e.props.scroll?.bodyRows || FALLBACK_BUDGET
765 let usageList = await usageSection($, els, isDesktop, now)
766 const usageCount = usageList.length === 1 && usageList[0].type === 'Text' ? 0 : usageList.length
767 // When the pane is very short (after the full usage block, not even name + 1 step fits), switch to compact usage: 1 line per limit
768 const compact = usageCount > 0 && budget - usageRows(usageCount) - 1 < 1 + ROWS.step
769 if (compact) usageList = await usageSection($, els, isDesktop, now, true)
770 const usageUsed = compact ? usageCount : usageRows(usageCount)
771 const contentRows = Math.max(1, budget - usageUsed - 1)
772 const { visible } = splitHidden(await loadOthers($, now), await loadHidden($))
773 // All other sessions are listed (no limit, by design); the current session keeps at least name + 1 step, and the pane becomes scrollable if that does not fit
774 const currentMin = 1 + ROWS.step
775 const shown = visible.length
776 const wrapExtra = visible.reduce((n, s) => n + wrapByWidth(s.name, nameCells(e.props.bodyColumns)).length - 1, 0)
777 const othersNeed = othersRows(shown, shown) + wrapExtra
778 const overflow = contentRows - othersNeed < currentMin
779 // SECTION_GAP blank lines between the current session and "other sessions"; this gap shrinks first when space is tight
780 const gap = shown > 0 ? Math.max(0, Math.min(SECTION_GAP, contentRows - othersNeed - currentMin)) : 0
781 const current = await currentSection($, els, isDesktop, now, overflow ? currentMin : contentRows - othersNeed - gap)
782 const children = [Box({ flexDirection: 'column', children: current })]
783 if (shown > 0) {
784 for (let g = 0; g < gap; g++) children.push(Text({ key: 'section-gap-' + g, children: [' '] }))
785 const rows = []
786 visible.slice(0, shown).forEach((s, i) => {
787 // 1 blank line between sessions, with a faint thin line drawn inside it
788 if (i > 0) rows.push(isDesktop && els.Svg ? svgBox(els, 'gap-' + s.id, svgSessionGap(), '') : Text({ key: 'gap-' + s.id, children: [' '] }))
789 rows.push(otherRow($, els, isDesktop, s, now, e.props.bodyColumns))
790 })
791 children.push(sectionDivider(els, isDesktop, e.props.bodyColumns), Box({ flexDirection: 'column', children: rows }))
792 }
793 // When it does not fit, do not fix the height or clip; let the pane show its own scrollbar
794 const content = overflow
795 ? Box({ key: 'content', flexDirection: 'column', flexShrink: 0, children })
796 : Box({ key: 'content', flexDirection: 'column', flexGrow: 1, flexShrink: 1, children })
797 const usage = Box({ key: 'usage', flexDirection: 'column', flexShrink: 0, rowGap: compact ? 0 : 1, children: usageList })
798 // Fill the available height with usage at the bottom; overflow:hidden is only a last resort, the content never exceeds it anyway
799 return overflow
800 ? Box({ flexDirection: 'column', rowGap: 1, children: [content, usage] })
801 : Box({ flexDirection: 'column', rowGap: 1, height: budget, children: [content, usage] })
802 })
803}
804hooks/i18n.js 116 lines1// UI strings. English is the default; set the env var SESSION_DASHBOARD_LANG=zh-TW
2// (for example in the "env" block of ~/.claude/settings.json) for Traditional Chinese.
3// To add a language, add a block below with the same keys.
4
5export const DEFAULT_LANG = 'en'
6
7export const STRINGS = {
8 en: {
9 othersTitle: 'Other sessions',
10 queuedHint: 'Not enough room for the pane. Press ☰ Sessions above the prompt to open it',
11 paneOpenFailed: (err) => 'Could not open the Sessions pane: ' + err,
12 jumpFailed: (name, err) => 'Could not jump to "' + name + '": ' + err,
13 working: 'Working',
14 background: 'background',
15 inProgress: 'In progress…',
16 noSteps: 'No steps in progress',
17 fromChecklist: 'from checklist',
18 usageEmpty: 'Usage: no data yet',
19 severeAlt: 'Severe problem',
20 doneCollapsed: (n) => n + ' done',
21 moreSteps: (n) => n + ' more',
22 commandDescription: 'Open the Sessions pane',
23 progressCleared: 'Cleared the steps on the pane',
24 progressUpdated: (done, total) => 'Pane updated: ' + done + '/' + total + ' done',
25 actEdit: (file) => 'Edit ' + file,
26 actRead: (file) => 'Read ' + file,
27 actRun: (d) => (d ? 'Run: ' + d : 'Run a command'),
28 actSearch: 'Search the code',
29 actWeb: 'Look something up online',
30 actAgent: (d) => (d ? 'Subagent: ' + d : 'Start a subagent'),
31 actTool: (tool) => 'Use ' + tool,
32 bgShell: 'Background command',
33 bgSubagent: 'Subagent',
34 bgMonitor: 'Monitor',
35 bgWorkflow: 'Workflow',
36 bgOther: 'Background work',
37 digestUser: 'User: ',
38 digestClaude: 'Claude: ',
39 renameTask: 'Give this conversation a short name that says what is being worked on, in the language the user writes in',
40 summaryTask: 'Condense the user request into a short phrase that says what to do, in the language the user writes in',
41 nameRule: (max) => '. At most ' + max + ' words (one English word counts as 1, one CJK character counts as 1). It must be a complete phrase, never cut off mid-way, and as short as possible. Reply with the name only: no quotes, no punctuation, no explanation.',
42 shortenTask: 'Rewrite this name to be shorter',
43 tooLong: (name, units) => 'The name "' + name + '" is ' + units + ' words, which is too long.',
44 toolDescription:
45 'Report the step-by-step progress of the current task to the Sessions pane. Call it when a multi-step task starts, when a step finishes, when you are blocked, and when the task is done; pass the full step list every time. ' +
46 'Split a task into at most 5 steps and keep each title to a few words (for example "Rework usage bar"), with no detail; a note is one sentence. ' +
47 'status: done, running, warn (a problem the user must handle), bad (a severe problem that may stop the work), todo. For warn and bad, the note says in one sentence what the user needs to do. ' +
48 'Passing an empty steps list clears the pane.',
49 toolTask: 'One-line name for the whole task (optional, a few words)',
50 toolSteps: 'At most 5 steps',
51 toolStepTitle: 'Step name, a few words',
52 toolStepNote: 'For warn/bad: one sentence on the problem and what the user needs to do',
53 },
54 'zh-TW': {
55 othersTitle: '其他 session',
56 queuedHint: '面板空間不夠,點輸入框上方的 ☰ Sessions 打開',
57 paneOpenFailed: (err) => 'Sessions 面板開不起來:' + err,
58 jumpFailed: (name, err) => '跳不到「' + name + '」:' + err,
59 working: '工作中',
60 background: '背景',
61 inProgress: '進行中…',
62 noSteps: '目前沒有進行中的步驟',
63 fromChecklist: '依清單',
64 usageEmpty: '用量:尚無資料',
65 severeAlt: '嚴重問題',
66 doneCollapsed: (n) => '已完成 ' + n + ' 步',
67 moreSteps: (n) => '還有 ' + n + ' 步',
68 commandDescription: '打開 Sessions 面板',
69 progressCleared: '已清除面板上的步驟',
70 progressUpdated: (done, total) => '已更新面板:' + done + '/' + total + ' 完成',
71 actEdit: (file) => '修改 ' + file,
72 actRead: (file) => '讀取 ' + file,
73 actRun: (d) => (d ? '執行指令:' + d : '執行指令'),
74 actSearch: '搜尋程式碼',
75 actWeb: '查網路資料',
76 actAgent: (d) => (d ? '派子代理:' + d : '派子代理'),
77 actTool: (tool) => '使用 ' + tool,
78 bgShell: '背景指令',
79 bgSubagent: '子代理',
80 bgMonitor: '監看',
81 bgWorkflow: '工作流程',
82 bgOther: '背景工作',
83 digestUser: '使用者:',
84 digestClaude: 'Claude:',
85 renameTask: '你替對話取一個繁體中文名稱,說出在做什麼即可',
86 summaryTask: '把使用者的請求濃縮成繁體中文,說出要做什麼即可',
87 nameRule: (max) => ',' + max + ' 個字以內(英文一個單字算 1 個字)。要完整、不要寫到一半,能短就短。只回名稱本身,不要引號、標點或解釋。',
88 shortenTask: '把名稱改寫得更短',
89 tooLong: (name, units) => '原本的名稱「' + name + '」有 ' + units + ' 個字,太長了。',
90 toolDescription:
91 '把目前任務的步驟進度回報到使用者的 Sessions 面板。多步驟任務開始時、每完成一步、卡住時、整件做完時呼叫;每次都傳完整的步驟清單。' +
92 '一個任務最多拆成 5 步,抓大方向就好;步驟名稱約 12 字以內、講大概即可(例如「改用量顯示」),不要寫細節;note 只寫一句話。' +
93 'status:done 完成、running 進行中、warn 有問題需要使用者處理、bad 嚴重問題可能停住(兩者的 note 都用一句話說明要使用者做什麼)、todo 還沒開始。' +
94 '傳空的 steps 會清掉面板上的步驟。',
95 toolTask: '整個任務的一句話名稱(選填,約 15 字以內)',
96 toolSteps: '最多 5 步',
97 toolStepTitle: '步驟名稱,約 12 字以內、講大概即可',
98 toolStepNote: 'warn/bad 時一句話說明問題與需要使用者做什麼',
99 },
100}
101
102let current = DEFAULT_LANG
103
104/** Pick the language from a locale-like code ("zh-TW", "zh_TW.UTF-8", "en"); unknown codes fall back to English. */
105export function setLang(code) {
106 const c = String(code ?? '').toLowerCase()
107 current = c.startsWith('zh') ? 'zh-TW' : DEFAULT_LANG
108 return current
109}
110
111/** Look up a UI string in the current language; function entries take arguments. */
112export function t(key, ...args) {
113 const entry = STRINGS[current][key] ?? STRINGS[DEFAULT_LANG][key]
114 return typeof entry === 'function' ? entry(...args) : entry
115}
116hooks/lib.js 636 lines1// Pure logic for session-dashboard: never touches $, so it can be unit-tested directly.
2import { t } from './i18n.js'
3
4// ===== Tunables (edit here) =====
5export const STALE_MS = 10 * 60 * 1000 // A session with no heartbeat for this long is treated as closed (crashed sessions get cleaned up too)
6export const BAR_WIDTH = 10 // Number of cells in the progress bar
7export const WARN_PCT = 50 // Usage >= this value is shown in yellow
8export const DANGER_PCT = 80 // Usage >= this value is shown in red
9
10// The kind names in $.session.usage().rateLimits (see SessionRateLimit in .claude-plugin/types/claude-code/index.d.ts)
11const WINDOW_LABELS = { five_hour: 'Session limit', seven_day: 'Weekly limit' }
12
13// TASKS.md checkbox lines: - [ ] / - [x] / * [X], indentation allowed
14const TASK_LINE = /^\s*[-*+]\s+\[( |x|X)\]\s+(.*)$/
15
16/** Percentage → "▓▓▓░░░" progress bar; values outside 0–100 are clamped. */
17export function bar(pct, width = BAR_WIDTH) {
18 const clamped = Math.max(0, Math.min(100, Number(pct) || 0))
19 const filled = Math.round((clamped / 100) * width)
20 return '▓'.repeat(filled) + '░'.repeat(width - filled)
21}
22
23/** Color for a usage percentage; undefined means the default color. */
24export function pctColor(pct) {
25 if (pct >= DANGER_PCT) return 'red'
26 if (pct >= WARN_PCT) return 'yellow'
27 return undefined
28}
29
30const pad2 = (n) => String(n).padStart(2, '0')
31
32/** Reset time (legacy format, kept for compatibility): HH:MM on the same day, otherwise M/D HH:MM. */
33export function formatReset(iso, nowMs) {
34 if (!iso) return ''
35 const t = new Date(iso)
36 if (Number.isNaN(t.getTime())) return ''
37 const hm = pad2(t.getHours()) + ':' + pad2(t.getMinutes())
38 const now = new Date(nowMs)
39 const sameDay = t.getFullYear() === now.getFullYear() && t.getMonth() === now.getMonth() && t.getDate() === now.getDate()
40 return sameDay ? hm : (t.getMonth() + 1) + '/' + t.getDate() + ' ' + hm
41}
42
43/**
44 * rateLimits → segments of the usage row, in the order 5h, weekly.
45 * Returns [{ label, pct, text, color }]; an empty array when there are no readings.
46 */
47export function usageSegments(rateLimits, nowMs) {
48 const list = Array.isArray(rateLimits) ? rateLimits : []
49 const segments = []
50 for (const kind of Object.keys(WINDOW_LABELS)) {
51 const w = list.find((r) => r && r.kind === kind)
52 if (!w || typeof w.percentUsed !== 'number') continue
53 const pct = w.percentUsed
54 const reset = relativeReset(w.resetsAt, nowMs)
55 const label = WINDOW_LABELS[kind]
56 segments.push({
57 label,
58 pct,
59 reset,
60 color: pctColor(pct),
61 text: label + ' ' + bar(pct) + ' ' + pct + '%' + (reset ? ' · ' + reset : ''),
62 })
63 }
64 return segments
65}
66
67/** The last path segment is the project name (handles both / and \). */
68export function folderName(cwd) {
69 const parts = String(cwd ?? '').split(/[\\/]+/).filter(Boolean)
70 return parts.length ? parts[parts.length - 1] : String(cwd ?? '')
71}
72
73/** Builds the checklist file path from the working folder (default TASKS.md), keeping cwd's original separator. */
74export function tasksPath(cwd, name = 'TASKS.md') {
75 const sep = String(cwd).includes('\\') ? '\\' : '/'
76 return String(cwd).replace(/[\\/]+$/, '') + sep + name
77}
78
79// Step checklist files, searched in order (edit here): the global CLAUDE.md specifies checklist.md, older projects still use TASKS.md
80export const CHECKLIST_FILES = ['checklist.md', 'TASKS.md']
81
82/** Fingerprint deciding whether the panel needs a redraw: only visible state (status light, name, progress); constantly changing values such as the heartbeat time are excluded. */
83export function othersSignature(entries, hiddenKeys = []) {
84 const shown = (entries ?? []).map((s) => [s.id, s.name, statusOf(s), s.stats?.pct ?? null, s.stats?.total ?? 0, s.eta ?? null, s.stepLabel ?? null])
85 return JSON.stringify([shown, [...hiddenKeys].sort()])
86}
87
88/**
89 * Keeps the sessions that are still alive, sorted by "first seen" time (earliest on top); the order never changes with heartbeats or work status afterwards, to avoid misclicks.
90 * Summaries from older versions without firstSeen go last, then ordered by id for stability.
91 */
92export function liveSessions(entries, nowMs, staleMs = STALE_MS) {
93 const first = (s) => (typeof s.firstSeen === 'number' ? s.firstSeen : Number.MAX_SAFE_INTEGER)
94 // When one Desktop session has changed its CLI session id (priorCliSessionIds), the old entry lingers until it expires; keep only the one with the most recent heartbeat, and use the earlier firstSeen so its position doesn't jump
95 const byDesktop = new Map()
96 const rest = []
97 for (const s of entries) {
98 if (!s || typeof s.lastSeen !== 'number' || nowMs - s.lastSeen > staleMs) continue
99 if (!s.desktopId) { rest.push(s); continue }
100 const prev = byDesktop.get(s.desktopId)
101 if (!prev) { byDesktop.set(s.desktopId, s); continue }
102 const [keep, drop] = s.lastSeen >= prev.lastSeen ? [s, prev] : [prev, s]
103 byDesktop.set(s.desktopId, { ...keep, firstSeen: Math.min(first(keep), first(drop)) })
104 }
105 return [...rest, ...byDesktop.values()]
106 .sort((a, b) => (first(a) - first(b)) || String(a.id).localeCompare(String(b.id)))
107}
108
109// ===== Step progress =====
110
111export const STEP_STATUSES = ['done', 'running', 'warn', 'bad', 'todo']
112
113/** Step identity: title + occurrence number among same-titled steps (each same-titled step keeps its own timing). */
114function stepKeys(steps) {
115 const seen = new Map()
116 return steps.map((s) => {
117 const n = (seen.get(s.title) ?? 0) + 1
118 seen.set(s.title, n)
119 return s.title + '#' + n
120 })
121}
122
123/**
124 * Merges the step list sent by the progress tool with the previous record to derive timing info.
125 * Every call sends the full list (idempotent); steps are matched by "title + occurrence number", recording when a step was first seen as non-todo and when it became done.
126 * Returns { task, startedAt, updatedAt, steps: [{ title, status, note, startedAt, doneAt }] }
127 */
128export function mergeProgress(prev, input, nowMs) {
129 const prevSteps = prev?.steps ?? []
130 const old = new Map(stepKeys(prevSteps).map((k, i) => [k, prevSteps[i]]))
131 const raw = (Array.isArray(input?.steps) ? input.steps : [])
132 .filter((s) => s && typeof s.title === 'string' && s.title.trim() !== '')
133 .map((s) => ({ ...s, title: s.title.trim() }))
134 const keys = stepKeys(raw)
135 const steps = raw.map((s, i) => {
136 const status = STEP_STATUSES.includes(s.status) ? s.status : 'todo'
137 const was = old.get(keys[i])
138 const startedAt = was?.startedAt ?? (status !== 'todo' ? nowMs : undefined)
139 const doneAt = status === 'done' ? (was?.doneAt ?? nowMs) : undefined
140 const note = typeof s.note === 'string' && s.note.trim() !== '' ? s.note.trim() : undefined
141 return { title: s.title, status, note, startedAt, doneAt }
142 })
143 // A whole new batch of tasks (none of the old steps remain) restarts the timer
144 const sameTask = keys.some((k) => old.has(k))
145 return {
146 task: typeof input?.task === 'string' && input.task.trim() !== '' ? input.task.trim() : (sameTask ? prev?.task : undefined),
147 startedAt: sameTask && prev?.startedAt ? prev.startedAt : nowMs,
148 updatedAt: nowMs,
149 steps,
150 }
151}
152
153/**
154 * TASKS.md → step list (fallback when Claude didn't call the progress tool).
155 * [x] = done; a [ ] containing BLOCKED = warn (the text after BLOCKED becomes the note); the first other [ ] = running; the rest are todo.
156 */
157export function parseTaskSteps(md) {
158 const steps = []
159 let runningAssigned = false
160 for (const line of String(md ?? '').split(/\r?\n/)) {
161 const m = line.match(TASK_LINE)
162 if (!m) continue
163 const text = m[2].trim()
164 if (m[1] !== ' ') {
165 steps.push({ title: text, status: 'done' })
166 continue
167 }
168 // CRITICAL (or its Chinese equivalent in the regex) = bad (red), BLOCKED = warn (orange)
169 const blocked = text.match(/^(.*?)\s*(BLOCKED|CRITICAL|嚴重)[::]?\s*(.*)$/)
170 if (blocked) {
171 steps.push({ title: blocked[1].trim() || text, status: blocked[2] === 'BLOCKED' ? 'warn' : 'bad', note: blocked[3].trim() || undefined })
172 } else if (!runningAssigned) {
173 steps.push({ title: text, status: 'running' })
174 runningAssigned = true
175 } else {
176 steps.push({ title: text, status: 'todo' })
177 }
178 }
179 return steps
180}
181
182/** Step stats: done count, total, percentage, whether any step is warn. */
183export function stepStats(steps) {
184 const list = Array.isArray(steps) ? steps : []
185 const done = list.filter((s) => s.status === 'done').length
186 const total = list.length
187 return {
188 done, total,
189 pct: total === 0 ? 0 : Math.round((done / total) * 100),
190 hasWarn: list.some((s) => s.status === 'warn' || s.status === 'bad'),
191 hasBad: list.some((s) => s.status === 'bad'),
192 }
193}
194
195/** Name of the step in progress: the first running one, otherwise the first unfinished one; null when all are done. */
196export function currentStepLabel(steps) {
197 const list = Array.isArray(steps) ? steps : []
198 return (list.find((s) => s.status === 'running') ?? list.find((s) => s.status !== 'done'))?.title ?? null
199}
200
201const WEEKDAYS_EN = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
202
203/** Relative reset time (abbreviated English): "resets in 25m", "resets in 2h", or "resets Fri" when more than a day away. */
204export function relativeReset(iso, nowMs) {
205 if (!iso) return ''
206 const t = new Date(iso)
207 if (Number.isNaN(t.getTime())) return ''
208 const min = Math.max(0, Math.round((t.getTime() - nowMs) / 60000))
209 if (min < 60) return 'resets in ' + min + 'm'
210 if (min < 24 * 60) return 'resets in ' + Math.round(min / 60) + 'h'
211 return 'resets ' + WEEKDAYS_EN[t.getDay()]
212}
213
214/**
215 * Estimated remaining time (minutes) = average duration of completed steps × remaining step count.
216 * Returns null when no step is done yet or there is no timing data (shown as "estimating").
217 */
218export function estimateRemainingMin(progress, nowMs) {
219 const steps = progress?.steps ?? []
220 const done = steps.filter((s) => s.status === 'done' && typeof s.doneAt === 'number')
221 const remaining = steps.filter((s) => s.status !== 'done').length
222 if (done.length === 0 || typeof progress?.startedAt !== 'number') return null
223 if (remaining === 0) return 0
224 const lastDone = Math.max(...done.map((s) => s.doneAt))
225 const avgMs = Math.max(0, lastDone - progress.startedAt) / done.length
226 return Math.max(1, Math.ceil((avgMs * remaining) / 60000))
227}
228
229/** Remaining time (abbreviated English): "~18m", "~1h 15m"; null = cannot estimate yet (returns an empty string), 0 = finished. */
230export function etaText(min) {
231 if (min === null || min === undefined) return ''
232 if (min === 0) return 'done'
233 if (min < 60) return '~' + min + 'm'
234 const m = min % 60
235 return '~' + Math.floor(min / 60) + 'h' + (m ? ' ' + m + 'm' : '')
236}
237
238/** Thin progress bar for the terminal (Desktop draws an Svg instead). */
239export function thinBar(pct, width = BAR_WIDTH) {
240 const clamped = Math.max(0, Math.min(100, Number(pct) || 0))
241 const filled = Math.round((clamped / 100) * width)
242 return '▰'.repeat(filled) + '▱'.repeat(width - filled)
243}
244
245// ===== Desktop graphics (Svg); color codes come from the Claude desktop sidebar design mockup =====
246
247// Colors (edit here)
248export const COLORS = { done: '#2f8f4e', warn: '#e07a10', bad: '#c8222b', running: '#0088b0', todo: '#a9a5a3', track: '#8883', line: '#8885' }
249export const LIMIT_WARN_PCT = 80 // Usage >= this value turns the bar orange
250export const LIMIT_BAD_PCT = 95 // Usage >= this value turns the bar red
251
252/** Usage bar color: blue normally, orange when high, red when nearly full. */
253export function limitColor(pct) {
254 if (pct >= LIMIT_BAD_PCT) return COLORS.bad
255 if (pct >= LIMIT_WARN_PCT) return COLORS.warn
256 return COLORS.running
257}
258
259/** 4px thin progress bar (mockup style: square corners, light gray track). */
260export function svgThinBar(pct, fill, width = 280, height = 4) {
261 const w = Math.round((Math.max(0, Math.min(100, Number(pct) || 0)) / 100) * width)
262 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
263 `<rect width="${width}" height="${height}" fill="${COLORS.track}"/>` +
264 (w > 0 ? `<rect width="${w}" height="${height}" fill="${fill}"/>` : '') + '</svg>'
265}
266
267export const NODE_HEIGHT = 48 // Height of each timeline node (px). Must be ≥ the height of one text row (title + one line of description) so the lines of adjacent nodes connect (edit here)
268const LINE_DONE = '#2f8f4e'
269const LINE_TODO = '#ffffff40'
270
271/**
272 * One timeline node: the dot sits in the center, with a connector segment above and below.
273 * kind: done / running / todo / warn / bad / collapsed (N steps done) / more (N more steps).
274 * label: text inside the dot (✓, step number, !, …). top / bottom: colors of the upper and lower segments, null = not drawn (the first node has no upper line, the last has no lower line).
275 */
276export function svgTimelineNode(kind, label, top, bottom, height = NODE_HEIGHT) {
277 const cx = 14
278 const cy = height / 2
279 const r = 11
280 const parts = []
281 if (top) parts.push(`<rect x="${cx - 1.5}" y="0" width="3" height="${cy}" fill="${top}"/>`)
282 if (bottom) parts.push(`<rect x="${cx - 1.5}" y="${cy}" width="3" height="${height - cy}" fill="${bottom}"/>`)
283 const fill = { done: COLORS.done, collapsed: COLORS.done, running: COLORS.running, warn: COLORS.warn, bad: COLORS.bad }[kind]
284 if (kind === 'running') parts.push(`<circle cx="${cx}" cy="${cy}" r="${r + 3}" fill="${COLORS.running}" fill-opacity="0.25"/>`)
285 if (fill) {
286 parts.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="${fill}"/>`)
287 } else {
288 // Not started / N more steps: hollow circle (filled with the background color first to cover the line passing through); "N more steps" uses a dashed stroke
289 parts.push(`<circle cx="${cx}" cy="${cy}" r="${r}" fill="#1f1f1e" stroke="${COLORS.todo}" stroke-width="2"${kind === 'more' ? ' stroke-dasharray="3 3"' : ''}/>`)
290 }
291 const color = fill ? '#ffffff' : COLORS.todo
292 parts.push(`<text x="${cx}" y="${cy + 4}" font-size="11" font-weight="600" text-anchor="middle" font-family="system-ui,sans-serif" fill="${color}">${esc(label)}</text>`)
293 return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="${height}" viewBox="0 0 28 ${height}">${parts.join('')}</svg>`
294}
295
296/**
297 * Lays out the fitSteps result as a list of timeline nodes: [N steps done] → shown steps → [N more steps].
298 * Each node carries its upper/lower line colors: green when both adjacent nodes are completed kinds (done / collapsed), otherwise gray; the first node has no upper line and the last has no lower line.
299 */
300export function timelineItems(fit) {
301 const items = []
302 if (fit.doneCollapsed > 0) items.push({ kind: 'collapsed', label: '✓', text: t('doneCollapsed', fit.doneCollapsed) })
303 for (const { step, index } of fit.shown) {
304 const kind = STEP_STATUSES.includes(step.status) ? step.status : 'todo'
305 const label = kind === 'done' ? '✓' : kind === 'warn' || kind === 'bad' ? '!' : String(index + 1)
306 items.push({ kind, label, step, index })
307 }
308 if (fit.more > 0) items.push({ kind: 'more', label: '…', text: t('moreSteps', fit.more) })
309 const isDone = (it) => it && (it.kind === 'done' || it.kind === 'collapsed')
310 const seg = (a, b) => (a && b ? (isDone(a) && isDone(b) ? LINE_DONE : LINE_TODO) : null)
311 return items.map((it, i) => ({ ...it, top: seg(items[i - 1], it), bottom: seg(it, items[i + 1]) }))
312}
313
314// ===== Desktop session records and jump links =====
315
316// Desktop jump links only accept ids of this form (taken from the Desktop app's link handler)
317const DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/
318
319/** Builds a jump link for a Desktop session id; returns null when the id format is invalid. */
320export function jumpUrl(desktopId) {
321 return DESKTOP_ID.test(String(desktopId ?? '')) ? 'claude://code/continue?session=' + desktopId : null
322}
323
324/** Whether a Desktop session record (%APPDATA%\Claude\claude-code-sessions\…\local_*.json) belongs to this Claude Code session. */
325export function desktopRecordMatches(record, cliId) {
326 if (!record || !cliId) return false
327 return record.cliSessionId === cliId || (Array.isArray(record.priorCliSessionIds) && record.priorCliSessionIds.includes(cliId))
328}
329
330/** Takes the last title from the transcript (jsonl): custom-title first, ai-title only if there is none. */
331export function titleFromTranscript(text) {
332 const s = String(text ?? '')
333 const last = (re) => {
334 let found = null
335 for (const m of s.matchAll(re)) found = m[1]
336 return found
337 }
338 const raw = last(/"customTitle":"((?:[^"\\]|\\.)*)"/g) ?? last(/"aiTitle":"((?:[^"\\]|\\.)*)"/g)
339 if (raw === null) return null
340 try {
341 return JSON.parse('"' + raw + '"')
342 } catch {
343 return raw
344 }
345}
346
347// ===== Second line of other sessions, and dividers =====
348
349/** Second line of another session (Desktop): the progress bar stretches almost to the right edge (same width as the usage bars and dividers); the percentage moves to the first line. */
350export function svgOtherLine(pct, width = 280) {
351 if (typeof pct !== 'number') return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="16" viewBox="0 0 ${width} 16"></svg>`
352 const w = Math.round((Math.max(0, Math.min(100, pct)) / 100) * width)
353 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="16" viewBox="0 0 ${width} 16">` +
354 `<rect x="0" y="6" width="${width}" height="4" rx="2" fill="${COLORS.track}"/>` +
355 (w > 0 ? `<rect x="0" y="6" width="${w}" height="4" rx="2" fill="${COLORS.running}"/>` : '') + '</svg>'
356}
357
358/** Blank row between other sessions (Desktop): a very faint thin line in the middle. */
359export function svgSessionGap(width = 280) {
360 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="16" viewBox="0 0 ${width} 16"><rect x="0" y="8" width="${width}" height="1" fill="#ffffff18"/></svg>`
361}
362
363/** Escapes & < > before putting text into an Svg, so names or titles can't break the image. */
364const esc = (t) => String(t).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
365
366/** Section divider (Desktop): thick solid line with the title text embedded in the middle. */
367export function svgSectionDivider(label, width = 280) {
368 const textW = 84
369 const side = Math.max(10, Math.round((width - textW) / 2))
370 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="16" viewBox="0 0 ${width} 16">` +
371 `<rect x="0" y="7" width="${side - 6}" height="3" fill="#ffffff66"/>` +
372 `<text x="${width / 2}" y="12" font-size="11" text-anchor="middle" font-family="system-ui,sans-serif" fill="#9a988f">${esc(label)}</text>` +
373 `<rect x="${width - side + 6}" y="7" width="${side - 6}" height="3" fill="#ffffff66"/></svg>`
374}
375
376// ===== Show the current action automatically when no steps are reported =====
377
378export const ACTIVITY_AFTER_MS = 60 * 1000 // When a turn runs longer than this without reporting through the progress tool, show the current action automatically (edit here)
379export const ACTIVITY_KEEP = 3 // Keep only this many most recent actions
380
381const baseName = (p) => folderName(p)
382const short = (s, n = 40) => {
383 const t = String(s ?? '').replace(/\s+/g, ' ').trim()
384 return t.length > n ? t.slice(0, n - 1) + '…' : t
385}
386
387// Text that looks like a path, command, or secret key (not shown; a generic name is used instead)
388const UNSAFE_TEXT = [
389 /[A-Za-z]:[\\/]/, // Windows path C:\ or C:/
390 /(^|\s)[~.]?\/[\w.-]+\//, // Unix path /a/b/, ~/x/, ./x/
391 /\|\||\||&&|;|>|<|`|\$\(/, // Pipes, chaining, redirection, subcommands
392 /(^|\s)--?[A-Za-z]/, // Command flags -x, --flag
393 /=/, // Assignment (environment variables, argument values)
394 /[A-Za-z0-9_\-]{24,}/, // Long alphanumeric run, likely a key or hash
395]
396
397/** Shows the description only when it is safe (truncated), otherwise returns fallback; keeps paths, commands, and keys off the panel. */
398export function safeText(text, fallback, max = 40) {
399 const t = String(text ?? '').replace(/\s+/g, ' ').trim()
400 if (!t || UNSAFE_TEXT.some((re) => re.test(t))) return fallback
401 return t.length > max ? t.slice(0, max - 1) + '…' : t
402}
403
404/**
405 * Tool call → a human-readable action name. Uses only the file name and the tool's own short description (sanitized first);
406 * never shows the full path or the command contents (which may contain passwords or keys).
407 */
408export function actionLabel(e) {
409 const tool = String(e?.tool ?? '')
410 if (['Edit', 'Write', 'NotebookEdit', 'MultiEdit'].includes(tool)) return t('actEdit', baseName(e.file_path ?? e.notebook_path ?? ''))
411 if (tool === 'Read') return t('actRead', baseName(e.file_path ?? ''))
412 if (tool === 'Bash' || tool === 'PowerShell') {
413 return t('actRun', safeText(e.description, ''))
414 }
415 if (tool === 'Grep' || tool === 'Glob') return t('actSearch')
416 if (tool === 'WebFetch' || tool === 'WebSearch') return t('actWeb')
417 if (tool === 'Agent' || tool === 'Task') {
418 return t('actAgent', safeText(e.description, ''))
419 }
420 return t('actTool', tool)
421}
422
423/** Puts the new action first and keeps only the most recent ACTIVITY_KEEP entries. */
424export function pushAction(actions, label, at) {
425 return [{ label, at }, ...(Array.isArray(actions) ? actions : [])].slice(0, ACTIVITY_KEEP)
426}
427
428/** Elapsed time (abbreviated English): "1m 12s", or "9s" under 1 minute. */
429export function formatElapsed(ms) {
430 const sec = Math.max(0, Math.floor(ms / 1000))
431 const m = Math.floor(sec / 60)
432 return m > 0 ? m + 'm ' + (sec % 60) + 's' : sec + 's'
433}
434
435/** Whether to show automatic activity: working, past the threshold, and no progress-tool steps in this turn. */
436export function showActivity(activity, hasToolSteps, nowMs, afterMs = ACTIVITY_AFTER_MS) {
437 return !!activity && typeof activity.startedAt === 'number' && !hasToolSteps && nowMs - activity.startedAt >= afterMs
438}
439
440// ===== Single-page layout (count rows first and draw only what fits, instead of relying on truncation) =====
441
442// Estimated rows per element (edit here). Desktop dots / thin bars are images whose real height can't be measured, so estimate conservatively: better to leave blank space than get a scrollbar
443export const ROWS = { step: 2, other: 2, limit: 2 }
444export const FALLBACK_BUDGET = 12 // Conservative value used when the available row count is unknown
445
446/** Rows of the usage block: 2 rows per bar, 1 blank row between bars; 1 row when there is no data. */
447export function usageRows(count) {
448 return count === 0 ? 1 : count * ROWS.limit + (count - 1)
449}
450
451export const WINDOW_SIZE = 5 // Max steps shown in the current-step area (centered on the current step; edit here)
452
453/**
454 * Centered on the "current step" (the first running one, otherwise the first unfinished one), takes the nearest WINDOW_SIZE steps (2 before + current + 2 after; near an edge, the other side is extended).
455 * When they don't fit, shrinks from the end farthest from the center; the current step is always kept.
456 * Returns { doneCollapsed (completed steps not shown), shown: [{ step, index }], more (unfinished steps not shown) }, with total rows ≤ maxRows.
457 */
458export function fitSteps(steps, maxRows) {
459 const list = Array.isArray(steps) ? steps : []
460 const n = list.length
461 const doneTotal = list.filter((s) => s.status === 'done').length
462 let center = list.findIndex((s) => s.status === 'running')
463 if (center === -1) center = list.findIndex((s) => s.status !== 'done')
464 if (center === -1) return { doneCollapsed: doneTotal, shown: [], more: 0 } // All done
465 const half = Math.floor(WINDOW_SIZE / 2)
466 let lo = Math.max(0, center - half)
467 let hi = Math.min(n - 1, lo + WINDOW_SIZE - 1)
468 lo = Math.max(0, hi - WINDOW_SIZE + 1)
469 const cost = (a, b) => {
470 const inside = list.slice(a, b + 1)
471 const doneOut = doneTotal - inside.filter((s) => s.status === 'done').length
472 const moreOut = (n - doneTotal) - inside.filter((s) => s.status !== 'done').length
473 return (inside.length + (doneOut > 0 ? 1 : 0) + (moreOut > 0 ? 1 : 0)) * ROWS.step
474 }
475 while (lo <= hi && cost(lo, hi) > maxRows) {
476 if (lo === hi) { lo = hi + 1; break } // Not even the current step fits
477 if (center - lo >= hi - center) lo++
478 else hi--
479 }
480 const shown = lo <= hi ? list.slice(lo, hi + 1).map((step, i) => ({ step, index: lo + i })) : []
481 if (shown.length === 0) return { doneCollapsed: 0, shown: [], more: 0 } // Not even the current step fits, so the collapsed nodes aren't drawn either (they each take ROWS.step rows)
482 const doneCollapsed = doneTotal - shown.filter((x) => x.step.status === 'done').length
483 const more = (n - doneTotal) - shown.filter((x) => x.step.status !== 'done').length
484 return { doneCollapsed, shown, more }
485}
486
487
488/** Rows of the other-sessions block: 1 divider + ROWS.other rows each + 1 blank row between them + 1 row for "…N more". */
489export function othersRows(shown, total) {
490 if (shown === 0) return 0
491 return 1 + shown * ROWS.other + (shown - 1) + (total > shown ? 1 : 0)
492}
493
494/** Title cleanup: strips quotes, punctuation, and line breaks. */
495export function cleanTitle(text) {
496 // Only strips quotes, punctuation, and extra lines; never truncates (a name cut in half is unreadable). Overlong names are left to nameUnits to detect, then the model is asked to rename
497 return String(text ?? '').split(/\r?\n/)[0].replace(/["'「」『』《》“”‘’*`#]/g, '').replace(/[。..!!??::]+$/, '').trim()
498}
499
500/** Name length: one English word (or a run of digits) counts as 1 unit, each CJK character counts as 1 unit, whitespace and symbols don't count. */
501export function nameUnits(text) {
502 return (String(text ?? '').match(/[A-Za-z0-9]+(?:[-_.'][A-Za-z0-9]+)*|[^\sA-Za-z0-9\p{P}\p{S}]/gu) ?? []).length
503}
504
505// ===== Status lights =====
506
507// Light colors (edit here): problem needing attention = red light, severe problem = red triangle icon, in progress = blue, finished but unread = orange, seen = gray hollow; there is no green light by design
508export const LIGHT = { bad: '#c8222b', warn: '#c8222b', working: '#0088b0', unread: '#e07a10', idle: '#77756f' }
509
510/** Icon for a severe problem (work may be unable to continue): red-outlined rounded triangle + exclamation mark, replacing the status dot. */
511export function svgAlertIcon(color = LIGHT.bad, size = 16) {
512 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">`
513 + `<path d="M12 3.5 L21.5 20 H2.5 Z" fill="none" stroke="${color}" stroke-width="2.2" stroke-linejoin="round"/>`
514 + `<path d="M12 9.5 V14" stroke="${color}" stroke-width="2.2" stroke-linecap="round"/>`
515 + `<circle cx="12" cy="17.2" r="1.3" fill="${color}"/></svg>`
516}
517
518/** Status of another session: bad / warn / working (including background) / unread (finished but not opened yet) / idle. */
519export function statusOf(s) {
520 const stats = s?.stats ?? {}
521 if (stats.hasBad) return 'bad'
522 if (stats.hasWarn) return 'warn'
523 if (s?.isWorking || s?.background) return 'working'
524 const seen = Math.max(s?.lastFocusedAt ?? 0, s?.seenAt ?? 0)
525 if (typeof s?.finishedAt === 'number' && s.finishedAt > seen) return 'unread'
526 return 'idle'
527}
528
529/** Right-side time: while working, shows the remaining time if it can be computed; once stopped, shows how long ago it finished (using the work end time, not the heartbeat time). */
530export function rightTime(s, nowMs) {
531 const busy = s?.isWorking || s?.background
532 if (busy) return s?.hasToolSteps && s?.eta ? s.eta : ''
533 return typeof s?.finishedAt === 'number' ? ago(s.finishedAt, nowMs) : ''
534}
535
536// ===== Background work =====
537
538// type of background_tasks (official BackgroundTaskSummary) → the localized fallback name (i18n key) used when there is no description
539const BG_TYPE_KEY = { shell: 'bgShell', subagent: 'bgSubagent', monitor: 'bgMonitor', workflow: 'bgWorkflow' }
540
541/**
542 * background_tasks from classic.Stop → { count, current }; returns null when empty or missing.
543 * Uses only the sanitized description, never the command contents (which may contain passwords or keys).
544 */
545export function summarizeBackground(tasks) {
546 const list = Array.isArray(tasks) ? tasks.filter((t) => t && typeof t === 'object') : []
547 if (list.length === 0) return null
548 const first = list[0]
549 const fallback = t(BG_TYPE_KEY[first.type] ?? 'bgOther')
550 return { count: list.length, current: safeText(first.description, fallback) }
551}
552
553// ===== Hide other projects =====
554
555/**
556 * Splits other sessions into "visible" and "hidden" according to the hide record ({ [sessionId]: { at } }).
557 * This is an "untrack": after ✕ is pressed the session is no longer shown; it only comes back once that session has been opened afterwards (lastFocusedAt > the untrack time),
558 * and is then listed in unhide so the caller can clear the record. Starting a new turn on its own or finishing background work does not bring it back.
559 * hiddenCount only counts sessions that are still open (others already excludes expired ones).
560 */
561export function splitHidden(others, hidden) {
562 const map = hidden && typeof hidden === 'object' ? hidden : {}
563 const visible = []
564 const unhide = []
565 let hiddenCount = 0
566 for (const s of others) {
567 const h = map[s.id]
568 if (!h) {
569 visible.push(s)
570 } else if (typeof s.lastFocusedAt === 'number' && s.lastFocusedAt > h.at) {
571 visible.push(s)
572 unhide.push(s.id)
573 } else {
574 hiddenCount += 1
575 }
576 }
577 return { visible, hiddenCount, unhide }
578}
579
580/** How long ago (abbreviated English): "just now", "40m ago", "2h 5m ago", "1d 3h ago". */
581export function ago(ms, nowMs) {
582 const min = Math.max(0, Math.floor((nowMs - ms) / 60000))
583 if (min < 1) return 'just now'
584 if (min < 60) return min + 'm ago'
585 const h = Math.floor(min / 60)
586 if (h < 24) return h + 'h' + (min % 60 ? ' ' + (min % 60) + 'm' : '') + ' ago'
587 const d = Math.floor(h / 24)
588 return d + 'd' + (h % 24 ? ' ' + (h % 24) + 'h' : '') + ' ago'
589}
590
591// ===== Auto-naming looks at the whole session =====
592
593/**
594 * Condenses the whole session into text for a small model to name it: every user message (up to 300 chars each) + the first Claude reply right after it (up to 150 chars);
595 * the tool activity in between is left out (too much would push out the earlier topic). When over budget chars, keeps the first 2 turns (the topic is usually at the start) and the most recent turns.
596 */
597export function conversationDigest(messages, budget = 6000) {
598 const turns = []
599 for (const m of messages ?? []) {
600 if (!m || typeof m.text !== 'string' || m.text.trim() === '') continue
601 if (m.role === 'user') turns.push({ user: m.text.trim().slice(0, 300), reply: null })
602 else if (turns.length > 0 && turns.at(-1).reply === null) turns.at(-1).reply = m.text.trim().slice(0, 150)
603 }
604 const lines = turns.map((turn) => t('digestUser') + turn.user + (turn.reply ? '\n' + t('digestClaude') + turn.reply : ''))
605 const join = (list) => list.join('\n')
606 if (join(lines).length <= budget) return join(lines)
607 const head = lines.slice(0, 2)
608 const tail = []
609 for (let i = lines.length - 1; i >= 2; i--) {
610 if (join([...head, '…', lines[i], ...tail]).length > budget) break
611 tail.unshift(lines[i])
612 }
613 return join([...head, '…', ...tail]).slice(0, budget)
614}
615
616// ===== Wrapping long text =====
617
618/** Wraps by display width: CJK characters count as 2 cells, others as 1; drops no characters, for name buttons where "every line must be clickable". */
619export function wrapByWidth(text, cells) {
620 const lines = []
621 let line = ''
622 let used = 0
623 for (const ch of String(text ?? '')) {
624 const w = ch.codePointAt(0) > 0x2e7f ? 2 : 1
625 if (used + w > cells && line !== '') {
626 lines.push(line)
627 line = ''
628 used = 0
629 }
630 line += ch
631 used += w
632 }
633 if (line !== '' || lines.length === 0) lines.push(line)
634 return lines
635}
636