GlanceFlow: see what Claude is doing, at a glance. Hides tool rows and shows one plain-English checklist above the prompt, with progress, helpers, history, a…

See what Claude is doing, at a glance. A calm checklist above the prompt: the plan, the step Claude is on, how far along it is, and a clear signal when Claude needs you.

<sub>A scripted job: the checklist is drawn by GlanceFlow 0.24.6 itself; the window and the notice around it are illustrative.</sub>
You need:
claude plugin test in any folder: "served off" means they aren't on yet.Then run:
claude plugin marketplace add Antreas-Strb/glanceflow
claude plugin install glanceflow@claude-mods
Then open a new chat. The first time, three short welcome cards above the prompt show what the checklist is, what Needs you means and where Settings and History are. Press Next ▶ or Skip; /glanceflow tour shows them again.
Update: claude plugin marketplace update claude-mods, then claude plugin update glanceflow@claude-mods, then open a new chat.
Coming from Glance? GlanceFlow is its new name. Uninstall the old one first (claude plugin uninstall glance@claude-mods), then install glanceflow@claude-mods. Your settings and history come along on the first chat; the command is now /glanceflow.
Remove: claude plugin uninstall glanceflow@claude-mods.
The button above the prompt cycles Simple → Details → Off; in the Desktop app it is the View menu at the top right, with Simple, Detailed and Off. The view and on/off are remembered after a restart.
62% 1m 52s · ~1m left in the terminal, or under a minute left in the Desktop app; finished steps show how long they tookExplore · Haiku 4.5 · low effort3k new · 45k cached; Claude's final answer counts in the job's total onlyPlan usage: 5-hour 42% · weekly 18% · chat 34% full (Usage: in the Desktop app)$0.42, where Claude Code keeps a cost (pay-as-you-go API use); the History panel shows it per job and per day
<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job. The tool rows in the chat above the checklist are not shown here.</sub>
Working · Build my landing page · 2 of 4 done · about 2m left). When the band is narrow, a header leaves out whole pieces rather than cut a word, and its bold words get shorter but stay whole (In the background, then Background). A screen reader reads each thing once: a line under a step starts in empty room, and an icon's label never repeats the words beside it (the current step's icon says Now, a warning's says Notice). The current step has a blue mark and the only bar; finished steps get a green ringed check; amber always means Claude needs you. The colour sits in the icon, and the words keep the app's text colour, so they read on a light window too. Every control is the app's own button. The View menu (Simple, Detailed or Off) sits at the top right; under the checklist the places to go (Plan, History, Settings) sit on the left, and everything that acts sits together on the right: Where we left off after a tidy-up, Tidy up now, Fresh chat, then Pause, Continue or Start, the main one last. Another mod that draws only buttons (Replay, say) has them join that group. The welcome cards and the empty band keep the same layout. Fresh chat shows once the job has stopped or finished; while a stopped job's chat is full, Tidy up now takes its place. At a usage limit the button reads Check again. Settings are rows with a menu each, as on a Mac. The panels have no extra Close buttons, since the app's own × closes them. The terminal keeps its one-cell symbols: where this README writes ▤ Plan, ≣ History, ⚙ Settings, ‖ Pause or ▶ Continue, the Desktop app shows the same button by its name alone./glanceflow approve on, and Claude shows its plan and waits. Press ▶ Start, or tell Claude what to change and it lays out a new plan.taking longer instead of a guess, and the total waits until it can be estimated again. A plan of more than five steps folds its finished steps, and the steps after the next one, into a line each; ▤ Plan shows them all.Now: Check the page on a phone screen, otherwise a short phrase like Reading files (3) or Running the tests. No file names or commands. In the terminal it sits under the current step; in the Desktop app it follows the step's bar.Added contact.html · changed styles.css and index.html), helpers' edits included, and ≣ History shows them under each job. Names only; the folder shows where two files share a name./glanceflow sound on plays a short chime when Claude needs you, gets stuck, or finishes a job that took over a minute, so you can look away. /glanceflow sound voice also says it ("Claude needs you"). Off by default (turn it on in ⚙ Settings). Works on macOS, Windows and Linux; on Linux it uses the sound player the computer has (PipeWire, PulseAudio or ALSA) and, for the words, speech-dispatcher or eSpeak./glanceflow notify on shows a notice from your computer when Claude needs you, gets stuck, or finishes a job that took over a minute, with the reason in it. You see it even while you work in another app. Off by default (turn it on in ⚙ Settings). Works on macOS, Windows and Linux./glanceflow calm on stops everything that moves (the sweeping bar, the helper spinners) and shows statuses in bold.?, or ; in Greek) shows Needs you and chimes, instead of All done. When Claude ends on a question, the Needs you line shows the question itself, in Claude's own last sentence (Which colour should the buttons be?), so you don't have to look through the chat for it. The status line and the desktop notice say the same: one plain line of up to 160 characters, with keys and passwords masked. Claude waiting for its own helpers is never Needs you.no news for 3 minutes and tells you that Esc stops Claude. With sounds or desktop notices on, you hear or see it too. It clears by itself as soon as Claude moves again./glanceflow guard off if it gets in your way.resets at 18:40); with desktop notices on, you get a notice too. A "Tidy it up" button ("Tidy up now" in the Desktop app) appears when the chat is half full (pick the point in ⚙ Settings). Tidying up first has Claude save a checkpoint of the work (goal, what is done and left, decisions, the next step); the compaction keeps it, and Claude keeps reading it for the rest of the chat, so nothing important is forgotten. Afterwards the checklist offers Where we left off, which shows you that checkpoint in a side panel (also in ⚙ Settings, or /glanceflow checkpoint).
<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows each header, for a sample job; the line under each says what it means.</sub>
Under the checklist, ‖ Pause stops Claude while it works, and ▶ Continue picks the same job up again, with nothing to type. Continue also shows after Esc, when Claude got stuck, or when it is waiting with steps left; in the Desktop app, after Claude got stuck it reads Try again, or Check again at a usage limit. The header says ‖ Paused after Pause and ■ Stopped after Esc. /glanceflow pause and /glanceflow continue do the same, and so does typing "continue" (or "συνέχισε") after Esc: the same job picks up, plan and all.
The checklist also survives closing Claude Code: resume the chat (claude --resume, or reopen it in the Desktop app) and it comes back as you left it. Work that was under way shows as ‖ Paused, so ▶ Continue picks it up; a plan waiting for Start still waits. GlanceFlow keeps the last 20 chats this way.
▤ Plan under the checklist (or /glanceflow plan) opens the whole plan in a side panel, with what the checklist has no room for: when the job started and about when it will be done, every step however long the plan, with long names shown whole, what each finished step got done and how long it took, about how long each step still to come should take, what Claude is doing now in its own words, and every helper. In Details it adds the command or file Claude is working on, tokens per step, the job's cost and plan usage. It stays live while Claude works, so the checklist above the prompt can stay short.

<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job.</sub>
⚙ Settings under the checklist (or /glanceflow settings) puts every choice in one panel. Each shows what is picked and a line on what that choice does: the view (Simple, Details or Off), sounds (with ▶ Play it to hear them), desktop notices, other chats (the band names another chat on this computer that needs you, from any project; turn it off before you share your screen, or use /glanceflow others off), calm mode, whether to approve the plan first, the password guard, and when to tidy up the chat (never, or at 40, 50, 60 or 75% full), with a Tidy up now button. Changes apply right away and stay for your next chats.

<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job.</sub>
≣ History under the checklist (or /glanceflow history) opens a side panel with today's jobs in this project: when each started, how it ended (done, stopped or stuck), steps done, how long it took and its tokens, the files it added or changed, with the day's totals at the bottom.

