SLOPSHOPPER

glanceflow

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…

newpanebandspinnerrowsguard
★ 2v0.24.6MITupdated 2026-10-09Antreas-Strb/glanceflow/glanceflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glanceflow
│ ┃ glanceflow-settings ✕ › fix the failing auth test╭───────────────────────────────────────╮ │ ┃ ⚙ GlanceFlow settings │ glanceflow │ │ ┃ Changes apply right away and stay for your n ⏺ Read(src/auth.ts) │ GlanceFlow is off: showing everything │ │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines ╰───────────────────────────────────────╯ │ ┃ ⏺ Update(src/auth.ts) │ ┃ View ⎿ Added 2 lines, removed 1 line │ ┃ [ ○ Simple ] [ ○ Details ] [ ● Off ] ⏺ Read(/work/app/src/auth.ts) │ ┃ Claude Code as usual: no checklist, every to ⎿ Denied by glanceflow: Call mcp__glanceflow__plan_steps fi │ ┃ shown. The password guard keeps its own sett │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Sounds │ ┃ [ ● Off ] [ ○ Chime ] [ ○ Chime and voice ] ✻ Worked for 42s · done 4:20 PM │ ┃ No sounds. Turn them on to look away while C │ ┃ › /glanceflow │ ┃ Desktop notices ⎿ glanceflow: GlanceFlow is off. │ ┃ [ ● Off ] [ ○ On ] │ ┃ Turn on to get a notice on your computer, ev │ ┃ work in another app. │ ┃ │ ┃ Other chats │ ┃ [ ○ Off ] [ ● On ] │ ┃ The band names another chat on this computer │ ┃ you, from any project. Turn off before you s │ ┃ screen. │ ┃ │ ┃ Calm mode [ ○ GlanceFlow: Off ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ○ GlanceFlow: Off ] ⟨Claude Code's own drawing⟩
Pane · glanceflow-settings
⚙ GlanceFlow settings Changes apply right away and stay for your next chats. ──────────────────────────────────────────────────────── View Off [ ○ Simple ] [ ○ Details ] [ ● Off ] Claude Code as usual: no checklist, every tool row shown. The password guard keeps its own setting. Sounds Off [ ● Off ] [ ○ Chime ] [ ○ Chime and voice ] No sounds. Turn them on to look away while Claude works. Desktop notices Off [ ● Off ] [ ○ On ] Turn on to get a notice on your computer, even while you work in another app. Other chats On [ ○ Off ] [ ● On ] The band names another chat on this computer that needs you, from any project. Turn off before you share your screen. Calm mode Off [ ● Off ] [ ○ On ] Bars and spinners move while Claude works. Turn on for a still screen. Approve the plan first Off [ ● Off ] [ ○ On ] Claude starts right after laying out its plan. Password guard On [ ○ Off ] [ ● On ] A message that looks like it has a password or key in it is held back. Press Enter again to send it anyway. Tidy up the chat At 50% full [ ○ Never ] [ ○ 40% ] [ ● 50% ] [ ○ 60% ] [ ○ 75% ] At 50% full, the checklist offers to tidy up. Claude first saves a checkpoint of the work (goal, what is done and left, decisions, the next step), so nothing important is lost when the chat is compacted. This chat is 49% full. [ Tidy up now ] ──────────────────────────────────────────────────────── [ Done ] [ Reset to defaults ]
Pane · glanceflow-recap
No checkpoint in this chat yet. When Claude tidies the chat up, it saves one first, and it shows here. [ Close ]
Pane · glanceflow-plan
No plan yet. Ask Claude for something and its plan shows here. [ Close ] [ History ]
Pane · glanceflow-history
Type /glanceflow history to see today's tasks. [ Close ]
README

GlanceFlow

GlanceFlow status badge

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.

GlanceFlow in the Claude Desktop app: the plan fills in as Claude works, Needs you shows Claude's question, and the job carries on to All done

<sub>A scripted job: the checklist is drawn by GlanceFlow 0.24.6 itself; the window and the notice around it are illustrative.</sub>

Install

You need:

  • Claude Code 2.1.288 or later, in the terminal or the Desktop app's Code tab.
  • Mods turned on for your account. Run 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.

For everyone, and for engineers

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.

  • Simple, for people who aren't technical: tool calls, file diffs and command output are hidden. You see only the checklist, plain-English step names and Claude's answers.
  • Details, for software engineers: the checklist stays, and the tool calls, diffs and command output stay in view too. Each step also shows:
  • its bar filling gradually from a time estimate, with running time and time left, e.g. 62% 1m 52s · ~1m left in the terminal, or under a minute left in the Desktop app; finished steps show how long they took
  • each helper's type, model and effort, e.g. Explore · Haiku 4.5 · low effort
  • tokens per step, new ones first, then the cheaper ones read back from the prompt cache, e.g. 3k new · 45k cached; Claude's final answer counts in the job's total only
  • plan usage all the time, e.g. Plan usage: 5-hour 42% · weekly 18% · chat 34% full (Usage: in the Desktop app)
  • what each job cost, e.g. $0.42, where Claude Code keeps a cost (pay-as-you-go API use); the History panel shows it per job and per day
  • Off: Claude Code as usual. Only the password guard stays on (it has its own switch in ⚙ Settings).

GlanceFlow in Details in the Desktop app: how long each finished step took, the current step's bar with time left, and a Haiku helper under its step

<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>

