SLOPSHOPPER

session-dashboard

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

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-10KeaneCloud/session-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dashboard
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ app │ ┃ No steps in progress ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Session limit 31% · resets in 1h ⏺ Update(src/auth.ts) │ ┃ ▰▰▰▱▱▱▱▱▱▱ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ☰ Sessions ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☰ Sessions ⟨Claude Code's own drawing⟩
Pane · Sessions
app No steps in progress Session limit 31% · resets in 1h ▰▰▰▱▱▱▱▱▱▱
README

session-dashboard

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.

What it does

  • Current session – a timeline of the task's steps (up to 5, centred on the one in progress). When Claude has not reported any steps, it shows a single line: what the task is and how many minutes it has been running.
  • Other sessions – one row each, with a status light, name, progress bar and time. Click a name to jump to that session.
  • Usage – the session (5-hour) limit and weekly limit, pinned to the bottom of the pane and refreshed once a minute.
  • Auto-naming – after 3 turns, a small model reads the whole conversation and gives the session a short name (10 words or fewer; a CJK character counts as one word). Sessions you renamed yourself are left alone.
  • ✕ Untrack – press it and the session disappears from the pane. It comes back when you open that session again.

Status lights

LightMeaning
Blue dotWorking (including background work that is still running)
Orange dotFinished, and you have not looked at it yet
Hollow dotYou have seen it
Red dotThere is a problem you need to handle
Red triangleA severe problem; the work may have stopped

Requirements

  • Claude Code 2.1.286 or later (for the desktop app, a build that bundles that version or later).
  • Developed and used on the Claude desktop app for Windows. Two features rely on Windows-only paths and commands and do nothing elsewhere:
  • reading a session's name and "last viewed" time from %APPDATA%\Claude\claude-code-sessions\;
  • jumping to another session (opens a claude:// link through rundll32.exe).
  • It also loads in the terminal, where the pane is drawn with text. Jumping between sessions and the "finished, not seen yet" light are not available there.

Install

  1. Clone the repository:
   git clone https://github.com/KeaneCloud/session-dashboard.git
  1. Add the folder to 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"
     }
   }
  1. Quit the Claude desktop app completely and open it again. A mod is loaded only when a session's process starts.

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.

Language

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.

Getting Claude to report steps

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

What costs usage

Two features call a small model (Haiku), each for a few tokens:

  • auto-naming – once per session (twice if the first name comes back too long);
  • the task summary line – once for each message you send.

To turn auto-naming off, set AUTO_RENAME to false at the top of hooks/register.js.

Settings

Tunable values are constants at the top of hooks/register.js and hooks/lib.js. Restart the app after changing them.

ConstantDefaultMeaning
AUTO_OPENtrueOpen the pane when a session starts
AUTO_RENAMEtrueName sessions automatically
RENAME_AFTER_TURNS3Turn after which a session is named
TITLE_MAX10Longest session name, in words
TASK_LABEL_MAX12Longest task summary, in words
TIME_REDRAW_MS60 sHow often times and usage are refreshed
CHANGE_CHECK_MS10 sHow often other sessions are checked for changes
LIGHT (lib.js)–Status light colours

What it reads, writes and sends

  • Shared store ($.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.
  • Files it reads: the desktop app's session records, and checklist.md / TASKS.md in the working folder.
  • What goes to the model: for auto-naming, what you said in that session and the first line of each of Claude's replies (both trimmed); for the task summary, the message you just sent. Nothing else leaves your machine.
  • The pane shows step names and summaries only, never full file paths or command text.

Development

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.

License

MIT

Source 3 files
hooks/register.js 804 lines
1// 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}
804
hooks/i18n.js 116 lines
1// 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}
116
hooks/lib.js 636 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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