SLOPSHOPPER

session-glance

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-glance
│ ┃ session-glance ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │toggle-summary copy-handoff│ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │Goal │ ⏺ Update(src/auth.ts) │ ┃ │OK │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │toggle-tasks toggle-manage│ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │No tasks yet. │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ │ ┃ │Add a task for the agent… ⏎ Add │ › /glance │ ┃ ╰──────────────────────────────────────────╯ ⎿ session-glance: Session pane closed. │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │toggle-tasks-mine toggle-manage-mine│ │ ┃ │ │ │ ┃ │Nothing for you yet. │ │ ┃ │ │ │ ┃ │Add a to-do for yourself… ⏎ Add │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │toggle-settings 1 call · 13 tokens│ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ g: toggle-paneNo status yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
g: toggle-paneNo status yet
Pane · session-glance
╭──────────────────────────────────────────────────────────╮ │toggle-summary copy-handoff│ │ │ │Goal │ │OK │ │ │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │toggle-tasks toggle-manage│ │ │ │No tasks yet. │ │ │ │Add a task for the agent… ⏎ Add │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │toggle-tasks-mine toggle-manage-mine│ │ │ │Nothing for you yet. │ │ │ │Add a to-do for yourself… ⏎ Add │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │toggle-settings 1 call · 13 tokens│ │ │ ╰──────────────────────────────────────────────────────────╯
README

session-glance

A pane beside the Claude Code chat that shows where a session stands, without asking the agent and spending a turn on it:

  • Status: a rolling summary after every answer: goal, done-when criteria, what was just done, what is next, and a quiet line when a turn drifted off the goal.
  • Agent tasks: a checklist that fills itself from the agent's own task tools (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.
  • Your to-dos: what you have to do yourself (push, review, decide, test by hand), with the next step suggested from the conversation.

The session pane beside the chat in the Claude desktop app, after /glance demo

Install

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.

Using it

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.

The same pane in a terminal session, with the band above the prompt

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.

The band above the prompt: Hide status, your move, the session title, the task count and the to-dos open for you

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.

CommandWhat it does
/glanceOpens or closes the pane
/glance copyCopies 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 taskAdds the selected text as a task for the agent
/glance todoAdds the selected text as a to-do of your own
/glance refreshUpdates the status from the last answer (for when automatic updates are off)
/glance purgeClears the status, title and every task, after asking
/glance demoFills the pane with a demo of every state it can show
`/glance mode smart\manual`Switches between Smart and Manual (see Settings)

Status card

  • Done when is a list of 1–4 criteria. The model marks them met as the work goes on; click one to tick or untick it yourself. A criterion you ticked stays met. When all are met, the card says the goal is reached.
  • Off goal appears when the last turn did something other than the goal. It never interrupts the agent.
  • Earlier (n) keeps what each earlier turn did (its Done line, numbered by prompt), newest first, so after a /resume you can see how the session got here. It holds the last 50 and is saved with the pane.
  • Copy puts the same markdown as /glance copy on the clipboard.
  • Title suggestion appears when the session's work as a whole has moved away from its title. Use as title sets it as the session title at your next prompt; Dismiss keeps it from being offered again.

Agent tasks and Your to-dos

  • Two cards: the agent's tasks, and the things you have to do yourself. Each has its own progress count; the band above the prompt shows the agent's count and how many to-dos are open for you.
  • Click a task to mark it done, start it or reopen it. A main task with subtasks shows how many are done, and its state follows them.
  • Edit opens Edit mode in that card (one card at a time): drag ⠿ 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.
  • Archived tasks leave the card and its count, and wait under Archived (n) at the bottom of the card, with Restore for each. An archived task of the agent's stays archived when it rewrites its list.
  • Each card has its own add field. The model tidies the wording and asks when it is unclear or a duplicate.
  • Your next step? appears in your card when the conversation leaves the next move to you, such as pushing a branch or answering a question. Add as to-do puts it in your to-dos; Dismiss keeps it from being offered again.
  • Every change you make is passed to the agent, so it works from the same lists. A to-do in your card reaches it as a note, not as work for it.

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.

Settings

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.

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

Privacy and cost

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:

  • After each answered turn (unless automatic updates are off): the previous status, your last prompt (first 4,000 characters) and the answer (first 8,000 characters). Subagent turns are skipped.
  • When you add or rename a task (unless the task check is off): the typed text, the session goal and the current task list.

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:

CallHaiku (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:

  • Mode Manual: no model calls at all, apart from /glance refresh when you ask for one.
  • Update after every answer off: no status call per turn, so almost nothing; /glance refresh costs one status call when you ask for it.
  • Check tasks you type off: no task-check calls.
  • Suggest a new title: about 65 more tokens in per status call, well under 1%.
  • Rename the session: makes no call of its own.
  • Model Sonnet: about 7 times the cost of Haiku. Haiku 5.5 thinks even at low effort; Sonnet 5.5 does not, so it writes far less, but each token costs 20 times as much.

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.

Development

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.

Changes

See CHANGELOG.md.

Licence

MIT, see LICENSE.

Source 10 files
hooks/register.tsx 990 lines
1import { 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}
990
hooks/demo.ts 45 lines
1import 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]
45
hooks/pane.tsx 673 lines
1import 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}
673
hooks/prompts.ts 79 lines
1import 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}
79
hooks/settings.ts 102 lines
1/**
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}
102
hooks/status.ts 132 lines
1import 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}
132
hooks/tree.ts 304 lines
1import 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}
304
hooks/icons.ts 48 lines
1import 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}
48
hooks/task-list.tsx 103 lines
1import 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
103
types/index.d.ts 54 lines
1export 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