What you get

  • Made for the Desktop app: in the Code tab, GlanceFlow draws its own icons, progress rings and slim bars, and lays its rows out for the app's font, so columns line up. Every header reads the same way: the state in bold, then the job and how far it is (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.
  • Plan first: before Claude uses a tool, it lays out 2 to 8 plain-English steps. A quick question needs no plan.
  • Approve the plan first (optional): turn it on in ⚙ Settings or with /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.
  • Live progress: each step's meter fills as Claude reports progress. The header shows how far along the job is (the percentage in the terminal; steps done in the Desktop app, where the ring beside it fills) and about how long is left: from the first step once History knows how fast your steps go in this project (a few finished jobs), otherwise after two steps. A step that runs past its usual time says 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.
  • What Claude is doing right now: a quiet line says it in one whole sentence: Claude's own description when it fits, like 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.
  • What each step got done: when a step is checked off, Claude adds one plain-English line on what it did ("Added a pricing table with three plans"). It shows under the step until Claude moves on, and goes into the History and the Team report.
  • Which files changed: ▤ Plan lists under each step the files Claude added or changed in it (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.
  • Sounds, if you want them: /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.
  • Desktop notices, if you want them: /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.
  • Calm mode: /glanceflow calm on stops everything that moves (the sweeping bar, the helper spinners) and shows statuses in bold.
  • Status at a glance: every status says what is happening and, when Claude needs you, where to answer. A finished job whose last message asks you something (a ?, 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.
  • May be stuck: if Claude shows nothing new for 3 minutes while it works (no reply, no step starting or ending), the checklist shows Stuck with 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.
  • Helpers and background work: subagents and background tasks show under the step that started them. GlanceFlow doesn't say All done while they still run.
  • Password guard: a message that looks like it holds a password, API key, token, card or bank number is not sent. It goes back into the prompt box; send it again within 2 minutes to send it anyway. This guard stays on even when GlanceFlow is off; turn it off in ⚙ Settings or with /glanceflow guard off if it gets in your way.
  • Privacy on screen: emails, phone numbers, keys and card numbers are masked in the conversation and in step names. Claude still reads the original text.
  • Plan limits: a warning shows at 80% of a plan window (red at 95%) and says when the limit resets (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).

What each status means

Each status GlanceFlow shows in the Desktop app, from Working to All done, with what it means and what to do

<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>

Pause and Continue

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.

The whole plan

▤ 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.

The Plan panel in the Desktop app: every step with what it got done and its time, what Claude is doing now, the files it changed and a helper at work

<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job.</sub>

Settings

⚙ 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.

The Settings panel in the Desktop app: a menu for each choice, from the view and sounds to the password guard and when to tidy up

<sub>Drawn by GlanceFlow 0.24.6 itself, as the Desktop app shows it, for a sample job.</sub>

History for a retro

≣ 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.

The History panel in the Desktop app: the day's jobs with start time, outcome, steps, time and files changed, the day picker and the totals

<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.

The Team report in the Desktop app: what got done, what is still open and the time spent, with Copy report

<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 (handoff)

↻ 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.

Commands

CommandWhat it does
/glanceflowTurns GlanceFlow on or off
/glanceflow on, /glanceflow offTurns it on or off
/glanceflow detailsSwitches between Simple and Details
/glanceflow details on, /glanceflow details offPicks Details or Simple
/glanceflow historyShows today's jobs in this project
/glanceflow history yesterday, /glanceflow history 2026-10-06Shows another day
/glanceflow weekYour week across every project, in a few lines, copied to share
/glanceflow tourShows the three welcome cards again
/glanceflow pause, /glanceflow continuePauses Claude, or picks the job up again
/glanceflow planOpens the whole plan in a side panel
/glanceflow settingsOpens the settings: view, sounds, desktop notices, other chats, calm mode, plan approval, password guard and when to tidy up
/glanceflow tidySaves a checkpoint, then tidies up the chat
/glanceflow checkpointShows where we left off: the checkpoint saved before the last tidy-up
/glanceflow tidy at 60, /glanceflow tidy offWhen the checklist offers to tidy up
/glanceflow handoffStarts a fresh chat from a handoff note
/glanceflow handoff notePuts the last handoff note in the prompt box
/glanceflow sound on, /glanceflow sound voice, /glanceflow sound offA chime (and words) when Claude needs you, gets stuck or finishes a long job
/glanceflow notify on, /glanceflow notify offA notice from your computer when Claude needs you, gets stuck or finishes a long job
/glanceflow calm on, /glanceflow calm offNothing moves; statuses in bold
/glanceflow others on, /glanceflow others offWhether the band names another chat on this computer that needs you
/glanceflow guard on, /glanceflow guard offTurns the password guard on or off
/glanceflow approve on, /glanceflow approve offClaude 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.

Limits

  • The Desktop app draws its own one-line tool summary ("Used 3 tools"); a mod can't hide it.
  • The VS Code extension doesn't draw mods yet (anthropics/claude-code#99423), so GlanceFlow shows nothing there. Run claude in VS Code's own terminal instead, or use the Desktop app's Code tab.
  • Pattern matching can't catch every secret written in plain words.
  • Background tasks are checked at the end of each of Claude's replies.
  • Each new job gets a short English name from Haiku in the background: one small model request per job.

Questions and ideas

Ask a question, suggest a feature or show how you use it in Discussions. For a clear bug, open an issue.

Credits

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.

License

MIT. See LICENSE. Third-party code is listed in THIRD-PARTY-NOTICES.md.

Source 8 files
hooks/register.tsx 8 lines
1import type { Register } from 'claude-code'
2
3import { registerGlance } from './glance'
4
5export const register: Register = on => {
6  registerGlance(on)
7}
8
hooks/glance.tsx 3677 lines
1import { 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 lines
1// 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}
303
hooks/privacy.ts 96 lines
1// 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}
96
hooks/logic.ts 653 lines
1import 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}
653
hooks/kit.tsx 60 lines
1import 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}
60
hooks/look.ts 164 lines
1/**
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}
164
types/index.d.ts 216 lines
1export 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