A pane with a rolling status of the session, the agent's tasks and your own to-dos

A pane beside the Claude Code chat that shows where a session stands, without asking the agent and spending a turn on it:
TaskCreate, TaskUpdate and TodoWrite in the terminal; the desktop app's own task list, update_plan, in its Code tab), with subtasks, progress and an Edit mode.![]()
At the prompt of a terminal session:
/plugin install session-glance --marketplace pixels-for-breakfast/claude-code-mods
From a shell:
claude plugin install session-glance --marketplace pixels-for-breakfast/claude-code-mods
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with pixels-for-breakfast/claude-code-mods, then Add plugin.
The pane opens at the start of a session. Toggle it with /glance, or with the Show status / Hide status button above the prompt (g in the terminal). Wherever you dock it, it fits: beside the chat the cards are stacked; docked at the top or bottom (from 110 cells wide) they sit side by side.
![]()
When a turn ends and the next step is yours, the band says so in the accent colour: ● your move: push develop, or ● your move: answer the question when the answer ends on one. It shows only between turns and goes away at your next prompt, so you can see it without opening the pane.
![]()
While text is selected on screen, + Task and + To-do appear beside that button: they add the selection as a task for the agent or as a to-do of your own, through the usual task check. The selection is checked a few times a second, because Claude Code raises no event when it changes; where a surface does not report its selection, the buttons stay hidden.
| Command | What it does | |
|---|---|---|
/glance | Opens or closes the pane | |
/glance copy | Copies the status and both checklists as markdown, for a PR description, a standup or the next session; prints it where no clipboard is reachable | |
/glance task | Adds the selected text as a task for the agent | |
/glance todo | Adds the selected text as a to-do of your own | |
/glance refresh | Updates the status from the last answer (for when automatic updates are off) | |
/glance purge | Clears the status, title and every task, after asking | |
/glance demo | Fills the pane with a demo of every state it can show | |
| `/glance mode smart\ | manual` | Switches between Smart and Manual (see Settings) |
Status card
/resume you can see how the session got here. It holds the last 50 and is saved with the pane./glance copy on the clipboard.Agent tasks and Your to-dos
⠿ to reorder, drag sideways to nest a task under the one above or take it out again, click a task to rename it, add subtasks or move it to the other card (a main task takes its subtasks along), archive it with the box or remove it with the bin at the end of its row. Nothing changes until Save.Open to-dos carry over. When a new session starts in a project where an earlier session left to-dos of yours open, your card offers them: Bring them adds them (with their open subtasks) and tells the agent they are yours; Dismiss leaves them behind. The earlier session keeps its own copy.
The pane is saved per session: /resume and a restart bring it back, /clear starts it empty. A compaction is told to keep the goal and done-when.
Set them in the Settings card at the bottom of the pane, in the config menu (/config), or with the commands below. A change reloads the mod; the pane keeps what it shows.
Mode sets how much the mod does for you. Smart (the default) has the model write the status after every answer and check the tasks you type. Manual makes no model calls: the pane keeps the agent's tasks and your to-dos as you track them, and ● your move still shows when an answer ends on a question. In Manual the Smart settings below are hidden and keep their values for when you switch back; /glance refresh still updates the status when you ask for it.
Show hides a card you don't need, such as everything but Your to-dos. A hidden card's count leaves the band above the prompt too. The Settings card always shows.
| Setting | Default | Command | What it does | |
|---|---|---|---|---|
Mode (mode) | smart | `/glance mode smart\ | manual` | Smart: the model writes the status and checks tasks. Manual: no model calls |
Rename the session (renameSession) | off | `/glance rename on\ | off` | Sets the session's title to the status title at every prompt |
Suggest a new title (suggestTitle) | on | `/glance suggest on\ | off` | Suggests a title when the work moves away from the current one; only while renaming is off |
Update the status after every answer (autoSummary) | on | `/glance auto on\ | off` | Off: the status only changes on /glance refresh |
Check tasks you type (checkTasks) | on | `/glance check on\ | off` | Off: typed tasks are added as typed, without a model call |
Model (summaryModel) | haiku | `/glance model haiku\ | sonnet` | The model for the status and the task check |
Show the Status card (showStatus) | on | `/glance show-status on\ | off` | Off hides the card; the band still shows the title |
Show the Agent tasks card (showAgentTasks) | on | `/glance show-tasks on\ | off` | Off hides the card and its count in the band |
Show the Your to-dos card (showTodos) | on | `/glance show-todos on\ | off` | Off hides the card and its count in the band |
Rename, suggest, auto, check and model act in Smart mode only.
The status and the task check are model calls made through your own Claude Code account, the same way your session's own calls are:
What it costs. Both calls run at low effort, since they only fill in a short form. Measured in October 2026 at API prices (Haiku 5.5 $0.10 / $0.50, Sonnet 5.5 $2 / $10 per million tokens), output tokens include the model's thinking:
| Call | Haiku (default) | Sonnet |
|---|---|---|
| Status, once per answered turn (~1,200 tokens in) | ~920 out: $0.0006, 6 cents per 100 turns | ~190 out: $0.004, 43 cents per 100 turns |
| Task check, once per task you type or rename (~730 in) | ~250 out: $0.0002 | ~$0.003 (estimated) |
What each setting changes:
/glance refresh when you ask for one./glance refresh costs one status call when you ask for it.On a Claude subscription the calls count toward your usage instead of being billed. The Settings card's header counts this session's calls and tokens.
Nothing is sent anywhere else. The pane's saved state (status, title, tasks, history and counts, for the 30 most recent sessions, with each session's project folder so a new session can offer what was left open) is kept in the plugin's own store in your Claude Code configuration folder.
claude --plugin-dir plugins/session-glance # run it from this folder
claude plugin validate plugins/session-glance
claude plugin test plugins/session-glance
hooks/register.tsx: the state, the actions that change it, and the events (the engine's scan only accepts state that is read and written in the file that defines it).hooks/pane.tsx: the pane and the band, drawn from plain data and a set of actions; no state.hooks/status.ts: reading the status the model writes (fields, criteria, drift, suggestions) and the markdown handoff.hooks/prompts.ts: the prompts for the status and the task check, and reading their replies.hooks/tree.ts: the pure task logic (display order, progress, drag-and-drop, owners, archive, what an edit changed).hooks/task-list.tsx: the drag layer of Edit mode.hooks/icons.ts, hooks/settings.ts, hooks/demo.ts: pictures, the settings table, the demo.types/index.d.ts: the state contract.tests/: glance.test.tsx (pane and band), manage.test.tsx (Edit mode), features.test.tsx (persistence, criteria, copy, settings, title suggestions).Built against Claude Code 2.1.308. The tests draw the pane for the terminal and the desktop app.
See CHANGELOG.md.
MIT, see LICENSE.
hooks/register.tsx 990 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelCompleteRequest, ModelCompleteResult, Register, RenderSurface, ToolCallResult } from 'claude-code'
3
4import type { HistoryEntry, Owner, Spent, Task } from '../types'
5import { DEMO_FOLDED, DEMO_HISTORY, DEMO_SUGGESTION, DEMO_SUMMARY, DEMO_TASKS, DEMO_TITLE } from './demo'
6import { drawBand, drawPane } from './pane'
7import type { BandView, PaneView } from './pane'
8import { parseVerdict, splitReply, summaryPrompt, validationPrompt } from './prompts'
9import { MODELS, MODES, SWITCHES, isSwitchOn, modeOf, modelOf } from './settings'
10import type { Criterion } from './status'
11import { appendHistory, compactNote, driftNote, handoffMarkdown, parseCriteria, parseSummary, selectionTask, suggestionFrom, yourMoveFrom } from './status'
12import { activeOf, applyPlan, describeChanges, mergeDraft, moveTask, newTodos, openTodos, ownedBy, ownerOf, planDrop, progress, replaceList, setArchived, shownStatus } from './tree'
13import type { PlanCall } from './tree'
14
15const PANE = 'session-glance'
16const summary = atom({ plugin: 'session-glance', key: 'summary' } as const, '')
17const lastPrompt = atom({ plugin: 'session-glance', key: 'lastPrompt' } as const, '')
18const isSummarizing = atom({ plugin: 'session-glance', key: 'isSummarizing' } as const, false)
19const tasks = atom({ plugin: 'session-glance', key: 'tasks' } as const, [] as Task[])
20const title = atom({ plugin: 'session-glance', key: 'title' } as const, '')
21const isSummaryOpen = atom({ plugin: 'session-glance', key: 'isSummaryOpen' } as const, true)
22const isTasksOpen = atom({ plugin: 'session-glance', key: 'isTasksOpen' } as const, true)
23const isPaneOpen = atom({ plugin: 'session-glance', key: 'isPaneOpen' } as const, false)
24const isManaging = atom({ plugin: 'session-glance', key: 'isManaging' } as const, false)
25/** The card in Edit mode; the other one stays as it is meanwhile. */
26const editingOwner = atom({ plugin: 'session-glance', key: 'editingOwner' } as const, 'agent' as Owner)
27/** Whether the full Edit-mode help shows below the nesting line; the info mark toggles it. */
28const isHelpOpen = atom({ plugin: 'session-glance', key: 'isHelpOpen' } as const, false)
29const isValidating = atom({ plugin: 'session-glance', key: 'isValidating' } as const, false)
30const addCount = atom({ plugin: 'session-glance', key: 'addCount' } as const, 0)
31const selectedId = atom({ plugin: 'session-glance', key: 'selectedId' } as const, '')
32const draft = atom({ plugin: 'session-glance', key: 'draft' } as const, [] as Task[])
33const draftBaseIds = atom({ plugin: 'session-glance', key: 'draftBaseIds' } as const, [] as string[])
34const subtaskCount = atom({ plugin: 'session-glance', key: 'subtaskCount' } as const, 0)
35const folded = atom({ plugin: 'session-glance', key: 'folded' } as const, [] as string[])
36const lastAnswer = atom({ plugin: 'session-glance', key: 'lastAnswer' } as const, '')
37/** Done-when criteria the person ticked by hand, lowercased; the model cannot untick them. */
38const ticked = atom({ plugin: 'session-glance', key: 'ticked' } as const, [] as string[])
39const spent = atom({ plugin: 'session-glance', key: 'spent' } as const, { calls: 0, input: 0, output: 0 } as Spent)
40/** The session id whose saved pane was loaded; another id means /resume, /clear or a restart moved on. */
41const restoredFor = atom({ plugin: 'session-glance', key: 'restoredFor' } as const, '')
42/** The title the session is known by: the first status title, or a suggestion the person used. */
43const titleAnchor = atom({ plugin: 'session-glance', key: 'titleAnchor' } as const, '')
44const titleSuggestion = atom({ plugin: 'session-glance', key: 'titleSuggestion' } as const, '')
45/** A title the person chose, set as the session title at their next prompt (the only time the host takes one). */
46const pendingTitle = atom({ plugin: 'session-glance', key: 'pendingTitle' } as const, '')
47/** Suggestions the person dismissed, lowercased, so the same one is not offered again. */
48const dismissedTitles = atom({ plugin: 'session-glance', key: 'dismissedTitles' } as const, [] as string[])
49const isMineOpen = atom({ plugin: 'session-glance', key: 'isMineOpen' } as const, true)
50const mineAddCount = atom({ plugin: 'session-glance', key: 'mineAddCount' } as const, 0)
51/** The next thing the person must do, as the status model reads the last exchange; offered in the You card. */
52const moveSuggestion = atom({ plugin: 'session-glance', key: 'moveSuggestion' } as const, '')
53const dismissedMoves = atom({ plugin: 'session-glance', key: 'dismissedMoves' } as const, [] as string[])
54/** Whether text is selected on screen now, polled: the API raises no event when a selection changes. */
55const hasSelection = atom({ plugin: 'session-glance', key: 'hasSelection' } as const, false)
56/** The cards whose Archived fold is open. */
57const openArchives = atom({ plugin: 'session-glance', key: 'openArchives' } as const, [] as Owner[])
58const isSettingsOpen = atom({ plugin: 'session-glance', key: 'isSettingsOpen' } as const, false)
59/** Open to-dos from the last session in this project, offered once when a new session starts. */
60const carryOffer = atom({ plugin: 'session-glance', key: 'carryOffer' } as const, [] as Task[])
61/** What the person is waiting to do after the last turn, shown in the band until their next prompt. */
62const yourMove = atom({ plugin: 'session-glance', key: 'yourMove' } as const, '')
63/** What each earlier turn did, oldest first: the Status card's history. */
64const history = atom({ plugin: 'session-glance', key: 'history' } as const, [] as HistoryEntry[])
65const isHistoryOpen = atom({ plugin: 'session-glance', key: 'isHistoryOpen' } as const, false)
66/** The desktop app's task-list tool: in its sessions the agent plans with it instead of TaskCreate or TodoWrite. */
67const PLAN_TOOL = 'mcp__ccd_turn__update_plan'
68
69/** How often the session id and the selection are checked: neither change raises an event. */
70const POLL_MS = 400
71
72/** The person's settings (`userConfig`), set by `register` on every load; a change in the config menu reloads it. */
73const settings = { model: 'haiku', isAutoSummary: true, isTaskCheck: true, isTitleSuggest: true }
74
75/** The mod's version from its manifest, read at `session.start` (which a reload runs again); empty until then. */
76let version = ''
77
78async function readVersion($: EngineInterface): Promise<string> {
79 try {
80 const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
81
82 return typeof manifest.version === 'string' ? manifest.version : ''
83 } catch {
84 return ''
85 }
86}
87
88/**
89 * Calls the model and adds what the call cost to the session's count, answered or not.
90 * Low effort: both calls fill in a short form, and the models think by default, which is billed as output.
91 */
92async function complete($: EngineInterface, request: Omit<ModelCompleteRequest, 'model'>): Promise<ModelCompleteResult> {
93 const reply = await $.model.complete({ ...request, model: settings.model, effort: 'low' })
94 const usage = 'usage' in reply ? reply.usage : undefined
95 if (usage !== undefined) {
96 await update($, spent, total => ({
97 calls: total.calls + 1,
98 input: total.input + usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens,
99 output: total.output + usage.output_tokens,
100 }))
101 }
102
103 return reply
104}
105
106/** One saved pane per session in `$.store`, plus the ids newest last, so the oldest can be dropped. */
107const SNAPSHOT_PREFIX = 'session:'
108const SNAPSHOT_INDEX = 'sessions'
109const KEEP_SESSIONS = 30
110
111export type Snapshot = {
112 /** The project the session ran in, so a new session there can offer what was left open. */
113 root: string
114 summary: string
115 title: string
116 tasks: Task[]
117 folded: string[]
118 ticked: string[]
119 spent: Spent
120 titleAnchor: string
121 titleSuggestion: string
122 dismissedTitles: string[]
123 moveSuggestion: string
124 dismissedMoves: string[]
125 history: HistoryEntry[]
126}
127
128/** A pane with nothing in it: what /clear and /glance purge leave, and the base a saved pane is read over. */
129const EMPTY_SNAPSHOT: Snapshot = {
130 root: '',
131 summary: '',
132 title: '',
133 tasks: [],
134 folded: [],
135 ticked: [],
136 spent: { calls: 0, input: 0, output: 0 },
137 titleAnchor: '',
138 titleSuggestion: '',
139 dismissedTitles: [],
140 moveSuggestion: '',
141 dismissedMoves: [],
142 history: [],
143}
144
145/** The session's project; empty where the host cannot say, which matches no other session. */
146async function projectRoot($: EngineInterface): Promise<string> {
147 try {
148 return await $.session.root()
149 } catch {
150 return ''
151 }
152}
153
154async function readSnapshot($: EngineInterface): Promise<Snapshot> {
155 return {
156 root: await projectRoot($),
157 summary: await read($, summary),
158 title: await read($, title),
159 tasks: await read($, tasks),
160 folded: await read($, folded),
161 ticked: await read($, ticked),
162 spent: await read($, spent),
163 titleAnchor: await read($, titleAnchor),
164 titleSuggestion: await read($, titleSuggestion),
165 dismissedTitles: await read($, dismissedTitles),
166 moveSuggestion: await read($, moveSuggestion),
167 dismissedMoves: await read($, dismissedMoves),
168 history: await read($, history),
169 }
170}
171
172/** Puts a saved (or empty) pane in place; what the person was in the middle of goes with it. */
173async function applySnapshot($: EngineInterface, snapshot: Snapshot): Promise<void> {
174 await update($, summary, () => snapshot.summary)
175 await update($, title, () => snapshot.title)
176 await update($, tasks, () => snapshot.tasks)
177 await update($, folded, () => snapshot.folded)
178 await update($, ticked, () => snapshot.ticked)
179 await update($, spent, () => snapshot.spent)
180 await update($, titleAnchor, () => snapshot.titleAnchor)
181 await update($, titleSuggestion, () => snapshot.titleSuggestion)
182 await update($, dismissedTitles, () => snapshot.dismissedTitles)
183 await update($, moveSuggestion, () => snapshot.moveSuggestion)
184 await update($, dismissedMoves, () => snapshot.dismissedMoves)
185 await update($, history, () => snapshot.history)
186 await update($, pendingTitle, () => '')
187 await update($, draft, () => [])
188 await update($, selectedId, () => '')
189 await update($, isManaging, () => false)
190}
191
192/** Whether a pane holds nothing a later session would want back. */
193function isEmptyPane(snapshot: Snapshot): boolean {
194 return snapshot.summary === '' && snapshot.tasks.length === 0 && snapshot.history.length === 0 && snapshot.spent.calls === 0
195}
196
197/** Saves what the pane shows under this session's id, so /resume and a restart bring it back. */
198async function save($: EngineInterface): Promise<void> {
199 try {
200 const id = await $.session.id()
201 // Only the session whose pane is loaded writes; right after /clear the old one's state is still here.
202 if (id !== (await read($, restoredFor))) {
203 return
204 }
205 const snapshot = await readSnapshot($)
206 // A resumed session starts under a passing id of its own; an empty pane is not worth a slot under it.
207 if (isEmptyPane(snapshot) && (await $.store.get(`${SNAPSHOT_PREFIX}${id}`)) === undefined) {
208 return
209 }
210 await $.store.set(`${SNAPSHOT_PREFIX}${id}`, snapshot)
211 const ids = [...(((await $.store.get(SNAPSHOT_INDEX)) as string[] | undefined) ?? []).filter(one => one !== id), id]
212 for (const old of ids.slice(0, -KEEP_SESSIONS)) {
213 await $.store.delete(`${SNAPSHOT_PREFIX}${old}`)
214 }
215 await $.store.set(SNAPSHOT_INDEX, ids.slice(-KEEP_SESSIONS))
216 } catch {
217 // The pane still works; this session just will not come back after a restart.
218 }
219}
220
221/** The open to-dos of the newest other session saved in this project; none when there is no such session. */
222async function lastOpenTodos($: EngineInterface, currentId: string): Promise<Task[]> {
223 const root = await projectRoot($)
224 const ids = ((await $.store.get(SNAPSHOT_INDEX)) as string[] | undefined) ?? []
225 for (const id of [...ids].reverse()) {
226 const saved = (await $.store.get(`${SNAPSHOT_PREFIX}${id}`)) as Partial<Snapshot> | undefined
227 if (root !== '' && id !== currentId && saved?.root === root) {
228 return openTodos(saved.tasks ?? [])
229 }
230 }
231
232 return []
233}
234
235/** Takes the offered to-dos into this session's card and tells the agent; the old session keeps its own copy. */
236async function bringTodos($: EngineInterface): Promise<void> {
237 const offered = newTodos(await read($, carryOffer), await read($, tasks))
238 await update($, carryOffer, () => [])
239 if (offered.length === 0) {
240 return
241 }
242 await update($, tasks, list => [...list, ...newTodos(offered, list)])
243 const subjects = offered.filter(task => task.parentId === undefined).map(task => `"${task.subject}"`)
244 await tellModel($, `The user brought their open to-dos from the last session in this project: ${subjects.join(', ')}. They are theirs to do, not yours.`)
245 await save($)
246}
247
248/** Loads this session's saved pane once per session id: /resume and a restart bring it back, /clear starts empty. */
249async function restore($: EngineInterface): Promise<void> {
250 try {
251 const id = await $.session.id()
252 const previous = await read($, restoredFor)
253 if (previous === id) {
254 return
255 }
256 const saved = (await $.store.get(`${SNAPSHOT_PREFIX}${id}`)) as Partial<Snapshot> | undefined
257 // A first load with nothing saved keeps what is here (an install or upgrade mid-session).
258 if (saved !== undefined || previous !== '') {
259 // Read over the empty pane, so a pane saved by an older version gets the fields it lacks.
260 await applySnapshot($, { ...EMPTY_SNAPSHOT, ...saved })
261 await update($, lastAnswer, () => '')
262 }
263 // A session with nothing saved is new: offer what the last one in this project left open.
264 await update($, carryOffer, () => [])
265 if (saved === undefined) {
266 const left = await lastOpenTodos($, id)
267 await update($, carryOffer, () => left)
268 }
269 await update($, restoredFor, () => id)
270 } catch {
271 // Nothing saved could be read: the pane starts from what it holds.
272 }
273}
274
275/** Tells the model about a change the person made in the pane; it reads this on its next turn. */
276async function tellModel($: EngineInterface, change: string): Promise<void> {
277 await $.session.append({
278 message: { type: 'user', content: [{ type: 'text', text: `<session-glance>${change} Treat that as a fact from now on.</session-glance>` }] },
279 })
280}
281
282/** Asks the app's question dialog; a dismissed dialog counts as cancel. */
283async function ask($: EngineInterface, question: string, options: string[]): Promise<string | undefined> {
284 try {
285 const answer = await $.ui.ask(question, { header: 'Task', options })
286
287 return answer === 'Cancel' ? undefined : answer
288 } catch {
289 return undefined
290 }
291}
292
293/** Has Haiku tidy the typed task and asks the person when it is unclear or a duplicate; undefined means cancelled. */
294async function validateTask($: EngineInterface, typed: string, others: readonly Task[]): Promise<string | undefined> {
295 if (!settings.isTaskCheck) {
296 return typed
297 }
298 await update($, isValidating, () => true)
299 try {
300 const reply = await complete($, {
301 system: 'Reply with one JSON object only, no prose, no code fence.',
302 prompt: validationPrompt(typed, parseSummary(await read($, summary)).goal ?? '', others),
303 maxTokens: 300,
304 })
305 const verdict = parseVerdict(reply.isAnswered ? reply.text : '', typed)
306 await update($, isValidating, () => false)
307 if (verdict.verdict === 'unclear') {
308 return ask($, verdict.question || `What exactly should "${typed}" do?`, [...verdict.options, 'Cancel'])
309 }
310 if (verdict.verdict === 'duplicate') {
311 const answer = await ask($, `This looks like "${verdict.duplicateOf}". Add it anyway?`, ['Add anyway', 'Cancel'])
312
313 return answer === undefined ? undefined : verdict.subject
314 }
315
316 return verdict.subject
317 } finally {
318 await update($, isValidating, () => false)
319 }
320}
321
322async function addTask($: EngineInterface, typed: string, owner: Owner = 'agent'): Promise<void> {
323 if (typed.trim() === '') {
324 return
325 }
326 // One update per atom: the engine lists what a module writes from the atom named at each call.
327 if (owner === 'user') {
328 await update($, mineAddCount, n => n + 1)
329 } else {
330 await update($, addCount, n => n + 1)
331 }
332 const subject = await validateTask($, typed.trim(), ownedBy(await read($, tasks), owner))
333 if (subject === undefined) {
334 return
335 }
336 await addOwnTask($, subject, owner)
337}
338
339/** Adds a checked task to a card; the agent hears of a to-do of the person's own as a note, not as work. */
340async function addOwnTask($: EngineInterface, subject: string, owner: Owner): Promise<void> {
341 const task: Task = { id: `user-${crypto.randomUUID().slice(0, 8)}`, subject, status: 'pending', ...(owner === 'user' ? { owner } : {}) }
342 await update($, tasks, list => [...list, task])
343 await tellModel(
344 $,
345 owner === 'user'
346 ? `The user noted a to-do of their own: "${subject}". It is theirs to do, not yours; bring it up when it becomes relevant.`
347 : `The user added the task "${subject}" to the session checklist.`,
348 )
349 await save($)
350}
351
352/** Redraws the band only when text gets selected or the selection goes away. */
353async function checkSelection($: EngineInterface): Promise<void> {
354 try {
355 const isSelected = selectionTask((await $.ui.selection())?.text) !== ''
356 if (isSelected !== (await read($, hasSelection))) {
357 await update($, hasSelection, () => isSelected)
358 }
359 } catch {
360 // A surface that cannot say: the buttons stay hidden.
361 }
362}
363
364/** Adds the current selection as a task for the agent or a to-do of the person's own; false with nothing selected. */
365async function addFromSelection($: EngineInterface, owner: Owner): Promise<boolean> {
366 const text = selectionTask((await $.ui.selection())?.text)
367 if (text === '') {
368 return false
369 }
370 await addTask($, text, owner)
371
372 return true
373}
374
375/** Takes the suggested next step into the You card. */
376async function acceptMove($: EngineInterface): Promise<void> {
377 const subject = await read($, moveSuggestion)
378 if (subject === '') {
379 return
380 }
381 await update($, moveSuggestion, () => '')
382 await addOwnTask($, subject, 'user')
383}
384
385async function dismissMove($: EngineInterface): Promise<void> {
386 const subject = await read($, moveSuggestion)
387 await update($, dismissedMoves, list => [...list, subject.toLowerCase()].slice(-20))
388 await update($, moveSuggestion, () => '')
389 await save($)
390}
391
392/** Renames a task in the edit draft, after Haiku's check; saved or dropped with the draft. */
393async function renameInDraft($: EngineInterface, task: Task, typed: string): Promise<void> {
394 if (typed.trim() === '' || typed.trim() === task.subject) {
395 return
396 }
397 const subject = await validateTask($, typed.trim(), (await read($, draft)).filter(one => one.id !== task.id))
398 if (subject === undefined) {
399 return
400 }
401 await update($, draft, list => list.map(one => (one.id === task.id ? { ...one, subject } : one)))
402 await update($, selectedId, () => '')
403}
404
405/** Adds a subtask under a main task in the edit draft, after Haiku's check. */
406async function addSubtaskInDraft($: EngineInterface, parent: Task, typed: string): Promise<void> {
407 if (typed.trim() === '') {
408 return
409 }
410 await update($, subtaskCount, n => n + 1)
411 const subject = await validateTask($, typed.trim(), await read($, draft))
412 if (subject === undefined) {
413 return
414 }
415 const child: Task = { id: `user-${crypto.randomUUID().slice(0, 8)}`, subject, status: 'pending', parentId: parent.id }
416 await update($, draft, list => [...list, child])
417}
418
419/** Removes a task from the edit draft; a main task takes its subtasks with it. */
420async function removeFromDraft($: EngineInterface, task: Task): Promise<void> {
421 await update($, draft, list => list.filter(one => one.id !== task.id && one.parentId !== task.id))
422 await update($, selectedId, current => (current === task.id ? '' : current))
423}
424
425/** Puts a task (and its subtasks) away in the edit draft; saved or dropped with the draft. */
426async function archiveInDraft($: EngineInterface, task: Task): Promise<void> {
427 await update($, draft, list => setArchived(list, task.id, true))
428 await update($, selectedId, current => (current === task.id ? '' : current))
429}
430
431/** Brings an archived task (and its subtasks) back into its card. */
432async function restoreTask($: EngineInterface, task: Task): Promise<void> {
433 await update($, tasks, list => setArchived(list, task.id, false))
434 await tellModel($, `The user restored the archived task "${task.subject}" to the session pane.`)
435 await save($)
436}
437
438/** Hands a task (and its subtasks) to the other card in the edit draft. */
439async function moveInDraft($: EngineInterface, task: Task): Promise<void> {
440 await update($, draft, list => moveTask(list, task.id))
441 await update($, selectedId, () => '')
442}
443
444async function startEditing($: EngineInterface, owner: Owner = 'agent'): Promise<void> {
445 const current = await read($, tasks)
446 await update($, draft, () => current)
447 await update($, draftBaseIds, () => current.map(task => task.id))
448 await update($, selectedId, () => '')
449 await update($, editingOwner, () => owner)
450 await update($, isManaging, () => true)
451}
452
453async function stopEditing($: EngineInterface, isSaved: boolean): Promise<void> {
454 if (isSaved) {
455 const before = await read($, tasks)
456 const baseIds = await read($, draftBaseIds)
457 const edited = await read($, draft)
458 const original = before.filter(task => baseIds.includes(task.id))
459 await update($, tasks, current => mergeDraft(current, edited, baseIds))
460 const change = describeChanges(original, edited)
461 if (change !== '') {
462 await tellModel($, change)
463 }
464 await save($)
465 }
466 await update($, isManaging, () => false)
467 await update($, selectedId, () => '')
468 await update($, draft, () => [])
469}
470
471const STATUS_CHOICES = {
472 pending: [
473 { label: 'Mark as done', status: 'completed' },
474 { label: 'Start', status: 'in_progress' },
475 ],
476 in_progress: [
477 { label: 'Mark as done', status: 'completed' },
478 { label: 'Back to pending', status: 'pending' },
479 ],
480 completed: [{ label: 'Reopen', status: 'pending' }],
481} as const
482const STATUS_WORDS = { pending: 'pending', in_progress: 'in progress', completed: 'completed' } as const
483
484/** Asks what to do with a task, then records the new status and tells the model. */
485async function changeStatus($: EngineInterface, task: Task): Promise<void> {
486 const all = await read($, tasks)
487 const shown = shownStatus(task, all)
488 const children = all.filter(one => one.parentId === task.id)
489 const choices = STATUS_CHOICES[shown === 'partial' ? 'in_progress' : shown]
490 const answer = await ask($, `What should happen to "${task.subject}"?`, [...choices.map(choice => choice.label), 'Cancel'])
491 const choice = choices.find(one => one.label === answer)
492 if (choice === undefined) {
493 return
494 }
495 // Done or reopened on a main task carries to its subtasks; starting it leaves them be.
496 const isCarried = choice.status !== 'in_progress' && children.length > 0
497 await update($, tasks, list =>
498 list.map(one => (one.id === task.id || (isCarried && one.parentId === task.id) ? { ...one, status: choice.status } : one)),
499 )
500 const sync =
501 task.id.startsWith('demo-') || task.id.startsWith('user-') || ownerOf(task) === 'user' ? '' : ` If it is in your task list (id ${task.id}), update it to match.`
502 const carried = isCarried ? `, and with it its ${children.length} subtasks` : ''
503 const which = ownerOf(task) === 'user' ? 'their own to-do' : 'the task'
504 await tellModel($, `The user marked ${which} "${task.subject}" as ${STATUS_WORDS[choice.status]}${carried} in the session pane.${sync}`)
505 await save($)
506}
507
508/** Folds the latest exchange into the status; false when there was nothing to fold or the model gave no reply. */
509async function summarize($: EngineInterface): Promise<boolean> {
510 const answer = await read($, lastAnswer)
511 if (answer === '') {
512 return false
513 }
514 await update($, isSummarizing, () => true)
515 const anchor = await read($, titleAnchor)
516 const dismissedTodo = await read($, dismissedMoves)
517 try {
518 const reply = await complete($, {
519 system: 'Answer only with the labelled lines asked for (TITLE, GOAL, DONE_WHEN, DONE, NEXT, DRIFT, YOUR_MOVE, and RETITLE when asked). No other labels, no extra lines.',
520 prompt: summaryPrompt(await read($, summary), await read($, lastPrompt), answer, await read($, ticked), settings.isTitleSuggest ? anchor : ''),
521 maxTokens: 300,
522 })
523 if (!reply.isAnswered) {
524 return false
525 }
526 const parts = splitReply(reply.text)
527 await update($, summary, () => parts.summary)
528 const turn = await $.session.turns().catch(() => 0)
529 // Where the host cannot count prompts, the entry takes the next number in the history.
530 await update($, history, list => appendHistory(list, parseSummary(parts.summary).done, turn > 0 ? turn : list.length + 1))
531 if (parts.title !== '') {
532 await update($, title, () => parts.title)
533 }
534 if (anchor === '' && parts.title !== '') {
535 await update($, titleAnchor, () => parts.title)
536 }
537 const suggestion = settings.isTitleSuggest ? suggestionFrom(parseSummary(parts.summary).retitle, anchor, await read($, dismissedTitles)) : ''
538 if (suggestion !== '' && suggestion !== (await read($, titleSuggestion))) {
539 await update($, titleSuggestion, () => suggestion)
540 $.ui.toast(`Title suggestion: ${suggestion}`)
541 }
542 const mine = ownedBy(await read($, tasks), 'user').filter(task => task.status !== 'completed').map(task => task.subject.toLowerCase())
543 await update($, moveSuggestion, () => suggestionFrom(parseSummary(parts.summary).your_move, '', [...(dismissedTodo ?? []), ...mine]))
544
545 return true
546 } finally {
547 await update($, isSummarizing, () => false)
548 await save($)
549 }
550}
551
552/** Takes the suggested title: it becomes the session title at the next prompt and the title later suggestions are measured against. */
553async function useSuggestion($: EngineInterface): Promise<void> {
554 const suggestion = await read($, titleSuggestion)
555 if (suggestion === '') {
556 return
557 }
558 await update($, pendingTitle, () => suggestion)
559 await update($, titleAnchor, () => suggestion)
560 await update($, titleSuggestion, () => '')
561 await save($)
562}
563
564async function dismissSuggestion($: EngineInterface): Promise<void> {
565 const suggestion = await read($, titleSuggestion)
566 await update($, dismissedTitles, list => [...list, suggestion.toLowerCase()].slice(-20))
567 await update($, titleSuggestion, () => '')
568 await save($)
569}
570
571/** Puts the handoff on a surface's clipboard; the result says whether it took, with the markdown either way. */
572async function copyHandoff($: EngineInterface, surface?: RenderSurface): Promise<{ isCopied: boolean; markdown: string; hasStatus: boolean }> {
573 const text = await read($, summary)
574 const markdown = handoffMarkdown(await read($, title), text, await read($, tasks), await read($, ticked))
575 const copied = await $.ui.copy(surface === undefined ? { text: markdown } : { text: markdown, surface })
576
577 return { isCopied: copied.isCopied, markdown, hasStatus: text.trim() !== '' }
578}
579
580/** Ticks or unticks a done-when criterion by hand and tells the model; a hand tick outlasts the model's own view. */
581async function toggleCriterion($: EngineInterface, criterion: Criterion): Promise<void> {
582 const key = criterion.text.toLowerCase()
583 await update($, ticked, keys => (criterion.isTicked ? keys.filter(one => one !== key) : [...keys, key]))
584 await tellModel(
585 $,
586 criterion.isTicked
587 ? `The user unticked the done-when criterion "${criterion.text}"; judge it again from the work.`
588 : `The user ticked the done-when criterion "${criterion.text}" as met.`,
589 )
590 await save($)
591}
592
593/** Clears everything the pane holds for this session, after the person confirms; true when it did. */
594async function purge($: EngineInterface): Promise<boolean> {
595 const answer = await ask($, 'Clear the status, the title and every task in the session pane? This cannot be undone.', ['Clear everything', 'Cancel'])
596 if (answer !== 'Clear everything') {
597 return false
598 }
599 const hadTasks = (await read($, tasks)).length > 0
600 await applySnapshot($, { ...EMPTY_SNAPSHOT, spent: await read($, spent) })
601 if (hadTasks) {
602 await tellModel($, 'The user cleared the session checklist; its tasks no longer apply unless they bring them up again.')
603 }
604 await save($)
605
606 return true
607}
608
609async function openPane($: EngineInterface): Promise<void> {
610 await $.ui.open({ id: PANE, title: 'Session' })
611 await update($, isPaneOpen, () => true)
612}
613
614
615/** A tool call that ran and did not fail: not denied, not an error. */
616function hasRun(ran: ToolCallResult): boolean {
617 return !('deny' in ran && ran.deny !== undefined) && ran.isError !== true
618}
619
620/** Writes one of the mod's settings; the engine then reloads the module with the new options. */
621async function setOption($: EngineInterface, key: string, value: boolean | string): Promise<void> {
622 const { deny } = await $.config.set({ key: `session-glance.${key}`, value })
623 if (deny !== undefined) {
624 $.ui.toast(`Could not change the setting: ${deny}`)
625 }
626}
627
628export const register: Register = (on, options) => {
629 // Manual makes no model calls: the Smart switches keep their values for when Smart comes back.
630 const isSmart = modeOf(options) === 'smart'
631 const isShown = { status: isSwitchOn(options, 'showStatus'), agent: isSwitchOn(options, 'showAgentTasks'), user: isSwitchOn(options, 'showTodos') }
632 settings.model = modelOf(options)
633 settings.isAutoSummary = isSmart && isSwitchOn(options, 'autoSummary')
634 settings.isTaskCheck = isSmart && isSwitchOn(options, 'checkTasks')
635 // With renaming on the title already follows the work, so there is nothing to suggest.
636 settings.isTitleSuggest = isSmart && isSwitchOn(options, 'suggestTitle') && !isSwitchOn(options, 'renameSession')
637
638 on('session.start', async ($, e, next) => {
639 version = await readVersion($)
640 await restore($)
641 // A reload runs session.start again and cancels the old timer with the old module.
642 // The id is checked too: a resumed session starts under a passing id and takes its own a moment later.
643 $.clock.every(POLL_MS, () => void restore($).then(() => checkSelection($)))
644 await $.command.register({ name: 'glance', description: 'Toggle the session pane (`/glance copy` copies it as markdown, `/glance refresh` updates the status, `/glance task|todo` adds the selected text, `/glance purge` clears it, `/glance demo` fills in a demo; settings: `/glance mode smart|manual`, `/glance rename|suggest|auto|check on|off`, `/glance show-status|show-tasks|show-todos on|off`, `/glance model haiku|sonnet`)' })
645 void openPane($)
646
647 return next(e)
648 })
649
650 on('command.run', { command: 'glance' }, async ($, e) => {
651 const [word, value] = e.args.trim().split(/\s+/)
652 const setting = SWITCHES.find(one => one.word === word)
653 if (setting !== undefined) {
654 // The plugin's own way to its settings: `/config` has two entries in the desktop app, and a recalled line picks the wrong one.
655 if (value !== undefined && value !== 'on' && value !== 'off') {
656 return { text: `Use \`/glance ${setting.word} on\`, \`/glance ${setting.word} off\`, or \`/glance ${setting.word}\` to switch it.` }
657 }
658 const isOn = value === undefined ? !isSwitchOn(options, setting.key) : value === 'on'
659 const { deny } = await $.config.set({ key: `session-glance.${setting.key}`, value: isOn })
660 if (deny !== undefined) {
661 return { text: `Could not change the setting: ${deny}` }
662 }
663
664 return { text: isOn ? setting.on : setting.off }
665 }
666 if (word === 'mode') {
667 const mode = MODES.find(one => one === value)
668 if (mode === undefined) {
669 return { text: `The pane is in ${modeOf(options)} mode. Use \`/glance mode smart\` or \`/glance mode manual\` to change it.` }
670 }
671 const { deny } = await $.config.set({ key: 'session-glance.mode', value: mode })
672 if (deny !== undefined) {
673 return { text: `Could not change the setting: ${deny}` }
674 }
675
676 return { text: mode === 'manual' ? 'Manual mode: no more model calls; the pane keeps the tasks and to-dos you track.' : 'Smart mode: the status updates and typed tasks are checked again.' }
677 }
678 if (word === 'model') {
679 const model = MODELS.find(one => one === value)
680 if (model === undefined) {
681 return { text: `The status uses ${settings.model}. Use \`/glance model haiku\` or \`/glance model sonnet\` to change it.` }
682 }
683 const { deny } = await $.config.set({ key: 'session-glance.summaryModel', value: model })
684 if (deny !== undefined) {
685 return { text: `Could not change the setting: ${deny}` }
686 }
687
688 return { text: `The status and task checks use ${model} from now on.` }
689 }
690 if (word === 'task' || word === 'todo') {
691 const owner: Owner = word === 'todo' ? 'user' : 'agent'
692 const isAdded = await addFromSelection($, owner)
693
694 return {
695 text: isAdded
696 ? `Added the selection ${owner === 'user' ? 'to your to-dos' : 'as a task for the agent'}, unless you cancelled the check.`
697 : 'Select some text first, then run it again (the selection is read from the screen).',
698 }
699 }
700 if (e.args.trim() === 'refresh') {
701 return { text: (await summarize($)) ? 'Updated the status from the last answer.' : 'Nothing to update: no answer yet, or the model gave no reply.' }
702 }
703 if (e.args.trim() === 'copy') {
704 if ((await read($, summary)).trim() === '' && (await read($, tasks)).length === 0) {
705 return { text: 'Nothing to copy yet: no status and no tasks.' }
706 }
707 const { isCopied, markdown, hasStatus } = await copyHandoff($)
708 if (!isCopied) {
709 return { text: `No clipboard to reach here; copy it from below.\n\n${markdown}` }
710 }
711
712 return { text: hasStatus ? 'Copied the session status and checklist as markdown.' : 'Copied the checklist as markdown; there is no status yet.' }
713 }
714 if (e.args.trim() === 'purge') {
715 return { text: (await purge($)) ? 'Cleared the status, title and every task.' : 'Nothing was cleared.' }
716 }
717 if (e.args.trim() === 'demo') {
718 await applySnapshot($, {
719 ...(await readSnapshot($)),
720 tasks: DEMO_TASKS,
721 summary: DEMO_SUMMARY,
722 title: DEMO_TITLE,
723 folded: DEMO_FOLDED,
724 ticked: [],
725 titleAnchor: DEMO_TITLE,
726 titleSuggestion: DEMO_SUGGESTION,
727 moveSuggestion: 'Push the branch',
728 history: DEMO_HISTORY,
729 })
730 await save($)
731 await openPane($)
732
733 return { text: `Seeded the demo: a status and ${DEMO_TASKS.length} tasks in every state.` }
734 }
735 if (await read($, isPaneOpen)) {
736 await $.ui.close({ id: PANE })
737
738 return { text: 'Session pane closed.' }
739 }
740 await openPane($)
741
742 return { text: 'Session pane opened.' }
743 })
744
745 on('ui.close', async ($, e, next) => {
746 if (e.id === PANE) {
747 await update($, isPaneOpen, () => false)
748 }
749
750 return next(e)
751 }).catch(($, e, next) => next(e))
752
753 on('prompt.submit', async ($, e, next) => {
754 // An in-process /resume or /clear starts no session.start, so the id is checked here too.
755 await restore($)
756 await update($, lastPrompt, () => e.text)
757 await update($, yourMove, () => '')
758
759 return next(e)
760 }).catch(($, e, next) => next(e))
761
762 // Off by default: a session list full of titles you did not write is hard to find your way in.
763 // The host only takes a new session title at a prompt, so it trails the summary by one turn.
764 on('classic.UserPromptSubmit', async ($, e, next) => {
765 const result = await next(e)
766 const chosen = await read($, pendingTitle)
767 if (chosen !== '') {
768 await update($, pendingTitle, () => '')
769
770 return { ...result, sessionTitle: chosen }
771 }
772 if (!isSmart || !isSwitchOn(options, 'renameSession')) {
773 return result
774 }
775 const current = await read($, title)
776
777 return current === '' ? result : { ...result, sessionTitle: current }
778 }).catch(($, e, next) => next(e))
779
780 on('tool.call', async ($, e, next) => {
781 const ran = await next(e)
782 // `tool` narrows the input to a call's own fields; a call that did not run changes nothing.
783 if (!hasRun(ran) || !('tool' in e)) {
784 return ran
785 }
786 // The desktop app's own task list stands in for the task tools there; its input is read as data.
787 if ((e.tool as string) === PLAN_TOOL) {
788 await update($, tasks, list => applyPlan(list, e as unknown as PlanCall)).catch(() => undefined)
789
790 return ran
791 }
792 // Bookkeeping only: a failure here must never fail the tool call it watched.
793 try {
794 if (e.tool === 'TaskCreate') {
795 const created = ran.result as { task: { id: string; subject: string } }
796 await update($, tasks, list => [...list, { id: created.task.id, subject: created.task.subject, status: 'pending' as const }])
797 } else if (e.tool === 'TaskUpdate') {
798 const { taskId, status, subject } = e
799 await update($, tasks, list =>
800 status === 'deleted'
801 ? list.filter(task => task.id !== taskId)
802 : list.map(task => (task.id === taskId ? { ...task, status: status ?? task.status, subject: subject ?? task.subject } : task)),
803 )
804 } else if (e.tool === 'TodoWrite') {
805 // TodoWrite sends the whole list; the person's own tasks stay.
806 await update($, tasks, list => replaceList(list, 'todo-', e.todos))
807 }
808 } catch {
809 // The pane just misses this task change.
810 }
811
812 return ran
813 })
814
815 on('turn.complete', async ($, e, next) => {
816 const done = await next(e)
817 // Only the main loop's answered turns move the summary; subagent turns would drown it.
818 if (e.agentId !== undefined || e.reason !== 'answer' || e.answer === '') {
819 return done
820 }
821 // Kept for /glance refresh; the prompt only reads the first 8000 characters.
822 await update($, lastAnswer, () => e.answer.slice(0, 8000))
823 if (settings.isAutoSummary) {
824 await summarize($)
825 } else {
826 await save($)
827 }
828 // Without a fresh status only the answer itself can say it ends on a question.
829 const move = settings.isAutoSummary ? parseSummary(await read($, summary)).your_move : undefined
830 await update($, yourMove, () => yourMoveFrom(e.answer, move))
831
832 return done
833 })
834
835 on('session.end', async ($, e, next) => {
836 await save($)
837
838 return next(e)
839 }).catch(($, e, next) => next(e))
840
841 // The pane keeps its own state through a compaction; this keeps the goal in the agent's summary too.
842 on('session.compact', async ($, e, next) => {
843 const keep = compactNote(await read($, summary))
844 if (e.agentId !== undefined || keep === '') {
845 return next(e)
846 }
847
848 return next({ ...e, instructions: e.instructions ? `${e.instructions}\n\n${keep}` : keep })
849 }).catch(($, e, next) => next(e))
850
851 on('ui.message', async ($, e, next) => {
852 if (e.element !== 'edit-overlay' && e.element !== 'edit-overlay-mine') {
853 return next(e)
854 }
855 const owner: Owner = e.element === 'edit-overlay-mine' ? 'user' : 'agent'
856 const data = e.data as { type: 'drop' | 'select'; id: string; slot?: number; dx?: number }
857 const task = (await read($, draft)).find(one => one.id === data.id)
858 if (task === undefined) {
859 return {}
860 }
861 if (data.type === 'drop') {
862 const foldedIds = await read($, folded)
863 // Each card has its own layer and rows, so the drop is planned within that card's tasks.
864 // The layer only knows the card's visible rows; its archived tasks are kept after them.
865 await update($, draft, list => [
866 ...planDrop(activeOf(ownedBy(list, owner)), data.id, data.slot ?? 0, data.dx ?? 0, foldedIds).list,
867 ...ownedBy(list, owner).filter(one => one.archived === true),
868 ...list.filter(one => ownerOf(one) !== owner),
869 ])
870 } else {
871 await update($, selectedId, current => (current === data.id ? '' : data.id))
872 }
873
874 return {}
875 })
876
877 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
878 if (e.props.hasSurvey) {
879 return next(e)
880 }
881 const isOpen = await read($, isPaneOpen)
882 const list = await read($, tasks)
883 const { done, total } = progress(activeOf(ownedBy(list, 'agent')))
884 const mineOpen = progress(activeOf(ownedBy(list, 'user')))
885 // A hidden card's count leaves the band too; a count of 0 is not drawn.
886 const view: BandView = {
887 isTerminal: e.surface === 'terminal',
888 isOpen,
889 isSmart,
890 title: await read($, title),
891 done,
892 total: isShown.agent ? total : 0,
893 openForYou: isShown.user ? mineOpen.total - mineOpen.done : 0,
894 isSelected: await read($, hasSelection),
895 // Shown only between turns: while the agent works, it is not the person's move.
896 yourMove: e.props.isWorking ? '' : await read($, yourMove),
897 }
898
899 return drawBand($.ui.resolve(e), view, {
900 togglePane: async () => {
901 if (isOpen) {
902 // Our own close is not echoed back as a `ui.close` event, so record it here.
903 await $.ui.close({ id: PANE })
904 await update($, isPaneOpen, () => false)
905 } else {
906 await openPane($)
907 }
908 },
909 addSelection: owner => addFromSelection($, owner),
910 })
911 })
912
913 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
914 const managing = await read($, isManaging)
915 const fields = parseSummary(await read($, summary))
916 const criteria = parseCriteria(fields.done_when, await read($, ticked))
917 const view: PaneView = {
918 isTerminal: e.surface === 'terminal',
919 fields,
920 criteria,
921 isReached: criteria.length > 0 && criteria.every(criterion => criterion.isMet),
922 drift: driftNote(fields.drift),
923 cost: await read($, spent),
924 model: settings.model,
925 suggestion: await read($, titleSuggestion),
926 chosenTitle: await read($, pendingTitle),
927 busy: await read($, isSummarizing),
928 checking: await read($, isValidating),
929 stored: managing ? await read($, draft) : await read($, tasks),
930 managing,
931 editedCard: await read($, editingOwner),
932 helpOpen: await read($, isHelpOpen),
933 adds: await read($, addCount),
934 mineAdds: await read($, mineAddCount),
935 selected: await read($, selectedId),
936 subtaskAdds: await read($, subtaskCount),
937 foldedIds: await read($, folded),
938 showSummary: await read($, isSummaryOpen),
939 showTasks: await read($, isTasksOpen),
940 showMine: await read($, isMineOpen),
941 showSettings: await read($, isSettingsOpen),
942 nextMove: await read($, moveSuggestion),
943 archiveOwners: await read($, openArchives),
944 // A to-do the card already holds (the same one, or a demo's) is not offered again.
945 carried: newTodos(await read($, carryOffer), await read($, tasks)),
946 history: await read($, history),
947 showHistory: await read($, isHistoryOpen),
948 settingRows: SWITCHES.map(row => ({ key: row.key, group: row.group, label: row.label, hint: row.hint, isOn: isSwitchOn(options, row.key) })),
949 isSmart,
950 cards: isShown,
951 version,
952 columns: e.props.bodyColumns,
953 }
954 const toggle = (id: string) => (ids: string[]) => (ids.includes(id) ? ids.filter(one => one !== id) : [...ids, id])
955
956 return drawPane($.ui.resolve(e), view, {
957 toggleSummary: () => update($, isSummaryOpen, open => !open),
958 toggleCard: owner => (owner === 'user' ? update($, isMineOpen, open => !open) : update($, isTasksOpen, open => !open)),
959 toggleSettings: () => update($, isSettingsOpen, open => !open),
960 toggleFold: id => update($, folded, toggle(id)),
961 toggleArchive: owner => update($, openArchives, owners => (owners.includes(owner) ? owners.filter(one => one !== owner) : [...owners, owner])),
962 toggleHelp: () => update($, isHelpOpen, open => !open),
963 toggleHistory: () => update($, isHistoryOpen, open => !open),
964 select: id => update($, selectedId, current => (current === id ? '' : id)),
965 changeStatus: task => changeStatus($, task),
966 startEditing: owner => startEditing($, owner),
967 stopEditing: isSaved => stopEditing($, isSaved),
968 rename: (task, value) => renameInDraft($, task, value),
969 addSubtask: (task, value) => addSubtaskInDraft($, task, value),
970 move: task => moveInDraft($, task),
971 archive: task => archiveInDraft($, task),
972 remove: task => removeFromDraft($, task),
973 restore: task => restoreTask($, task),
974 add: (value, owner) => addTask($, value, owner),
975 acceptMove: () => acceptMove($),
976 bringTodos: () => bringTodos($),
977 ignoreTodos: () => update($, carryOffer, () => []),
978 dismissMove: () => dismissMove($),
979 useTitle: () => useSuggestion($),
980 dismissTitle: () => dismissSuggestion($),
981 toggleCriterion: criterion => toggleCriterion($, criterion),
982 copy: async () => {
983 const { isCopied } = await copyHandoff($, e.surface)
984 $.ui.toast(isCopied ? 'Copied the session as markdown' : 'No clipboard here: /glance copy prints it')
985 },
986 setOption: (key, value) => setOption($, key, value),
987 })
988 })
989}
990hooks/demo.ts 45 lines1import type { HistoryEntry, Task } from '../types'
2
3/** What `/glance demo` fills the pane with. */
4
5export const DEMO_TASKS: Task[] = [
6 { id: 'demo-1', subject: 'Sketch the pane layout', status: 'completed' },
7 { id: 'demo-2', subject: 'Write the summary prompt', status: 'completed' },
8 { id: 'demo-3', subject: 'Add the toggle above the prompt', status: 'in_progress' },
9 { id: 'demo-3a', subject: 'Draw the button', status: 'completed', parentId: 'demo-3' },
10 { id: 'demo-3b', subject: 'Wire the hotkey', status: 'pending', parentId: 'demo-3' },
11 { id: 'demo-4', subject: 'Seed fake tasks for a demo', status: 'pending' },
12 { id: 'demo-5', subject: 'Try it in a fresh session', status: 'pending' },
13 { id: 'demo-6', subject: 'Polish the terminal spacing', status: 'in_progress' },
14 { id: 'demo-7', subject: 'Ship the drag-and-drop editor', status: 'pending' },
15 { id: 'demo-7a', subject: 'Draw the drop line', status: 'completed', parentId: 'demo-7' },
16 { id: 'demo-7b', subject: 'Nest by dragging sideways', status: 'completed', parentId: 'demo-7' },
17 { id: 'demo-8', subject: 'Write the README', status: 'pending', owner: 'user' },
18 { id: 'demo-8a', subject: 'Explain the /glance command', status: 'pending', parentId: 'demo-8', owner: 'user' },
19 { id: 'demo-8b', subject: 'Add screenshots', status: 'pending', parentId: 'demo-8', owner: 'user' },
20]
21
22/**
23 * The demo's status card and title, in the shape Haiku writes them. With DEMO_TASKS it shows every
24 * state the pane draws: pending, in progress, done, a partly done main task, a main task done by its
25 * subtasks, and a folded one (DEMO_FOLDED).
26 */
27export const DEMO_TITLE = 'Session glance demo'
28/** A title the work has moved on to, so the demo shows the Status card's title suggestion too. */
29export const DEMO_SUGGESTION = 'Session pane with drag-and-drop editing'
30export const DEMO_SUMMARY = [
31 'GOAL: Glanceable status and checklist beside the session',
32 'DONE_WHEN: [x] Every task state shows; [ ] Subtasks fold and unfold; [ ] Dragging reorders the list',
33 'DONE: Seeded a demo of everything the pane can show',
34 'NEXT: Click a task, then try Edit mode and drag one',
35].join('\n')
36export const DEMO_FOLDED = ['demo-8']
37
38/** Oldest first; the newest entry is the Done line of DEMO_SUMMARY, which the Status card shows above Earlier. */
39export const DEMO_HISTORY: HistoryEntry[] = [
40 { turn: 1, done: 'Sketched the pane layout' },
41 { turn: 2, done: 'Wrote the summary prompt' },
42 { turn: 3, done: 'Drew the toggle button above the prompt' },
43 { turn: 4, done: 'Seeded a demo of everything the pane can show' },
44]
45hooks/pane.tsx 673 lines1import type { ElementTable, RenderChildren, RenderElement } from 'claude-code'
2
3import type { HistoryEntry, Owner, Spent, Task } from '../types'
4import { ARCHIVE, CHEVRON, FOLD, GLYPH, ICON, TRASH, infoIcon } from './icons'
5import { MODELS } from './settings'
6import type { Criterion } from './status'
7import { FIELDS, formatTokens } from './status'
8import { HANDLE_CELLS } from './task-list'
9import { activeOf, displayRows, ownedBy, progress } from './tree'
10import type { Row } from './tree'
11
12/**
13 * The pane and the band, drawn from plain data: `register.tsx` reads the state and passes
14 * it in as a view, with the actions a press runs. Nothing here reads or writes state.
15 */
16
17/** Everything Edit mode can do, shown when the info mark is clicked. */
18const EDIT_HELP =
19 'Drag ⠿ up or down to reorder. Drag it right to nest a task under the one above, left to take it out again. ' +
20 'Click a task to rename it, add subtasks or move it to the other card. The box archives a task, the bin removes it. ' +
21 'Nothing changes until Save.'
22
23/**
24 * From this width the cards sit side by side: three of about 35 cells. Measured in the desktop app,
25 * the pane docked at the side is 71 cells wide and 50 high, at the top or bottom 158 by 23.
26 */
27export const WIDE_COLUMNS = 110
28
29/** Type and spacing: two steps up the API's five sizes (caption, footnote, body, heading, title), for reading at a glance. */
30const TYPE = { text: 'heading', label: 'body', head: 'title', icon: 24, padX: 'xl', padY: 'lg' } as const
31
32/** What the mod's own model calls cost this session: '2 calls · 3.2k tokens'. */
33export function spentLine(cost: Spent): string {
34 return `${cost.calls} ${cost.calls === 1 ? 'call' : 'calls'} · ${formatTokens(cost.input + cost.output)} tokens`
35}
36
37/** One setting row of the Settings card. */
38export type SettingRow = { key: string; group: 'smart' | 'card'; label: string; hint: string; isOn: boolean }
39
40/** What the pane draws, read from the state for one render. */
41export type PaneView = {
42 isTerminal: boolean
43 fields: Record<string, string>
44 criteria: Criterion[]
45 isReached: boolean
46 drift: string
47 cost: Spent
48 model: string
49 suggestion: string
50 chosenTitle: string
51 busy: boolean
52 checking: boolean
53 stored: Task[]
54 managing: boolean
55 editedCard: Owner
56 helpOpen: boolean
57 adds: number
58 mineAdds: number
59 selected: string
60 subtaskAdds: number
61 foldedIds: string[]
62 showSummary: boolean
63 showTasks: boolean
64 showMine: boolean
65 showSettings: boolean
66 nextMove: string
67 archiveOwners: Owner[]
68 /** Open to-dos from the last session in this project, offered in the You card. */
69 carried: Task[]
70 /** What each earlier turn did, oldest first. */
71 history: HistoryEntry[]
72 showHistory: boolean
73 settingRows: SettingRow[]
74 /** Smart: the model writes the status; Manual: no model calls, and the Smart settings are hidden. */
75 isSmart: boolean
76 /** Which cards the person chose to show; Settings always shows. */
77 cards: { status: boolean; agent: boolean; user: boolean }
78 /** The mod's version, drawn dim at the bottom of Settings; empty draws nothing. */
79 version: string
80 /** The pane's body width in cells: from WIDE_COLUMNS the cards sit side by side. */
81 columns: number
82}
83
84/** What a press in the pane runs; each one is a closure over the engine in `register.tsx`. */
85export type PaneActions = {
86 toggleSummary: () => unknown
87 toggleCard: (owner: Owner) => unknown
88 toggleSettings: () => unknown
89 toggleFold: (id: string) => unknown
90 toggleArchive: (owner: Owner) => unknown
91 toggleHelp: () => unknown
92 toggleHistory: () => unknown
93 select: (id: string) => unknown
94 changeStatus: (task: Task) => unknown
95 startEditing: (owner: Owner) => unknown
96 stopEditing: (isSaved: boolean) => unknown
97 rename: (task: Task, value: string) => unknown
98 addSubtask: (task: Task, value: string) => unknown
99 move: (task: Task) => unknown
100 archive: (task: Task) => unknown
101 remove: (task: Task) => unknown
102 restore: (task: Task) => unknown
103 add: (value: string, owner: Owner) => unknown
104 acceptMove: () => unknown
105 bringTodos: () => unknown
106 ignoreTodos: () => unknown
107 dismissMove: () => unknown
108 useTitle: () => unknown
109 dismissTitle: () => unknown
110 toggleCriterion: (criterion: Criterion) => unknown
111 copy: () => unknown
112 setOption: (key: string, value: boolean | string) => unknown
113}
114
115/** The session pane: the Status card, the two task cards, Settings and the cost line. */
116export function drawPane(table: ElementTable, view: PaneView, act: PaneActions): RenderElement {
117 const { Box, Button, Text } = table
118 const Client = 'Client' in table ? table.Client : undefined
119 const Input = 'Input' in table ? table.Input : undefined
120 const Svg = 'Svg' in table && !view.isTerminal ? table.Svg : undefined
121 const {
122 isTerminal, fields, criteria, isReached, drift, cost, suggestion, chosenTitle, busy, checking, stored, managing, editedCard,
123 helpOpen, adds, mineAdds, selected, subtaskAdds, foldedIds, showSummary, showTasks, showMine, showSettings, nextMove,
124 archiveOwners, carried, history, showHistory, settingRows, isSmart, cards,
125 } = view
126 const hasSummary = Object.keys(fields).length > 0
127 const type = TYPE
128 // The terminal lays out whole cells only: two per Edit row, a blank one above the text that the drop line can use.
129 // Elsewhere two fit the heading-size text and icons, close to the pitch of the rows outside Edit mode.
130 const rowHeight = 2
131
132 // The terminal draws the small spacing steps as no rows at all, so it gets whole blank rows where the desktop's gaps fall.
133 const airy = isTerminal ? 1 : 0
134 const header = (key: string, label: string, isOpen: boolean, aside: RenderChildren, onPress: () => unknown) => (
135 <Box justifyContent="space-between" alignItems="center" marginBottom={airy}>
136 <Box alignItems="center" gap="xs">
137 <Button key={key} plain onPress={onPress}>
138 <Text weight="medium" size={type.head} color="claude">
139 {label}
140 </Text>
141 {!Svg && <Text color="inactive">{isOpen ? ' ▾' : ' ▸'}</Text>}
142 </Button>
143 {Svg && <Svg source={isOpen ? CHEVRON.open : CHEVRON.closed} alt={isOpen ? 'collapse' : 'expand'} width={14} height={14} />}
144 </Box>
145 {aside}
146 </Box>
147 )
148 /** Something the model suggests, framed in the accent so it stands out, with a main button to take it and a quiet one to dismiss it for good. */
149 const suggestionBox = (label: string, text: string, acceptKey: string, acceptLabel: string, onAccept: () => unknown, dismissKey: string, onDismiss: () => unknown) => (
150 <Box flexDirection="column" gap="sm" marginBottom={airy} borderStyle="round" borderColor="claude" paddingX="md" paddingY="sm">
151 <Text size={type.label} color="claude" weight="medium">
152 ✦ {label}
153 </Text>
154 <Text size={type.text} weight="medium">
155 {text}
156 </Text>
157 <Box gap="md" alignItems="center">
158 <Button key={acceptKey} variant="primary" label={acceptLabel} onPress={onAccept} />
159 <Button key={dismissKey} plain dimColor onPress={onDismiss}>
160 <Text size={type.text} color="subtle">
161 Dismiss
162 </Text>
163 </Button>
164 </Box>
165 </Box>
166 )
167 const note = (text: string) => (
168 <Text size={type.text} color="inactive">
169 {text}
170 </Text>
171 )
172 // A Button holds text only, so an icon that takes a press is a picture with an empty Button laid over it.
173 const iconButton = (key: string, source: string, alt: string, glyph: string, onPress: () => unknown) =>
174 Svg ? (
175 <Box position="relative">
176 <Svg source={source} alt={alt} width={type.icon} height={type.icon} />
177 <Box position="absolute" top={0} left={0} width="100%" height="100%">
178 <Button key={key} plain onPress={onPress}>
179 <Text>{'\u2003'}</Text>
180 </Button>
181 </Box>
182 </Box>
183 ) : (
184 <Button key={key} plain onPress={onPress}>
185 <Text color="inactive">{glyph}</Text>
186 </Button>
187 )
188
189 /** One task row: checkbox, text that shrinks before anything is pushed out, subtask count and fold caret, trash in Edit mode. */
190 const taskRow = (row: Row, isEditing: boolean, isFirst: boolean) => {
191 const isFolded = foldedIds.includes(row.task.id)
192 // The caret sits at the row's end, so rows with and without one keep their checkboxes in line.
193 const fold =
194 row.children > 0
195 ? iconButton(`fold-${row.task.id}`, isFolded ? FOLD.closed : FOLD.open, isFolded ? 'expand' : 'collapse', isFolded ? '▸' : '▾', () => act.toggleFold(row.task.id))
196 : null
197
198 return (
199 <Box
200 key={`${isEditing ? 'edit' : 'row'}-${row.task.id}`}
201 height={isEditing ? rowHeight : undefined}
202 alignItems={isEditing && isTerminal ? 'flex-end' : 'center'}
203 gap="sm"
204 minWidth={0}
205 paddingLeft={row.depth === 1 && !isEditing ? 3 : 0}
206 // Edit rows keep their fixed pitch: the layer over them maps the pointer by it.
207 marginTop={!isEditing && row.depth === 0 && !isFirst ? airy : 0}
208 >
209 {isEditing && (
210 <Box width={HANDLE_CELLS + (row.depth === 1 ? 3 : 0)} flexShrink={0}>
211 <Text color="inactive">⠿</Text>
212 </Box>
213 )}
214 {Svg ? (
215 <Svg source={ICON[row.shown]} alt={row.shown.replace('_', ' ')} width={type.icon} height={type.icon} />
216 ) : (
217 <Text color={row.shown === 'in_progress' || row.shown === 'partial' ? 'claude' : 'inactive'}>{GLYPH[row.shown]}</Text>
218 )}
219 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
220 <Button
221 key={`${isEditing ? 'select' : 'task'}-${row.task.id}`}
222 plain
223 onPress={() => (isEditing ? act.select(row.task.id) : act.changeStatus(row.task))}
224 >
225 <Text
226 size={type.text}
227 color={row.shown === 'completed' ? 'inactive' : undefined}
228 strikethrough={!isEditing && row.shown === 'completed'}
229 underline={isEditing && row.task.id === selected}
230 wrap="truncate-end"
231 >
232 {row.task.subject}
233 </Text>
234 </Button>
235 </Box>
236 {row.children > 0 && note(`${row.childrenDone}/${row.children}`)}
237 {!isEditing && fold}
238 </Box>
239 )
240 }
241
242 /** The buttons of one Edit-mode row, at the row's pitch so they line up with it: fold, archive, remove; one click each, beside the drag layer. */
243 const editControls = (row: Row) => (
244 <Box key={`controls-${row.task.id}`} height={rowHeight} alignItems={isTerminal ? 'flex-end' : 'center'} justifyContent="flex-end" gap="sm">
245 {row.children > 0 &&
246 (foldedIds.includes(row.task.id)
247 ? iconButton(`fold-${row.task.id}`, FOLD.closed, 'expand', '▸', () => act.toggleFold(row.task.id))
248 : iconButton(`fold-${row.task.id}`, FOLD.open, 'collapse', '▾', () => act.toggleFold(row.task.id)))}
249 {iconButton(`archive-${row.task.id}`, ARCHIVE, 'Archive', '▤', () => act.archive(row.task))}
250 {iconButton(`remove-${row.task.id}`, TRASH, 'Remove', '✕', () => act.remove(row.task))}
251 </Box>
252 )
253
254 const isWide = view.columns >= WIDE_COLUMNS
255 /**
256 * A card as it sits in the layout: wide, an equal share of the width, the card stretched across it and down to the
257 * tallest card (a desktop gives a card only its content's size otherwise); stacked, the full width.
258 */
259 const column = (card: RenderElement) =>
260 isWide ? (
261 <Box flexDirection="column" alignItems="stretch" flexGrow={1} flexShrink={1} width={0} minWidth={0}>
262 {card}
263 </Box>
264 ) : (
265 card
266 )
267
268 /** One section of the Settings card: an equal share of the width when wide, the full width when stacked. */
269 const section = (...rows: RenderChildren[]) => (
270 <Box flexDirection="column" gap="sm" flexGrow={isWide ? 1 : 0} flexShrink={1} width={isWide ? 0 : undefined} minWidth={0}>
271 {rows}
272 </Box>
273 )
274
275 /** One On/Off row of the Settings card. */
276 const switchRow = (row: SettingRow) => (
277 <Box key={`setting-row-${row.key}`} justifyContent="space-between" alignItems="center" gap="md" minWidth={0}>
278 <Box flexDirection="column" flexShrink={1} minWidth={0}>
279 <Text size={type.text}>{row.label}</Text>
280 <Text size={type.label} color="inactive">
281 {row.hint}
282 </Text>
283 </Box>
284 <Button key={`setting-${row.key}`} plain onPress={() => act.setOption(row.key, !row.isOn)}>
285 <Text size={type.text} color={row.isOn ? 'claude' : 'subtle'} weight={row.isOn ? 'medium' : undefined}>
286 {row.isOn ? 'On' : 'Off'}
287 </Text>
288 </Button>
289 </Box>
290 )
291 /** A Settings row that picks one of a few values: the one in use is the primary button, as a desktop draws a plain Button natively and ignores its text color. */
292 const choiceRow = (key: string, label: string, hint: string, choices: readonly string[], current: string) => (
293 <Box key={`setting-row-${key}`} justifyContent="space-between" alignItems="center" gap="md" minWidth={0}>
294 <Box flexDirection="column" flexShrink={1} minWidth={0}>
295 <Text size={type.text}>{label}</Text>
296 <Text size={type.label} color="inactive">
297 {hint}
298 </Text>
299 </Box>
300 <Box gap="md" alignItems="center">
301 {choices.map(choice => {
302 const name = choice.charAt(0).toUpperCase() + choice.slice(1)
303
304 return current === choice ? (
305 <Button key={`${key === 'summaryModel' ? 'model' : key}-${choice}`} variant="primary" label={name} onPress={() => act.setOption(key, choice)} />
306 ) : (
307 <Button key={`${key === 'summaryModel' ? 'model' : key}-${choice}`} plain dimColor onPress={() => act.setOption(key, choice)}>
308 <Text size={type.text} color="subtle">
309 {name}
310 </Text>
311 </Button>
312 )
313 })}
314 </Box>
315 </Box>
316 )
317
318 /** One task card: the agent's tasks, or the person's own to-dos. One card edits at a time. */
319 const taskCard = (owner: Owner) => {
320 const isMine = owner === 'user'
321 // The agent's card keeps the keys it had before the split.
322 const suffix = isMine ? '-mine' : ''
323 const list = activeOf(ownedBy(stored, owner))
324 // Shown once in the fold: a subtask archived with its main task goes with it.
325 const archivedTasks = ownedBy(stored, owner).filter(
326 task => task.archived === true && !stored.some(one => one.id === task.parentId && one.archived === true),
327 )
328 const isArchiveOpen = archiveOwners.includes(owner)
329 const rows = displayRows(list, foldedIds)
330 const { done, total } = progress(list)
331 const isOpen = isMine ? showMine : showTasks
332 // One card edits at a time; while it does, the other shows its tasks and no Edit button.
333 const isEditing = managing && editedCard === owner
334
335 return (
336 <Box flexDirection="column" gap="sm" flexGrow={isWide ? 1 : 0} borderStyle="round" borderColor="subtle" paddingX={type.padX} paddingY={type.padY}>
337 {header(
338 `toggle-tasks${suffix}`,
339 isMine ? 'Your to-dos' : 'Agent tasks',
340 isOpen,
341 <Box gap="sm" alignItems="center">
342 {checking || total === 0 || done < total ? (
343 note(checking ? 'checking…' : total > 0 ? `${done}/${total}` : '')
344 ) : (
345 <Text size={type.text} color="success">
346 {`${done}/${total}`}
347 </Text>
348 )}
349 {isOpen && !managing && (
350 <Button key={`toggle-manage${suffix}`} plain onPress={() => act.startEditing(owner)}>
351 <Text size={type.text} color="subtle">
352 Edit
353 </Text>
354 </Button>
355 )}
356 {isOpen && isEditing && (
357 <Button key={`cancel-edit${suffix}`} plain onPress={() => act.stopEditing(false)}>
358 <Text size={type.text} color="subtle">
359 Cancel
360 </Text>
361 </Button>
362 )}
363 {isOpen && isEditing && (
364 <Button key={`save-edit${suffix}`} plain onPress={() => act.stopEditing(true)}>
365 <Text size={type.text} color="claude" weight="medium">
366 Save
367 </Text>
368 </Button>
369 )}
370 </Box>,
371 () => act.toggleCard(owner),
372 )}
373 {isOpen && isMine && carried.length > 0 && !isEditing && (() => {
374 const mains = carried.filter(task => task.parentId === undefined)
375 const label = mains.length === 1 ? '1 open to-do from your last session here' : `${mains.length} open to-dos from your last session here`
376
377 return suggestionBox(label, mains.map(task => task.subject).join(' · '), 'bring-todos', 'Bring them', act.bringTodos, 'ignore-todos', act.ignoreTodos)
378 })()}
379 {isOpen && isMine && nextMove !== '' && !isEditing && suggestionBox('Your next step?', nextMove, 'add-move', 'Add as to-do', act.acceptMove, 'dismiss-move', act.dismissMove)}
380 {isOpen && rows.length === 0 && note(isMine ? 'Nothing for you yet.' : 'No tasks yet.')}
381 {isOpen && isEditing && Client && rows.length > 0 && (
382 // The rows below hold the icons; the layer on top draws handles and the drop line and takes the clicks.
383 // The layer covers the handle and text only; the buttons beside it are real Buttons, one click each.
384 <Box gap="sm" minWidth={0}>
385 <Box flexDirection="column" position="relative" flexGrow={1} flexShrink={1} minWidth={0}>
386 {rows.map((row, i) => taskRow(row, true, i === 0))}
387 {isTerminal && <Box height={1} />}
388 <Box position="absolute" top={0} left={0} right={0} bottom={0}>
389 <Client
390 key={`edit-overlay${suffix}`}
391 module="./task-list.tsx"
392 props={{ tasks: list, folded: foldedIds, rowHeight, hasGapRows: isTerminal }}
393 width="100%"
394 height={Math.ceil(rows.length * rowHeight) + (isTerminal ? 1 : 0)}
395 />
396 </Box>
397 </Box>
398 <Box flexDirection="column" flexShrink={0}>
399 {rows.map(row => editControls(row))}
400 {isTerminal && <Box height={1} />}
401 </Box>
402 </Box>
403 )}
404 {isOpen &&
405 isEditing &&
406 Input &&
407 rows
408 .filter(row => row.task.id === selected)
409 .map(row => (
410 <Box flexDirection="column" gap="xs" marginTop="sm" borderStyle="round" borderColor="subtle" paddingX="sm" paddingY="sm">
411 {note('Rename')}
412 <Input key={`rename-${row.task.id}`} value={row.task.subject} submitLabel="Rename" onSubmit={value => act.rename(row.task, value)} />
413 {row.depth === 0 && (
414 <Box flexDirection="column" gap="xs" marginTop="xs">
415 {note('Add a subtask')}
416 <Input
417 key={`subtask-${row.task.id}-${subtaskAdds}`}
418 placeholder="What needs doing…"
419 submitLabel="Add"
420 onSubmit={value => act.addSubtask(row.task, value)}
421 />
422 </Box>
423 )}
424 <Box marginTop="xs">
425 <Button key={`move-${row.task.id}`} plain onPress={() => act.move(row.task)}>
426 <Text size={type.text} color="subtle">
427 {isMine ? 'Hand to the agent' : 'Move to your to-dos'}
428 </Text>
429 </Button>
430 </Box>
431 </Box>
432 ))}
433 {isOpen && isEditing && !selected && rows.length > 0 && (
434 // One short line about nesting, the least obvious move; a click on the info mark shows the rest.
435 <Box marginTop={airy} alignItems="center" gap="sm" minWidth={0}>
436 {iconButton('edit-help', infoIcon(helpOpen), helpOpen ? 'Hide help' : 'Show help', '?', act.toggleHelp)}
437 <Box flexShrink={1} minWidth={0}>{note('Drag ⠿ right to nest a task under the one above')}</Box>
438 </Box>
439 )}
440 {isOpen && isEditing && helpOpen && !selected && rows.length > 0 && (
441 // A blank row above on the terminal, lined up with the line's text after the ?.
442 <Box marginTop={isTerminal ? 1 : 'xs'} paddingLeft={isTerminal ? 2 : 0} minWidth={0}>
443 {note(EDIT_HELP)}
444 </Box>
445 )}
446 {isOpen && (!isEditing || !Client) && rows.map((row, i) => taskRow(row, false, i === 0))}
447 {isOpen && !isEditing && archivedTasks.length > 0 && (
448 <Box flexDirection="column" gap="xs" marginTop={airy}>
449 <Button
450 key={`toggle-archived${suffix}`}
451 plain
452 onPress={() => act.toggleArchive(owner)}
453 >
454 <Text size={type.text} color="inactive">
455 {`Archived (${archivedTasks.length}) ${isArchiveOpen ? '▾' : '▸'}`}
456 </Text>
457 </Button>
458 {isArchiveOpen &&
459 archivedTasks.map(task => (
460 <Box key={`archived-${task.id}`} alignItems="center" gap="sm" minWidth={0}>
461 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
462 <Text size={type.text} color="inactive" wrap="truncate-end">
463 {task.subject}
464 </Text>
465 </Box>
466 <Button key={`restore-${task.id}`} plain onPress={() => act.restore(task)}>
467 <Text size={type.text} color="subtle">
468 Restore
469 </Text>
470 </Button>
471 </Box>
472 ))}
473 </Box>
474 )}
475 {isOpen && Input && (
476 <Box marginTop={isTerminal ? 1 : 'sm'}>
477 <Input
478 key={`add-task${suffix}-${isMine ? mineAdds : adds}`}
479 placeholder={isMine ? 'Add a to-do for yourself…' : 'Add a task for the agent…'}
480 submitLabel="Add"
481 onSubmit={value => act.add(value, owner)}
482 />
483 </Box>
484 )}
485 </Box>
486 )
487 }
488
489 return (
490 <Box flexDirection="column" gap="lg" padding={isTerminal ? 0 : 'md'}>
491 {/* Wide (docked at the top or bottom): the shown cards as equal columns; Settings stays full width below. */}
492 <Box key="cards" flexDirection={isWide ? 'row' : 'column'} alignItems="stretch" gap="lg">
493 {cards.status && column(
494 <Box flexDirection="column" gap="sm" flexGrow={isWide ? 1 : 0} borderStyle="round" borderColor="subtle" paddingX={type.padX} paddingY={type.padY}>
495 {header(
496 'toggle-summary',
497 'Status',
498 showSummary,
499 <Box gap="sm" alignItems="center">
500 {note(busy ? 'updating…' : '')}
501 {(hasSummary || stored.length > 0) && !managing && (
502 <Button
503 key="copy-handoff"
504 plain
505 onPress={act.copy}
506 >
507 <Text size={type.text} color="subtle">
508 Copy
509 </Text>
510 </Button>
511 )}
512 </Box>,
513 act.toggleSummary,
514 )}
515 {suggestion !== '' && suggestionBox('Title suggestion', suggestion, 'use-title', 'Use as title', act.useTitle, 'dismiss-title', act.dismissTitle)}
516 {chosenTitle !== '' && note(`Renames the session to "${chosenTitle}" at your next prompt.`)}
517 {showSummary && !hasSummary && (
518 <Text size={type.text} color="inactive">
519 Appears after the first answer.
520 </Text>
521 )}
522 {showSummary &&
523 FIELDS.filter(field => fields[field.key]).map(field => (
524 <Box flexDirection="column" marginBottom={airy}>
525 <Text size={type.label} color={field.color}>
526 {field.label}
527 </Text>
528 {field.key === 'done_when' ? (
529 <Box flexDirection="column">
530 {criteria.map(criterion => (
531 <Box key={`criterion-row-${criterion.text}`} alignItems="center" gap="sm" minWidth={0}>
532 {Svg ? (
533 <Svg source={criterion.isMet ? ICON.completed : ICON.pending} alt={criterion.isMet ? 'met' : 'open'} width={type.icon} height={type.icon} />
534 ) : (
535 <Text color="inactive">{criterion.isMet ? GLYPH.completed : GLYPH.pending}</Text>
536 )}
537 <Box flexGrow={1} flexShrink={1} minWidth={0}>
538 <Button key={`criterion-${criterion.text}`} plain onPress={() => act.toggleCriterion(criterion)}>
539 <Text size={type.text} color={criterion.isMet ? 'inactive' : undefined}>
540 {criterion.text}
541 </Text>
542 </Button>
543 </Box>
544 </Box>
545 ))}
546 {isReached && (
547 <Text size={type.text} color="success">
548 Goal reached: wrap up, or set a new goal.
549 </Text>
550 )}
551 </Box>
552 ) : (
553 <Text size={type.text}>{fields[field.key]}</Text>
554 )}
555 </Box>
556 ))}
557 {showSummary && drift !== '' && <Text size={type.text} color="inactive">{`Off goal: ${drift}`}</Text>}
558 {showSummary && history.length > 1 && (
559 // The newest entry is the Done line above, so the history starts one turn earlier.
560 <Box flexDirection="column" gap="xs" marginTop={airy}>
561 <Button key="toggle-history" plain onPress={act.toggleHistory}>
562 <Text size={type.text} color="inactive">
563 {`Earlier (${history.length - 1}) ${showHistory ? '▾' : '▸'}`}
564 </Text>
565 </Button>
566 {showHistory &&
567 history
568 .slice(0, -1)
569 .reverse()
570 .map(entry => (
571 <Box key={`history-${entry.turn}-${entry.done}`} gap="sm" minWidth={0}>
572 <Text size={type.label} color="inactive">
573 {`#${entry.turn}`}
574 </Text>
575 <Box flexShrink={1} minWidth={0}>
576 <Text size={type.text} color="inactive">
577 {entry.done}
578 </Text>
579 </Box>
580 </Box>
581 ))}
582 </Box>
583 )}
584 </Box>
585 )}
586
587 {cards.agent && column(taskCard('agent'))}
588 {cards.user && column(taskCard('user'))}
589 </Box>
590 <Box flexDirection="column" gap="sm" borderStyle="round" borderColor="subtle" paddingX={type.padX} paddingY={type.padY}>
591 {/* What the mod itself spent sits in the Settings header: visible, without a line of its own under the cards. */}
592 {header('toggle-settings', 'Settings', showSettings, cost.calls > 0 ? note(spentLine(cost)) : null, act.toggleSettings)}
593 {/* Two sections, what the mod does and what the pane shows: side by side when wide, one under the other otherwise. */}
594 {showSettings && (
595 <Box key="settings-sections" flexDirection={isWide ? 'row' : 'column'} alignItems="stretch" gap={isWide ? 'xl' : 'sm'}>
596 {section(
597 choiceRow('mode', 'Mode', isSmart ? 'The model writes the status and checks tasks' : 'No model calls: you track tasks and to-dos', ['smart', 'manual'], isSmart ? 'smart' : 'manual'),
598 isSmart && settingRows.filter(row => row.group === 'smart').map(switchRow),
599 isSmart &&
600 choiceRow(
601 'summaryModel',
602 'Model',
603 cost.calls > 0 ? `For the status and the task check · ${formatTokens(cost.output)} tokens out so far` : 'For the status and the task check',
604 MODELS,
605 view.model,
606 ),
607 )}
608 {section(
609 <Text size={type.label} color="inactive">
610 Show
611 </Text>,
612 settingRows.filter(row => row.group === 'card').map(switchRow),
613 )}
614 </Box>
615 )}
616 {/* Set apart from the last setting: more room above, the smallest size, dimmer than the hints. */}
617 {showSettings && view.version !== '' && (
618 <Box marginTop={isTerminal ? 1 : 'md'}>
619 <Text size="footnote" color="inactive" dimColor>
620 {`session-glance ${view.version}`}
621 </Text>
622 </Box>
623 )}
624 </Box>
625 </Box>
626 )
627}
628
629/** What the band above the prompt draws. */
630export type BandView = { isTerminal: boolean; isOpen: boolean; isSmart: boolean; title: string; done: number; total: number; openForYou: number; isSelected: boolean; yourMove: string }
631export type BandActions = { togglePane: () => unknown; addSelection: (owner: Owner) => unknown }
632
633/** The band above the prompt: the pane toggle, the selection buttons and a one-line status. */
634export function drawBand(table: ElementTable, view: BandView, act: BandActions): RenderElement {
635 const { Box, Button, Text } = table
636 const { isOpen, done, total, openForYou, isSelected, yourMove } = view
637 const current = view.title
638
639 return (
640 <Box gap="sm" alignItems="center">
641 <Button key="toggle-pane" plain dimColor hotkey={view.isTerminal ? 'g' : undefined} onPress={act.togglePane}>
642 <Text size="footnote" color="subtle">
643 {isOpen ? '◂ Hide status' : '▸ Show status'}
644 </Text>
645 </Button>
646 {isSelected && (
647 <Button key="task-from-selection" plain onPress={() => act.addSelection('agent')}>
648 <Text size="footnote" color="claude">
649 + Task
650 </Text>
651 </Button>
652 )}
653 {isSelected && (
654 <Button key="todo-from-selection" plain onPress={() => act.addSelection('user')}>
655 <Text size="footnote" color="claude">
656 + To-do
657 </Text>
658 </Button>
659 )}
660 {yourMove !== '' && (
661 <Text size="footnote" color="claude" weight="medium" wrap="truncate-end">
662 {`● your move: ${yourMove}`}
663 </Text>
664 )}
665 <Text size="footnote" color="inactive" wrap="truncate-end">
666 {current || (view.isSmart ? 'No status yet' : '')}
667 {total > 0 ? ` · ${done}/${total} tasks` : ''}
668 {openForYou > 0 ? ` · ${openForYou} for you` : ''}
669 </Text>
670 </Box>
671 )
672}
673hooks/prompts.ts 79 lines1import type { Task } from '../types'
2import { FIELDS, formatCriteria, parseCriteria, parseSummary } from './status'
3
4/** What the mod asks the model, and how its replies are read back. */
5
6/** Splits the model's reply into the session title and the summary lines below it. */
7export function splitReply(reply: string): { title: string; summary: string } {
8 const match = reply.trim().match(/^TITLE:\s*(.+)\n?([\s\S]*)$/)
9
10 return match ? { title: (match[1] ?? '').trim().slice(0, 80), summary: (match[2] ?? '').trim() } : { title: '', summary: reply.trim() }
11}
12
13/** Builds the rolling-summary prompt: the previous status is folded together with the latest exchange. */
14export function summaryPrompt(previous: string, prompt: string, answer: string, tickedKeys: readonly string[] = [], titleToCheck = ''): string {
15 const fields = parseSummary(previous)
16 const doneWhen = formatCriteria(parseCriteria(fields.done_when, tickedKeys))
17
18 return [
19 `You keep a glanceable status of a coding session. Reply with exactly these ${titleToCheck === '' ? 'seven' : 'eight'} lines and nothing else:`,
20 'TITLE: <session title, max 6 words>',
21 'GOAL: <what the user is after, max 12 words>',
22 'DONE_WHEN: <1-4 checkable criteria that mean the goal is reached, max 8 words each, separated by "; ", each starting [x] when met, else [ ]>',
23 'DONE: <what was just finished, max 12 words>',
24 'NEXT: <the next step or open question, max 12 words>',
25 'DRIFT: <when the latest exchange did not serve GOAL, what happened instead, max 10 words; else none>',
26 "YOUR_MOVE: <when the next step is the user's own (push, review, answer a question, decide, test by hand), that step as a short imperative, max 8 words; else none>",
27 ...(titleToCheck === ''
28 ? []
29 : [
30 `RETITLE: <only when the session's work as a whole has moved away from what the title "${titleToCheck}" describes (a detour of one exchange is not enough): a title that fits it now, max 6 words; else none>`,
31 ]),
32 'Keep criteria that still apply word for word; a criterion marked [x] in the previous status stays [x]. A new goal gets new criteria.',
33 'Terse fragments, no full sentences, no markdown, no repetition between lines.',
34 `<previous_status>${previous ? FIELDS.map(field => `${field.key.toUpperCase()}: ${(field.key === 'done_when' ? doneWhen : fields[field.key]) ?? ''}`).join('\n') : '(none yet)'}</previous_status>`,
35 `<latest_user_message>${prompt.slice(0, 4000)}</latest_user_message>`,
36 `<latest_assistant_reply>${answer.slice(0, 8000)}</latest_assistant_reply>`,
37 ].join('\n')
38}
39
40export type Verdict = { verdict: 'ok' | 'unclear' | 'duplicate'; subject: string; question: string; options: string[]; duplicateOf: string }
41
42/** Reads Haiku's JSON verdict on a task; anything unreadable is treated as "fine as typed". */
43export function parseVerdict(reply: string, typed: string): Verdict {
44 const fallback: Verdict = { verdict: 'ok', subject: typed, question: '', options: [], duplicateOf: '' }
45 const json = reply.match(/\{[\s\S]*\}/)?.[0]
46 if (json === undefined) {
47 return fallback
48 }
49 try {
50 const raw = JSON.parse(json) as Partial<Verdict>
51 const verdict = raw.verdict === 'unclear' || raw.verdict === 'duplicate' ? raw.verdict : 'ok'
52 const options = Array.isArray(raw.options) ? raw.options.filter(o => typeof o === 'string' && o.trim() !== '').slice(0, 3) : []
53
54 return {
55 verdict: verdict === 'unclear' && options.length === 0 ? 'ok' : verdict,
56 subject: typeof raw.subject === 'string' && raw.subject.trim() !== '' ? raw.subject.trim() : typed,
57 question: typeof raw.question === 'string' ? raw.question.trim() : '',
58 options,
59 duplicateOf: typeof raw.duplicateOf === 'string' ? raw.duplicateOf.trim() : '',
60 }
61 } catch {
62 return fallback
63 }
64}
65
66/** Builds the validation prompt for a task the person typed. */
67export function validationPrompt(typed: string, goal: string, existing: readonly Task[]): string {
68 return [
69 'A user is adding a task to the checklist of a coding session. Judge the typed text and reply with one JSON object:',
70 '{"verdict":"ok"|"unclear"|"duplicate","subject":"<clean imperative task, max 10 words>","question":"<one short question>","options":["<2-3 concrete candidate tasks>"],"duplicateOf":"<existing task>"}',
71 '- ok: the task is concrete enough to act on; "subject" is its tidied wording (keep the meaning, fix typos).',
72 '- unclear: only when you cannot write a concrete task without guessing; ask the question and give options that each resolve it.',
73 '- duplicate: it means the same as an open existing task; name it in "duplicateOf".',
74 `<session_goal>${goal || '(unknown)'}</session_goal>`,
75 `<existing_tasks>${existing.map(task => `- [${task.status}] ${task.subject}`).join('\n') || '(none)'}</existing_tasks>`,
76 `<typed_task>${typed.slice(0, 500)}</typed_task>`,
77 ].join('\n')
78}
79hooks/settings.ts 102 lines1/**
2 * The settings the mod keeps in `userConfig`: one row each feeds the Settings card, `/glance <word>` and the defaults.
3 * `smart` rows only act, and only show, in Smart mode; `card` rows show or hide a card in either mode.
4 */
5export const SWITCHES = [
6 {
7 key: 'renameSession',
8 group: 'smart',
9 word: 'rename',
10 label: 'Rename the session',
11 hint: 'The status title becomes the session title',
12 isOnByDefault: false,
13 on: 'The status title will now rename this session at your next prompt.',
14 off: 'Sessions keep their own title now.',
15 },
16 {
17 key: 'suggestTitle',
18 group: 'smart',
19 word: 'suggest',
20 label: 'Suggest a new title',
21 hint: 'When the work moves on; only while renaming is off',
22 isOnByDefault: true,
23 on: 'A new title is suggested when the work moves away from the session title (while renaming is off).',
24 off: 'No more title suggestions.',
25 },
26 {
27 key: 'autoSummary',
28 group: 'smart',
29 word: 'auto',
30 label: 'Update after every answer',
31 hint: 'Off: only on /glance refresh',
32 isOnByDefault: true,
33 on: 'The status updates after every answer again.',
34 off: 'The status only changes on /glance refresh now.',
35 },
36 {
37 key: 'checkTasks',
38 group: 'smart',
39 word: 'check',
40 label: 'Check tasks you type',
41 hint: 'Tidy the wording, ask when unclear',
42 isOnByDefault: true,
43 on: 'Typed tasks are checked again.',
44 off: 'Typed tasks are added as typed now.',
45 },
46 {
47 key: 'showStatus',
48 group: 'card',
49 word: 'show-status',
50 label: 'Status card',
51 hint: 'Goal, done when, done, next',
52 isOnByDefault: true,
53 on: 'The Status card is shown again.',
54 off: 'The Status card is hidden; turn it on again in Settings.',
55 },
56 {
57 key: 'showAgentTasks',
58 group: 'card',
59 word: 'show-tasks',
60 label: 'Agent tasks card',
61 hint: "The agent's own task list",
62 isOnByDefault: true,
63 on: 'The Agent tasks card is shown again.',
64 off: 'The Agent tasks card is hidden; turn it on again in Settings.',
65 },
66 {
67 key: 'showTodos',
68 group: 'card',
69 word: 'show-todos',
70 label: 'Your to-dos card',
71 hint: 'What you have to do yourself',
72 isOnByDefault: true,
73 on: 'The Your to-dos card is shown again.',
74 off: 'The Your to-dos card is hidden; turn it on again in Settings.',
75 },
76] as const
77
78export type SwitchKey = (typeof SWITCHES)[number]['key']
79
80export const MODELS = ['haiku', 'sonnet'] as const
81
82/** Smart has the model write the status and check tasks; Manual makes no model calls and keeps only what you track by hand. */
83export const MODES = ['smart', 'manual'] as const
84export type Mode = (typeof MODES)[number]
85
86/** The mode in the stored options; anything else is Smart, the default. */
87export function modeOf(options: Readonly<Record<string, unknown>>): Mode {
88 return options.mode === 'manual' ? 'manual' : 'smart'
89}
90
91/** Whether a switch is on in the stored options; one never set is at its default. */
92export function isSwitchOn(options: Readonly<Record<string, unknown>>, key: SwitchKey): boolean {
93 const value = options[key]
94
95 return typeof value === 'boolean' ? value : (SWITCHES.find(one => one.key === key)?.isOnByDefault ?? false)
96}
97
98/** The model for the status and the task check; anything not offered falls back to haiku. */
99export function modelOf(options: Readonly<Record<string, unknown>>): string {
100 return MODELS.find(one => one === options.summaryModel) ?? 'haiku'
101}
102hooks/status.ts 132 lines1import type { HistoryEntry, Owner, Task } from '../types'
2import { activeOf, displayRows, ownedBy } from './tree'
3
4/** The status card's text: what the model wrote, read into fields, criteria and the markdown handoff. */
5
6/** The status fields, each label in a theme colour for what it means, so the card reads at a glance in light and dark. */
7export const FIELDS = [
8 { key: 'goal', label: 'Goal', color: 'suggestion' },
9 { key: 'done_when', label: 'Done when', color: 'permission' },
10 { key: 'done', label: 'Done', color: 'success' },
11 { key: 'next', label: 'Next', color: 'warning' },
12] as const
13
14/** Reads `Label: value` lines (any case) into goal / done_when / done / next / drift; prose without labels comes back whole as `goal`. */
15export function parseSummary(text: string): Record<string, string> {
16 const fields: Record<string, string> = {}
17 for (const line of text.split('\n')) {
18 const match = line.match(/^\s*[-*]?\s*(goal|done[\s_-]*when|done|next|drift|retitle|your[\s_-]*move)\s*:\s*(.+)$/i)
19 if (match?.[1] && match[2]) {
20 fields[match[1].toLowerCase().replace(/[\s-]+/g, '_')] = match[2].trim()
21 }
22 }
23
24 return Object.keys(fields).length === 0 && text.trim() !== '' ? { goal: text.trim() } : fields
25}
26
27export type Criterion = { text: string; isMet: boolean; isTicked: boolean }
28
29/**
30 * Splits DONE_WHEN into criteria: `[x] met; [ ] open`. A criterion the person ticked stays met
31 * whatever the model says. A line without markers is one open criterion, as older summaries wrote it.
32 */
33export function parseCriteria(doneWhen: string | undefined, tickedKeys: readonly string[]): Criterion[] {
34 if (doneWhen === undefined || doneWhen.trim() === '') {
35 return []
36 }
37
38 return doneWhen
39 .split(';')
40 .map(part => part.trim().match(/^(?:\[([ xX])\]\s*)?(.+)$/))
41 .flatMap(match => {
42 const text = match?.[2]?.trim() ?? ''
43 const isTicked = tickedKeys.includes(text.toLowerCase())
44
45 return text === '' ? [] : [{ text, isMet: isTicked || match?.[1]?.toLowerCase() === 'x', isTicked }]
46 })
47}
48
49/** DONE_WHEN written back from criteria, for the next summary prompt. */
50export function formatCriteria(criteria: readonly Criterion[]): string {
51 return criteria.map(criterion => `[${criterion.isMet ? 'x' : ' '}] ${criterion.text}`).join('; ')
52}
53
54/** A RETITLE line worth offering: not "none", not the title the session already has, not one the person dismissed. */
55export function suggestionFrom(retitle: string | undefined, anchor: string, dismissed: readonly string[]): string {
56 const suggestion = driftNote(retitle).replace(/^["']|["']$/g, '').slice(0, 80)
57
58 return suggestion === '' || suggestion.toLowerCase() === anchor.toLowerCase() || dismissed.includes(suggestion.toLowerCase()) ? '' : suggestion
59}
60
61/** The drift line to show, or empty when the turn served the goal. */
62export function driftNote(drift: string | undefined): string {
63 return drift === undefined || /^(none|no|n\/a|-)?\.?$/i.test(drift.trim()) ? '' : drift.trim()
64}
65
66/**
67 * The session as markdown to paste into a PR, a standup or the next session: title, status,
68 * done-when as checkboxes and the whole checklist (folded subtasks too) in the pane's order.
69 */
70export function handoffMarkdown(sessionTitle: string, text: string, all: readonly Task[], tickedKeys: readonly string[]): string {
71 const fields = parseSummary(text)
72 const criteria = parseCriteria(fields.done_when, tickedKeys)
73 const status = [
74 fields.goal ? `**Goal:** ${fields.goal}` : '',
75 criteria.length > 0 ? ['**Done when:**', ...criteria.map(criterion => `- [${criterion.isMet ? 'x' : ' '}] ${criterion.text}`)].join('\n') : '',
76 fields.done ? `**Done:** ${fields.done}` : '',
77 fields.next ? `**Next:** ${fields.next}` : '',
78 ].filter(part => part !== '')
79 const section = (heading: string, owner: Owner): string[] => {
80 const lines = displayRows(activeOf(ownedBy(all, owner))).map(row => `${row.depth === 1 ? ' ' : ''}- [${row.shown === 'completed' ? 'x' : ' '}] ${row.task.subject}`)
81
82 return lines.length > 0 ? [heading, lines.join('\n')] : []
83 }
84
85 return [`# ${sessionTitle || 'Session'}`, ...status, ...section('## Agent tasks', 'agent'), ...section('## Your to-dos', 'user')].join('\n\n')
86}
87
88/** What a compaction is told to keep: the goal and its done-when, word for word; empty with neither. */
89export function compactNote(text: string): string {
90 const fields = parseSummary(text)
91 const lines = [fields.goal ? `Goal: ${fields.goal}` : '', fields.done_when ? `Done when: ${fields.done_when}` : ''].filter(line => line !== '')
92
93 return lines.length === 0 ? '' : `Keep the session's goal in the summary, word for word. ${lines.join('. ')}.`
94}
95
96/** Token counts as the footer shows them: 950, 1.2k, 18k. */
97export function formatTokens(count: number): string {
98 return count < 1000 ? `${count}` : count < 10_000 ? `${(count / 1000).toFixed(1)}k` : `${Math.round(count / 1000)}k`
99}
100
101/** The selection as a task text: whitespace folded, cut to what the task check reads. */
102export function selectionTask(text: string | undefined): string {
103 return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 500)
104}
105
106/**
107 * What the person is waiting to do after a turn: the status model's YOUR_MOVE when it names one,
108 * else "answer the question" when the answer ends on one; empty when the agent can go on alone.
109 */
110export function yourMoveFrom(answer: string, yourMove: string | undefined): string {
111 const move = driftNote(yourMove)
112 if (move !== '') {
113 return move
114 }
115 const lastLine = answer.trim().split('\n').filter(line => line.trim() !== '').pop() ?? ''
116
117 return /\?\s*\**\s*$/.test(lastLine) ? 'answer the question' : ''
118}
119
120/** How many earlier turns the history keeps. */
121export const HISTORY_SIZE = 50
122
123/** Adds a turn's DONE line to the history, unless it says what the last entry already said. */
124export function appendHistory(history: readonly HistoryEntry[], done: string | undefined, turn: number): HistoryEntry[] {
125 const text = (done ?? '').trim()
126 if (text === '' || history.at(-1)?.done.toLowerCase() === text.toLowerCase()) {
127 return [...history]
128 }
129
130 return [...history, { turn, done: text }].slice(-HISTORY_SIZE)
131}
132hooks/tree.ts 304 lines1import type { Owner, Task, TaskStatus } from '../types'
2
3/** What a row shows: a task's own status, or `partial` for a main task whose subtasks are part done. */
4export type ShownStatus = TaskStatus | 'partial'
5/** One drawn row; a main task carries its subtask count, so a folded one can still say how far along it is. */
6export type Row = { task: Task; depth: 0 | 1; shown: ShownStatus; children: number; childrenDone: number }
7
8const ORDER = { in_progress: 0, pending: 1, completed: 2 } as const
9
10/** A main task's state follows its subtasks: all done is done, some done or started is partial. */
11export function shownStatus(task: Task, all: readonly Task[]): ShownStatus {
12 const children = all.filter(one => one.parentId === task.id)
13 if (children.length === 0) {
14 return task.status
15 }
16 if (children.every(child => child.status === 'completed')) {
17 return 'completed'
18 }
19
20 return children.some(child => child.status !== 'pending') ? 'partial' : task.status
21}
22
23/**
24 * The rows the pane draws: main tasks by status (open and partial first, done last),
25 * each followed by its subtasks by status, keeping the stored order within a status.
26 * The subtasks of a main task in `folded` are left out.
27 */
28export function displayRows(all: readonly Task[], folded: readonly string[] = []): Row[] {
29 const rank = (task: Task): number => {
30 const shown = shownStatus(task, all)
31
32 return ORDER[shown === 'partial' ? 'in_progress' : shown]
33 }
34 const byStatus = (list: Task[]): Task[] => list.map((task, i) => ({ task, i })).sort((a, b) => rank(a.task) - rank(b.task) || a.i - b.i).map(one => one.task)
35 const ids = new Set(all.map(task => task.id))
36 const mains = byStatus(all.filter(task => task.parentId === undefined || !ids.has(task.parentId)))
37
38 return mains.flatMap(main => {
39 const children = byStatus(all.filter(task => task.parentId === main.id))
40 const row: Row = {
41 task: main,
42 depth: 0,
43 shown: shownStatus(main, all),
44 children: children.length,
45 childrenDone: children.filter(child => child.status === 'completed').length,
46 }
47
48 return [
49 row,
50 ...(folded.includes(main.id) ? [] : children.map(child => ({ task: child, depth: 1 as const, shown: child.status, children: 0, childrenDone: 0 }))),
51 ]
52 })
53}
54
55/** Done and total over what is actually worked: subtasks, and main tasks without any. */
56export function progress(all: readonly Task[]): { done: number; total: number } {
57 const leaves = all.filter(task => !all.some(one => one.parentId === task.id))
58
59 return { done: leaves.filter(task => task.status === 'completed').length, total: leaves.length }
60}
61
62/** Cells of sideways drag that make a task a subtask (right) or a main task again (left). */
63export const INDENT_CELLS = 3
64
65/** Where a drop would land: the list after it, and where to draw the line in the rows as they are now. */
66export type DropPlan = { list: Task[]; lineBeforeId: string | null; depth: 0 | 1 }
67
68/**
69 * Plans dropping `dragId` at `slot`, the gap above display row `slot` (`rows.length` is
70 * below the last), dragged `dx` cells sideways. Right by INDENT_CELLS nests it under the
71 * main task above the gap; left un-nests it; a main task with subtasks stays a main task.
72 * The line comes from the list after the drop, so it shows where the task really lands
73 * once rows sort by status.
74 */
75export function planDrop(all: readonly Task[], dragId: string, slot: number, dx: number, folded: readonly string[] = []): DropPlan {
76 const rows = displayRows(all, folded)
77 const dragged = rows.find(row => row.task.id === dragId)
78 if (dragged === undefined) {
79 return { list: [...all], lineBeforeId: null, depth: 0 }
80 }
81 const isInBlock = (row: Row): boolean => row.task.id === dragId || row.task.parentId === dragId
82 const rest = rows.filter(row => !isInBlock(row))
83 let gap = rows.slice(0, Math.max(0, Math.min(rows.length, slot))).filter(row => !isInBlock(row)).length
84 const hasChildren = all.some(task => task.parentId === dragId)
85 let depth: 0 | 1 = hasChildren ? 0 : dx >= INDENT_CELLS ? 1 : dx <= -INDENT_CELLS ? 0 : dragged.depth
86 const above = rest[gap - 1]
87 const parent = depth === 1 && above ? (above.depth === 0 ? above.task : all.find(task => task.id === above.task.parentId)) : undefined
88 if (parent === undefined) {
89 depth = 0
90 }
91 // A main task never lands inside another task's subtasks: it moves past them.
92 while (depth === 0 && rest[gap]?.depth === 1) {
93 gap += 1
94 }
95
96 const moving: Task = { ...dragged.task, parentId: depth === 1 ? parent?.id : undefined }
97 const list = all.filter(task => task.id !== dragId)
98 const prev = rest[gap - 1]
99 let at = 0
100 if (depth === 1 && parent) {
101 const isSibling = prev !== undefined && prev.task.parentId === parent.id
102 const firstChild = list.findIndex(task => task.parentId === parent.id)
103 at = isSibling ? list.indexOf(prev.task) + 1 : firstChild >= 0 ? firstChild : list.indexOf(parent) + 1
104 } else {
105 // Rows sort by status, so anchor to the neighbour of the same status: before the main task below
106 // the gap, or after the one above it. An anchor of another status would land the task elsewhere.
107 const mainOf = (row: Row | undefined): Task | undefined =>
108 row === undefined ? undefined : row.depth === 0 ? row.task : list.find(task => task.id === row.task.parentId)
109 const rank = (task: Task): string => {
110 const shown = shownStatus(task, all)
111
112 return shown === 'partial' ? 'in_progress' : shown
113 }
114 const below = mainOf(rest[gap])
115 const above = mainOf(prev)
116 const own = rank(dragged.task)
117 if (below && rank(below) === own) {
118 at = list.indexOf(below)
119 } else if (above) {
120 at = list.indexOf(above) + 1
121 } else if (below) {
122 at = list.indexOf(below)
123 }
124 }
125 list.splice(at, 0, moving)
126
127 // The line goes above whatever follows the moved task (its own subtasks skipped) once the list is re-sorted.
128 const after = displayRows(list, folded)
129 const landed = after.findIndex(row => row.task.id === dragId)
130 const next = after.slice(landed + 1).find(row => !isInBlock(row))
131
132 return { list, lineBeforeId: next ? next.task.id : null, depth }
133}
134
135/** Whose a task is; a task saved before owners existed is the agent's. */
136export function ownerOf(task: Task): Owner {
137 return task.owner ?? 'agent'
138}
139
140/** The tasks one card shows, in their stored order. */
141export function ownedBy(all: readonly Task[], owner: Owner): Task[] {
142 return all.filter(task => ownerOf(task) === owner)
143}
144
145/**
146 * Hands a task to the other card. A main task takes its subtasks along; a subtask goes
147 * on its own and becomes a main task there, since its parent stays behind.
148 */
149export function moveTask(all: readonly Task[], id: string): Task[] {
150 const task = all.find(one => one.id === id)
151 if (task === undefined) {
152 return [...all]
153 }
154 const owner: Owner = ownerOf(task) === 'agent' ? 'user' : 'agent'
155
156 return all.map(one => {
157 if (one.id === id) {
158 const { parentId: _parent, ...rest } = one
159
160 return { ...rest, owner }
161 }
162
163 return one.parentId === id ? { ...one, owner } : one
164 })
165}
166
167/** Tasks that are not archived: what the cards, counts and handoff show. */
168export function activeOf(all: readonly Task[]): Task[] {
169 return all.filter(task => task.archived !== true)
170}
171
172/** Archives a task, or restores it; a main task takes its subtasks along either way. */
173export function setArchived(all: readonly Task[], id: string, isArchived: boolean): Task[] {
174 return all.map(task => {
175 if (task.id !== id && task.parentId !== id) {
176 return task
177 }
178 const { archived: _archived, ...rest } = task
179
180 return isArchived ? { ...rest, archived: true } : rest
181 })
182}
183
184/** Puts a list in the order of `ids`; tasks the ids do not name keep their place at the end. */
185export function reorder(list: readonly Task[], ids: readonly string[]): Task[] {
186 const named = ids.map(id => list.find(task => task.id === id)).filter((task): task is Task => task !== undefined)
187
188 return [...named, ...list.filter(task => !ids.includes(task.id))]
189}
190
191/** Commits an edit draft: tasks that arrived while editing (from Claude, or added) are kept after it. */
192export function mergeDraft(current: readonly Task[], edited: readonly Task[], baseIds: readonly string[]): Task[] {
193 return [...edited, ...current.filter(task => !baseIds.includes(task.id))]
194}
195
196/** Describes what an edit changed, for the model; empty when nothing did. */
197export function describeChanges(before: readonly Task[], after: readonly Task[]): string {
198 const removed = before.filter(task => !after.some(one => one.id === task.id)).map(task => `"${task.subject}"`)
199 const added = after
200 .filter(task => !before.some(one => one.id === task.id))
201 .map(task => {
202 const parent = after.find(one => one.id === task.parentId)
203
204 return parent ? `"${task.subject}" as a subtask of "${parent.subject}"` : `"${task.subject}"`
205 })
206 const renamed = after.flatMap(task => {
207 const old = before.find(one => one.id === task.id)
208
209 return old && old.subject !== task.subject ? [`"${old.subject}" to "${task.subject}"`] : []
210 })
211 const moved = after.flatMap(task => {
212 const old = before.find(one => one.id === task.id)
213
214 return old && ownerOf(old) !== ownerOf(task) && task.parentId === undefined
215 ? [ownerOf(task) === 'user' ? `moved "${task.subject}" to their own to-dos (theirs to do now, not yours)` : `handed "${task.subject}" to you`]
216 : []
217 })
218 const archived = after
219 .filter(task => task.archived === true && before.some(one => one.id === task.id && one.archived !== true))
220 .filter(task => !after.some(one => one.id === task.parentId && one.archived === true))
221 .map(task => `"${task.subject}"`)
222 const kept = before.filter(task => after.some(one => one.id === task.id)).map(task => task.id)
223 const isReordered = kept.join() !== after.filter(task => kept.includes(task.id)).map(task => task.id).join()
224 const parts = [
225 added.length > 0 ? `added ${added.join(', ')}` : '',
226 removed.length > 0 ? `removed ${removed.join(', ')} (do not work on them unless asked)` : '',
227 renamed.length > 0 ? `renamed ${renamed.join(', ')}` : '',
228 ...moved,
229 archived.length > 0 ? `archived ${archived.join(', ')} (put away in the pane, not deleted)` : '',
230 isReordered ? `reordered the checklist to: ${after.map(task => `"${task.subject}"`).join(', ')}` : '',
231 ].filter(part => part !== '')
232
233 return parts.length === 0 ? '' : `The user edited the session checklist: ${parts.join('; ')}.`
234}
235
236/** The person's open to-dos worth bringing into a new session: open main to-dos, with their open subtasks. */
237export function openTodos(all: readonly Task[]): Task[] {
238 const open = activeOf(ownedBy(all, 'user')).filter(task => task.status !== 'completed')
239 const mains = open.filter(task => task.parentId === undefined)
240
241 return [...mains, ...open.filter(task => mains.some(main => main.id === task.parentId))]
242}
243
244/** The offered to-dos this card does not hold yet (by id or wording), with their subtasks. */
245export function newTodos(offered: readonly Task[], list: readonly Task[]): Task[] {
246 const isHeld = (task: Task) => list.some(one => one.id === task.id || one.subject.toLowerCase() === task.subject.toLowerCase())
247 const mains = offered.filter(task => task.parentId === undefined && !isHeld(task))
248
249 return [...mains, ...offered.filter(task => mains.some(main => main.id === task.parentId))]
250}
251
252/** A step of the desktop app's task list (`update_plan`): its text and status. */
253export type PlanItem = { content: string; status: TaskStatus }
254
255/** The desktop app's task-list call: a whole new list, or moves on the current one (1-based steps). */
256export type PlanCall = { items?: readonly PlanItem[]; advance?: boolean; done?: readonly number[]; start?: number }
257
258/**
259 * Replaces the tasks a tool keeps as a whole list (TodoWrite, update_plan), ids `<prefix><index>`.
260 * Ids follow the list's order, so an archived one is recognised by its subject.
261 */
262export function replaceList(list: readonly Task[], prefix: string, items: readonly PlanItem[]): Task[] {
263 const archivedSubjects = list.filter(task => task.id.startsWith(prefix) && task.archived === true).map(task => task.subject)
264
265 return [
266 ...list.filter(task => !task.id.startsWith(prefix)),
267 ...items.map((item, i) => ({
268 id: `${prefix}${i}`,
269 subject: item.content,
270 status: item.status,
271 ...(archivedSubjects.includes(item.content) ? { archived: true } : {}),
272 })),
273 ]
274}
275
276/** The task list after an `update_plan` call: a new list replaces the plan, `advance` finishes the step in progress and starts the next. */
277export function applyPlan(list: readonly Task[], call: PlanCall): Task[] {
278 const prefix = 'plan-'
279 if (call.items !== undefined) {
280 return replaceList(list, prefix, call.items.filter(item => typeof item?.content === 'string'))
281 }
282 const steps = list.filter(task => task.id.startsWith(prefix))
283 const statusOf = new Map(steps.map(task => [task.id, task.status]))
284 const at = (step: number) => `${prefix}${step - 1}`
285 if (call.advance === true) {
286 const current = steps.findIndex(task => task.status === 'in_progress')
287 const next = steps.findIndex((task, i) => i > current && task.status === 'pending')
288 if (current >= 0) {
289 statusOf.set(steps[current]!.id, 'completed')
290 }
291 if (next >= 0) {
292 statusOf.set(steps[next]!.id, 'in_progress')
293 }
294 }
295 for (const step of call.done ?? []) {
296 statusOf.set(at(step), 'completed')
297 }
298 if (call.start !== undefined) {
299 statusOf.set(at(call.start), 'in_progress')
300 }
301
302 return list.map(task => (statusOf.has(task.id) ? { ...task, status: statusOf.get(task.id)! } : task))
303}
304hooks/icons.ts 48 lines1import type { ShownStatus } from './tree'
2
3/** The pictures and glyphs the pane draws: SVG on surfaces that draw pictures, glyphs on the terminal. */
4
5export const GLYPH = { in_progress: '◐', partial: '◒', pending: '○', completed: '●' } as const
6
7/** Rounded-square checkboxes in the desktop sidebar's grey; in progress and partly done in the accent. */
8export const ICON: Record<ShownStatus, string> = {
9 partial:
10 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="3" fill="none" stroke="#d97757" stroke-width="1.4"/><path d="M5.2 8h5.6" stroke="#d97757" stroke-width="1.8" stroke-linecap="round"/></svg>',
11 pending:
12 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="3" fill="none" stroke="#8a8a8a" stroke-width="1.4"/></svg>',
13 in_progress:
14 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="3" fill="none" stroke="#d97757" stroke-width="1.4"/><rect x="5.5" y="5.5" width="5" height="5" rx="1.2" fill="#d97757"/></svg>',
15 completed:
16 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="3" fill="#6f6f6f"/><path d="M5 8.2l2 2 4-4.4" fill="none" stroke="#f4f4f4" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>',
17}
18
19/** Fold chevrons for a main task with subtasks. */
20export const FOLD = {
21 open: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" fill="none" stroke="#8a8a8a" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
22 closed: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" fill="none" stroke="#8a8a8a" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
23} as const
24
25/** A trash bin and an archive box in the sidebar's grey: drawn as pictures, pressed through the Button laid over each. */
26export const TRASH =
27 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#8a8a8a" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.2h11M6.2 4.2V2.8h3.6v1.4M4 4.2l.7 9h6.6l.7-9M6.7 6.6v4.6M9.3 6.6v4.6"/></svg>'
28export const ARCHIVE =
29 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="#8a8a8a" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="12" height="3" rx="0.8"/><path d="M3.2 6v6.5h9.6V6M6.5 8.8h3"/></svg>'
30
31/** Desktop chevrons in the sidebar's grey, drawn beside the header label as the app's own cards do. */
32export const CHEVRON = {
33 open: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4.5 6.5l3.5 3.5 3.5-3.5" fill="none" stroke="#8a8a8a" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
34 closed: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M6.5 4.5l3.5 3.5-3.5 3.5" fill="none" stroke="#8a8a8a" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>',
35} as const
36
37/** An info mark: grey at rest, in the accent while its help is open. */
38export function infoIcon(isOpen: boolean): string {
39 const color = isOpen ? '#d97757' : '#8a8a8a'
40
41 return (
42 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">' +
43 `<circle cx="8" cy="8" r="6.2" fill="none" stroke="${color}" stroke-width="1.3"/>` +
44 `<path d="M8 7.2v4" stroke="${color}" stroke-width="1.4" stroke-linecap="round"/>` +
45 `<circle cx="8" cy="4.9" r="0.85" fill="${color}"/></svg>`
46 )
47}
48hooks/task-list.tsx 103 lines1import type { ClientModule } from 'claude-code'
2
3import type { Task } from '../types'
4import { INDENT_CELLS, displayRows, planDrop } from './tree'
5
6/** Cells the handle takes at a row's left, as the pane lays it out. */
7export const HANDLE_CELLS = 2
8
9/**
10 * `hasGapRows`: each row's text sits on its last cell with blank cells above it, plus one
11 * blank row after the list (the terminal), so the line goes in a blank row and never over text.
12 */
13export type OverlayProps = { tasks: Task[]; folded: string[]; rowHeight: number; hasGapRows?: boolean }
14type DragState = { dragId: string; startX: number; startY: number; slot: number; dx: number; isMoved: boolean }
15
16const IDLE: DragState = { dragId: '', startX: 0, startY: 0, slot: -1, dx: 0, isMoved: false }
17
18/**
19 * A see-through layer over the Edit-mode rows. While a handle is dragged it draws
20 * the line where the task will land, on its own: the pane is not redrawn until the
21 * drop, as the desktop holds that back while the pointer is captured. Being on top,
22 * it also takes the clicks on the task text and posts them to the pane. The row's own
23 * buttons (fold, archive, remove) sit in a column beside the layer, so a click reaches
24 * them directly.
25 */
26const EditOverlay: ClientModule<OverlayProps, DragState> = (props, surface) => {
27 const { Box, Text } = surface.elements
28 const drag = surface.state ?? IDLE
29 const rows = displayRows(props.tasks, props.folded)
30 const pitch = props.rowHeight
31 // The layer's own size, from its props: the desktop sizes the region to what it draws, and
32 // `surface.rows` is 0 until it has measured, so drawing by that left a one-cell strip.
33 const height = Math.max(1, Math.ceil(rows.length * pitch) + (props.hasGapRows ? 1 : 0))
34 const yOf = (event: { y: number; fine?: { y: number } }): number => event.fine?.y ?? event.y + 0.5
35 const xOf = (event: { x: number; fine?: { x: number } }): number => event.fine?.x ?? event.x + 0.5
36 const rowAt = (y: number): number => Math.max(0, Math.min(rows.length - 1, Math.floor(y / pitch)))
37 // The gap nearest the pointer: 0 is above the first row, rows.length below the last.
38 const slotAt = (y: number): number => Math.max(0, Math.min(rows.length, Math.round(y / pitch)))
39 const dxAt = (x: number): number => {
40 const dx = x - drag.startX
41
42 return dx >= INDENT_CELLS ? INDENT_CELLS : dx <= -INDENT_CELLS ? -INDENT_CELLS : 0
43 }
44
45 /** A press that did not drag: on the task text it selects the row. */
46 const click = (x: number, y: number): void => {
47 const row = rows[rowAt(y)]
48 if (row !== undefined && x > HANDLE_CELLS) {
49 surface.post({ type: 'select', id: row.task.id })
50 }
51 }
52
53 surface.onPointer(event => {
54 if (event.type === 'down' && event.button === 'left') {
55 const isHandle = xOf(event) <= HANDLE_CELLS
56 const row = rows[rowAt(yOf(event))]
57 surface.setState({
58 dragId: isHandle && row ? row.task.id : '',
59 startX: xOf(event),
60 startY: yOf(event),
61 slot: rowAt(yOf(event)),
62 dx: 0,
63 isMoved: false,
64 })
65 } else if (event.type === 'move' && drag.dragId !== '') {
66 const next = { ...drag, slot: slotAt(yOf(event)), dx: dxAt(xOf(event)), isMoved: true }
67 if (next.slot !== drag.slot || next.dx !== drag.dx || !drag.isMoved) {
68 surface.setState(next)
69 }
70 } else if (event.type === 'up' && event.button === 'left') {
71 if (drag.dragId !== '' && drag.isMoved) {
72 surface.post({ type: 'drop', id: drag.dragId, slot: slotAt(yOf(event)), dx: dxAt(xOf(event)) })
73 } else if (drag.dragId === '') {
74 click(drag.startX, drag.startY)
75 }
76 surface.setState(IDLE)
77 }
78 })
79
80 const plan = drag.dragId !== '' && drag.isMoved ? planDrop(props.tasks, drag.dragId, drag.slot, drag.dx, props.folded) : undefined
81 const lineRow = plan ? (plan.lineBeforeId === null ? rows.length : rows.findIndex(row => row.task.id === plan.lineBeforeId)) : -1
82 // Cells are whole here: a gap on a cell edge is drawn as the cell's top stroke, a gap mid-cell as its middle stroke.
83 const gapY = lineRow * pitch
84 const lineCell = Math.min(Math.max(0, Math.floor(gapY)), height - 1)
85 // Below the last row there is no cell under the gap, so the last cell's bottom stroke marks it.
86 const stroke = props.hasGapRows ? '─' : gapY >= height ? '▁' : gapY === Math.floor(gapY) ? '▔' : '─'
87
88 return (
89 <Box flexDirection="column" width="100%" height={height} position="relative">
90 {plan && lineRow >= 0 && (
91 <Box position="absolute" top={lineCell} left={HANDLE_CELLS + (plan.depth === 1 ? 3 : 0)} right={0} height={1} overflow="hidden">
92 {/* Wrapped, not truncated: the strokes past the edge go to a second line the one-row Box clips, where a cut ends in "…". */}
93 <Text color="claude" wrap="wrap">
94 {stroke.repeat(400)}
95 </Text>
96 </Box>
97 )}
98 </Box>
99 )
100}
101
102export default EditOverlay
103types/index.d.ts 54 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2/** Whose task it is: the agent's (from its task tools, or added for it) or the person's own to-do. */
3export type Owner = 'agent' | 'user'
4/** A task; `parentId` makes it a subtask of that main task (one level deep). No `owner` means the agent's; `archived` puts it away. */
5export type Task = { id: string; subject: string; status: TaskStatus; parentId?: string; owner?: Owner; archived?: boolean }
6/** One turn's DONE line, kept for the Status card's history; `turn` counts the person's prompts. */
7export type HistoryEntry = { turn: number; done: string }
8/** What the mod's own model calls cost this session, summed from each call's usage. */
9export type Spent = { calls: number; input: number; output: number }
10
11declare module 'claude-code' {
12 interface PluginState {
13 'session-glance': {
14 summary: string
15 lastPrompt: string
16 lastAnswer: string
17 isSummarizing: boolean
18 tasks: Task[]
19 title: string
20 isSummaryOpen: boolean
21 isTasksOpen: boolean
22 isPaneOpen: boolean
23 isManaging: boolean
24 isValidating: boolean
25 addCount: number
26 selectedId: string
27 draft: Task[]
28 draftBaseIds: string[]
29 subtaskCount: number
30 folded: string[]
31 ticked: string[]
32 spent: Spent
33 restoredFor: string
34 titleAnchor: string
35 titleSuggestion: string
36 pendingTitle: string
37 dismissedTitles: string[]
38 isMineOpen: boolean
39 mineAddCount: number
40 moveSuggestion: string
41 dismissedMoves: string[]
42 hasSelection: boolean
43 openArchives: Owner[]
44 isSettingsOpen: boolean
45 editingOwner: Owner
46 isHelpOpen: boolean
47 carryOffer: Task[]
48 yourMove: string
49 history: HistoryEntry[]
50 isHistoryOpen: boolean
51 }
52 }
53}
54