<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job.</sub>
In the panel, ◀ Earlier, the day drop-down, Later ▶ and Today move between days (Previous day and Next day in the Desktop app) (or type /glanceflow history yesterday, /glanceflow history 2026-10-06). The history stays on this computer and keeps 30 days.
Team report turns the day into a short update in plain words for the team, a manager or a CEO, and copies it to paste into Slack, Teams or an email: what got done (step by step, in Claude's words), what is still open and what is next, what needs a decision, and the time spent. This week switches it to the 7 days up to the day picked, for a weekly update or a retro. In the Desktop app the buttons read Day report and Week report. No tokens, models or file names, and quick questions are left out.

<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job.</sub>
Your week (or /glanceflow week; All projects this week in the Desktop app) is a short summary just for you, across every project: how many steps Claude checked off in how many tasks, the time at work, your busiest day and the biggest tasks. It covers the 7 days up to the day picked and is copied, ready to keep or share.
Your week with Claude · 30 Sept 2026 to 6 Oct 2026
Claude checked off 23 steps in 9 tasks, and finished 7 of them.
Time at work: 4 h 10 min, across 3 projects.
Busiest day: Tuesday, with 4 tasks.
Biggest tasks
• Build the pricing page (6 steps)
• Write the welcome email (4 steps)
• Fix the contact form (3 steps)
↻ Fresh chat under the checklist (or /glanceflow handoff), shown once the chat has some work in it (in the Desktop app, once the job has stopped or finished; while a stopped job's chat is full, Tidy up now takes its place), moves the work to a fresh chat: when a chat is too long, close to its limits, or whenever you want a clean start. Press it twice (the first press asks to confirm). Claude writes a short handoff note (goal, what is done, what is left, decisions, the next step), the chat is cleared, and the note is sent as the fresh chat's first message. The note is also saved: /glanceflow handoff note puts the last one back in the prompt box.
| Command | What it does |
|---|---|
/glanceflow | Turns GlanceFlow on or off |
/glanceflow on, /glanceflow off | Turns it on or off |
/glanceflow details | Switches between Simple and Details |
/glanceflow details on, /glanceflow details off | Picks Details or Simple |
/glanceflow history | Shows today's jobs in this project |
/glanceflow history yesterday, /glanceflow history 2026-10-06 | Shows another day |
/glanceflow week | Your week across every project, in a few lines, copied to share |
/glanceflow tour | Shows the three welcome cards again |
/glanceflow pause, /glanceflow continue | Pauses Claude, or picks the job up again |
/glanceflow plan | Opens the whole plan in a side panel |
/glanceflow settings | Opens the settings: view, sounds, desktop notices, other chats, calm mode, plan approval, password guard and when to tidy up |
/glanceflow tidy | Saves a checkpoint, then tidies up the chat |
/glanceflow checkpoint | Shows where we left off: the checkpoint saved before the last tidy-up |
/glanceflow tidy at 60, /glanceflow tidy off | When the checklist offers to tidy up |
/glanceflow handoff | Starts a fresh chat from a handoff note |
/glanceflow handoff note | Puts the last handoff note in the prompt box |
/glanceflow sound on, /glanceflow sound voice, /glanceflow sound off | A chime (and words) when Claude needs you, gets stuck or finishes a long job |
/glanceflow notify on, /glanceflow notify off | A notice from your computer when Claude needs you, gets stuck or finishes a long job |
/glanceflow calm on, /glanceflow calm off | Nothing moves; statuses in bold |
/glanceflow others on, /glanceflow others off | Whether the band names another chat on this computer that needs you |
/glanceflow guard on, /glanceflow guard off | Turns the password guard on or off |
/glanceflow approve on, /glanceflow approve off | Claude waits for ▶ Start before it works, or starts right away |
The button above the prompt cycles Simple, Details and Off; in the Desktop app the View menu at the top right picks Simple, Detailed or Off.
claude in VS Code's own terminal instead, or use the Desktop app's Code tab.Ask a question, suggest a feature or show how you use it in Discussions. For a clear bug, open an issue.
The secret and personal-detail patterns in glanceflow/hooks/privacy.ts are adapted from Nate Herk's Recording Mode (MIT licence). The overall progress and time-left idea comes from his Goal Meter.
MIT. See LICENSE. Third-party code is listed in THIRD-PARTY-NOTICES.md.
hooks/register.tsx 8 lines1import type { Register } from 'claude-code'
2
3import { registerGlance } from './glance'
4
5export const register: Register = on => {
6 registerGlance(on)
7}
8hooks/glance.tsx 3677 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderChildren, RenderElement, RenderSurface, Timer } from 'claude-code'
3
4import type {
5 GlanceFile,
6 GlanceChecklist,
7 GlanceHelper,
8 GlanceHistoryEntry,
9 GlanceHistoryView,
10 GlancePhase,
11 GlanceTask,
12 GlanceUsage,
13} from '../types'
14import {
15 HISTORY_PREFIX,
16 WEEK_DAYS,
17 clockTime,
18 dayEntries,
19 dayFromArgument,
20 dayKey,
21 entryFiles,
22 entryFromChecklist,
23 expiredHistoryKeys,
24 filesNote,
25 historyDay,
26 historyKey,
27 longDay,
28 mergedEntries,
29 projectName,
30 shiftDay,
31 weekSummary,
32 paceFromHistory,
33 plainDuration,
34 sharedDayKeys,
35 upsertEntry,
36} from './history'
37import { findSecrets, maskPrivate } from './privacy'
38import {
39 MAX_DETAIL,
40 MAX_NAME,
41 PLUGIN,
42 QUESTION,
43 RESUME_PREFIX,
44 WAITING,
45 activityOf,
46 activityTarget,
47 apiErrorSentence,
48 applyProgress,
49 asksQuestion,
50 carryTokens,
51 cleanName,
52 currentStepId,
53 durationWords,
54 formatCost,
55 formatDuration,
56 formatLeft,
57 formatTokens,
58 hasUnfinishedWork,
59 headerDetails,
60 isBusy,
61 isContinueWords,
62 isFinished,
63 isLatinText,
64 isStartWords,
65 jobCost,
66 leftLabel,
67 liftButtons,
68 localTimes,
69 needsText,
70 noticeBody,
71 otherChats,
72 otherChatsWords,
73 overallProgress,
74 ownWords,
75 prettyModel,
76 projectPath,
77 questionOf,
78 reconcileBackground,
79 reportOf,
80 resetTime,
81 sentenceCase,
82 sizeOf,
83 stampTimes,
84 staleResumeKeys,
85 stepEstimate,
86 timeLeft,
87 todosToTasks,
88 tokenNote,
89 tourNeeds,
90 wholePieces,
91 widthOf,
92 fit,
93 settle,
94 task,
95 toggled,
96} from './logic'
97
98import type { SavedChecklist } from './logic'
99import { kitOf } from './kit'
100import { GLYPH } from './look'
101import type { IconName } from './look'
102
103export { activityOf, asksQuestion, carryTokens, cleanName, fit, formatCost, formatTokens, headerDetails, isButtonsOnly, isContinueWords, isLatinText, isStartWords, liftButtons, localTimes, ownWords, prettyModel, questionOf, resetTime, sentenceCase, tokenNote, wholePieces } from './logic'
104const PLAN_TOOL = `mcp__${PLUGIN}__plan_steps`
105const PROGRESS_TOOL = `mcp__${PLUGIN}__report_progress`
106const ALWAYS_ALLOWED = new Set([
107 'ToolSearch',
108 'TodoWrite',
109 'TaskCreate',
110 'TaskUpdate',
111 'AskUserQuestion',
112 PLAN_TOOL,
113 PROGRESS_TOOL,
114])
115const STORE_KEY = 'glanceEnabled'
116const DETAIL_KEY = 'glanceDetail'
117const SOUND_KEY = 'glanceSound'
118const CALM_KEY = 'glanceCalm'
119const GUARD_KEY = 'glanceGuard'
120const APPROVE_KEY = 'glanceApprove'
121const NOTICE_KEY = 'glanceNotice'
122// Whether the band names the other chats that need the person; on unless turned off.
123const OTHERS_KEY = 'glanceOthers'
124const TOUR_KEY = 'glanceTour'
125/** The welcome, three short cards the first time: what the checklist is, what Needs you means, where the rest lives. */
126const TOUR_FIRST = 'A checklist shows here when you ask Claude for something with a few steps: each step, how far along it is, and about how long is left.'
127const TOUR_LAST = 'Settings, below, holds every choice. History shows your past jobs and Your week. To see this welcome again, type /glanceflow tour.'
128const TOUR = [TOUR_FIRST, tourNeeds('in yellow'), TOUR_LAST]
129/** Elsewhere than the terminal the words keep the text colour and the yellow is the bell beside them. */
130const TOUR_VECTOR = [TOUR_FIRST, tourNeeds('beside a yellow bell'), TOUR_LAST]
131const HANDOFF_KEY = 'lastHandoff'
132// Below this width the mode button drops the name; below METER_MIN_COLUMNS the bars go, so step names keep their room.
133const NARROW = 60
134const METER_MIN_COLUMNS = 50
135// Longer plans fold finished and later steps into one line each; the Plan panel has them all.
136const COMPACT_AFTER = 5
137const MAX_SUMMARY = 100
138const METER = 10
139const LABEL_WIDTH = 7
140const TICK_MS = 250
141// Calm mode redraws only to keep times current.
142const CALM_TICK_MS = 5000
143// A job shorter than this finishes without a sound.
144const LONG_JOB_MS = 60_000
145const COLLAPSE_MS = 5000
146const RESEND_WINDOW_MS = 2 * 60 * 1000
147const LIMIT_WARN = 80
148const LIMIT_ALERT = 95
149// How full the chat gets before the band offers to tidy it up, unless the person picks another point.
150const TIDY_AT_DEFAULT = 50
151const TIDY_CHOICES = [0, 40, 50, 60, 75] as const
152const TIDY_KEY = 'glanceTidyAt'
153const CHECKPOINT_KEY = 'lastCheckpoint'
154// Up to 0.24 every chat's checklist sat under this one key; now each has its own (RESUME_PREFIX).
155const OLD_RESUME_KEY = 'glanceResume'
156// The recent chats whose checklists are kept, so a resumed chat picks up where it was.
157const RESUME_KEEP = 20
158const LIMIT_LABEL: Record<string, string> = { five_hour: '5-hour', seven_day: 'weekly', spend_limit: 'spending' }
159
160const DENIED = 'you said no to a step, so Claude paused'
161const FAILING = 'a step keeps failing, Claude is trying another way'
162const STALLED = 'no news for 3 minutes. Press Esc to stop Claude, or give it a little longer'
163// No sign of life from Claude (a piece of its reply, a tool starting or ending) for this long while it works.
164const STALL_MS = 3 * 60_000
165const PERMISSION = "Answer Claude's request in the chat"
166// What a mark beside a state's own bold word says: the word follows, so a screen reader hears it once, not twice.
167const STATUS = 'Status'
168// What a mark beside a notice says: a usage limit, a full chat, a tidy-up, a panel with nothing in it yet. The words
169// beside it say which; a panel's own name is its title's, read just before.
170const NOTICE = 'Notice'
171const APPROVE = 'Read the plan, then press Start or tell Claude what to change'
172
173const enabledAtom = atom({ plugin: 'glanceflow', key: 'glanceEnabled' } as const, true)
174const detailAtom = atom({ plugin: 'glanceflow', key: 'detailLevel' } as const, 'simple')
175const checklistAtom = atom({ plugin: 'glanceflow', key: 'checklist' } as const, null)
176const tickAtom = atom({ plugin: 'glanceflow', key: 'tick' } as const, 0)
177const NO_USAGE: GlanceUsage = { limits: [], limitPercent: null, limitLabel: null, limitResetsAt: null, contextPercent: null, costUsd: null }
178const usageAtom = atom({ plugin: 'glanceflow', key: 'usage' } as const, NO_USAGE)
179const soundAtom = atom({ plugin: 'glanceflow', key: 'soundMode' } as const, 'off')
180const calmAtom = atom({ plugin: 'glanceflow', key: 'isCalm' } as const, false)
181const guardAtom = atom({ plugin: 'glanceflow', key: 'isGuarded' } as const, true)
182const approveAtom = atom({ plugin: 'glanceflow', key: 'approvePlan' } as const, false)
183const noticeAtom = atom({ plugin: 'glanceflow', key: 'isNoticing' } as const, false)
184const tourAtom = atom({ plugin: 'glanceflow', key: 'tourStep' } as const, null)
185const tidyAtAtom = atom({ plugin: 'glanceflow', key: 'tidyAt' } as const, TIDY_AT_DEFAULT)
186const checkpointAtom = atom({ plugin: 'glanceflow', key: 'checkpointAt' } as const, null)
187const recapAtom = atom({ plugin: 'glanceflow', key: 'isRecapShown' } as const, false)
188const othersAtom = atom({ plugin: 'glanceflow', key: 'otherChats' } as const, [])
189const showsOthersAtom = atom({ plugin: 'glanceflow', key: 'showsOthers' } as const, true)
190// shortcut: each read of the store parses its whole file, one per other chat; fine for 20 chats every 20 s, a single
191// shared key would read once but brings back the race of two chats saving at once.
192const OTHERS_EVERY_MS = 20_000
193const historyAtom = atom({ plugin: 'glanceflow', key: 'historyView' } as const, null)
194const HISTORY_PANE = 'glanceflow-history'
195const PLAN_PANE = 'glanceflow-plan'
196const RECAP_PANE = 'glanceflow-recap'
197const SETTINGS_PANE = 'glanceflow-settings'
198const handoffAtom = atom({ plugin: 'glanceflow', key: 'handoffState' } as const, 'idle')
199const HANDOFF_CONFIRM_MS = 8000
200// What the Continue button sends, as the person's own words; turn.start knows it and keeps the job going.
201const CONTINUE_TEXT = 'Please continue where you left off.'
202// What the Start button sends when the person approves the plan.
203const START_TEXT = 'The plan looks good. Please start.'
204const HANDOFF_PROMPT = `Write a handoff note so a brand-new chat can carry on this work without this conversation.
205Cover, briefly: the goal; what is already done; what is left, in order; decisions made and why; the files, commands
206or links that matter; and the very next step. Under 300 words, no preamble.
207Start with exactly: "Continuing from an earlier chat. Here is where things stand:"`
208
209const CHECKPOINT_PROMPT = `This chat is about to be compacted to free up room. Write a checkpoint so the work carries on
210without losing anything that matters. Cover, briefly: the goal; what is already done; what is left, in order; decisions
211made and why; what the person asked for or ruled out; the files, commands or links that matter; open questions; and the
212very next step. Under 300 words, no preamble. Start with exactly: "Checkpoint:"`
213
214const PROMPT_SECTION = `# GlanceFlow (progress checklist)
215The person follows your work on a checklist above the prompt, in plain English.
216- A question you can answer straight away, with no tools, needs no plan: just answer.
217- Before you use any other tool, call plan_steps (tool ${PLAN_TOOL}) with 2 to 8 steps in order. If it is deferred, load it with ToolSearch first. If this session has TodoWrite or TaskCreate you may use that to-do list as the plan instead.
218- Give each step a size in plan_steps' sizes list, in the same order: S (a few minutes), M, or L (the biggest pieces of work).
219- Then call report_progress (tool ${PROGRESS_TOOL}) as real progress happens, and with percent 100 the moment a step finishes. With percent 100, add a summary: one short plain-English sentence on what the step got done, like "Added a pricing table with three plans".
220- Write every step name in plain English a non-technical person understands, under 40 characters, starting with a verb, like "Build the pricing section".
221- Never put file paths, file names, commands, code or tool names in a step name.`
222
223const SPINNER = ['◐', '◓', '◑', '◒']
224const HELPERS_PER_STEP = 3
225
226// The tools that write files, and the field each names the file in.
227const FILE_TOOLS: Record<string, 'file_path' | 'notebook_path'> = { Write: 'file_path', Edit: 'file_path', MultiEdit: 'file_path', NotebookEdit: 'notebook_path' }
228
229type $ = EngineInterface
230
231// Module state: a reload starts these over (session.start restarts the ticker).
232let ticker: Timer | undefined
233let collapse: Timer | undefined
234let saveTimer: Timer | undefined
235// The save under way, so the chat's end waits for it rather than have it land after.
236let saving: Promise<void> = Promise.resolve()
237let othersTimer: Timer | undefined
238// Set when the chat ends: anything still saved after that (a late job name, say) keeps it marked closed.
239let hasEnded = false
240let isRefreshing = false
241let failuresInARow = 0
242// A message held back for a password: memory only, never stored, and only to let the same one through on a resend.
243let heldMessage: { text: string; at: number } | null = null
244let limitLevel = 0
245// The main loop's running turn, for the Pause button; and whether a pause asked for its end.
246let runningTurn: string | undefined
247let isPausing = false
248// Mirrors the calm setting for the ticker, which runs outside any hook.
249let isCalmMode = false
250/** The computer this runs on, asked when the session starts; null until known, and Claude Code plays the sounds itself. */
251let platform: 'mac' | 'windows' | 'linux' | null = null
252let tickerEvery = 0
253// Signs of life: set as Claude's reply streams or a tool starts or ends, stamped by the ticker, which runs while Claude works.
254let hasSign = false
255let lastSignAt = 0
256let isStalled = false
257
258const now = ($: $) => $.clock.now()
259
260const syncTicker = ($: $, list: GlanceChecklist | null) => {
261 const isAnimated = list !== null && (list.phase === 'working' || list.phase === 'needsYou' || list.phase === 'background')
262 // Calm mode: nothing moves, so the clock only ticks to keep the times current.
263 const every = isCalmMode ? CALM_TICK_MS : TICK_MS
264 if (ticker !== undefined && (!isAnimated || tickerEvery !== every)) {
265 ticker.cancel()
266 ticker = undefined
267 }
268 if (isAnimated && ticker === undefined) {
269 tickerEvery = every
270 ticker = $.clock.every(every, () => {
271 void update($, tickAtom, tick => (tick ?? 0) + 1)
272 void checkStall($)
273 })
274 }
275}
276
277/** Claude showed a sign of life; a job that looked stalled is working again. */
278const noteSign = ($: $) => {
279 hasSign = true
280 if (isStalled) {
281 isStalled = false
282 void setWorking($)
283 }
284}
285
286/** On each tick: a running turn with no sign of life for 3 minutes may be stuck. */
287const checkStall = async ($: $) => {
288 const at = await now($)
289 if (hasSign || runningTurn === undefined) {
290 hasSign = false
291 lastSignAt = at
292 return
293 }
294 if (isStalled || at - lastSignAt < STALL_MS || !(await read($, enabledAtom))) return
295 if ((await read($, checklistAtom))?.phase !== 'working') return
296 isStalled = true
297 await setStuck($, STALLED)
298}
299
300const ALERTS: Partial<Record<GlancePhase, { asset: string; words: string }>> = {
301 needsYou: { asset: 'sounds/needs-you.wav', words: 'Claude needs you' },
302 stuck: { asset: 'sounds/stuck.wav', words: 'Claude is stuck' },
303 done: { asset: 'sounds/done.wav', words: 'All done' },
304}
305
306const findPlatform = async ($: $) => {
307 if ((await $.env.get('OS')) === 'Windows_NT') return 'windows' as const
308 const { stdout } = await $.process.run(['uname', '-s']).catch(() => ({ stdout: '' }))
309 const name = stdout.trim()
310
311 return name === 'Darwin' ? ('mac' as const) : name ? ('linux' as const) : null
312}
313
314/** Runs the first of these commands that this computer has; rejects when it has none. */
315const runFirst = async ($: $, commands: readonly (readonly string[])[]) => {
316 for (const argv of commands) {
317 try {
318 const { exitCode } = await $.process.run(argv)
319 if (exitCode === 0) return
320 } catch {
321 // Not installed here: try the next one.
322 }
323 }
324 throw new Error('No sound player on this computer')
325}
326
327/** Plays one of GlanceFlow's sounds: Claude Code's own player on a Mac, the computer's own player on Windows and Linux. */
328const playSound = async ($: $, asset: string) => {
329 if (platform === 'windows') {
330 const file = `${$.plugin.root}\\${asset.replaceAll('/', '\\')}`.replaceAll("'", "''")
331 return runFirst($, [['powershell', '-NoProfile', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`]])
332 }
333 if (platform !== 'linux') return $.audio.play({ asset })
334 const file = `${$.plugin.root}/${asset}`
335 return runFirst($, [['paplay', file], ['pw-play', file], ['aplay', '-q', file]])
336}
337
338/** Says a few words: the platform's own voice, or on Windows and Linux the speech tools they come with. */
339const sayWords = async ($: $, words: string) => {
340 try {
341 await $.audio.speak(words)
342 } catch (error) {
343 if (platform !== 'windows' && platform !== 'linux') throw error
344 const quoted = words.replaceAll("'", "''")
345 await runFirst(
346 $,
347 platform === 'windows'
348 ? [['powershell', '-NoProfile', '-Command', `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${quoted}')`]]
349 : [['spd-say', '-w', words], ['espeak-ng', words], ['espeak', words]],
350 )
351 }
352}
353
354/** Windows' own toast, shown as Windows PowerShell's; the text comes in through the environment, never as script. */
355const WINDOWS_TOAST = [
356 '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
357 '$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
358 "$text = $xml.GetElementsByTagName('text')",
359 '$text.Item(0).AppendChild($xml.CreateTextNode($env:GLANCEFLOW_TITLE)) | Out-Null',
360 '$text.Item(1).AppendChild($xml.CreateTextNode($env:GLANCEFLOW_BODY)) | Out-Null',
361 "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($xml))",
362].join('; ')
363
364/** A desktop notice through the computer's own notifications: Windows, macOS or Linux. The text goes in as arguments or variables, never as script. */
365const showNotice = async ($: $, title: string, body: string) => {
366 if (platform === 'windows') {
367 await $.process
368 .run(['powershell', '-NoProfile', '-Command', WINDOWS_TOAST], { env: { GLANCEFLOW_TITLE: title, GLANCEFLOW_BODY: body } })
369 .catch(() => undefined)
370 return
371 }
372 const script = ['-e', 'on run argv', '-e', 'display notification (item 2 of argv) with title (item 1 of argv)', '-e', 'end run']
373 try {
374 await $.process.run(['osascript', ...script, title, body])
375 } catch {
376 // No osascript: not a Mac. notify-send is Linux's; elsewhere the band still shows it.
377 await $.process.run(['notify-send', '--app-name=Claude Code', title, body]).catch(() => undefined)
378 }
379}
380
381/** A desktop notice when Claude needs you, gets stuck or finishes a long job, as for the sounds. */
382const noticeFor = async ($: $, list: GlanceChecklist) => {
383 const alert = ALERTS[list.phase]
384 if (!alert || !(await read($, noticeAtom)) || !(await read($, enabledAtom))) return
385 if (list.phase === 'done' && (isBusy(list) || (list.finishedAt ?? (await now($))) - list.startedAt < LONG_JOB_MS)) return
386 await showNotice($, alert.words, noticeBody(list))
387}
388
389/** A short sound (and, in voice mode, a few words) when Claude needs you, gets stuck or finishes a long job. */
390const alertFor = async ($: $, list: GlanceChecklist) => {
391 const alert = ALERTS[list.phase]
392 const mode = await read($, soundAtom)
393 if (!alert || mode === 'off' || !(await read($, enabledAtom))) return
394 // A quick answer needs no sound, and a job whose helpers still run is not finished yet.
395 if (list.phase === 'done' && (isBusy(list) || (list.finishedAt ?? (await now($))) - list.startedAt < LONG_JOB_MS)) return
396 try {
397 await playSound($, alert.asset)
398 if (mode === 'voice') await sayWords($, alert.words)
399 } catch {
400 // No player or voice on this computer: the band still shows it.
401 }
402}
403
404/** Keeps this chat's checklist, a second after it changes, for when the chat is resumed. */
405const saveSoon = ($: $) => {
406 if (saveTimer !== undefined) return
407 saveTimer = $.clock.after(1000, () => {
408 saveTimer = undefined
409 saving = saveChecklist($).catch(() => undefined)
410 })
411}
412
413/** Saves this chat's checklist under its own key; `isOpen` false once the chat has ended, so the others stop naming it. */
414const saveChecklist = async ($: $, isOpen = !hasEnded, id?: string) => {
415 const key = `${RESUME_PREFIX}${id ?? (await $.session.id())}`
416 const list = await read($, checklistAtom)
417 if (list === null) await $.store.delete(key)
418 else await $.store.set(key, { at: await now($), list, isOpen } satisfies SavedChecklist)
419}
420
421/** Reads what the other chats saved and keeps those worth a word; writes only on a change, so the band redraws then. */
422const refreshOthers = async ($: $) => {
423 // One read at a time: a slow one that finished after a newer one would bring back what has changed since.
424 if (isRefreshing) return
425 isRefreshing = true
426 try {
427 // Off says nothing, and turning back on starts from a fresh read rather than an old list.
428 if (!(await read($, enabledAtom)) || !(await read($, showsOthersAtom))) {
429 await update($, othersAtom, () => [])
430 return
431 }
432 const mine = `${RESUME_PREFIX}${await $.session.id()}`
433 const saved: (SavedChecklist | undefined)[] = []
434 for (const key of await $.store.keys()) {
435 if (key.startsWith(RESUME_PREFIX) && key !== mine) saved.push((await $.store.get(key)) as SavedChecklist | undefined)
436 }
437 const others = otherChats(saved, await now($))
438 if (JSON.stringify(await read($, othersAtom)) !== JSON.stringify(others)) await update($, othersAtom, () => others)
439 } catch {
440 // A store that can't be read says nothing, rather than keep naming chats that may have moved on.
441 await update($, othersAtom, () => [])
442 } finally {
443 isRefreshing = false
444 }
445}
446
447/**
448 * Moves the one shared key of older versions to a key per chat, then keeps only the most recent chats, never this one.
449 * Moved checklists aren't marked open: those chats are named again only once they save, so a closed one never is.
450 */
451const tidySavedChecklists = async ($: $) => {
452 const mine = `${RESUME_PREFIX}${await $.session.id()}`
453 const old = await $.store.get(OLD_RESUME_KEY)
454 if (old !== undefined) {
455 for (const [id, saved] of Object.entries((old ?? {}) as Record<string, SavedChecklist>)) {
456 // shortcut: the store has no write-if-unchanged, so a chat that saves between this check and the set loses that
457 // save until its next one; checking right before the set keeps that window as small as it can be.
458 if ((await $.store.get(`${RESUME_PREFIX}${id}`)) === undefined) await $.store.set(`${RESUME_PREFIX}${id}`, saved)
459 }
460 await $.store.delete(OLD_RESUME_KEY)
461 }
462 const saved: [string, number][] = []
463 for (const key of (await $.store.keys()).filter(one => one.startsWith(RESUME_PREFIX) && one !== mine)) {
464 saved.push([key, ((await $.store.get(key)) as SavedChecklist | undefined)?.at ?? 0])
465 }
466 const stale = new Set(staleResumeKeys(saved, RESUME_KEEP - 1))
467 for (const [key, at] of saved) {
468 // A chat that saved since it was read is in use again: it stays.
469 if (stale.has(key) && (at === 0 || ((await $.store.get(key)) as SavedChecklist | undefined)?.at === at)) await $.store.delete(key)
470 }
471}
472
473/** A resumed chat gets its checklist back; work that was under way shows as paused, with Continue. */
474const restoreChecklist = async ($: $) => {
475 const id = await $.session.id().catch(() => null)
476 const saved = id === null ? undefined : ((await $.store.get(`${RESUME_PREFIX}${id}`)) as SavedChecklist | undefined)
477 if (saved === undefined || isFinished(saved.list)) return
478 const list = saved.list
479 // Still true after a restart: a plan waiting for Start, or Claude waiting for a reply. Anything else was cut off.
480 const holds = list.phase === 'stopped' || (list.phase === 'needsYou' && (list.needsYouReason === APPROVE || list.needsYouReason === WAITING))
481 const restored: GlanceChecklist = {
482 ...list,
483 activity: null,
484 isCollapsed: false,
485 helpers: list.helpers.filter(one => one.status !== 'running'),
486 ...(holds ? {} : { phase: 'stopped' as const, stopKind: 'pause' as const, needsYouReason: null, stuckReason: null, finishedAt: saved.at }),
487 }
488 await update($, checklistAtom, () => restored)
489 syncTicker($, restored)
490 // Saved again as open, so the other chats see it as it is now.
491 saveSoon($)
492}
493
494const change = async ($: $, fn: (list: GlanceChecklist) => GlanceChecklist | null) => {
495 const at = await now($)
496 const before: { phase: GlancePhase | null } = { phase: null }
497 const next = await update($, checklistAtom, list => {
498 before.phase = list?.phase ?? null
499 const changed = list ? fn(list) : list
500 return changed ? { ...changed, tasks: stampTimes(changed.tasks, at) } : changed
501 })
502 syncTicker($, next)
503 saveSoon($)
504 if (next !== null && next.phase !== before.phase) {
505 void alertFor($, next)
506 void noticeFor($, next)
507 }
508
509 return next
510}
511
512/** Every chat's jobs on these days (all kept days when none are named), each once, oldest first. */
513const readHistory = async ($: $, days?: readonly string[]): Promise<GlanceHistoryEntry[]> => {
514 const stored: unknown[] = []
515 for (const key of await $.store.keys()) {
516 if (key.startsWith(HISTORY_PREFIX) && (days === undefined || days.includes(historyDay(key)))) stored.push(await $.store.get(key))
517 }
518
519 return mergedEntries(stored)
520}
521
522/** This project's pace from the History of the last 30 days; null when it isn't known yet. */
523const learnPace = async ($: $): Promise<number | null> => {
524 try {
525 return paceFromHistory(await readHistory($), await $.session.cwd())
526 } catch {
527 return null
528 }
529}
530
531const startJob = async ($: $, text: string, jobId: string) => {
532 const previous = await read($, checklistAtom)
533 const list: GlanceChecklist = {
534 // Until Haiku names it in English, a request in another language shows a neutral title.
535 title: isLatinText(text.split('\n')[0] ?? '') ? cleanName(text.split('\n')[0]) : 'Working on your request',
536 phase: 'working',
537 tasks: stampTimes([task('Understand your request', 'active'), task('Plan the steps', 'upcoming')], await now($)),
538 needsYouReason: null,
539 stuckReason: null,
540 startedAt: await now($),
541 finishedAt: null,
542 isCollapsed: false,
543 hasPlan: false,
544 jobId,
545 planAt: null,
546 plannedCount: 0,
547 extraTokens: 0,
548 extraCachedTokens: 0,
549 stopKind: null,
550 activity: null,
551 approval: 'none',
552 costAtStart: (await read($, usageAtom)).costUsd,
553 paceMs: await learnPace($),
554 // Work still running from the last job stays in view.
555 helpers: (previous?.helpers ?? []).filter(one => one.status === 'running'),
556 }
557 failuresInARow = 0
558 collapse?.cancel()
559 await update($, checklistAtom, () => list)
560 syncTicker($, list)
561 saveSoon($)
562 // Name the job in the background; a newer job wins.
563 $.clock.after(0, () => void nameJob($, text, jobId))
564}
565
566const nameJob = async ($: $, text: string, jobId: string) => {
567 try {
568 const answer = await $.model.complete({
569 model: 'haiku',
570 effort: 'low',
571 timeoutMs: 15000,
572 prompt:
573 'Name this request in 2 to 6 plain English words that start with a verb, ' +
574 'like "Build my landing page", in sentence case: only the first word and names get a capital. ' +
575 'Always in English, even when the request is in another language. ' +
576 'No file names, code, quotes or punctuation. ' +
577 `Reply with the name only.\n\nRequest:\n${text.slice(0, 2000)}`,
578 })
579 if (!answer.isAnswered) {
580 return
581 }
582 const title = sentenceCase(cleanName((answer.text.split('\n')[0] ?? '').replace(/["'.]/g, '')))
583 await change($, list => (list.jobId === jobId ? { ...list, title } : list))
584 } catch {
585 // The placeholder title stays.
586 }
587}
588
589// A plan waiting for Start stays on Needs you while Claude finishes laying it out.
590const setWorking = ($: $) =>
591 change($, list =>
592 list.approval !== 'waiting' && (list.phase === 'needsYou' || list.phase === 'stuck')
593 ? { ...list, phase: 'working', needsYouReason: null, question: null, stuckReason: null }
594 : list,
595 )
596
597const setNeedsYou = ($: $, reason: string) =>
598 change($, list =>
599 list.phase === 'done' || list.phase === 'stopped' ? list : { ...list, phase: 'needsYou', needsYouReason: reason, question: null },
600 )
601
602/** Claude wrote or edited a file: the current step keeps it, created or changed, once. */
603const noteFile = async ($: $, path: string, isNew: boolean) => {
604 const file: GlanceFile = { path: projectPath(path, await $.session.cwd().catch(() => '')), isNew }
605 await change($, list => {
606 const stepId = currentStepId(list)
607 return {
608 ...list,
609 tasks: list.tasks.map(one =>
610 one.id !== stepId || (one.files ?? []).some(known => known.path === file.path) ? one : { ...one, files: [...(one.files ?? []), file] },
611 ),
612 }
613 })
614}
615
616const setStuck = ($: $, reason: string) =>
617 change($, list => ({ ...list, phase: 'stuck', stuckReason: reason, needsYouReason: null }))
618
619/** Stops Claude's running turn; Continue picks it up again. */
620const pauseJob = async ($: $) => {
621 if (runningTurn === undefined) {
622 $.ui.toast('Nothing is running right now.')
623 return
624 }
625 isPausing = true
626 try {
627 await $.turn.abort({ turnId: runningTurn })
628 $.ui.toast('Paused. Press Continue to pick up where Claude stopped.')
629 } catch {
630 isPausing = false
631 $.ui.toast("Couldn't pause right now. Press Esc to stop instead.")
632 }
633}
634
635/** Sends "continue" for the person: the same job goes on, nothing to type. */
636const continueJob = async ($: $) => {
637 try {
638 await $.prompt.submit({ text: CONTINUE_TEXT, asUser: true })
639 } catch {
640 $.ui.toast("Couldn't continue right now. Type: continue")
641 }
642}
643
644/** Loads one day of this project's history into the pane, and opens the pane when asked. */
645const showHistory = async ($: $, day: string, isOpening: boolean) => {
646 const project = await $.session.cwd()
647 // Entries saved before the report fields existed get empty ones.
648 const filled = (one: GlanceHistoryEntry) => ({ ...one, doneSteps: one.doneSteps ?? [], openSteps: one.openSteps ?? [], isQuickAnswer: one.isQuickAnswer ?? false })
649 const entries = (await readHistory($, [day]))
650 .filter(one => one.project === project)
651 .map(filled)
652 const today = dayKey(await now($))
653 const saved = (await $.store.keys()).filter(key => key.startsWith(HISTORY_PREFIX)).map(historyDay)
654 const days = [...new Set([today, day, ...saved])].sort().reverse()
655 const myWeek = await readHistory($, Array.from({ length: WEEK_DAYS }, (_, back) => shiftDay(day, -back)))
656 await update($, historyAtom, (view): GlanceHistoryView => ({
657 day,
658 project,
659 entries,
660 days,
661 isReportShown: false,
662 reportSpan: view?.reportSpan === 'week' ? 'week' : 'day',
663 weekEntries: myWeek.filter(one => one.project === project).map(filled),
664 myWeekEntries: myWeek.map(filled),
665 }))
666
667 return isOpening ? $.ui.open({ id: HISTORY_PANE, title: `History · ${projectName(project)}`, closeOnEscape: true }) : null
668}
669
670/** Opens the whole plan in a side panel: every step, what each got done, its time and helpers. */
671const showPlan = ($: $) => $.ui.open({ id: PLAN_PANE, title: 'Plan', closeOnEscape: true })
672
673/** Where we left off: the checkpoint Claude saved before the chat was tidied up, in a side panel. */
674const showRecap = async ($: $) => {
675 await update($, recapAtom, () => false)
676 return $.ui.open({ id: RECAP_PANE, title: 'Where we left off', closeOnEscape: true })
677}
678
679/** Shows the team report for the day or the week, or your own week across projects, and copies it. */
680const showReportSpan = async ($: $, span: GlanceHistoryView['reportSpan'], surface?: RenderSurface) => {
681 await update($, historyAtom, view => (view ? { ...view, reportSpan: span } : view))
682 const view = await read($, historyAtom)
683 if (view !== null) await showReport($, true, reportOf(view), surface)
684}
685
686const showReport = async ($: $, isShown: boolean, text: string, surface?: RenderSurface) => {
687 await update($, historyAtom, view => (view ? { ...view, isReportShown: isShown } : view))
688 if (!isShown) return
689 const copied = await $.ui.copy({ text, surface })
690 const isMine = (await read($, historyAtom))?.reportSpan === 'mine'
691 $.ui.toast(
692 !copied.isCopied
693 ? 'The report is in the panel: select it there to copy it.'
694 : isMine
695 ? 'Your week copied: paste it wherever you like.'
696 : 'Team report copied: paste it into Slack, Teams or an email.',
697 )
698}
699
700/** First press arms the Fresh chat button; a second press within 8 seconds starts the handoff. */
701const pressHandoff = async ($: $) => {
702 const state = await read($, handoffAtom)
703 if (state === 'working') return
704 if (state === 'idle') {
705 await update($, handoffAtom, () => 'armed')
706 $.clock.after(HANDOFF_CONFIRM_MS, () => {
707 void update($, handoffAtom, current => (current === 'armed' ? 'idle' : current))
708 })
709 return
710 }
711 await startFreshChat($)
712}
713
714/** Writes a handoff note over this chat, clears it, and sends the note as the fresh chat's first message. */
715const startFreshChat = async ($: $) => {
716 await update($, handoffAtom, () => 'working')
717 try {
718 $.ui.toast('Writing a handoff note for the fresh chat…')
719 const reply = await $.model.fork({ prompt: HANDOFF_PROMPT })
720 if (!reply.isAnswered || !reply.text.trim()) {
721 $.ui.toast(`Couldn't write the handoff note${reply.isAnswered ? '' : ` (${reply.reason})`}. Nothing was cleared.`)
722 return
723 }
724 const note = reply.text.trim()
725 // Kept in case anything below fails: /glanceflow handoff note puts it back in the prompt box.
726 await $.store.set(HANDOFF_KEY, { at: await now($), note })
727 try {
728 await $.command.run({ command: 'clear', args: '' })
729 } catch {
730 $.ui.toast("Couldn't clear the chat. Type /glanceflow handoff note to get the handoff note.")
731 return
732 }
733 try {
734 await $.prompt.submit({ text: note, asUser: true })
735 $.ui.toast('Fresh chat started from a handoff note.')
736 } catch {
737 await $.prompt.fill({ text: note })
738 $.ui.toast('Chat cleared. The handoff note is in the prompt box: press Enter to send it.')
739 }
740 } finally {
741 await update($, handoffAtom, () => 'idle')
742 }
743}
744
745/** Saves the job to the day's history (this computer only) and refreshes an open history pane. */
746const recordJob = async ($: $) => {
747 try {
748 const list = await read($, checklistAtom)
749 if (list === null) return
750 const project = await $.session.cwd()
751 const day = dayKey(list.startedAt)
752 // This chat's own key only: the store locks one set at a time, so a shared key could lose another chat's job.
753 const key = historyKey(day, await $.session.id())
754 await $.store.set(key, upsertEntry(await $.store.get(key), entryFromChecklist(list, project, jobCost(list, await read($, usageAtom)))))
755 const view = await read($, historyAtom)
756 if (view !== null && view.day === day && view.project === project) {
757 const entries = await readHistory($, [day])
758 await update($, historyAtom, () => ({ ...view, entries: entries.filter(one => one.project === project) }))
759 }
760 } catch {
761 // The history is a convenience: a failed save never gets in the way of the work.
762 }
763}
764
765/** Once Claude has answered: done, or still working in the background while helpers run. */
766const settleFinished = async ($: $, at: number, isNewAnswer: boolean) => {
767 let changed = isNewAnswer
768 const list = await change($, current => {
769 if (current.phase !== 'done' && current.phase !== 'background') return current
770 const phase = isBusy(current) ? ('background' as const) : ('done' as const)
771 if (phase === current.phase && !isNewAnswer) return current
772 changed = true
773 return { ...current, phase, finishedAt: phase === 'done' ? at : null, isCollapsed: false }
774 })
775 if (!changed || list === null) return
776 await recordJob($)
777 collapse?.cancel()
778 if (list.phase === 'done') {
779 const jobId = list.jobId
780 collapse = $.clock.after(COLLAPSE_MS, () => {
781 void change($, current =>
782 current.jobId === jobId && current.phase === 'done' ? { ...current, isCollapsed: true } : current,
783 )
784 })
785 }
786}
787
788const addHelper = ($: $, helper: Omit<GlanceHelper, 'stepId'>) =>
789 change($, current => ({
790 ...current,
791 helpers: [...current.helpers.filter(one => one.id !== helper.id), { ...helper, stepId: currentStepId(current) }],
792 }))
793
794const noteHelperStep = async ($: $, agentId: string, model: string, effort: unknown) => {
795 const list = await read($, checklistAtom)
796 const helper = list?.helpers.find(one => one.id === agentId)
797 const named = prettyModel(model)
798 const level = effort === undefined || effort === null ? null : String(effort)
799 if (!helper || (helper.model === named && helper.effort === level)) return
800 await change($, current => ({
801 ...current,
802 helpers: current.helpers.map(one => (one.id === agentId ? { ...one, model: named, effort: level } : one)),
803 }))
804}
805
806type StepUsage = {
807 input_tokens: number
808 output_tokens: number
809 cache_read_input_tokens: number
810 cache_creation_input_tokens: number
811}
812
813/** One model request's tokens: to the step it worked for, and to the helper that made it. */
814const addTokens = ($: $, agentId: string | undefined, usage: StepUsage) => {
815 const total =
816 usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
817 if (total <= 0) return Promise.resolve(null)
818
819 const cached = usage.cache_read_input_tokens
820
821 return change($, current => {
822 const helper = agentId === undefined ? undefined : current.helpers.find(one => one.id === agentId)
823 const stepId =
824 helper && current.tasks.some(one => one.id === helper.stepId)
825 ? helper.stepId
826 : current.tasks.find(one => one.status === 'active')?.id
827 // No step is running (Claude writing its final answer): the job's total only.
828 if (stepId === undefined && !helper) {
829 return {
830 ...current,
831 extraTokens: current.extraTokens + total,
832 extraCachedTokens: current.extraCachedTokens + cached,
833 }
834 }
835 return {
836 ...current,
837 tasks: current.tasks.map(one =>
838 one.id === stepId
839 ? { ...one, tokens: one.tokens + total, cachedTokens: one.cachedTokens + usage.cache_read_input_tokens }
840 : one,
841 ),
842 helpers: helper
843 ? current.helpers.map(one => (one.id === helper.id ? { ...one, tokens: one.tokens + total } : one))
844 : current.helpers,
845 }
846 })
847}
848
849const finishHelper = async ($: $, agentId: string, status: 'done' | 'failed') => {
850 const list = await change($, current => ({
851 ...current,
852 helpers: current.helpers.map(one => (one.id === agentId && one.status === 'running' ? { ...one, status } : one)),
853 }))
854 if (list?.phase === 'background') await settleFinished($, await now($), false)
855}
856
857/**
858 * Tidies the chat up without forgetting: Claude first writes a checkpoint of the work, the compaction is told to keep
859 * it, and it stays in Claude's instructions for the rest of this chat. Uses /compact itself when the engine refuses.
860 */
861const tidyUp = async ($: $) => {
862 $.ui.toast('Saving a checkpoint, then tidying up the chat…')
863 let note: string | null = null
864 try {
865 const reply = await $.model.fork({ prompt: CHECKPOINT_PROMPT })
866 if (reply.isAnswered && reply.text.trim()) note = reply.text.trim()
867 } catch {
868 // Tidying up still helps without a checkpoint.
869 }
870 if (note !== null) {
871 const at = await now($)
872 await $.store.set(CHECKPOINT_KEY, { sessionId: await $.session.id(), at, note })
873 await update($, checkpointAtom, () => at)
874 } else {
875 $.ui.toast("Couldn't save a checkpoint; tidying up anyway.")
876 }
877 const instructions = note === null ? '' : `Keep everything in this checkpoint; it is what the work needs to carry on.\n\n${note}`
878 try {
879 const done = await $.session.compact(note === null ? undefined : { instructions })
880 if (done.skip !== undefined) $.ui.toast(`The chat was not tidied up: ${done.skip}`)
881 else if (note !== null) await update($, recapAtom, () => true)
882 return
883 } catch (refused) {
884 $.ui.log(`glanceflow: compaction refused: ${refused instanceof Error ? refused.message : String(refused)}`, { to: 'debug' })
885 }
886 try {
887 await $.command.run({ command: 'compact', args: instructions })
888 } catch (failed) {
889 const reason = failed instanceof Error ? failed.message : String(failed)
890 $.ui.toast(`Couldn't tidy up the chat (${reason.slice(0, 80)}). Type /compact to try again.`)
891 }
892}
893
894/** The checkpoint saved before this chat was last tidied up; null in another chat or when there is none. */
895const checkpointOf = async ($: $): Promise<string | null> => {
896 const saved = (await $.store.get(CHECKPOINT_KEY)) as { sessionId?: string; note?: string } | undefined
897 return saved?.note && saved.sessionId === (await $.session.id()) ? saved.note : null
898}
899
900const setTidyAt = async ($: $, percent: number) => {
901 await update($, tidyAtAtom, () => percent)
902 await $.store.set(TIDY_KEY, percent)
903}
904
905const holdSecretMessage = async ($: $, text: string, kinds: string[]) => {
906 heldMessage = { text, at: await now($) }
907 // Put it back in the box so nothing is lost.
908 $.clock.after(50, () => void $.prompt.fill({ text }).catch(() => undefined))
909 $.ui.toast(
910 `This message looks like it has ${kinds.join(' and ')} in it, so it was not sent. ` +
911 'Press Enter again to send it anyway, or take it out first.',
912 )
913}
914
915const measureUsage = async ($: $, usage: GlanceUsage) => {
916 await update($, usageAtom, () => usage)
917 const level = usage.limitPercent === null ? 0 : usage.limitPercent >= LIMIT_ALERT ? 2 : usage.limitPercent >= LIMIT_WARN ? 1 : 0
918 if (level > limitLevel && usage.limitPercent !== null) {
919 const reset = resetTime(usage.limitResetsAt, await now($))
920 const words = `You've used ${Math.round(usage.limitPercent)}% of your ${usage.limitLabel ?? ''} limit`.replace(' ', ' ')
921 const message = reset === null ? words : `${words}. It resets ${reset}.`
922 $.ui.toast(message)
923 if ((await read($, noticeAtom)) && (await read($, enabledAtom))) {
924 await showNotice($, 'Plan limit', message)
925 }
926 }
927 limitLevel = level
928}
929
930const setEnabled = async ($: $, isEnabled: boolean) => {
931 await update($, enabledAtom, () => isEnabled)
932 await $.store.set(STORE_KEY, isEnabled)
933 $.ui.toast(isEnabled ? 'GlanceFlow is on: tool details are hidden' : 'GlanceFlow is off: showing everything')
934}
935
936/** Sounds: off, a chime, or a chime and a few words. A chime plays at once so the person hears what they picked. */
937const setSound = async ($: $, mode: 'off' | 'chime' | 'voice') => {
938 await update($, soundAtom, () => mode)
939 await $.store.set(SOUND_KEY, mode)
940 if (mode !== 'off') void playSound($, 'sounds/needs-you.wav').catch(() => undefined)
941}
942
943/** Calm mode: nothing moves, statuses in bold; the band redraws only to keep times current. */
944const setCalm = async ($: $, isOn: boolean) => {
945 isCalmMode = isOn
946 await update($, calmAtom, () => isOn)
947 await $.store.set(CALM_KEY, isOn)
948 syncTicker($, await read($, checklistAtom))
949}
950
951/** Moves the welcome on a card, or ends it for good. */
952const stepTour = async ($: $, step: number | null) => {
953 await update($, tourAtom, () => (step === null || step >= TOUR.length ? null : step))
954 if (step === null || step >= TOUR.length) await $.store.set(TOUR_KEY, true)
955}
956
957/** Desktop notices: on shows one when Claude needs you, gets stuck or finishes a long job. A sample shows at once. */
958const setNotice = async ($: $, isOn: boolean) => {
959 await update($, noticeAtom, () => isOn)
960 await $.store.set(NOTICE_KEY, isOn)
961 if (isOn) void showNotice($, 'Claude needs you', 'This is how GlanceFlow will tell you.')
962}
963
964/** The line naming the other chats that need the person: off before sharing the screen, say. */
965const setOthers = async ($: $, isOn: boolean) => {
966 await update($, showsOthersAtom, () => isOn)
967 await $.store.set(OTHERS_KEY, isOn)
968 if (isOn) await refreshOthers($).catch(() => undefined)
969}
970
971/** The password guard: on holds back a message that looks like it has a password or key in it. */
972const setGuard = async ($: $, isOn: boolean) => {
973 await update($, guardAtom, () => isOn)
974 await $.store.set(GUARD_KEY, isOn)
975}
976
977/** Approve the plan first: Claude lays out its plan, then waits for Start or a change. */
978const setApprove = async ($: $, isOn: boolean) => {
979 await update($, approveAtom, () => isOn)
980 await $.store.set(APPROVE_KEY, isOn)
981}
982
983/** With Approve the plan first on, a new plan waits for Start or a change; true when it now waits. */
984const holdForApproval = async ($: $): Promise<boolean> => {
985 if (!(await read($, approveAtom)) || (await read($, checklistAtom))?.approval === 'approved') {
986 return false
987 }
988 await change($, list => ({ ...list, approval: 'waiting', phase: 'needsYou', needsYouReason: APPROVE }))
989
990 return true
991}
992
993/** The person approves the plan: the same job starts. */
994const startPlan = async ($: $) => {
995 try {
996 await $.prompt.submit({ text: START_TEXT, asUser: true })
997 } catch {
998 $.ui.toast("Couldn't start right now. Type: start")
999 }
1000}
1001
1002/** Picks Simple, Details or Off directly, as the settings panel does. */
1003const setView = async ($: $, view: 'simple' | 'detailed' | 'off') => {
1004 await update($, enabledAtom, () => view !== 'off')
1005 await $.store.set(STORE_KEY, view !== 'off')
1006 if (view !== 'off') {
1007 await update($, detailAtom, () => view)
1008 await $.store.set(DETAIL_KEY, view)
1009 }
1010}
1011
1012/** Plays what the person picked, so they know what they will hear. */
1013const alertSample = async ($: $, mode: 'off' | 'chime' | 'voice') => {
1014 if (mode === 'off') return
1015 await playSound($, 'sounds/needs-you.wav').catch(() => undefined)
1016 if (mode === 'voice') await sayWords($, 'Claude needs you').catch(() => undefined)
1017}
1018
1019/** Back to how GlanceFlow starts: Simple, no sounds or notices, calm off, password guard on, no plan approval, tidy up at 50%. */
1020const resetSettings = async ($: $) => {
1021 await setView($, 'simple')
1022 await setSound($, 'off')
1023 await setNotice($, false)
1024 await setOthers($, true)
1025 await setCalm($, false)
1026 await setGuard($, true)
1027 await setApprove($, false)
1028 await setTidyAt($, TIDY_AT_DEFAULT)
1029 $.ui.toast('Settings are back to their defaults.')
1030}
1031
1032const showSettings = ($: $) => $.ui.open({ id: SETTINGS_PANE, title: 'GlanceFlow settings', closeOnEscape: true })
1033
1034/** The one button: Simple → Details → Off → Simple. */
1035const cycleMode = async ($: $, isEnabled: boolean, isDetailed: boolean) => {
1036 const nextEnabled = !(isEnabled && isDetailed)
1037 const nextDetailed = isEnabled && !isDetailed
1038 await update($, enabledAtom, () => nextEnabled)
1039 await update($, detailAtom, () => (nextDetailed ? 'detailed' : 'simple'))
1040 await $.store.set(STORE_KEY, nextEnabled)
1041 await $.store.set(DETAIL_KEY, nextDetailed ? 'detailed' : 'simple')
1042 $.ui.toast(
1043 !nextEnabled
1044 ? 'GlanceFlow is off: showing everything'
1045 : nextDetailed
1046 ? 'GlanceFlow details: models, time, tokens, cache and plan usage'
1047 : 'GlanceFlow simple: just the steps and progress',
1048 )
1049}
1050
1051const setDetail = async ($: $, isDetailed: boolean) => {
1052 await update($, detailAtom, () => (isDetailed ? 'detailed' : 'simple'))
1053 await $.store.set(DETAIL_KEY, isDetailed ? 'detailed' : 'simple')
1054 $.ui.toast(
1055 isDetailed ? 'Details on: models, tokens, cache and plan usage' : 'Details off: just the steps and progress',
1056 )
1057}
1058
1059// GlanceFlow was called Glance until 0.9: once, bring over Glance's settings and history from its own store file.
1060const ADOPTED_KEY = 'adoptedGlance'
1061const adoptGlanceStore = async ($: $) => {
1062 if (await $.store.get(ADOPTED_KEY)) {
1063 return
1064 }
1065 await $.store.set(ADOPTED_KEY, true)
1066 try {
1067 const config = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
1068 const dir = `${config}/plugins/store`
1069 const settings = new Set([STORE_KEY, DETAIL_KEY, SOUND_KEY, CALM_KEY, GUARD_KEY, APPROVE_KEY, NOTICE_KEY])
1070 const mine = new Set(await $.store.keys())
1071 for (const file of await $.fs.list(dir)) {
1072 if (!/^glance_.*\.json$/.test(file.name)) {
1073 continue
1074 }
1075 const old = JSON.parse(await $.fs.read(`${dir}/${file.name}`)) as Record<string, unknown>
1076 for (const [key, value] of Object.entries(old)) {
1077 if (key.startsWith(HISTORY_PREFIX) && Array.isArray(value)) {
1078 const own = historyKey(historyDay(key), 'glance')
1079 let entries = await $.store.get(own)
1080 // Only Glance's own jobs: another mod may be called glance too.
1081 for (const entry of value as GlanceHistoryEntry[]) {
1082 if (typeof entry?.jobId === 'string' && Array.isArray(entry.doneSteps)) {
1083 entries = upsertEntry(entries, entry)
1084 }
1085 }
1086 await $.store.set(own, entries ?? [])
1087 } else if (settings.has(key) && !mine.has(key)) {
1088 await $.store.set(key, value)
1089 }
1090 }
1091 }
1092 } catch {
1093 // Nothing to bring over.
1094 }
1095}
1096
1097// Up to 0.24 every chat's jobs of a day shared one key (sharedDayKeys); once, each moves into this chat's own key for its day.
1098const splitSharedDays = async ($: $) => {
1099 try {
1100 const id = await $.session.id()
1101 for (const key of sharedDayKeys(await $.store.keys())) {
1102 const own = historyKey(historyDay(key), id)
1103 await $.store.set(own, mergedEntries([await $.store.get(key), await $.store.get(own)]))
1104 await $.store.delete(key)
1105 }
1106 } catch {
1107 // Left as it is, the old day still reads; the next chat moves it.
1108 }
1109}
1110
1111export function registerGlance(on: On): void {
1112 // A chat that ends says so, so the other chats stop mentioning it; its checklist stays for when it is resumed.
1113 on('session.end', async ($, e, next) => {
1114 saveTimer?.cancel()
1115 saveTimer = undefined
1116 try {
1117 await saving
1118 await saveChecklist($, false, e.sessionId)
1119 } catch {
1120 // The end goes on: the other chats stop mentioning this one once it falls silent.
1121 }
1122 // After /clear or a resume the process goes on as another chat, with no session.start: that one is open, and
1123 // keeps reading the others.
1124 if (e.reason !== 'clear' && e.reason !== 'resume') {
1125 hasEnded = true
1126 othersTimer?.cancel()
1127 othersTimer = undefined
1128 }
1129 return next(e)
1130 })
1131 on('session.start', async ($, e, next) => {
1132 void findPlatform($)
1133 .then(found => {
1134 platform = found
1135 })
1136 .catch(() => undefined)
1137 await adoptGlanceStore($)
1138 // The welcome shows once, to someone new: anyone with settings or history already knows their way.
1139 const keys = await $.store.keys()
1140 if (!keys.includes(TOUR_KEY)) {
1141 if (keys.every(key => key === ADOPTED_KEY)) await update($, tourAtom, () => 0)
1142 else await $.store.set(TOUR_KEY, true)
1143 }
1144 const stored = await $.store.get(STORE_KEY)
1145 await update($, enabledAtom, () => stored !== false)
1146 const detail = await $.store.get(DETAIL_KEY)
1147 await update($, detailAtom, () => (detail === 'detailed' ? 'detailed' : 'simple'))
1148 const sound = await $.store.get(SOUND_KEY)
1149 await update($, soundAtom, () => (sound === 'chime' || sound === 'voice' ? sound : 'off'))
1150 isCalmMode = (await $.store.get(CALM_KEY)) === true
1151 const isGuarded = (await $.store.get(GUARD_KEY)) !== false
1152 await update($, guardAtom, () => isGuarded)
1153 const approves = (await $.store.get(APPROVE_KEY)) === true
1154 await update($, approveAtom, () => approves)
1155 const isNoticing = (await $.store.get(NOTICE_KEY)) === true
1156 await update($, noticeAtom, () => isNoticing)
1157 const showsOthers = (await $.store.get(OTHERS_KEY)) !== false
1158 await update($, showsOthersAtom, () => showsOthers)
1159 const tidyAt = await $.store.get(TIDY_KEY)
1160 await update($, tidyAtAtom, () => (typeof tidyAt === 'number' ? tidyAt : TIDY_AT_DEFAULT))
1161 const saved = (await $.store.get(CHECKPOINT_KEY)) as { sessionId?: string; at?: number } | undefined
1162 const thisSession = await $.session.id().catch(() => null)
1163 await update($, checkpointAtom, () => (saved?.sessionId === thisSession && typeof saved?.at === 'number' ? saved.at : null))
1164 await update($, calmAtom, () => isCalmMode)
1165 hasEnded = false
1166 // Tidying is housekeeping: a store that fails at it must not keep the chat from starting. It never prunes this
1167 // chat's own checklist, so the restore below still finds it.
1168 await tidySavedChecklists($).catch(() => undefined)
1169 othersTimer?.cancel()
1170 othersTimer = $.clock.every(OTHERS_EVERY_MS, () => void refreshOthers($).catch(() => undefined))
1171 void refreshOthers($).catch(() => undefined)
1172 // A resumed chat: its checklist comes back. A reload keeps the one it has.
1173 if ((await read($, checklistAtom)) === null) {
1174 await restoreChecklist($)
1175 }
1176 await splitSharedDays($)
1177 // The history keeps 30 days.
1178 for (const key of expiredHistoryKeys(await $.store.keys(), await now($))) {
1179 await $.store.delete(key)
1180 }
1181 await $.tool.register({
1182 name: 'plan_steps',
1183 description:
1184 'Lay out every step of the job up front, 2 to 8 short plain-English names in order. ' +
1185 'Call this before using any other tool; a quick answer that needs no tools needs no plan. ' +
1186 'The first step starts right away.',
1187 inputSchema: {
1188 type: 'object',
1189 properties: {
1190 steps: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 8 },
1191 sizes: {
1192 type: 'array',
1193 items: { type: 'string', enum: ['S', 'M', 'L'] },
1194 description: 'One size per step, in order: S small, M medium, L large.',
1195 },
1196 },
1197 required: ['steps'],
1198 },
1199 })
1200 await $.tool.register({hooks/history.ts 303 lines1// The day's history of jobs, for a retro: pure functions, no engine calls.
2
3import type { GlanceChecklist, GlanceFile, GlanceHistoryEntry, GlanceOutcome, GlanceTaskSize } from '../types'
4
5export const HISTORY_PREFIX = 'history:'
6export const HISTORY_DAYS = 30
7/** How much each step size counts toward progress and pace. */
8export const SIZE_WEIGHT: Record<GlanceTaskSize, number> = { S: 1, M: 2, L: 3 }
9// The project's pace is trusted once its finished jobs hold this many size units: about three medium steps.
10const MIN_PACE_UNITS = 6
11const DAY_MS = 24 * 60 * 60 * 1000
12
13function pad(value: number): string {
14 return String(value).padStart(2, '0')
15}
16
17/** A moment's local day, YYYY-MM-DD: the store key's suffix. */
18export function dayKey(at: number): string {
19 const date = new Date(at)
20
21 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
22}
23
24/** A moment's local time of day, HH:MM. */
25export function clockTime(at: number): string {
26 const date = new Date(at)
27
28 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
29}
30
31/** The day a /glanceflow history argument names: today, yesterday, or YYYY-MM-DD. Null when it names none. */
32export function dayFromArgument(argument: string, now: number): string | null {
33 const word = argument.trim().toLowerCase()
34 if (word === '' || word === 'today') return dayKey(now)
35 if (word === 'yesterday') return dayKey(now - DAY_MS)
36
37 return /^\d{4}-\d{2}-\d{2}$/.test(word) ? word : null
38}
39
40const OUTCOME: Record<GlanceChecklist['phase'], GlanceOutcome> = {
41 working: 'working',
42 needsYou: 'waiting',
43 stuck: 'stuck',
44 stopped: 'stopped',
45 background: 'background',
46 done: 'done',
47}
48
49/** "Added contact.html · changed styles.css and app.js": file names only, with the folder where two share a name. */
50export function filesNote(files: readonly GlanceFile[], most = 4): string {
51 const base = (path: string) => path.split(/[\\/]/).pop() ?? path
52 const counts = new Map<string, number>()
53 for (const one of files) counts.set(base(one.path), (counts.get(base(one.path)) ?? 0) + 1)
54 const label = (path: string) => ((counts.get(base(path)) ?? 0) > 1 ? path.split(/[\\/]/).slice(-2).join('/') : base(path))
55 const group = (verb: string, names: string[]) => {
56 if (names.length === 0) return null
57 const shown = names.slice(0, most)
58 const more = names.length - shown.length
59 const last = more > 0 ? `${more} more` : shown.pop()
60 return `${verb} ${shown.length > 0 ? `${shown.join(', ')} and ${last}` : last}`
61 }
62 const text = [
63 group('added', files.filter(one => one.isNew).map(one => label(one.path))),
64 group('changed', files.filter(one => !one.isNew).map(one => label(one.path))),
65 ]
66 .filter(Boolean)
67 .join(' · ')
68
69 return text.charAt(0).toUpperCase() + text.slice(1)
70}
71
72/** A History entry's files, as a step's are kept. */
73export function entryFiles(entry: GlanceHistoryEntry): GlanceFile[] {
74 return [...(entry.filesAdded ?? []).map(path => ({ path, isNew: true })), ...(entry.filesChanged ?? []).map(path => ({ path, isNew: false }))]
75}
76
77/** One job as the history keeps it; `costUsd` is what it cost, where the host keeps a ledger. */
78export function entryFromChecklist(list: GlanceChecklist, project: string, costUsd: number | null = null): GlanceHistoryEntry {
79 const files = list.tasks.flatMap(one => one.files ?? [])
80 const added = [...new Set(files.filter(one => one.isNew).map(one => one.path))]
81 const tokens = list.tasks.reduce((sum, one) => sum + one.tokens, list.extraTokens)
82 const cached = list.tasks.reduce((sum, one) => sum + one.cachedTokens, list.extraCachedTokens)
83 const timed = list.hasPlan
84 ? list.tasks.filter(one => one.status === 'done' && one.startedAt !== null && one.finishedAt !== null && one.finishedAt > one.startedAt)
85 : []
86
87 return {
88 jobId: list.jobId,
89 project,
90 startedAt: list.startedAt,
91 finishedAt: list.finishedAt,
92 title: list.title,
93 // A job that finished with a question for the person is still a finished job.
94 outcome: list.phase === 'needsYou' && list.finishedAt !== null ? 'done' : OUTCOME[list.phase],
95 stepsDone: list.tasks.filter(one => one.status === 'done').length,
96 stepsTotal: list.tasks.length,
97 newTokens: tokens - cached,
98 cachedTokens: cached,
99 doneSteps: list.hasPlan ? list.tasks.filter(one => one.status === 'done').map(one => one.name) : [],
100 doneNotes: list.hasPlan ? list.tasks.filter(one => one.status === 'done').map(one => one.summary ?? '') : [],
101 openSteps: list.hasPlan ? list.tasks.filter(one => one.status !== 'done').map(one => one.name) : [],
102 filesAdded: added,
103 filesChanged: [...new Set(files.filter(one => !one.isNew && !added.includes(one.path)).map(one => one.path))],
104 isQuickAnswer: !list.hasPlan,
105 costUsd,
106 doneUnits: timed.reduce((sum, one) => sum + SIZE_WEIGHT[one.size], 0),
107 doneMs: timed.reduce((sum, one) => sum + (one.finishedAt! - one.startedAt!), 0),
108 }
109}
110
111/** How long one unit of step size takes in this project, from its finished jobs; null until there is enough to go on. */
112export function paceFromHistory(entries: readonly GlanceHistoryEntry[], project: string): number | null {
113 let units = 0
114 let ms = 0
115 for (const one of entries) {
116 if (one.project === project && one.outcome === 'done' && (one.doneUnits ?? 0) > 0 && (one.doneMs ?? 0) > 0) {
117 units += one.doneUnits!
118 ms += one.doneMs!
119 }
120 }
121
122 return units >= MIN_PACE_UNITS ? Math.round(ms / units) : null
123}
124
125/** A stored day's entries; a missing or wrong value reads as none. */
126export function dayEntries(stored: unknown): GlanceHistoryEntry[] {
127 return Array.isArray(stored) ? (stored as GlanceHistoryEntry[]) : []
128}
129
130/** The store key of one chat's jobs on a day: each chat rewrites only its own, so two chats never lose each other's. */
131export function historyKey(day: string, sessionId: string): string {
132 return `${HISTORY_PREFIX}${day}:${sessionId}`
133}
134
135/** The day a history key is for. */
136export function historyDay(key: string): string {
137 return key.slice(HISTORY_PREFIX.length, HISTORY_PREFIX.length + 'YYYY-MM-DD'.length)
138}
139
140/** Keys from before 0.25, when every chat's jobs of a day shared one key. */
141export function sharedDayKeys(keys: readonly string[]): string[] {
142 return keys.filter(key => key.startsWith(HISTORY_PREFIX) && key.length === HISTORY_PREFIX.length + 'YYYY-MM-DD'.length)
143}
144
145/** The jobs of several stored lists, each once (the later list's copy wins), oldest first. */
146export function mergedEntries(stored: readonly unknown[]): GlanceHistoryEntry[] {
147 const byJob = new Map<string, GlanceHistoryEntry>()
148 for (const one of stored.flatMap(dayEntries)) byJob.set(one.jobId, one)
149
150 return [...byJob.values()].sort((a, b) => a.startedAt - b.startedAt)
151}
152
153/** The day's entries with this one added, or replacing its earlier self. */
154export function upsertEntry(entries: unknown, entry: GlanceHistoryEntry): GlanceHistoryEntry[] {
155 return [...dayEntries(entries).filter(one => one.jobId !== entry.jobId), entry].sort((a, b) => a.startedAt - b.startedAt)
156}
157
158/** Store keys of days older than the history keeps. */
159export function expiredHistoryKeys(keys: readonly string[], now: number): string[] {
160 const oldest = dayKey(now - HISTORY_DAYS * DAY_MS)
161
162 return keys.filter(key => key.startsWith(HISTORY_PREFIX) && key.slice(HISTORY_PREFIX.length) < oldest)
163}
164
165/** The folder name people know a project by. */
166export function projectName(project: string): string {
167 return project.split(/[\\/]/).filter(Boolean).pop() ?? project
168}
169
170/** The day before or after a YYYY-MM-DD day. */
171export function shiftDay(day: string, by: number): string {
172 const [year, month, date] = day.split('-').map(Number)
173
174 return dayKey(new Date(year!, month! - 1, date! + by, 12).getTime())
175}
176
177/** The days the weekly report covers: the day picked and the 6 before it. */
178export const WEEK_DAYS = 7
179
180function dateLabel(day: string, options: Intl.DateTimeFormatOptions): string {
181 const [year, month, date] = day.split('-').map(Number)
182 try {
183 return new Date(year!, month! - 1, date!, 12).toLocaleDateString('en-GB', options)
184 } catch {
185 return day
186 }
187}
188
189/** YYYY-MM-DD as "30 Sep 2026"; left as it is where the runtime has no date names. */
190export const shortDay = (day: string) => dateLabel(day, { day: 'numeric', month: 'short', year: 'numeric' })
191
192/** "Tuesday 6 October 2026". */
193export const longDay = (day: string) => dateLabel(day, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
194
195/** Time as a manager would say it: "under a minute", "12 min", "1 h 5 min". */
196export function plainDuration(ms: number): string {
197 const minutes = Math.round(ms / 60_000)
198 if (minutes < 1) return 'under a minute'
199 if (minutes < 60) return `${minutes} min`
200
201 return `${Math.floor(minutes / 60)} h${minutes % 60 ? ` ${minutes % 60} min` : ''}`
202}
203
204/**
205 * A short daily update for the team, a manager or a CEO: what got done, what is still open, and the time
206 * spent. Plain words only: no tokens, models or file names, and quick questions are left out.
207 */
208export function teamReport(
209 view: { day: string; project: string; entries: GlanceHistoryEntry[] },
210 span: 'day' | 'week' = 'day',
211): string {
212 const jobs = view.entries.filter(one => !one.isQuickAnswer)
213 const took = (one: GlanceHistoryEntry) =>
214 one.finishedAt === null ? '' : ` (${plainDuration(one.finishedAt - one.startedAt)})`
215 const heading =
216 span === 'week'
217 ? `Weekly update · ${projectName(view.project)} · ${shortDay(shiftDay(view.day, -(WEEK_DAYS - 1)))} to ${shortDay(view.day)}`
218 : `Daily update · ${projectName(view.project)} · ${longDay(view.day)}`
219 const lines = [heading, '']
220
221 if (jobs.length === 0) {
222 return [...lines, `No planned work was recorded ${span === 'week' ? 'this week' : 'on this day'}.`].join('\n')
223 }
224
225 const done = jobs.filter(one => one.outcome === 'done')
226 const open = jobs.filter(one => one.outcome !== 'done' && one.outcome !== 'stuck')
227 const blocked = jobs.filter(one => one.outcome === 'stuck')
228
229 if (done.length > 0) {
230 lines.push('Done')
231 for (const one of done) {
232 lines.push(`• ${one.title}${took(one)}`)
233 const notes = one.doneNotes ?? []
234 if (notes.some(Boolean)) {
235 // What each step got done, in Claude's words where it gave them.
236 one.doneSteps.forEach((name, at) => lines.push(` ✓ ${notes[at] || name}`))
237 } else if (one.doneSteps.length > 1) {
238 lines.push(` ${one.doneSteps.join(' · ')}`)
239 }
240 }
241 lines.push('')
242 }
243 if (open.length > 0) {
244 // "Open", not "in progress": a stopped job is here too, and nobody may be on it right now.
245 lines.push('Still open')
246 for (const one of open) {
247 const next = one.openSteps[0] ? `; next: ${one.openSteps[0].charAt(0).toLowerCase()}${one.openSteps[0].slice(1)}` : ''
248 lines.push(`• ${one.title}: ${one.doneSteps.length} of ${one.stepsTotal} steps done${next}`)
249 }
250 lines.push('')
251 }
252 if (blocked.length > 0) {
253 lines.push('Needs attention')
254 for (const one of blocked) lines.push(`• ${one.title}: it got stuck and needs a decision`)
255 lines.push('')
256 }
257
258 const time = jobs.reduce((sum, one) => sum + (one.finishedAt === null ? 0 : one.finishedAt - one.startedAt), 0)
259 lines.push(
260 `${done.length} of ${jobs.length} ${jobs.length === 1 ? 'task' : 'tasks'} finished · ${plainDuration(time)} of work`,
261 )
262
263 return lines.join('\n')
264}
265
266const weekday = (day: string) => dateLabel(day, { weekday: 'long' })
267
268/**
269 * A personal summary of the 7 days up to `day`, across every project: how many steps Claude checked off,
270 * in how many tasks, the time, the busiest day and the biggest tasks. Plain words, made to share.
271 */
272export function weekSummary(entries: readonly GlanceHistoryEntry[], day: string): string {
273 const jobs = entries.filter(one => !one.isQuickAnswer)
274 const heading = `Your week with Claude · ${shortDay(shiftDay(day, -(WEEK_DAYS - 1)))} to ${shortDay(day)}`
275 if (jobs.length === 0) {
276 return `${heading}\n\nNo planned work was recorded this week. Ask Claude for something with a few steps, and it shows here.`
277 }
278
279 const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
280 const steps = jobs.reduce((sum, one) => sum + one.doneSteps.length, 0)
281 const finished = jobs.filter(one => one.outcome === 'done').length
282 const time = jobs.reduce((sum, one) => sum + (one.finishedAt === null ? 0 : one.finishedAt - one.startedAt), 0)
283 const projects = new Set(jobs.map(one => one.project))
284 const perDay = new Map<string, number>()
285 for (const one of jobs) perDay.set(dayKey(one.startedAt), (perDay.get(dayKey(one.startedAt)) ?? 0) + 1)
286 const [busiest, busiestCount] = [...perDay].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]!
287 const biggest = [...jobs].filter(one => one.doneSteps.length > 0).sort((a, b) => b.doneSteps.length - a.doneSteps.length).slice(0, 3)
288
289 const lines = [
290 heading,
291 '',
292 `Claude checked off ${plural(steps, 'step', 'steps')} in ${plural(jobs.length, 'task', 'tasks')}, and finished ${finished} of them.`,
293 `Time at work: ${plainDuration(time)}${projects.size > 1 ? `, across ${projects.size} projects` : ''}.`,
294 ]
295 if (perDay.size > 1) lines.push(`Busiest day: ${weekday(busiest)}, with ${plural(busiestCount, 'task', 'tasks')}.`)
296 if (biggest.length > 0) {
297 lines.push('', 'Biggest tasks')
298 for (const one of biggest) lines.push(`• ${one.title} (${plural(one.doneSteps.length, 'step', 'steps')})`)
299 }
300
301 return lines.join('\n')
302}
303hooks/privacy.ts 96 lines1// Finds and masks secrets and personal details.
2// Patterns adapted from Nate Herk's Recording Mode (MIT licence):
3// https://github.com/nateherkai/claude-code-mods
4
5const DOTS = '••••••••'
6
7const SECRET_PATTERNS = [
8 /sk-ant-[A-Za-z0-9_-]{16,}/g,
9 /\bsk-(?:proj-|live-|test-|svcacct-)?[A-Za-z0-9_-]{20,}/g,
10 /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
11 /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
12 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
13 /\bAIza[0-9A-Za-z_-]{30,}/g,
14 /\bAKIA[0-9A-Z]{16}\b/g,
15 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
16 /\b(?:hf|r8|pk_live|sk_live|rk_live|whsec|pat|key)_[A-Za-z0-9]{20,}/g,
17 /\bBearer\s+[A-Za-z0-9._~+/-]{20,}=*/gi,
18 /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
19]
20
21// NAME=value or "name": "value" where the name looks like a credential.
22// The keyword ends the name or meets a separator, so "author:" and "tokens:" are not credentials.
23const ASSIGNMENT =
24 /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|AUTH[_-]?TOKEN|ACCESS[_-]?TOKEN|CLIENT[_-]?SECRET|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|AUTH)(?:[_.-][A-Za-z0-9_.-]*)?)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{6,})/gi
25
26// "my password is X", "ο κωδικός μου είναι X"
27const PHRASE =
28 /(?<!\p{L})((?:password|passcode|passphrase|pin|κωδικ[όο]ς(?: πρόσβασης)?|συνθηματικ[όο])(?:\s+(?:μου|is|my))?\s*(?:is|είναι|ειναι|=|:)\s*["']?)([^\s"']{4,})/giu
29
30/** A plain word after "auth:" or "OAuth:" is a setting; a key has a digit, a symbol or length. */
31const isCredential = (name: string, value: string) =>
32 /pass|secret/i.test(name) || /[^\p{L}_-]/u.test(value) || value.length >= 16
33
34/** "PIN: optional" and "ο κωδικός: γράψε" are prose: a PIN is digits, a bare κωδικός (code) needs more than letters. */
35const isPhraseSecret = (head: string, value: string) => {
36 const word = head.toLowerCase()
37 if (/^pin(?!\p{L})/u.test(word)) return /^\d{4,}$/.test(value)
38 if (/^κωδικ/u.test(word) && !/πρόσβασης|μου/u.test(word)) return /[^\p{L}]/u.test(value)
39 return true
40}
41
42const matches = (re: RegExp, text: string) => [...text.matchAll(new RegExp(re.source, re.flags))]
43
44const CARD = /(?<![\w-])(?:4\d{3}|5[1-5]\d{2}|2[2-7]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))(?:[ -]?\d{2,4}){3,4}(?![\w-])/g
45const IBAN = /\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\b/g
46const EMAIL = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g
47const PHONE = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
48const PHONE_INTL = /(?<![\w.])\+\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
49
50function luhn(digits: string): boolean {
51 let sum = 0
52 for (let i = 0; i < digits.length; i += 1) {
53 let d = Number(digits[digits.length - 1 - i])
54 if (i % 2 === 1) {
55 d *= 2
56 if (d > 9) d -= 9
57 }
58 sum += d
59 }
60 return sum % 10 === 0
61}
62
63function isCard(match: string): boolean {
64 const digits = match.replace(/\D/g, '')
65 return digits.length >= 13 && digits.length <= 19 && luhn(digits)
66}
67
68/** What kinds of secret a message holds: passwords, keys, card or bank numbers. */
69export function findSecrets(text: string): string[] {
70 const kinds = new Set<string>()
71 if (SECRET_PATTERNS.some(re => new RegExp(re.source, re.flags).test(text))) kinds.add('a key or token')
72 if (matches(ASSIGNMENT, text).some(m => isCredential(m[1]!, m[3]!))) kinds.add('a password or key')
73 if (matches(PHRASE, text).some(m => isPhraseSecret(m[1]!, m[2]!))) kinds.add('a password')
74 if ((text.match(CARD) ?? []).some(isCard)) kinds.add('a card number')
75 if (new RegExp(IBAN.source, IBAN.flags).test(text)) kinds.add('a bank account number')
76
77 return [...kinds]
78}
79
80/** Masks secrets and personal details for the screen; what Claude reads is unchanged. */
81export function maskPrivate(text: string): string {
82 let out = text
83 for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
84 out = out.replace(ASSIGNMENT, (match, name: string, sep: string, value: string) =>
85 isCredential(name, value) ? name + sep + DOTS : match,
86 )
87 out = out.replace(PHRASE, (match, head: string, value: string) => (isPhraseSecret(head, value) ? head + DOTS : match))
88 out = out.replace(CARD, match => (isCard(match) ? '•••• •••• •••• ••••' : match))
89 out = out.replace(IBAN, '•••')
90 out = out.replace(EMAIL, '•••@•••')
91 out = out.replace(PHONE, '•••-•••-••••')
92 out = out.replace(PHONE_INTL, '+•• •••')
93
94 return out
95}
96hooks/logic.ts 653 lines1import type { GlanceChecklist, GlanceHelper, GlanceHistoryView, GlanceOtherChat, GlanceTask, GlanceTaskSize, GlanceUsage } from '../types'
2import { SIZE_WEIGHT, teamReport, weekSummary } from './history'
3import { maskPrivate } from './privacy'
4
5export const PLUGIN = 'glanceflow'
6
7export const tourNeeds = (mark: string) =>
8 `When Claude needs you, the checklist says Needs you ${mark}, and what to do. Turn on sounds or desktop notices in Settings to hear or see it from another app.`
9
10export const MAX_NAME = 40
11
12export const QUESTION = "Answer Claude's question in the chat"
13
14export const WAITING = 'Reply to Claude in the box below'
15
16// The longest plain sentence Claude's own description of a tool call keeps.
17export const MAX_DETAIL = 90
18
19export const CODE_FILE =
20 /\.(tsx?|jsx?|mjs|cjs|mts|cts|py|rb|go|rs|java|kts?|swift|c|cc|cpp|h|hpp|cs|php|sh|zsh|bash|json|ya?ml|toml|css|scss|sass|less|html?|md|mdx|sql|vue|svelte|lock|env|xml|ini|cfg|conf|txt|csv|log)\W*$/i
21
22export const pad2 = (value: number) => String(value).padStart(2, '0')
23
24/** "10:01:45 UTC" in the person's own time, "13:01:45" in Athens; a time without UTC, GMT or Z is left as written. */
25export function localTimes(text: string, today = new Date()): string {
26 return text.replace(/(?<![\d:])(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s?(?:UTC|GMT|Z)\b/g, (whole, hours, minutes, seconds) => {
27 if (Number(hours) > 23 || Number(minutes) > 59) return whole
28 const at = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate(), Number(hours), Number(minutes), Number(seconds ?? 0)))
29 return `${pad2(at.getHours())}:${pad2(at.getMinutes())}${seconds === undefined ? '' : `:${pad2(at.getSeconds())}`}`
30 })
31}
32
33/** When a plan window resets, in the person's own time: "at 18:40", or "on Thu at 09:00" when it is more than a day away. */
34export function resetTime(resetsAt: string | null, at: number): string | null {
35 const when = resetsAt ? new Date(resetsAt) : null
36 if (when === null || Number.isNaN(when.getTime())) return null
37 const time = `${pad2(when.getHours())}:${pad2(when.getMinutes())}`
38 if (when.getTime() - at <= 24 * 3_600_000) return `at ${time}`
39 return `on ${when.toLocaleDateString('en-GB', { weekday: 'short' })} at ${time}`
40}
41
42/** Turns any step or job name, or a step's summary, into short plain English. */
43export function cleanName(raw: unknown, max = MAX_NAME): string {
44 const words = localTimes(maskPrivate(String(raw ?? '')))
45 .replace(/`[^`]*`/g, ' ')
46 .replace(/`/g, ' ')
47 .split(/\s+/)
48 .filter(word => word && !word.includes('•') && !/[/\\]/.test(word) && !CODE_FILE.test(word))
49 let name = words.join(' ').replace(/\s+/g, ' ').trim()
50 if (!name) {
51 return 'Working on it'
52 }
53 name = name.charAt(0).toUpperCase() + name.slice(1)
54 if (name.length <= max) {
55 return name
56 }
57 const cut = name.slice(0, max - 1)
58 const space = cut.lastIndexOf(' ')
59
60 return `${(space > 0 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–]+$/, '')}…`
61}
62
63/**
64 * A name in Title Case ("Copy Mod With Button Choices") in the sentence case the checklist uses everywhere else. A
65 * word with a capital past its first letter (GlanceFlow, PR) keeps its case; a name already in sentence case stays.
66 */
67export function sentenceCase(name: string): string {
68 const words = name.split(' ')
69 if (words.length < 3 || words.some(word => /^\p{Ll}/u.test(word))) return name
70 return words.map((word, index) => (index === 0 || /\p{Lu}/u.test(word.slice(1)) ? word : word.toLowerCase())).join(' ')
71}
72
73export function apiErrorSentence(kind: string, details = ''): string {
74 if (/too long|context (window|length|limit)|max(imum)? context/i.test(details)) {
75 return 'type /compact and try again'
76 }
77 if (kind === 'rate_limit' || kind === 'billing_error') {
78 return 'you hit your usage limit, try again a little later'
79 }
80 if (kind === 'overloaded') {
81 return "Claude's servers are busy, try again in a minute"
82 }
83 if (/authentication|oauth|credential/.test(kind)) {
84 return 'type /login'
85 }
86 if (/network|connect|ECONN|ENOTFOUND|socket|fetch failed|offline/i.test(details)) {
87 return 'the internet connection dropped'
88 }
89
90 return "Claude's servers are busy, try again in a minute"
91}
92
93export function formatDuration(ms: number): string {
94 const seconds = Math.max(0, Math.round(ms / 1000))
95 if (seconds < 60) {
96 return `${seconds}s`
97 }
98 const minutes = Math.floor(seconds / 60)
99 if (minutes < 60) {
100 return `${minutes}m ${seconds % 60}s`
101 }
102
103 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
104}
105
106/** A duration where words flow (the desktop): a whole minute drops its seconds, `1m` rather than `1m 0s`. */
107export function durationWords(ms: number): string {
108 return formatDuration(ms).replace(/^(\d+m) 0s$/, '$1')
109}
110
111/** Terminal cells a character takes: two for CJK and emoji, none for joiners and accents. */
112export function cellsOf(char: string): number {
113 const code = char.codePointAt(0) ?? 0
114 if (code === 0x200d || (code >= 0x300 && code <= 0x36f) || (code >= 0xfe00 && code <= 0xfe0f)) return 0
115 const isWide =
116 (code >= 0x1100 && code <= 0x115f) ||
117 (code >= 0x2e80 && code <= 0xa4cf) ||
118 (code >= 0xac00 && code <= 0xd7a3) ||
119 (code >= 0xf900 && code <= 0xfaff) ||
120 (code >= 0xfe30 && code <= 0xfe4f) ||
121 (code >= 0xff00 && code <= 0xff60) ||
122 (code >= 0xffe0 && code <= 0xffe6) ||
123 (code >= 0x1f300 && code <= 0x1faff) ||
124 (code >= 0x20000 && code <= 0x3fffd)
125
126 return isWide ? 2 : 1
127}
128
129/** Pads or trims text to exactly `width` terminal cells, so columns line up in any script. */
130export function fit(text: string, width: number): string {
131 if (width <= 0) {
132 return ''
133 }
134 const chars = Array.from(text)
135 const total = chars.reduce((sum, char) => sum + cellsOf(char), 0)
136 if (total <= width) {
137 return text + ' '.repeat(width - total)
138 }
139 let kept = ''
140 let used = 0
141 for (const char of chars) {
142 const size = cellsOf(char)
143 if (used + size > width - 1) break
144 kept += char
145 used += size
146 }
147
148 return `${kept}…${' '.repeat(width - 1 - used)}`
149}
150
151export function task(name: string, status: GlanceTask['status'], id = name, size: GlanceTaskSize = 'M'): GlanceTask {
152 return { id, name, status, percent: status === 'done' ? 100 : 0, hasReported: status === 'done', size, summary: null, tokens: 0, cachedTokens: 0, startedAt: null, finishedAt: null }
153}
154
155/** Token counts move to the new plan's step of the same name; the rest go to its first step, so the job total holds. */
156export function carryTokens(old: GlanceTask[], next: GlanceTask[]): GlanceTask[] {
157 const used = new Set<string>()
158 const carried = next.map(one => {
159 const same = old.find(before => before.name === one.name && !used.has(before.id))
160 if (!same) return one
161 used.add(same.id)
162 return { ...one, tokens: same.tokens, cachedTokens: same.cachedTokens, summary: same.summary, files: same.files }
163 })
164 const leftover = old.filter(before => !used.has(before.id))
165 const [first, ...rest] = carried
166 if (!first) return carried
167
168 return [
169 {
170 ...first,
171 tokens: first.tokens + leftover.reduce((sum, one) => sum + one.tokens, 0),
172 cachedTokens: first.cachedTokens + leftover.reduce((sum, one) => sum + one.cachedTokens, 0),
173 },
174 ...rest,
175 ]
176}
177
178/** 950 → 950, 12_400 → 12.4k, 3_200_000 → 3.2M. */
179export function formatTokens(count: number): string {
180 if (count < 1000) return String(count)
181 if (count < 1_000_000) return `${(count / 1000).toFixed(count < 100_000 ? 1 : 0).replace(/\.0$/, '')}k`
182
183 return `${(count / 1_000_000).toFixed(1)}M`
184}
185
186/** New tokens first (what really counts), then the cheap ones read back from the cache. */
187export function tokenNote(tokens: number, cachedTokens: number): string {
188 if (tokens <= 0) return ''
189 if (cachedTokens <= 0) return `${formatTokens(tokens)} tokens`
190
191 return `${formatTokens(tokens - cachedTokens)} new · ${formatTokens(cachedTokens)} cached`
192}
193
194export function sizeOf(raw: unknown): GlanceTaskSize {
195 const size = String(raw ?? '').trim().toUpperCase()
196
197 return size === 'S' || size === 'L' ? size : 'M'
198}
199
200/** Overall progress: finished work out of the plan, S/M/L counting 1/2/3, the current step in part. */
201export function overallProgress(
202 tasks: GlanceTask[],
203 activePercent?: number,
204): { percent: number; doneCount: number; work: number } {
205 const total = tasks.reduce((sum, one) => sum + SIZE_WEIGHT[one.size], 0)
206 const work = tasks.reduce((sum, one) => {
207 const weight = SIZE_WEIGHT[one.size]
208 if (one.status === 'done') return sum + weight
209 if (one.status === 'active' && activePercent !== undefined) return sum + (weight * activePercent) / 100
210 if (one.status === 'active' && one.hasReported) return sum + (weight * one.percent) / 100
211 return sum
212 }, 0)
213
214 return {
215 percent: total ? Math.round((100 * work) / total) : 0,
216 doneCount: tasks.filter(one => one.status === 'done').length,
217 work: total ? work / total : 0,
218 }
219}
220
221/** Each step's start and end time, stamped as its status changes. */
222export function stampTimes(tasks: GlanceTask[], at: number): GlanceTask[] {
223 return tasks.map(one => {
224 if (one.status === 'upcoming') return one.startedAt === null && one.finishedAt === null ? one : { ...one, startedAt: null, finishedAt: null }
225 if (one.status === 'active') return one.startedAt === null || one.finishedAt !== null ? { ...one, startedAt: one.startedAt ?? at, finishedAt: null } : one
226 return one.finishedAt === null ? { ...one, startedAt: one.startedAt ?? at, finishedAt: at } : one
227 })
228}
229
230// A step of size M is expected to take 3 minutes until this job's or this project's pace is known.
231export const DEFAULT_UNIT_MS = 90_000
232
233// The project's pace from History counts as much as this many units of this job's own finished steps.
234export const PACE_WEIGHT = 2
235
236/**
237 * How far along the current step looks: time spent against the time this job's finished steps took per
238 * unit of size. It fills gradually, stays below 100 until the step is checked off, and never falls below
239 * what Claude reported.
240 *
241 * Time left is null once the step runs past what it should take: the percentage then sits near the top
242 * whatever happens, so time spent against it would keep saying "under a minute". A lower percentage that
243 * Claude reported still gives an estimate.
244 */
245export function stepEstimate(list: GlanceChecklist, one: GlanceTask, at: number) {
246 const done = list.tasks.filter(
247 step => step.status === 'done' && step.startedAt !== null && step.finishedAt !== null && step.finishedAt > step.startedAt,
248 )
249 const units = done.reduce((sum, step) => sum + SIZE_WEIGHT[step.size], 0)
250 const spent = done.reduce((sum, step) => sum + (step.finishedAt! - step.startedAt!), 0)
251 const prior = list.paceMs === null ? 0 : PACE_WEIGHT
252 const unitMs = units + prior > 0 ? (spent + (list.paceMs ?? 0) * prior) / (units + prior) : DEFAULT_UNIT_MS
253 const expected = Math.max(10_000, unitMs * SIZE_WEIGHT[one.size])
254 const elapsedMs = one.startedAt === null ? 0 : Math.max(0, at - one.startedAt)
255 const estimate = Math.min(95, Math.round((100 * elapsedMs) / expected))
256 const reported = one.hasReported && one.percent > 0 && one.percent < LONG_STEP_REPORT ? one.percent : null
257 // Past its expected time, time spent says nothing more: what Claude reported is the better guess.
258 const percent = elapsedMs > expected && reported !== null ? reported : Math.min(99, Math.max(one.hasReported ? one.percent : 0, estimate))
259 const leftMs =
260 elapsedMs <= expected
261 ? percent > 0
262 ? (elapsedMs * (100 - percent)) / percent
263 : expected
264 : reported === null
265 ? null
266 : (elapsedMs * (100 - reported)) / reported
267
268 return { percent, elapsedMs, expectedMs: expected, leftMs }
269}
270
271// Past its expected time, a step's own report below this still says how far along it is.
272export const LONG_STEP_REPORT = 90
273
274/** A step's time left: short for the terminal's columns (`<1m left`), in words where text flows (`under a minute left`). */
275export function leftLabel(ms: number | null, isWords = false): string {
276 if (ms === null) return 'taking longer'
277 if (isWords) return ms < 60_000 ? 'under a minute left' : `about ${formatLeft(ms)} left`
278 return ms < 60_000 ? '<1m left' : `~${formatLeft(ms)} left`
279}
280
281/**
282 * Time left at this job's own pace since the plan once two steps are done. Before that, the steps left at
283 * this project's usual pace from History; nothing when History doesn't know it yet.
284 */
285export function timeLeft(list: GlanceChecklist, at: number, activePercent?: number): number | null {
286 const { doneCount, work } = overallProgress(list.tasks, activePercent)
287 if (list.planAt === null || work >= 1) return null
288 // A step running long makes any total a guess: say nothing rather than something too short.
289 const active = list.tasks.find(one => one.status === 'active')
290 if (active && stepEstimate(list, active, at).leftMs === null) return null
291 if (doneCount >= 2 && work > 0) return Math.round(((at - list.planAt) / work) * (1 - work))
292 if (list.paceMs === null) return null
293
294 return Math.round(
295 list.tasks
296 .filter(one => one.status !== 'done')
297 .reduce((sum, one) => sum + (stepEstimate(list, one, at).leftMs ?? 0), 0),
298 )
299}
300
301/**
302 * Steps still open after Claude answered. The last step alone still open counts as done:
303 * Claude finished but forgot to report 100.
304 */
305export function hasUnfinishedWork(tasks: GlanceTask[]): boolean {
306 const open = tasks.filter(one => one.status !== 'done')
307
308 return open.length > 1 || (open.length === 1 && open[0] !== tasks[tasks.length - 1])
309}
310
311/** Header details in order of importance; the least important drop first so the title stays readable. */
312export function headerDetails(parts: string[], room: number): string {
313 const kept = parts.filter(Boolean)
314 while (kept.length > 1 && kept.map(one => ` · ${one}`).join('').length > room) {
315 kept.pop()
316 }
317
318 return kept.map(one => ` · ${one}`).join('')
319}
320
321/**
322 * The pieces of a line that fit in `room` characters, joined by " · ": whole pieces, in order, leaving out any that
323 * don't fit, so no word is ever cut in half. When none fits, the first keeps the whole words it has room for.
324 */
325export function wholePieces(pieces: string[], room: number): string {
326 const all = pieces.filter(Boolean)
327 const kept: string[] = []
328 let used = 0
329 for (const piece of all) {
330 const size = widthOf(piece) + (kept.length > 0 ? 3 : 0)
331 if (used + size <= room) {
332 kept.push(piece)
333 used += size
334 }
335 }
336 if (kept.length > 0 || all.length === 0) return kept.join(' · ')
337 let cut = ''
338 for (const word of (all[0] ?? '').split(' ')) {
339 const next = cut === '' ? word : `${cut} ${word}`
340 if (widthOf(`${next}…`) > room) break
341 cut = next
342 }
343 return cut === '' ? '' : `${cut.replace(/[\s·,;:]+$/, '')}…`
344}
345
346/** A model id as people say it: claude-haiku-4-5-20251001 → Haiku 4.5. */
347export function prettyModel(model: string | null | undefined): string | null {
348 if (!model) return null
349 const match = /(opus|sonnet|haiku|fable)(?:[-_ ](\d+)(?:[-.](\d{1,2})(?!\d))?)?/i.exec(model)
350 if (!match) return model
351 const name = match[1]!.charAt(0).toUpperCase() + match[1]!.slice(1).toLowerCase()
352
353 return [name, [match[2], match[3]].filter(Boolean).join('.')].filter(Boolean).join(' ')
354}
355
356/** The step new helpers belong to: the current one, or the last one once all are done. */
357export function currentStepId(list: GlanceChecklist): string {
358 return (list.tasks.find(one => one.status === 'active') ?? list.tasks[list.tasks.length - 1])?.id ?? ''
359}
360
361/** A file's path within the project; outside it, just its name. */
362export function projectPath(path: string, project: string): string {
363 const root = project.replace(/[\\/]+$/, '')
364 if (root !== '' && (path.startsWith(`${root}/`) || path.startsWith(`${root}\\`))) return path.slice(root.length + 1)
365
366 return path.split(/[\\/]/).pop() ?? path
367}
368
369export type BackgroundTask = { id: string; type: string; status: string; description: string }
370
371/** Background tasks as the latest list has them: new ones join, ones no longer listed have finished. */
372export function reconcileBackground(list: GlanceChecklist, tasks: readonly BackgroundTask[]): GlanceHelper[] {
373 const listed = new Map(tasks.map(one => [`bg:${one.id}`, one]))
374 const kept = list.helpers.map(helper => {
375 if (helper.kind !== 'background' || helper.status !== 'running') return helper
376 const still = listed.get(helper.id)
377 return still ? helper : { ...helper, status: 'done' as const }
378 })
379 const known = new Set(kept.map(one => one.id))
380 const added = tasks
381 .filter(one => !known.has(`bg:${one.id}`))
382 .map(
383 (one): GlanceHelper => ({
384 id: `bg:${one.id}`,
385 stepId: currentStepId(list),
386 kind: 'background',
387 label: cleanName(one.description, MAX_DETAIL),
388 type: one.type,
389 model: null,
390 effort: null,
391 status: 'running',
392 tokens: 0,
393 }),
394 )
395
396 return [...kept, ...added]
397}
398
399/** What a tool call is doing, in plain words; null for planning tools and helpers, which show elsewhere. */
400export function activityOf(tool: string, input: { command?: unknown }): string | null {
401 if (tool === 'Read' || tool === 'NotebookRead') return 'Reading files'
402 if (tool === 'Grep' || tool === 'Glob' || tool === 'LS') return 'Searching the project'
403 if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'Editing files'
404 if (tool === 'Write') return 'Writing files'
405 if (tool === 'WebFetch') return 'Reading a web page'
406 if (tool === 'WebSearch') return 'Searching the web'
407 if (tool === 'Skill') return 'Following a skill'
408 if (tool.startsWith('mcp__') && !tool.startsWith(`mcp__${PLUGIN}__`)) return 'Using a connected app'
409 if (tool === 'Bash' || tool === 'PowerShell') {
410 const command = String(input.command ?? '')
411 if (/\b(test|tests|jest|vitest|pytest|mocha|playwright|rspec)\b/i.test(command)) return 'Running the tests'
412 if (/\b(npm|pnpm|yarn|bun|pip3?|brew|cargo|gem)\s+(install|add|i)\b/i.test(command)) return 'Installing packages'
413 if (/\bgit\s+\w+/.test(command)) return 'Working with git'
414 if (/\b(build|tsc|compile)\b/i.test(command)) return 'Building the project'
415 return 'Running a command'
416 }
417 return null
418}
419
420/** A job's cost in plain dollars: "$0.42", or "<$0.01" for less than a cent. */
421export function formatCost(usd: number): string {
422 return usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`
423}
424
425/** What the job has cost so far, from the session's spend; null where the host keeps no ledger. */
426export function jobCost(list: GlanceChecklist, usage: GlanceUsage): number | null {
427 return usage.costUsd === null || list.costAtStart === null ? null : Math.max(0, usage.costUsd - list.costAtStart)
428}
429
430export function isBusy(list: GlanceChecklist): boolean {
431 return list.helpers.some(one => one.status === 'running')
432}
433
434/** A short "carry on" the person typed, in English or Greek: it continues the job instead of starting a new one. */
435export function isContinueWords(text: string): boolean {
436 const words = text.trim().toLowerCase().replace(/[.!…,;:]+$/g, '').replace(/\s+/g, ' ')
437 return (
438 /^(please )?(continue|go on|keep going|resume|carry on|proceed|go ahead)( please)?$/.test(words) ||
439 /^(σε παρακαλώ |παρακαλώ )?(συνέχισε|συνεχισε|συνέχεια|συνεχεια|προχώρα|προχωρα)( σε παρακαλώ| παρακαλώ| παρακαλω)?$/.test(words)
440 )
441}
442
443/** A short "go ahead" the person typed: it approves the plan. */
444export function isStartWords(text: string): boolean {
445 const words = text.trim().toLowerCase().replace(/[.!…,;:]+$/g, '').replace(/\s+/g, ' ')
446 return /^(start|go|go ahead|ok|okay|yes|looks good|approved?|ξεκίνα|ξεκινα|ναι|οκ|εντάξει|ενταξει)( please)?$/.test(words)
447}
448
449/**
450 * Claude's last message ends by asking the person something. Greek writes its question mark as ";", so a ";" counts
451 * in Greek text and nowhere else; a code block never does.
452 */
453export function asksQuestion(answer: string): boolean {
454 const end = answer.trimEnd().replace(/[*_"'”»)\s]+$/g, '')
455 return /[??؟]$/.test(end) || (end.endsWith(';') && /\p{Script=Greek}/u.test(end))
456}
457
458export const MAX_QUESTION = 160
459
460/** The question Claude ended on, as one plain line: its last sentence, or the whole last paragraph when that sentence is a few words. */
461export function questionOf(answer: string): string | null {
462 if (!asksQuestion(answer)) return null
463 const paragraph = (answer.trim().split(/\n\s*\n/).pop() ?? '').replace(/[*_`#>]+/g, '').replace(/\s+/g, ' ').trim()
464 const last = paragraph.split(/(?<=[.!?;?؟])\s+/).pop() ?? ''
465 const line = maskPrivate(last.length >= 12 ? last : paragraph)
466
467 return line === '' ? null : line.length > MAX_QUESTION ? `${line.slice(0, MAX_QUESTION - 1)}…` : line
468}
469
470/** True when the text has no letters outside the Latin alphabet, so it can stand as an English title. */
471export function isLatinText(text: string): boolean {
472 return !/(?!\p{Script=Latin})\p{L}/u.test(text)
473}
474
475/** What a tool call works on, for the Plan in Details: the command, a file's name or a site. */
476export function activityTarget(input: { command?: unknown; file_path?: unknown; notebook_path?: unknown; url?: unknown }): string | null {
477 if (typeof input.command === 'string' && input.command.trim()) {
478 return maskPrivate(input.command.trim().split('\n')[0]!).slice(0, 160)
479 }
480 const file = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
481 if (file) return file.split(/[\\/]/).pop() ?? null
482 if (typeof input.url === 'string') {
483 const host = /^[a-z]+:\/\/([^/]+)/i.exec(input.url)
484 return host ? host[1]! : null
485 }
486 return null
487}
488
489export const widthOf = (text: string) => Array.from(text).reduce((sum, char) => sum + cellsOf(char), 0)
490
491export const childrenOf = (children: unknown) =>
492 (Array.isArray(children) ? children : [children]).filter(kid => kid !== null && kid !== undefined && kid !== false)
493
494/** A drawing of nothing but buttons: a Button, or Boxes that hold only buttons. */
495export function isButtonsOnly(tree: unknown): boolean {
496 if (tree === null || typeof tree !== 'object') return false
497 const { type, children } = tree as { type?: unknown; children?: unknown }
498 if (type === 'Button') return true
499 if (type !== 'Box') return false
500 const kids = childrenOf(children)
501 return kids.length > 0 && kids.every(isButtonsOnly)
502}
503
504/** The buttons in a drawing of nothing but buttons, in order, to line up in a row of their own. */
505export const buttonsIn = (tree: unknown): unknown[] =>
506 (tree as { type?: unknown }).type === 'Button' ? [tree] : childrenOf((tree as { children?: unknown }).children).flatMap(buttonsIn)
507
508/**
509 * What other mods draw beneath, split: their lone buttons (Replay, say), which join GlanceFlow's actions, and the
510 * rest, which stays under the band. A drawing of nothing but buttons comes up whole; inside a stack (a column) only a
511 * button standing on its own does, so a mod's own group of buttons (the next-steps suggestions) stays with its words.
512 */
513export function liftButtons(tree: unknown, isWhole = true): { buttons: unknown[]; rest: unknown } {
514 if (tree === null || typeof tree !== 'object') return { buttons: [], rest: tree }
515 const { type, props, children } = tree as { type?: unknown; props?: { flexDirection?: unknown }; children?: unknown }
516 if (type === 'Button' || (isWhole && isButtonsOnly(tree))) return { buttons: buttonsIn(tree), rest: null }
517 if (type !== 'Box' || props?.flexDirection !== 'column') return { buttons: [], rest: tree }
518 const parts = childrenOf(children).map(kid => liftButtons(kid, false))
519 const buttons = parts.flatMap(part => part.buttons)
520 if (buttons.length === 0) return { buttons: [], rest: tree }
521 const kept = parts.map(part => part.rest).filter(one => one !== null && one !== undefined)
522 return { buttons, rest: kept.length === 0 ? null : { ...(tree as object), children: kept } }
523}
524
525/** The person's own words: notes an app adds around a prompt (<system-reminder>…) are not the request. */
526export function ownWords(text: string): string {
527 return text.replace(/<([A-Za-z][\w-]*)\b[^>]*>[\s\S]*?<\/\1>/g, ' ').trim()
528}
529
530export function formatLeft(ms: number): string {
531 const minutes = Math.max(1, Math.round(ms / 60000))
532
533 return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`
534}
535
536/** Keeps exactly one active step while any step is unfinished. */
537export function settle(tasks: GlanceTask[]): GlanceTask[] {
538 const lastActive = tasks.map(one => one.status).lastIndexOf('active')
539 const fixed = tasks.map((one, index) =>
540 one.status === 'active' && index !== lastActive ? { ...one, status: 'upcoming' as const } : one,
541 )
542 if (lastActive !== -1) {
543 return fixed
544 }
545 const next = fixed.findIndex(one => one.status === 'upcoming')
546
547 return fixed.map((one, index) => (index === next ? { ...one, status: 'active' as const } : one))
548}
549
550/** Applies a progress report: earlier steps check off, 100 moves to the next. */
551export function applyProgress(tasks: GlanceTask[], rawName: string, rawPercent: number): GlanceTask[] {
552 const name = cleanName(rawName)
553 const percent = Math.min(100, Math.max(0, Math.round(Number(rawPercent) || 0)))
554 let list = [...tasks]
555 let index = list.findIndex(one => one.name.toLowerCase() === name.toLowerCase())
556 if (index === -1) {
557 const active = list.findIndex(one => one.status === 'active')
558 index = active === -1 ? list.length : active
559 list.splice(index, 0, task(name, 'upcoming', `${name}#${list.length}`))
560 }
561 list = list.map((one, at) => {
562 if (at < index) {
563 return { ...one, status: 'done', percent: 100, hasReported: true }
564 }
565 if (at === index) {
566 return { ...one, status: percent >= 100 ? 'done' : 'active', percent, hasReported: true }
567 }
568 return one.status === 'active' ? { ...one, status: 'upcoming' } : one
569 })
570
571 return settle(list)
572}
573
574export function todosToTasks(
575 todos: ReadonlyArray<{ content: string; status: string }>,
576 previous: GlanceTask[],
577): GlanceTask[] {
578 return carryTokens(previous, settle(
579 todos.map((todo, index) => {
580 const name = cleanName(todo.content)
581 const before = previous.find(one => one.name === name)
582 const status = todo.status === 'completed' ? 'done' : todo.status === 'in_progress' ? 'active' : 'upcoming'
583 const kept = before && before.status === status ? before : task(name, status, `todo#${index}`)
584
585 return { ...kept, name, status }
586 }),
587 ))
588}
589
590/** What the notice says under its title: why Claude waits or is stuck, or the job's name. */
591export const noticeBody = (list: GlanceChecklist) =>
592 (list.phase === 'needsYou' ? needsText(list) : list.phase === 'stuck' ? list.stuckReason : null) ?? list.title
593
594/** A job that ended: All done, or finished with a question for the person (Needs you, with a finish time). */
595export const isFinished = (list: GlanceChecklist) => list.phase === 'done' || (list.phase === 'needsYou' && list.finishedAt !== null)
596
597/** Why the person is needed: what Claude asked, when it ended on a question, else the general reason. */
598export const needsText = (list: GlanceChecklist) =>
599 (list.question && (list.needsYouReason === WAITING || list.needsYouReason === QUESTION) ? list.question : list.needsYouReason) ?? null
600
601export const reportOf = (view: GlanceHistoryView) =>
602 view.reportSpan === 'mine'
603 ? weekSummary(view.myWeekEntries, view.day)
604 : view.reportSpan === 'week'
605 ? teamReport({ ...view, entries: view.weekEntries }, 'week')
606 : teamReport(view)
607
608/** The value that `/glanceflow <setting> on` or `off` asks for. With neither word, the opposite of the current value. */
609export const toggled = (choice: string, current: boolean) => (choice === 'on' ? true : choice === 'off' ? false : !current)
610
611/** A chat's checklist as kept for when the chat is resumed, and for the other chats to see; `isOpen` until it ends. */
612export type SavedChecklist = { at: number; list: GlanceChecklist; isOpen?: boolean }
613
614// Each chat's checklist sits under a key of its own: two chats saving at once never drop each other's.
615export const RESUME_PREFIX = 'resume:'
616
617/** The keys of saved checklists, `[key, at]`, beyond the `keep` most recent. */
618export function staleResumeKeys(saved: readonly (readonly [string, number])[], keep: number): string[] {
619 return [...saved].sort((a, b) => b[1] - a[1]).slice(keep).map(([key]) => key)
620}
621
622// A chat at work saves its checklist with every step and tool it runs; one silent this long has most likely closed.
623export const OTHER_WORKING_MS = 10 * 60_000
624// shortcut: a chat that closed without ending (a crash, a closed terminal tab) shows as waiting this long; a live
625// heartbeat would know sooner. By then its own sound or notice has long told the person.
626export const OTHER_WAITING_MS = 2 * 60 * 60_000
627
628/** The other chats worth a word: those that need the person, then those still at work, most recent first. */
629export function otherChats(saved: readonly (SavedChecklist | undefined)[], at: number): GlanceOtherChat[] {
630 const isNeeding = (one: SavedChecklist) => one.list.phase === 'needsYou' || one.list.phase === 'stuck'
631 return saved
632 .filter((one): one is SavedChecklist => {
633 // Only a chat that said it is open: one that ended, or saved before 0.25, is never named.
634 if (!one?.list || one.isOpen !== true || typeof one.at !== 'number') return false
635 const isWorking = one.list.phase === 'working' || one.list.phase === 'background'
636 return isNeeding(one) ? at - one.at <= OTHER_WAITING_MS : isWorking && at - one.at <= OTHER_WORKING_MS
637 })
638 .sort((a, b) => Number(isNeeding(b)) - Number(isNeeding(a)) || b.at - a.at)
639 .map(one => ({ title: one.list.title, needsYou: isNeeding(one) }))
640}
641
642/** What the band says of the other chats, in whole pieces: who needs the person first, then how many are at work. */
643export function otherChatsWords(others: readonly GlanceOtherChat[]): string[] {
644 const waiting = others.filter(one => one.needsYou)
645 const working = others.length - waiting.length
646 if (waiting.length === 0) return working === 0 ? [] : [`${working === 1 ? '1 other chat' : `${working} other chats`} at work`]
647 const first =
648 waiting.length === 1
649 ? `Another chat needs you: ${waiting[0]?.title ?? ''}`
650 : `${waiting.length} other chats need you: ${waiting.map(one => one.title).join(', ')}`
651 return [first, ...(working === 0 ? [] : [`${working} more at work`])]
652}
653hooks/kit.tsx 60 lines1import type { ElementTable, RenderElement, RenderSurface } from 'claude-code'
2
3import { GLYPH, TONE_TEXT, badgeSvg, dotsSvg, iconSvg, meterSvg, openRingSvg, ringSvg } from './look'
4import type { IconName, Tone } from './look'
5
6/** Icon size in the band and the panels, in CSS px: a touch smaller than the desktop's 14.5px text. */
7const ICON = 15
8/** The desktop's line of text, in CSS px: marks are drawn this tall, centered, so they line up with a first line. */
9const LINE = 21
10/** The room a mark of ICON size takes, in cells: as wide as the room a line under it keeps, so the two start alike. */
11const MARK = 2
12
13/**
14 * The marks every screen draws, from one icon set (look.ts): SVG where the surface draws vectors (the desktop app,
15 * VS Code, the phone), one-cell glyphs on the terminal, so a status reads the same everywhere.
16 */
17export function kitOf(table: ElementTable, surface: RenderSurface) {
18 const { Box, Text } = table
19 // The terminal's table answers for Svg too, and draws it as an empty box: go by the surface.
20 const Svg = surface !== 'terminal' && 'Svg' in table ? table.Svg : null
21 // Every drawing says what it is: the desktop app draws nothing for an Svg whose alt is empty.
22 const draw = (source: string, alt: string, width: number, height = width): RenderElement | null =>
23 Svg ? <Svg source={source} alt={alt} width={width} height={height} /> : null
24 // A mark of ICON size sits in MARK cells, so a blank of the same cells lines a line up under it with nothing to read.
25 const placed = (drawn: RenderElement | null, size: number) =>
26 drawn !== null && size === ICON ? (
27 <Box width={MARK} flexShrink={0}>
28 {drawn}
29 </Box>
30 ) : (
31 drawn
32 )
33 const glyph = (name: IconName, tone: Tone) => (
34 <Text color={TONE_TEXT[tone]} dimColor={tone === 'quiet'}>
35 {GLYPH[name]}
36 </Text>
37 )
38
39 return {
40 isVector: Svg !== null,
41 /** A line icon. */
42 icon: (name: IconName, tone: Tone, alt: string, size = ICON) => placed(draw(iconSvg(name, tone, size, LINE), alt, size, LINE), size) ?? glyph(name, tone),
43 /** A filled disc with the icon in white: done, failed, waiting on you, stopped. */
44 badge: (name: IconName, tone: Tone, alt: string, size = ICON) =>
45 placed(draw(badgeSvg(name, tone, size, LINE), alt, size, LINE), size) ?? glyph(name, tone),
46 /** The current step: a ring filled to `percent`, or turning with `turn` while there is none. */
47 ring: (percent: number | null, turn: number, alt: string, size = ICON, tone: Tone = 'active') =>
48 placed(draw(ringSvg(tone, size, percent, turn, LINE), alt, size, LINE), size) ?? glyph('play', tone),
49 /** A step still to come. */
50 open: (alt: string, size = ICON) => placed(draw(openRingSvg(size, LINE), alt, size, LINE), size) ?? <Text dimColor>○</Text>,
51 /** A mark's room with nothing in it, so a line under a step starts where the step's name does; no drawing, so a
52 * screen reader passes over it. */
53 blank: () => (Svg ? <Box width={MARK} flexShrink={0} /> : <Text> </Text>),
54 /** A slim bar; vector surfaces only. */
55 meter: (tone: Tone, percent: number | null, sweep: number, alt: string, width = 96) => draw(meterSvg(tone, width, percent, sweep), alt, width, 6),
56 /** Where the welcome cards are; vector surfaces only. */
57 dots: (count: number, current: number) => draw(dotsSvg(count, current), `Card ${current + 1} of ${count}`, count * 11 + 3, 6),
58 }
59}
60hooks/look.ts 164 lines1/**
2 * GlanceFlow's look: one icon set and one set of tones, so every screen draws the same marks.
3 *
4 * Surfaces that draw vectors (the desktop app, VS Code, the phone) get these SVG icons and meters; the terminal draws
5 * the glyph named beside each, one cell wide. Both come from the same names, so a status reads the same everywhere.
6 */
7
8export type Tone = 'ok' | 'active' | 'warn' | 'alert' | 'quiet'
9
10/**
11 * The vector tones, matched to the desktop app's own green, blue, amber and grey; an image can't read the app's
12 * theme, so each also stays visible on a light window.
13 */
14export const TONE: Record<Tone, string> = {
15 ok: '#3fa45a',
16 active: '#3194d4',
17 warn: '#d9970b',
18 alert: '#e0574b',
19 quiet: '#8e8e8e',
20}
21
22/** The same tones as the terminal names them, for text and glyphs. */
23export const TONE_TEXT: Record<Tone, string | undefined> = {
24 ok: 'green',
25 active: 'cyan',
26 warn: 'yellow',
27 alert: 'red',
28 quiet: undefined,
29}
30
31/** Each icon on a 24 × 24 grid, stroked 1.75 wide with round ends and joins: one weight and one corner for all. */
32const PATHS = {
33 check: '<path d="M6.8 12.5l3.4 3.4 7-7.4"/>',
34 done: '<circle cx="12" cy="12" r="8.5"/><path d="M8.5 12.4l2.4 2.4 4.7-5"/>',
35 alert: '<path d="M10.3 5.1a2 2 0 0 1 3.4 0l6.9 11.9a2 2 0 0 1-1.7 3H5.1a2 2 0 0 1-1.7-3z"/><path d="M12 10v3.6"/><path d="M12 16.6v.01"/>',
36 clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.6V12l2.9 1.9"/>',
37 stop: '<rect x="7" y="7" width="10" height="10" rx="2"/>',
38 pause: '<path d="M9 6.6v10.8M15 6.6v10.8" stroke-width="3.2"/>',
39 play: '<path d="M8.6 6.8v10.4a.8.8 0 0 0 1.2.7l8.2-5.2a.8.8 0 0 0 0-1.4L9.8 6.1a.8.8 0 0 0-1.2.7z"/>',
40 close: '<path d="M8 8l8 8M16 8l-8 8"/>',
41 plan: '<path d="M10 6.5h10M10 12h10M10 17.5h10"/><path d="M4.8 6.5h.01M4.8 12h.01M4.8 17.5h.01"/>',
42 history: '<path d="M3.8 12a8.2 8.2 0 1 0 2.4-5.8"/><path d="M3.6 4.3v4.1h4.1"/><path d="M12 7.8v4.4l2.9 1.8"/>',
43 sliders: '<path d="M4 7.5h9.3M18.1 7.5H20"/><circle cx="15.7" cy="7.5" r="2.3"/><path d="M4 16.5h1.9M10.7 16.5H20"/><circle cx="8.3" cy="16.5" r="2.3"/>',
44 fresh: '<path d="M5.6 5h12.8A1.6 1.6 0 0 1 20 6.6v8.8a1.6 1.6 0 0 1-1.6 1.6H10.6L6 20.4V17h-.4A1.6 1.6 0 0 1 4 15.4V6.6A1.6 1.6 0 0 1 5.6 5z"/><path d="M12 8.4v5.2M9.4 11h5.2"/>',
45 bang: '<path d="M12 7.2v5.6"/><path d="M12 16.6v.01"/>',
46 eye: '<path d="M2.8 12S6 6.2 12 6.2 21.2 12 21.2 12 18 17.8 12 17.8 2.8 12 2.8 12z"/><circle cx="12" cy="12" r="2.6"/>',
47 sound: '<path d="M4.2 9.8v4.4h3.3l4.2 3.5V6.3L7.5 9.8z"/><path d="M15.3 9.3a3.8 3.8 0 0 1 0 5.4"/><path d="M17.9 6.8a7.4 7.4 0 0 1 0 10.4"/>',
48 bell: '<path d="M6.3 16.6v-5.1a5.7 5.7 0 0 1 11.4 0v5.1l1.4 1.8H4.9z"/><path d="M10.1 21h3.8"/>',
49 moon: '<path d="M19.6 14.4A7.9 7.9 0 0 1 9.6 4.4a7.9 7.9 0 1 0 10 10z"/>',
50 approve: '<rect x="4.2" y="4.2" width="15.6" height="15.6" rx="3.2"/><path d="M8.3 12.2l2.5 2.5 4.9-5.1"/>',
51 lock: '<rect x="5.2" y="10.6" width="13.6" height="9.4" rx="2.2"/><path d="M8.3 10.6V8a3.7 3.7 0 0 1 7.4 0v2.6"/><path d="M12 14.3v2"/>',
52 tidy: '<path d="M8 4.8l4 4 4-4"/><path d="M8 19.2l4-4 4 4"/><path d="M5 12h14"/>',
53 file: '<path d="M13.6 3.9H7.3a1.4 1.4 0 0 0-1.4 1.4v13.4a1.4 1.4 0 0 0 1.4 1.4h9.4a1.4 1.4 0 0 0 1.4-1.4V8.4z"/><path d="M13.6 3.9v4.5h4.5"/>',
54 sub: '<path d="M7 5v7a3 3 0 0 0 3 3h7.5"/><path d="M14.6 12l3 3-3 3"/>',
55 spark: '<path d="M12 4.5c.5 3.9 3.6 7 7.5 7.5-3.9.5-7 3.6-7.5 7.5-.5-3.9-3.6-7-7.5-7.5 3.9-.5 7-3.6 7.5-7.5z"/>',
56 bookmark: '<path d="M7 4.5h10a1 1 0 0 1 1 1v14l-6-4-6 4v-14a1 1 0 0 1 1-1z"/>',
57 more: '<path d="M6.4 12h.01M12 12h.01M17.6 12h.01" stroke-width="3.4"/>',
58} as const
59
60export type IconName = keyof typeof PATHS
61
62/** What a terminal draws for each icon: one cell wide, so rows line up. */
63export const GLYPH: Record<IconName, string> = {
64 check: '✓',
65 done: '✓',
66 alert: '⚠',
67 clock: '◷',
68 stop: '■',
69 pause: '‖',
70 play: '▶',
71 close: '✗',
72 plan: '▤',
73 history: '≣',
74 sliders: '⚙',
75 fresh: '↻',
76 bang: '!',
77 eye: '◉',
78 sound: '♪',
79 bell: '◈',
80 moon: '☾',
81 approve: '▣',
82 lock: '◆',
83 tidy: '⇅',
84 file: '▫',
85 sub: '↳',
86 spark: '✦',
87 bookmark: '◆',
88 more: '…',
89}
90
91const XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
92
93/**
94 * A line icon in one tone, `size` px wide. Given a taller `height` (a line of text), it sits centered in it, so a
95 * mark lines up with the first line of the text beside it.
96 */
97export function iconSvg(name: IconName, tone: Tone, size = 16, height = size): string {
98 // Small icons get a heavier line, so they keep the weight of the text beside them.
99 return `<svg ${XMLNS} viewBox="0 0 24 24" width="${size}" height="${height}" fill="none" stroke="${TONE[tone]}" stroke-width="${size <= 14 ? 2.1 : 1.75}" stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</svg>`
100}
101
102/** A filled disc with the icon cut out in white: a step that is done, failed, waits on you or stopped. */
103export function badgeSvg(name: IconName, tone: Tone, size = 16, height = size): string {
104 return `<svg ${XMLNS} viewBox="0 0 24 24" width="${size}" height="${height}" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" fill="${TONE[tone]}"/><g stroke="#fff" stroke-width="2.3">${PATHS[name]}</g></svg>`
105}
106
107const RING = 2 * Math.PI * 8.5
108
109/**
110 * The current step: a ring that fills with its percentage. With none, a short arc stands at the turn `turn` gives
111 * (a twelfth of a circle each), so a screen redrawn each tick spins it and a still screen holds it.
112 */
113export function ringSvg(tone: Tone, size = 16, percent: number | null = null, turn = 0, height = size): string {
114 const known = percent !== null
115 // A ring barely begun still shows a sliver, so it never reads as a step still to come.
116 const length = (known ? Math.max(8, Math.min(100, percent)) / 100 : 0.3) * RING
117 const start = known ? -90 : -90 + (turn % 12) * 30
118 const arc =
119 length > 0.5
120 ? `<circle cx="12" cy="12" r="8.5" stroke="${TONE[tone]}" stroke-width="3" stroke-dasharray="${length.toFixed(2)} ${RING.toFixed(2)}" transform="rotate(${start} 12 12)"/>`
121 : ''
122
123 // The track takes the ring's own tone, faint, so a ring barely begun still reads apart from a step to come.
124 return `<svg ${XMLNS} viewBox="0 0 24 24" width="${size}" height="${height}" fill="none" stroke-linecap="round"><circle cx="12" cy="12" r="8.5" stroke="${TONE[tone]}" stroke-opacity=".4" stroke-width="3"/>${arc}</svg>`
125}
126
127/** A step still to come: an open ring. */
128export function openRingSvg(size = 16, height = size): string {
129 return `<svg ${XMLNS} viewBox="0 0 24 24" width="${size}" height="${height}" fill="none"><circle cx="12" cy="12" r="8.5" stroke="${TONE.quiet}" stroke-width="1.9"/></svg>`
130}
131
132/**
133 * A slim progress bar `width` px wide. A known `percent` fills from the left; with none, a short bar travels along
134 * the track, `sweep` (0 to 1) saying how far.
135 */
136export function meterSvg(tone: Tone, width = 96, percent: number | null = null, sweep = 0): string {
137 const height = 6
138 const track = `<rect width="${width}" height="${height}" rx="3" fill="${TONE.quiet}" fill-opacity=".28"/>`
139 let fill = ''
140 if (percent !== null) {
141 const filled = Math.round((Math.max(0, Math.min(100, percent)) / 100) * width)
142 if (filled > 0) fill = `<rect width="${Math.max(filled, height)}" height="${height}" rx="3" fill="${TONE[tone]}"/>`
143 } else {
144 const bar = Math.round(width * 0.28)
145 const at = Math.round((width - bar) * Math.max(0, Math.min(1, sweep)))
146 fill = `<rect x="${at}" width="${bar}" height="${height}" rx="3" fill="${TONE[tone]}"/>`
147 }
148
149 return `<svg ${XMLNS} viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">${track}${fill}</svg>`
150}
151
152/** Step dots for a short sequence (the welcome cards): the current one long, the rest round. */
153export function dotsSvg(count: number, current: number): string {
154 let x = 0
155 const marks = Array.from({ length: count }, (_, index) => {
156 const width = index === current ? 14 : 6
157 const mark = `<rect x="${x}" width="${width}" height="6" rx="3" fill="${index === current ? TONE.active : TONE.quiet}"${index === current ? '' : ' fill-opacity=".45"'}/>`
158 x += width + 5
159 return mark
160 })
161
162 return `<svg ${XMLNS} viewBox="0 0 ${x - 5} 6" width="${x - 5}" height="6">${marks.join('')}</svg>`
163}
164types/index.d.ts 216 lines1export type GlanceTaskStatus = 'done' | 'active' | 'upcoming'
2
3/** How big a step is: small, medium or large, counting 1, 2 or 3 toward overall progress. */
4export type GlanceTaskSize = 'S' | 'M' | 'L'
5
6export type GlanceTask = {
7 id: string
8 name: string
9 status: GlanceTaskStatus
10 percent: number
11 hasReported: boolean
12 size: GlanceTaskSize
13 /** What the step got done, in one plain-English line, as Claude said when it checked it off; null until then. */
14 summary: string | null
15 /** Tokens the model handled for this step: sent, read from the cache, written to it, and written back. */
16 tokens: number
17 /** Of those, the tokens read from the prompt cache. */
18 cachedTokens: number
19 /** When the step became the current one; null while upcoming. */
20 startedAt: number | null
21 /** When it was checked off; null until then. */
22 finishedAt: number | null
23 /** Files Claude wrote or edited during the step; absent on steps saved before 0.20. */
24 files?: GlanceFile[]
25}
26
27/** A file Claude wrote or edited: its path within the project (just its name outside it), and whether the step created it. */
28export type GlanceFile = { path: string; isNew: boolean }
29
30/** `background`: Claude answered, but helpers or background tasks still run. */
31export type GlancePhase = 'working' | 'needsYou' | 'stuck' | 'stopped' | 'background' | 'done'
32
33/** A helper working under a step: a subagent, or a background task such as a long command. */
34export type GlanceHelper = {
35 id: string
36 /** The step it was started under. */
37 stepId: string
38 kind: 'helper' | 'background'
39 /** What it works on, in plain words. */
40 label: string
41 /** The helper type (Explore, general-purpose) or the background task's type (shell). */
42 type: string
43 /** The model it runs on, as people say it ("Haiku 4.5"); null until known. */
44 model: string | null
45 /** How hard it thinks (low to max); null until known or for a model without effort. */
46 effort: string | null
47 status: 'running' | 'done' | 'failed'
48 /** Tokens this helper's own model requests handled. */
49 tokens: number
50}
51
52export type GlanceChecklist = {
53 title: string
54 phase: GlancePhase
55 tasks: GlanceTask[]
56 needsYouReason: string | null
57 /** What Claude asked, in its own words, when the turn ended on a question; shown in place of the general reason. */
58 question?: string | null
59 stuckReason: string | null
60 startedAt: number
61 finishedAt: number | null
62 isCollapsed: boolean
63 /** True once Claude laid out a real plan (plan_steps, TodoWrite or TaskCreate). */
64 hasPlan: boolean
65 /** The turn that started this job; a late job name for another job is ignored. */
66 jobId: string
67 /** When the real plan arrived; the time-left estimate counts from here. */
68 planAt: number | null
69 /** How many steps the first plan had, to say when the plan grew. */
70 plannedCount: number
71 /** Subagents and background tasks this job started. */
72 helpers: GlanceHelper[]
73 /** Tokens of requests outside any step, such as Claude's final answer: counted in the job's total only. */
74 extraTokens: number
75 /** Of those, the tokens read from the prompt cache. */
76 extraCachedTokens: number
77 /** How a stopped job was stopped: Esc, or the Pause button. */
78 stopKind: 'esc' | 'pause' | null
79 /** What Claude is doing right now, in plain words ("Reading files"), and how many times in a row. */
80 activity: {
81 label: string
82 count: number
83 /** Claude's own one-line description of the call, when it gave one. */
84 detail: string | null
85 /** What the call works on, for the Plan in Details: the command, a file's name or a site. */
86 target: string | null
87 } | null
88 /** Approve the plan first: `waiting` until the person presses Start; `approved` for the rest of the job. */
89 approval: 'none' | 'waiting' | 'approved'
90 /** The session's spend in US dollars when the job started; null where the host keeps no ledger. */
91 costAtStart: number | null
92 /** How long one unit of step size took in this project's earlier finished jobs, in ms; null until History knows. */
93 paceMs: number | null
94}
95
96/** One plan window and how much of it is used. */
97export type GlanceLimit = {
98 /** In plain words: "5-hour", "weekly", "spending". */
99 label: string
100 /** 0 to 100. */
101 percent: number
102}
103
104/** The plan limits and chat length, shown under the checklist. */
105export type GlanceUsage = {
106 /** Every plan window the last response reported; empty off a subscription. */
107 limits: GlanceLimit[]
108 /** The fullest plan window, 0 to 100; null off a subscription. */
109 limitPercent: number | null
110 /** Which window that is, in plain words ("5-hour", "weekly"). */
111 limitLabel: string | null
112 /** When that window resets, as an ISO 8601 timestamp; null when not reported. */
113 limitResetsAt: string | null
114 /** How full this chat's context window is, 0 to 100. */
115 contextPercent: number | null
116 /** What the session has cost so far in US dollars; null where the host keeps no ledger. */
117 costUsd: number | null
118}
119
120/** Another chat on this computer, as this one mentions it: its job's name, and whether it needs the person or works on. */
121export type GlanceOtherChat = {
122 title: string
123 /** True when it waits on the person or is stuck; false while it works. */
124 needsYou: boolean
125}
126
127/** How a job ended, as the history shows it. */
128export type GlanceOutcome = 'done' | 'stopped' | 'stuck' | 'waiting' | 'background' | 'working'
129
130/** One job in the day's history: kept in the store for 30 days, on this computer only. */
131export type GlanceHistoryEntry = {
132 jobId: string
133 /** The folder the session ran in. */
134 project: string
135 startedAt: number
136 finishedAt: number | null
137 title: string
138 outcome: GlanceOutcome
139 stepsDone: number
140 stepsTotal: number
141 newTokens: number
142 cachedTokens: number
143 /** Names of the steps it finished, for the team report. */
144 doneSteps: string[]
145 /** What each finished step got done, in the order of `doneSteps`; '' where Claude gave no summary. */
146 doneNotes?: string[]
147 /** Names of the steps still open. */
148 openSteps: string[]
149 /** Files the job created, and files it changed that were there before, as paths within the project. */
150 filesAdded?: string[]
151 filesChanged?: string[]
152 /** A quick answer with no plan: listed in the panel, left out of the team report. */
153 isQuickAnswer: boolean
154 /** What the job cost in US dollars; absent where the host keeps no ledger. */
155 costUsd?: number | null
156 /** Size units of the steps it checked off (S 1, M 2, L 3), and the time they took in ms: the project's pace. */
157 doneUnits?: number
158 doneMs?: number
159}
160
161/** What the history pane shows: one day of one project. */
162export type GlanceHistoryView = {
163 /** YYYY-MM-DD, local time. */
164 day: string
165 project: string
166 entries: GlanceHistoryEntry[]
167 /** Days with saved history, newest first, for the day picker. */
168 days: string[]
169 /** True while the plain-English team report shows in place of the list. */
170 isReportShown: boolean
171 /** The team report for the day or the 7 days up to it, or `mine`: your own week across every project. */
172 reportSpan: 'day' | 'week' | 'mine'
173 /** This project's entries for the 7 days up to `day`, for the weekly report. */
174 weekEntries: GlanceHistoryEntry[]
175 /** Every project's entries for the 7 days up to `day`, for your own weekly summary. */
176 myWeekEntries: GlanceHistoryEntry[]
177}
178
179declare module 'claude-code' {
180 interface PluginState {
181 'glanceflow': {
182 glanceEnabled: boolean
183 /** `simple`: steps, progress and time. `detailed`: also models, effort, tokens, cache and plan usage. */
184 detailLevel: 'simple' | 'detailed'
185 checklist: GlanceChecklist | null
186 tick: number
187 usage: GlanceUsage
188 historyView: GlanceHistoryView | null
189 /** The Fresh chat button: `armed` waits for a second press to confirm. */
190 handoffState: 'idle' | 'armed' | 'working'
191 /** Alerts: `chime` plays a short sound when Claude needs you, gets stuck or finishes a long job; `voice` also says it. */
192 soundMode: 'off' | 'chime' | 'voice'
193 /** Desktop notices: the computer's own notification when Claude needs you, gets stuck or finishes a long job. */
194 isNoticing: boolean
195 /** The welcome card in view, 0 to 2; null once it is done or skipped. */
196 tourStep: number | null
197 /** After a tidy-up with a checkpoint, until the next message: the band offers Where we left off. */
198 isRecapShown: boolean
199 /** Calm mode: nothing moves, and statuses read in bold. */
200 isCalm: boolean
201 /** Password guard: a message that looks like it holds a password or key is held back until sent twice. */
202 isGuarded: boolean
203 /** Approve the plan first: Claude lays out its plan and waits for Start or a change. */
204 approvePlan: boolean
205 /** How full the chat gets, in percent, before the band offers to tidy it up; 0 never offers. */
206 tidyAt: number
207 /** When the last checkpoint was saved before tidying up; null when none was. */
208 checkpointAt: number | null
209 /** The other chats that need the person or are at work, read every little while from what each one saves. */
210 otherChats: GlanceOtherChat[]
211 /** Whether the band names the other chats at all: off before sharing the screen, say. */
212 showsOthers: boolean
213 }
214 }
215}
216