A quiet side pane: what this session is doing now, its plan, its agents as a tree, and every other Claude Code session with theirs

<h1> <img alt="glimt" src="docs/logo-light.svg" height="56"> </h1>
A quiet side pane for Claude Code: what this session is doing, its plan, its agents, and every other session.
glimt is Danish for a glimpse, a brief flash of light. Say it like glimpse without the -pse: GLIMT, with a short i.

/plugin install glimt --marketplace mmedum/glimt
Needs Claude Code 2.1.289 or later in a terminal (2.1.295 for desktop notifications); tested with 2.1.295. Mods don't draw in the VS Code panel or with -p.
The pane opens by itself when the terminal is at least 144 columns wide. /glimt opens it at any width, with the keyboard.
Third-party marketplaces don't update on their own. To update, run claude plugin update glimt@glimt and restart Claude Code.
The keys work while the pane holds the keyboard: open it with /glimt, click it, or press ctrl+x tab. The key row shows the keys that apply to the selected row; i lists them all.
| Key | Does |
|---|---|
j k | move down and up (↓ ↑ and Tab work too) |
l | go into an agent or a session: its task and live activity |
h | back out, one level at a time |
o | open or close the row: a step's description, an agent's task, a session's agents |
n | new agent here |
s | new background session (claude --bg), from the i list |
r | rename this session, or another session running glimt |
c | clear this conversation, after asking |
m | write to the agent or session you are in |
a | copy a background session's claude attach command |
x | stop a background session, after asking |
i | all keys |
q | close the pane, from the i list |
A spinner marks whatever is working right now: a running agent, a working session, the step in progress while Claude is on it. The rest keep still marks: ◉ waiting for you, ○ idle, ✓ done, ✗ failed, ■ stopped.
A waiting session says what it wants: approve for a permission prompt, answer for a question. Each other session shows how long it has been in its state, counted from when glimt saw that state begin, and its name turns bold when it stops, until you open it.
Mods run with your permissions, so here is everything glimt touches. claude plugin validate lists the same.
metadata.parent).claude agents --json every 5 seconds while the pane is drawn. Under ~/.claude/projects/ it lists an open session's subagents every 5 seconds, and reads transcripts with tail and head, every 2 seconds while you are inside one.claude --bg, claude stop, /rename and /clear, only when you press their keys. /rename also runs when you rename this session from another session's glimt.It makes no network requests.
The plan comes from Claude's task list. Claude Code offers that list by default only on some models, and on every model in background sessions (agent view, or claude --bg); see task tool availability. To have it in a terminal session on other models, start Claude Code with CLAUDE_CODE_ENABLE_TODO_TOOLS=1. Without it, the plan says so and everything else works the same.
It can't switch this terminal to another session: Claude Code offers mods no way to do that (#100519). In an attached background session, ← on an empty prompt returns to agent view.
Questions and bugs: open an issue. For a pull request, open an issue first.
Run the tests with claude plugin test . and check the manifests with claude plugin validate --strict .. Format with npx oxfmt and lint with npx -p oxlint -p oxlint-tsgolint oxlint --type-aware hooks tests; CI pins the versions. Claude Code writes the API types into .claude-plugin/types/ when an interactive session loads the mod from its folder (claude --plugin-dir .); after that, tsc -p . type-checks it. CI gets the same types another way; see .github/workflows/ci.yml.
To release: bump version in .claude-plugin/plugin.json (installed copies only update when it changes), add the version's section to CHANGELOG.md, then tag vX.Y.Z and publish a GitHub Release with that section.
Apache-2.0 © 2026 Mark Medum Bundgaard
hooks/register.tsx 1344 lines1// glimt's hooks, and everything that talks to the engine: the engine follows
2// $ only into functions of this file, and reads state only through atoms
3// declared here, so all of that lives here. The other modules are pure: what
4// the pane computes and draws.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
8import type { Activity, Agent, AgentState, Composer, Feed, Opened, Plan, RemoteAgent, RemoteList, Session, Shared, SharedAgent, Step } from '../types'
9import { agentSection, contextTokens, isTurnRunning, mainAgent } from './agents'
10import type { Spawned } from './agents'
11import { FEED_KEPT, TAIL_BYTES, callLine, drillSection, isShownText, parseTranscript } from './drill'
12import { FORM_ROWS, composerSection, helpSection, keySection, selfSection } from './keys'
13import { applyUpdate, nowSection, parentFrom, planLines, planSection, planTitle, planUnits, rowCount } from './plan'
14import type { StepUpdate } from './plan'
15import {
16 HEARTBEAT_MS,
17 REMOTE_KEPT,
18 SHARED_TASK,
19 STALE_MS,
20 isReachable,
21 isSessionBusy,
22 isShared,
23 nextPhases,
24 parseSessions,
25 sessionSection,
26 sortSessions,
27 waitingNote,
28} from './sessions'
29import { PANE, runtime } from './state'
30import type { Toggle } from './state'
31import { FRAME_MS, describeCall, firstLine, fit, isRecord, spin, wrap } from './text'
32import type { View } from './view'
33
34// Who counts as asking: the person, typing or through Remote Control or the
35// SDK. A task notification, a peer or a plugin is not a new request.
36const PERSON: ReadonlySet<PromptOrigin['kind']> = new Set(['composer', 'bridge', 'sdk'])
37
38// The shares of a docked pane's rows the agents and the other sessions may
39// take from the plan, which gets the rest.
40const AGENT_SHARE = 0.35
41const SESSION_SHARE = 0.25
42
43// How often the session list is read again while the pane is drawn, and how
44// often this session shares its running agents (when they changed) and
45// looks for a name another session's glimt asked it to take.
46const POLL_MS = 5000
47
48// Drilled into another session, its transcript is read again this often,
49// from its last TAIL_BYTES; an agent of this session is read as it acts.
50// The newest FEED_KEPT items are kept.
51const FEED_MS = 2000
52// Rows the chat leaves out while the pane shows them.
53const PLAN_TOOLS: ReadonlySet<string> = new Set(['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TodoWrite'])
54
55// One line in the system prompt, sent while the task tools are on offer, so
56// Claude files a sub-step under its step.
57const SUB_STEP_NOTE = "When a task you create with TaskCreate is a sub-step of another task, set metadata.parent to that task's id."
58
59const focus = atom({ plugin: 'glimt', key: 'focus' } as const, null)
60const plan = atom({ plugin: 'glimt', key: 'plan' } as const, null)
61const steps = atom({ plugin: 'glimt', key: 'steps' } as const, [])
62const agents = atom({ plugin: 'glimt', key: 'agents' } as const, [])
63const now = atom({ plugin: 'glimt', key: 'now' } as const, 0)
64const isShown = atom({ plugin: 'glimt', key: 'isShown' } as const, false)
65const expanded = atom({ plugin: 'glimt', key: 'expanded' } as const, [])
66const cursor = atom({ plugin: 'glimt', key: 'cursor' } as const, null)
67const sessions = atom({ plugin: 'glimt', key: 'sessions' } as const, null)
68const sessionsError = atom({ plugin: 'glimt', key: 'sessionsError' } as const, null)
69const phases = atom({ plugin: 'glimt', key: 'phases' } as const, {})
70const hasTaskList = atom({ plugin: 'glimt', key: 'hasTaskList' } as const, null)
71const stopping = atom({ plugin: 'glimt', key: 'stopping' } as const, null)
72const self = atom({ plugin: 'glimt', key: 'self' } as const, null)
73const shared = atom({ plugin: 'glimt', key: 'shared' } as const, {})
74const composer = atom({ plugin: 'glimt', key: 'composer' } as const, null)
75const clearing = atom({ plugin: 'glimt', key: 'clearing' } as const, false)
76const opened = atom({ plugin: 'glimt', key: 'opened' } as const, [])
77const feed = atom({ plugin: 'glimt', key: 'feed' } as const, null)
78const remote = atom({ plugin: 'glimt', key: 'remote' } as const, {})
79const remoteTasks = atom({ plugin: 'glimt', key: 'remoteTasks' } as const, {})
80const help = atom({ plugin: 'glimt', key: 'help' } as const, false)
81
82const transcripts = new Map<string, string>()
83
84// Each subagent's type and description, by its meta file: they never change.
85const metas = new Map<string, { type: string; description: string }>()
86
87export const register: Register = on => {
88 // The command last: a refused registration then leaves the pane and its clock running.
89 on('session.start', async ($, e, next) => {
90 $.clock.every(FRAME_MS, () => void tick($))
91 $.clock.every(POLL_MS, () => void readSessions($, false).catch(() => undefined))
92 $.clock.every(POLL_MS, () => void share($).catch(() => undefined))
93 $.clock.every(FEED_MS, () => void readFeed($, true).catch(() => undefined))
94 $.clock.every(POLL_MS, () => void readRemote($).catch(() => undefined))
95 void openPane($).catch(() => undefined)
96 runtime.home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
97 const configured = await $.env.get('CLAUDE_CONFIG_DIR').catch(() => undefined)
98 runtime.configDir = configured !== undefined && configured !== '' ? configured : `${runtime.home}/.claude`
99 await learnSelf($)
100 void readSessions($, true).catch(() => undefined)
101 await seedSteps($).catch(() => undefined)
102 await $.command.register({
103 name: 'glimt',
104 description: 'Open the glimt pane: what is in progress, the plan, the agents, and the other sessions',
105 })
106
107 return next(e)
108 })
109
110 // /clear, /resume and /branch put every $.state value back to its default
111 // and raise no session.start: learn the session's id again, and whether
112 // the pane is drawn.
113 on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
114 await learnSelf($)
115 void openPane($).catch(() => undefined)
116 void readSessions($, true).catch(() => undefined)
117
118 return next(e)
119 })
120
121 on('command.run', { command: 'glimt' }, async $ => {
122 await openPane($, true)
123
124 return {}
125 })
126
127 on('ui.close', async ($, e, next) => {
128 if (e.id === PANE) {
129 await update($, isShown, () => false)
130 }
131
132 return next(e)
133 })
134
135 // Tab and the arrows move Claude Code's focus ring; the cursor follows it
136 // onto a toggle. Leaving the form's field that way, or by a click, closes
137 // the form.
138 on('ui.focus', async ($, e, next) => {
139 const key = e.element
140 if (e.requestId === PANE && key !== undefined && runtime.toggles.some(one => one.key === key)) {
141 await update($, cursor, () => key)
142 }
143 if (e.requestId === PANE && e.origin.kind === 'person' && key !== 'composer-field' && (await read($, composer)) !== null) {
144 await update($, composer, () => null)
145 }
146
147 return next(e)
148 })
149
150 // Whether Claude has a task list depends on the model; the plan says so
151 // while it is empty.
152 on('prompt.compose', async ($, e, next) => {
153 const composed = await next(e)
154 const isOffered = e.tools.includes('TaskCreate') || e.tools.includes('TodoWrite')
155 await update($, hasTaskList, was => was === true || isOffered)
156 if (!e.tools.includes('TaskCreate')) {
157 return composed
158 }
159
160 return { sections: [...composed.sections, { id: 'glimt:sub-steps', text: SUB_STEP_NOTE, scope: 'session' as const }] }
161 })
162
163 // While the pane is drawn the chat leaves out the plan's rows and squeezes a
164 // running agent to one line; an errored call still shows.
165 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
166 const isPlanRow = PLAN_TOOLS.has(e.props.tool)
167 const isLiveAgent = e.props.tool === 'Agent' && e.props.isRunning
168 if ((!isPlanRow && !isLiveAgent) || e.props.isErrored || !(await read($, isShown))) {
169 return next(e)
170 }
171
172 const { Box, Text } = $.ui.resolve(e)
173 if (isPlanRow) {
174 return <Box />
175 }
176
177 const input = isRecord(e.props.input) ? e.props.input : {}
178 const type = typeof input.subagent_type === 'string' ? input.subagent_type : 'Agent'
179 const description = typeof input.description === 'string' ? input.description : ''
180
181 return (
182 <Text dimColor wrap="truncate-end">
183 {spin(await read($, now))} {type} · {description}
184 </Text>
185 )
186 })
187
188 // A /clear starts a new conversation: nothing on show belongs to it.
189 on('session.end', async ($, e, next) => {
190 // The session's share goes with it; after a /clear the next is under a new id.
191 const id = await read($, self)
192 if (id !== null) {
193 await $.store.delete(`agents:${id}`).catch(() => undefined)
194 }
195 runtime.lastShared = ''
196 if (e.reason === 'clear') {
197 await update($, focus, () => null)
198 await update($, plan, () => null)
199 await update($, steps, () => [])
200 await update($, agents, () => [])
201 await update($, expanded, () => [])
202 await update($, cursor, () => null)
203 await update($, stopping, () => null)
204 await update($, opened, () => [])
205 await update($, feed, () => null)
206 await update($, help, () => false)
207 }
208
209 return next(e)
210 })
211
212 // A new request from the person clears the agents that finished before it.
213 on('prompt.submit', async ($, e, next) => {
214 if (PERSON.has(e.origin.kind) && !e.text.startsWith('/')) {
215 await update($, agents, list => list.filter(a => a.state === 'running'))
216 }
217
218 return next(e)
219 })
220
221 // The main loop's turns: a subagent's run raises no turn.start.
222 on('turn.start', async ($, e, next) => {
223 runtime.isBusy = true
224 const at = await $.clock.now()
225 await update($, focus, () => ({ startedAt: at, tools: 0 }))
226 await update($, now, () => at)
227
228 return next(e)
229 })
230
231 on('turn.complete', async ($, e, next) => {
232 const at = await $.clock.now()
233 const agentId = e.agentId
234 const state: AgentState = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
235 if (agentId === undefined) {
236 await update($, focus, f => (f === null ? f : { ...f, endedAt: at, outcome: state, tool: undefined, doing: undefined }))
237 } else {
238 await update($, agents, list => list.map(a => (a.agentId === agentId ? { ...a, state, endedAt: at, tool: undefined, doing: undefined } : a)))
239 await readAgentFeed($, agentId).catch(() => undefined)
240 }
241 await update($, now, () => at)
242
243 return next(e)
244 })
245
246 // An agent's own tasks nest under the plan step in progress when it
247 // started; one spawned by another agent nests where that agent does.
248 on('agent.spawn', async ($, e, next) => {
249 const started = await next(e)
250 if (started.deny !== undefined) {
251 return started
252 }
253
254 await recordAgent($, {
255 agentId: started.agentId,
256 fallbackId: e.tool_use_id,
257 parentId: e.parentAgentId,
258 type: e.subagentType,
259 description: e.description,
260 task: e.prompt,
261 })
262
263 return started
264 })
265
266 // Each model request of any loop: the size of that loop's context, and for
267 // the main conversation how full its window is.
268 on('turn.step', async function* ($, e, next) {
269 const result = yield* next(e)
270 if (result.usage !== null) {
271 const tokens = contextTokens(result.usage)
272 const agentId = e.agentId
273 if (agentId === undefined) {
274 const percent = (await $.session.usage().catch(() => undefined))?.context.percent
275 await update($, focus, f => (f === null ? f : { ...f, tokens, percent }))
276 } else {
277 await update($, agents, list => list.map(a => (a.agentId === agentId ? { ...a, tokens } : a)))
278 }
279 }
280 if (e.agentId !== undefined) {
281 await readAgentFeed($, e.agentId).catch(() => undefined)
282 }
283
284 return result
285 })
286
287 // Each loop's calls: the tool it runs now and how many it has made. The
288 // pane's own calls (TaskList, TaskGet) never pass its own hooks.
289 on('tool.call', async ($, e, next) => {
290 const agentId = e.agentId
291 const tool = e.tool
292 const doing = describeCall(e)
293 if (agentId === undefined) {
294 await update($, focus, f => (f === null || !isTurnRunning(f) ? f : { ...f, tools: (f.tools ?? 0) + 1, tool, doing }))
295 try {
296 return await next(e)
297 } finally {
298 await update($, focus, f => (f !== null && f.tool === tool ? { ...f, tool: undefined, doing: undefined } : f))
299 }
300 }
301
302 runtime.isBusy = true
303 await update($, agents, list =>
304 list.map(a => (a.agentId === agentId ? { ...a, state: 'running', endedAt: undefined, tools: a.tools + 1, tool, doing } : a)),
305 )
306 await readAgentFeed($, agentId).catch(() => undefined)
307 try {
308 return await next(e)
309 } finally {
310 await update($, agents, list => list.map(a => (a.agentId === agentId && a.tool === tool ? { ...a, tool: undefined, doing: undefined } : a)))
311 await readAgentFeed($, agentId).catch(() => undefined)
312 }
313 })
314
315 // The plan is the main loop's task list: TaskCreate and TaskUpdate, or
316 // TodoWrite where a build still uses it. A subagent's own tasks join it as
317 // sub-steps (see ownerOf).
318 on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
319 const ran = await next(e)
320 const owner = await ownerOf($, e.agentId)
321 if (ran.deny !== undefined || ran.isError || owner === null) {
322 return ran
323 }
324
325 const { id, subject } = ran.result.task
326 const parent = parentFrom(e.metadata)
327 const step: Step = {
328 id: owner.prefix + id,
329 subject,
330 description: e.description,
331 activeForm: e.activeForm,
332 status: 'pending',
333 parentId: typeof parent === 'string' ? owner.prefix + parent : owner.stepId,
334 }
335 await update($, steps, list => [...list, step])
336
337 return ran
338 })
339
340 on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
341 const ran = await next(e)
342 const owner = await ownerOf($, e.agentId)
343 if (ran.deny !== undefined || ran.isError || !ran.result.success || owner === null) {
344 return ran
345 }
346
347 const parent = parentFrom(e.metadata)
348 const change: StepUpdate = {
349 taskId: owner.prefix + e.taskId,
350 subject: e.subject,
351 description: e.description,
352 activeForm: e.activeForm,
353 status: e.status,
354 parent: typeof parent === 'string' ? owner.prefix + parent : parent,
355 }
356 const at = await $.clock.now()
357 // A subagent's task the pane never saw made is none of the plan's.
358 await update($, steps, list => (owner.prefix !== '' && !list.some(step => step.id === change.taskId) ? list : applyUpdate(list, change, at)))
359
360 return ran
361 })
362
363 on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
364 const ran = await next(e)
365 if (e.agentId === undefined && ran.deny === undefined && !ran.isError) {
366 const todos = ran.result.newTodos
367 const at = await $.clock.now()
368 // TodoWrite sends the whole list each time: a todo still in progress
369 // under the same words keeps the time it started.
370 await update($, steps, list =>
371 todos.map((todo, i) => {
372 const before = list.find(step => step.subject === todo.content && step.status === 'in_progress')
373 return {
374 id: String(i + 1),
375 subject: todo.content,
376 description: '',
377 activeForm: todo.activeForm,
378 status: todo.status,
379 startedAt: todo.status === 'in_progress' ? (before?.startedAt ?? at) : undefined,
380 }
381 }),
382 )
383 }
384
385 return ran
386 })
387
388 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
389 const ran = await next(e)
390 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError) {
391 return ran
392 }
393
394 const text = ran.result.plan
395 if (text !== null && text !== '') {
396 const approved: Plan = { title: planTitle(text), path: ran.result.filePath }
397 await update($, plan, () => approved)
398 }
399
400 return ran
401 })
402
403 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
404 const ui = $.ui.resolve(e)
405 const { Box, Text } = ui
406 const request = await read($, focus)
407 const approved = await read($, plan)
408 const list = await read($, steps)
409 const team = await read($, agents)
410 const open = await read($, expanded)
411 const view: View = {
412 ui,
413 Input: 'Input' in ui ? ui.Input : undefined,
414 columns: Math.max(12, e.props.bodyColumns),
415 at: await read($, now),
416 isTurnRunning: isTurnRunning(request),
417 cursor: await read($, cursor),
418 toggle: key => void toggle($, key),
419 }
420
421 // This session at the top; the other sessions on the machine, and what
422 // their glimts share, at the bottom.
423 const listed = await read($, sessions)
424 const selfId = await read($, self)
425 const others = listed === null ? null : sortSessions(listed.filter(s => s.sessionId !== selfId))
426 const sharedBy = await read($, shared)
427 const pointed = others?.find(s => view.cursor === `toggle-session-${s.sessionId}`)
428 const isOnSelf = view.cursor === 'toggle-self'
429 const renameTarget = isOnSelf ? selfId : pointed !== undefined && sharedBy[pointed.sessionId] !== undefined ? pointed.sessionId : null
430 const keys = e.props.isFocused
431 ? keySection(
432 view,
433 {
434 canOpen: view.cursor !== null,
435 canClear: isOnSelf,
436 canRename: renameTarget !== null,
437 canReach: pointed !== undefined && isReachable(pointed),
438 },
439 {
440 down: () => void move($, 1),
441 up: () => void move($, -1),
442 into: () => void goInto($),
443 back: () => void goBack($),
444 open: () => void openFocused($),
445 spawn: () => void openComposer($, { kind: 'spawn', where: 'here' }),
446 help: () => void update($, help, () => true),
447 rename: () => void (renameTarget === null ? undefined : openComposer($, { kind: 'rename', sessionId: renameTarget })),
448 clear: () => void update($, clearing, () => true),
449 attach: () => void attachSession($),
450 stop: () => void askStop($),
451 },
452 )
453 : undefined
454
455 const header = selfSection(view, {
456 session: listed?.find(s => s.sessionId === selfId),
457 isOpen: open.includes('self'),
458 isClearing: await read($, clearing),
459 answer: isYes => void answerClear($, isYes),
460 })
461 // Esc in the form's field hands the keyboard back to the prompt and raises
462 // nothing else: drawn without the keyboard, the form shows no more, and
463 // the clock's next frame closes it (a drawing may not write).
464 runtime.isFocused = e.props.isFocused
465 const form = e.props.isFocused ? await read($, composer) : null
466 const formRows = form === null ? 0 : FORM_ROWS
467 const formNode =
468 form === null
469 ? undefined
470 : composerSection(view, form, listed ?? [], {
471 spawn: (task, where) => void spawnAgent($, task, where),
472 rename: (sessionId, name) => void renameSession($, sessionId, name),
473 cancel: () => void update($, composer, () => null),
474 })
475
476 // The sections stack from the top, each in a place that never moves: this
477 // session, any open form, what is in progress, the plan, the agents, the
478 // other sessions, and the keys while the pane holds the keyboard. Docked,
479 // the pane's height is a budget: the agents and the sessions take up to
480 // their shares and the plan, the longest list, gets what is left and
481 // folds to fit. Inline, nothing is cut.
482 const rows = e.props.placement === 'dock' ? e.props.scroll.bodyRows : undefined
483
484 // i: the key list takes the pane until i again, or a key it lists.
485 if (await read($, help)) {
486 runtime.toggles = []
487 const leave = async (then: () => Promise<unknown>) => {
488 await update($, help, () => false)
489 await then()
490 }
491 return (
492 <Box flexDirection="column" width={view.columns}>
493 {helpSection(view, {
494 spawn: () => void leave(() => openComposer($, { kind: 'spawn', where: 'here' })),
495 session: () => void leave(() => openComposer($, { kind: 'spawn', where: 'session' })),
496 close: () => void leave(() => $.ui.close({ id: PANE })),
497 back: () => void update($, help, () => false),
498 })}
499 </Box>
500 )
501 }
502
503 // Gone into an agent or a session, it takes the pane: j and k walk a
504 // session's agents, and h steps back out to where it was.
505 const into = (await read($, opened)).at(-1)
506 const remoteBy = await read($, remote)
507 const tasks = await read($, remoteTasks)
508 if (into !== undefined) {
509 const sessionId = into.kind === 'agent' ? undefined : into.sessionId
510 // h goes back to the session the pane went into first, else to the overview.
511 const below = (await read($, opened)).at(-2)
512 const parent = below?.kind === 'session' ? listed?.find(s => s.sessionId === below.sessionId) : undefined
513 const drill = drillSection(view, {
514 into,
515 backTo: below === undefined ? 'Overview' : (parent?.name ?? 'back'),
516 agent: into.kind === 'agent' ? team.find(a => a.id === into.id) : undefined,
517 session: sessionId === undefined ? undefined : listed?.find(s => s.sessionId === sessionId),
518 phase: into.kind === 'session' ? (await read($, phases))[into.sessionId] : undefined,
519 remoteAgent: into.kind === 'remote' ? remoteBy[into.sessionId]?.agents.find(a => a.id === into.agentId) : undefined,
520 remote: sessionId === undefined ? undefined : remoteBy[sessionId],
521 shared: sessionId === undefined ? [] : (sharedBy[sessionId]?.agents ?? []),
522 isSharing: sessionId !== undefined && sharedBy[sessionId] !== undefined,
523 tasks,
524 open,
525 feed: await read($, feed),
526 asked: await read($, stopping),
527 isFocused: e.props.isFocused && formNode === undefined,
528 room: rows === undefined ? undefined : rows - formRows,
529 act: {
530 back: () => void goBack($),
531 into: () => void goInto($),
532 down: () => void move($, 1),
533 up: () => void move($, -1),
534 open: () => void openFocused($),
535 message: () => void $.ui.focus({ requestId: PANE, key: 'message-field' }).catch(() => undefined),
536 send: (to, name, text) => void sendFromField($, to, name, text),
537 rename: () => void (into.kind === 'session' ? openComposer($, { kind: 'rename', sessionId: into.sessionId }) : undefined),
538 attach: () => void attachSession($),
539 stop: () => void askStop($),
540 answer: isYes => void answerStop($, isYes),
541 help: () => void update($, help, () => true),
542 },
543 })
544 runtime.toggles = drill.toggles
545 return (
546 <Box flexDirection="column" width={view.columns}>
547 {drill.node}
548 {formNode !== undefined && (
549 <Box flexDirection="column" marginTop={1}>
550 {formNode}
551 </Box>
552 )}
553 </Box>
554 )
555 }
556
557 // Before anything has happened, one quiet line says what will show here.
558 const isEmpty =
559 list.length === 0 && team.length === 0 && request?.startedAt === undefined && others?.length === 0 && (await read($, sessionsError)) === null
560 if (isEmpty) {
561 runtime.toggles = [{ key: 'toggle-self', target: 'self' }]
562 const footer = formNode ?? keys?.node
563 return (
564 <Box flexDirection="column" width={view.columns} {...(rows === undefined ? {} : { height: rows })}>
565 {header.node}
566 <Box key="empty" flexDirection="column" marginTop={1}>
567 {wrap('glimt · the plan, agents and other sessions show here as they start', view.columns).map(text => (
568 <Text dimColor>{text}</Text>
569 ))}
570 </Box>
571 <Box key="gap" flexGrow={1} />
572 {footer !== undefined && (
573 <Box flexDirection="column" marginTop={1}>
574 {footer}
575 </Box>
576 )}
577 </Box>
578 )
579 }
580
581 const shareOf = (part: number) => (rows === undefined ? Infinity : Math.max(3, Math.floor(rows * part)))
582 const current = nowSection(view, list)
583 const units = planUnits(list, open, view.columns)
584 // The footer: the form while one is open, else the keys while they show.
585 const footerRows = formNode !== undefined ? formRows : keys === undefined ? 0 : 1 + keys.rows
586 // Rows the plan does not need go to the agents, then the sessions, before
587 // either folds: the pane less this session's rows, the plan's heading and
588 // its lines unfolded, both shares, a blank row above each section, and
589 // the footer.
590 const spare =
591 rows === undefined
592 ? 0
593 : Math.max(
594 0,
595 rows -
596 header.rows -
597 current.rows -
598 1 -
599 Math.max(1, rowCount(units.flat())) -
600 shareOf(AGENT_SHARE) -
601 shareOf(SESSION_SHARE) -
602 4 -
603 footerRows,
604 )
605 const crew = agentSection(view, mainAgent(request), team, open, shareOf(AGENT_SHARE) + spare)
606 const listing = sessionSection(
607 view,
608 {
609 list: others,
610 error: await read($, sessionsError),
611 shared: sharedBy,
612 remote: remoteBy,
613 tasks,
614 open,
615 asked: await read($, stopping),
616 answer: isYes => void answerStop($, isYes),
617 phases: await read($, phases),
618 },
619 shareOf(SESSION_SHARE) + spare - Math.max(0, crew.rows - shareOf(AGENT_SHARE)),
620 )
621 // Left for the plan: less this session's rows, its own heading, the blank
622 // row above each section, and the footer.
623 const planRoom = rows === undefined ? Infinity : Math.max(1, rows - header.rows - 1 - current.rows - crew.rows - listing.rows - 4 - footerRows)
624 const lines = planLines(units, planRoom)
625 runtime.toggles = [
626 { key: 'toggle-self', target: 'self' },
627 ...lines.flatMap(line => (line.kind === 'step' ? [{ key: `toggle-${line.step.id}`, target: line.step.id }] : [])),
628 ...crew.toggles,
629 ...listing.toggles,
630 ]
631
632 // Docked, the sessions and the footer keep to the bottom: agents coming
633 // and going change the gap above them, never where they stand.
634 const footer = formNode ?? keys?.node
635 return (
636 <Box flexDirection="column" width={view.columns} {...(rows === undefined ? {} : { height: rows })}>
637 {header.node}
638 <Box flexDirection="column" marginTop={1}>
639 {current.node}
640 </Box>
641 <Box flexDirection="column" marginTop={1}>
642 {planSection(view, approved, list, lines, await read($, hasTaskList))}
643 </Box>
644 <Box flexDirection="column" marginTop={1}>
645 {crew.node}
646 </Box>
647 <Box key="gap" flexGrow={1} />
648 <Box flexDirection="column" marginTop={1}>
649 {listing.node}
650 </Box>
651 {footer !== undefined && (
652 <Box flexDirection="column" marginTop={1}>
653 {footer}
654 </Box>
655 )}
656 </Box>
657 )
658 })
659}
660
661// Opened unasked, at session start, the pane waits for a wide terminal and
662// leaves the keyboard alone; opened by /glimt it takes the keyboard.
663async function openPane($: EngineInterface, isAsked = false) {
664 const pane = { id: PANE, title: 'glimt' }
665 const shown = await $.ui.open(isAsked ? { ...pane, focus: true as const } : pane)
666 await update($, isShown, () => shown.isPlaced)
667}
668
669// A resumed session already holds a task list: read it once, so the plan
670// shows before it next changes.
671async function seedSteps($: EngineInterface) {
672 const listed = await $.tool.call({ tool: 'TaskList' })
673 if (listed.deny !== undefined || listed.isError) {
674 return
675 }
676
677 const tasks = listed.result.tasks
678 await update($, steps, list => (list.length > 0 ? list : tasks.map(task => ({ id: task.id, subject: task.subject, status: task.status }))))
679}
680
681// Whose task list a call changes. The main loop's is the plan itself. A
682// subagent's joins it under the step its run belongs to, its ids led by the
683// agent's so they never meet the main list's; a subagent started while no
684// step was in progress keeps its tasks to itself (null).
685async function ownerOf($: EngineInterface, agentId: string | undefined): Promise<{ prefix: string; stepId?: string } | null> {
686 if (agentId === undefined) {
687 return { prefix: '' }
688 }
689
690 const agent = (await read($, agents)).find(a => a.agentId === agentId)
691
692 return agent?.stepId === undefined ? null : { prefix: `${agentId}/`, stepId: agent.stepId }
693}
694
695// Opens or closes a step (keyed by its task id) or an agent ("agent:" and its
696// id). A step seeded from TaskList has no description yet: the first
697// opening reads it with TaskGet.
698async function toggle($: EngineInterface, id: string) {
699 const open = await update($, expanded, ids => (ids.includes(id) ? ids.filter(other => other !== id) : [...ids, id]))
700 // Another session shows its agents as it opens; one of them, its task.
701 const [kind = '', sessionId = '', agentId = ''] = id.split(':')
702 if (open.includes(id) && kind === 'session') {
703 await markSeen($, sessionId)
704 await readRemote($, [sessionId]).catch(() => undefined)
705 }
706 if (open.includes(id) && kind === 'remote') {
707 await loadRemoteTask($, sessionId, agentId).catch(() => undefined)
708 }
709 const step = (await read($, steps)).find(one => one.id === id)
710 if (!open.includes(id) || step === undefined || step.description !== undefined) {
711 return
712 }
713
714 const got = await $.tool.call({ tool: 'TaskGet', taskId: id }).catch(() => undefined)
715 const task = got === undefined || got.deny !== undefined || got.isError ? null : got.result.task
716 const description = task?.description ?? ''
717 await update($, steps, list => list.map(one => (one.id === id ? { ...one, description } : one)))
718}
719
720// j and k: the cursor to the next or the previous toggle, from the first or
721// the last when it is on none of them; past either end it stays. Claude
722// Code's focus ring is asked to follow, so Enter presses the same toggle;
723// where it can't, the cursor still stands.
724async function move($: EngineInterface, by: 1 | -1) {
725 const current = await read($, cursor)
726 const at = runtime.toggles.findIndex(one => one.key === current)
727 await point($, runtime.toggles[at === -1 ? (by === 1 ? 0 : runtime.toggles.length - 1) : at + by])
728}
729
730// l: one step in, as Claude Code's agent view goes into a session: from the
731// overview into one of this session's agents, another session or one of its
732// agents; from a session, into one of its agents. The pane shows it alone,
733// its activity read at once. On a step or on this session, l opens the row.
734async function goInto($: EngineInterface) {
735 const key = await read($, cursor)
736 const target = runtime.toggles.find(one => one.key === key)?.target
737 if (target === undefined) {
738 return
739 }
740
741 const [kind = '', id = '', agentId = ''] = target.split(':')
742 const from = key ?? undefined
743 const step: Opened | null =
744 kind === 'agent'
745 ? { kind: 'agent', id: target.slice('agent:'.length), from }
746 : kind === 'session'
747 ? { kind: 'session', sessionId: id, from }
748 : kind === 'remote'
749 ? { kind: 'remote', sessionId: id, agentId, from }
750 : null
751 if (step === null) {
752 if (!(await read($, expanded)).includes(target)) {
753 await toggle($, target)
754 }
755 return
756 }
757
758 await update($, feed, () => null)
759 await update($, opened, trail => [...trail, step])
760 if (step.kind === 'session') {
761 await markSeen($, step.sessionId)
762 await readRemote($, [step.sessionId]).catch(() => undefined)
763 }
764 if (step.kind === 'remote') {
765 await loadRemoteTask($, step.sessionId, step.agentId).catch(() => undefined)
766 }
767 await readFeed($).catch(() => undefined)
768}
769
770// h: one step back out, the cursor on the row it went in from; in the
771// overview, closes the row the cursor is on.
772async function goBack($: EngineInterface) {
773 const last = (await read($, opened)).at(-1)
774 if (last !== undefined) {
775 await update($, opened, trail => trail.slice(0, -1))
776 await update($, feed, () => null)
777 await update($, cursor, () => last.from ?? null)
778 if (last.from !== undefined) {
779 await $.ui.focus({ requestId: PANE, key: last.from }).catch(() => undefined)
780 }
781 await readFeed($).catch(() => undefined)
782 return
783 }
784
785 const key = await read($, cursor)
786 const target = runtime.toggles.find(one => one.key === key)?.target
787 if (target !== undefined && (await read($, expanded)).includes(target)) {
788 await toggle($, target)
789 }
790}
791
792// The activity of the step the pane shows: one of this session's agents'
793// messages as the session holds them, another session's or its agent's
794// from the tail of its transcript. The timer reads only those on disk. A
795// read that a move overtook is dropped.
796async function readFeed($: EngineInterface, isTimer = false) {
797 const top = (await read($, opened)).at(-1)
798 if (top === undefined || (isTimer && top.kind === 'agent')) {
799 return
800 }
801
802 const reading =
803 top.kind === 'agent' ? agentFeed($, top.id) : top.kind === 'session' ? sessionFeed($, top.sessionId) : remoteFeed($, top.sessionId, top.agentId)
804 const got = await reading.catch((error: unknown): Feed => ({ items: [], error: error instanceof Error ? error.message : String(error) }))
805 const still = (await read($, opened)).at(-1)
806 if (still !== undefined && JSON.stringify(still) === JSON.stringify(top)) {
807 await update($, feed, () => ({ ...got, items: got.items.slice(-FEED_KEPT) }))
808 }
809}
810
811// After an agent of this session acts: its activity again, when the pane
812// shows it.
813async function readAgentFeed($: EngineInterface, agentId: string) {
814 const top = (await read($, opened)).at(-1)
815 if (top?.kind !== 'agent') {
816 return
817 }
818
819 const agent = (await read($, agents)).find(a => a.id === top.id)
820 if (agent?.agentId === agentId) {
821 await readFeed($)
822 }
823}
824
825async function agentFeed($: EngineInterface, id: string): Promise<Feed> {
826 const agent = (await read($, agents)).find(a => a.id === id)
827 if (agent?.agentId === undefined) {
828 return { items: [], error: 'This agent has no transcript to read.' }
829 }
830
831 const found = await $.session.messages({ agentId: agent.agentId })
832 if ('deny' in found) {
833 return { items: [], error: found.deny }
834 }
835
836 // Its first message is the task, drawn above the activity already.
837 return {
838 items: found.flatMap((message): Activity[] => [
839 ...(isShownText(message.text) && !(message.role === 'user' && message.text.trim() === agent.task.trim())
840 ? [{ kind: message.role === 'user' ? ('asked' as const) : ('said' as const), text: message.text.trim() }]
841 : []),
842 ...message.toolUses.map(use => ({ kind: 'call' as const, text: callLine(use.tool, use.input) })),
843 ]),
844 }
845}
846
847async function sessionFeed($: EngineInterface, sessionId: string): Promise<Feed> {
848 const s = (await read($, sessions))?.find(one => one.sessionId === sessionId)
849 const path = s === undefined ? null : await transcriptOf($, s)
850 if (path === null) {
851 return { items: [], error: 'No transcript found for this session.' }
852 }
853
854 return tailFeed($, path, false)
855}
856
857// Another session's agent: the tail of its own transcript, less its task.
858async function remoteFeed($: EngineInterface, sessionId: string, agentId: string): Promise<Feed> {
859 const s = (await read($, sessions))?.find(one => one.sessionId === sessionId)
860 const dir = s === undefined ? null : await subagentsDir($, s)
861 if (dir === null) {
862 return { items: [], error: 'No transcript found for this agent.' }
863 }
864
865 const got = await tailFeed($, `${dir}/agent-${agentId}.jsonl`, true)
866 const task = (await read($, remoteTasks))[`${sessionId}:${agentId}`]
867 return { ...got, items: got.items.filter(item => !(item.kind === 'asked' && item.text === task)) }
868}
869
870async function tailFeed($: EngineInterface, path: string, isSubagent: boolean): Promise<Feed> {
871 const ran = await $.process.run(['tail', '-c', String(TAIL_BYTES), path], { timeoutMs: 5000 })
872 if (ran.exitCode !== 0) {
873 return { items: [], error: firstLine(ran.stderr) ?? 'Could not read the transcript.' }
874 }
875
876 return { items: parseTranscript(ran.stdout, isSubagent) }
877}
878
879// The subagents of the sessions opened in the list or drilled into: every
880// POLL_MS while the pane is drawn, and at once when one opens.
881async function readRemote($: EngineInterface, only?: string[]) {
882 if (only === undefined && !(await read($, isShown))) {
883 return
884 }
885
886 const listed = (await read($, sessions)) ?? []
887 const open = await read($, expanded)
888 const trail = await read($, opened)
889 const ids = only ?? [
890 ...new Set([
891 ...open.flatMap(key => (key.startsWith('session:') ? [key.slice('session:'.length)] : [])),
892 ...trail.flatMap(step => (step.kind === 'agent' ? [] : [step.sessionId])),
893 ]),
894 ]
895 for (const id of ids) {
896 const s = listed.find(one => one.sessionId === id)
897 if (s !== undefined) {
898 const list = await remoteAgentsOf($, s)
899 await update($, remote, all => ({ ...all, [id]: list }))
900 }
901 }
902}
903
904// A session's subagents, from the folder beside its transcript: the newest
905// written first, each with the type and description of its meta file.
906async function remoteAgentsOf($: EngineInterface, s: Session): Promise<RemoteList> {
907 const dir = await subagentsDir($, s)
908 if (dir === null) {
909 return { agents: [], total: 0 }
910 }
911
912 const files = (await $.fs.list(dir).catch(() => [])).flatMap(entry => {
913 const id = /^agent-(.+)\.jsonl$/.exec(entry.name)?.[1]
914 return entry.kind === 'file' && id !== undefined ? [{ id, writtenAt: entry.mtimeMs }] : []
915 })
916 const found: RemoteAgent[] = []
917 for (const file of files.toSorted((a, b) => b.writtenAt - a.writtenAt).slice(0, REMOTE_KEPT)) {
918 found.push({ ...file, ...(await metaOf($, `${dir}/agent-${file.id}.meta.json`)) })
919 }
920
921 return { agents: found, total: files.length }
922}
923
924async function subagentsDir($: EngineInterface, s: Session): Promise<string | null> {
925 const path = await transcriptOf($, s)
926
927 return path === null ? null : `${path.slice(0, -'.jsonl'.length)}/subagents`
928}
929
930async function metaOf($: EngineInterface, path: string): Promise<{ type: string; description: string }> {
931 const known = metas.get(path)
932 if (known !== undefined) {
933 return known
934 }
935
936 const text = await $.fs.read(path).catch(() => undefined)
937 let fields: Record<string, unknown> = {}
938 try {
939 const parsed: unknown = text === undefined ? undefined : JSON.parse(text)
940 fields = isRecord(parsed) ? parsed : {}
941 } catch {
942 // A meta file being written reads as none; the next read tries again.
943 }
944 const meta = {
945 type: typeof fields.agentType === 'string' ? fields.agentType : 'Agent',
946 description: typeof fields.description === 'string' ? fields.description : '',
947 }
948 if (typeof fields.agentType === 'string') {
949 metas.set(path, meta)
950 }
951
952 return meta
953}
954
955// Another session's agent's task: what its transcript's first line asked,
956// read once.
957async function loadRemoteTask($: EngineInterface, sessionId: string, agentId: string) {
958 const key = `${sessionId}:${agentId}`
959 if ((await read($, remoteTasks))[key] !== undefined) {
960 return
961 }
962
963 const s = (await read($, sessions))?.find(one => one.sessionId === sessionId)
964 const dir = s === undefined ? null : await subagentsDir($, s)
965 if (dir === null) {
966 return
967 }
968 const ran = await $.process.run(['head', '-n', '1', `${dir}/agent-${agentId}.jsonl`], { timeoutMs: 5000 })
969 if (ran.exitCode === 0) {
970 const task = parseTranscript(ran.stdout, true).find(item => item.kind === 'asked')?.text ?? ''
971 await update($, remoteTasks, all => ({ ...all, [key]: task }))
972 }
973}
974
975// A session's transcript: in Claude Code's projects folder, under the folder
976// it runs in with every character but a letter or a digit made a dash; where
977// it started somewhere else, in whichever project folder holds it.
978async function transcriptOf($: EngineInterface, s: Session): Promise<string | null> {
979 const known = transcripts.get(s.sessionId)
980 if (known !== undefined) {
981 return known
982 }
983
984 const root = `${runtime.configDir}/projects`
985 const file = `${s.sessionId}.jsonl`
986 const exists = (path: string) => $.fs.exists(path).catch(() => false)
987 const guess = `${root}/${s.cwd.replace(/[^a-zA-Z0-9]/g, '-')}/${file}`
988 let found = (await exists(guess)) ? guess : undefined
989 if (found === undefined) {
990 for (const entry of await $.fs.list(root).catch(() => [])) {
991 const path = `${root}/${entry.name}/${file}`
992 if (entry.kind === 'dir' && (await exists(path))) {
993 found = path
994 break
995 }
996 }
997 }
998 if (found === undefined) {
999 return null
1000 }
1001
1002 transcripts.set(s.sessionId, found)
1003 return found
1004}
1005
1006async function point($: EngineInterface, target: Toggle | undefined) {
1007 if (target === undefined) {
1008 return
1009 }
1010
1011 await update($, cursor, () => target.key)
1012 await $.ui.focus({ requestId: PANE, key: target.key }).catch(() => undefined)
1013}
1014
1015// o: opens or closes what the cursor is on.
1016async function openFocused($: EngineInterface) {
1017 const key = await read($, cursor)
1018 const target = runtime.toggles.find(one => one.key === key)
1019 if (target !== undefined) {
1020 await toggle($, target.target)
1021 }
1022}
1023
1024// Reads every session from `claude agents --json`: at session start, after a
1025// stop, and every POLL_MS while the pane is drawn.
1026async function readSessions($: EngineInterface, isAsked: boolean) {
1027 if (!isAsked && !(await read($, isShown))) {
1028 return
1029 }
1030
1031 const ran = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 10_000 }).catch(() => undefined)
1032 if (ran === undefined || ran.exitCode !== 0) {
1033 const why = firstLine(ran?.stderr) ?? 'Could not run claude agents --json'
1034 await update($, sessionsError, () => why)
1035 return
1036 }
1037
1038 let list: Session[]
1039 try {
1040 list = parseSessions(JSON.parse(ran.stdout))
1041 } catch {
1042 await update($, sessionsError, () => 'claude agents --json printed something other than a list of sessions')
1043 return
1044 }
1045 // The clock may have stood still a while: ages count from now.
1046 const at = await $.clock.now()
1047 await update($, now, () => at)
1048 await update($, sessions, () => list)
1049 await update($, sessionsError, () => null)
1050 // A background session that started waiting since the last read is told
1051 // of; one in a terminal of its own notifies from there.
1052 const selfId = await read($, self)
1053 let started: Session[] = []
1054 await update($, phases, before => {
1055 const after = nextPhases(before, list, at)
1056 started = list.filter(
1057 s =>
1058 s.sessionId !== selfId &&
1059 s.kind === 'background' &&
1060 before[s.sessionId] !== undefined &&
1061 before[s.sessionId]?.state !== 'waiting' &&
1062 after[s.sessionId]?.state === 'waiting',
1063 )
1064 return after
1065 })
1066 for (const s of started) {
1067 void tellWaiting($, s).catch(() => undefined)
1068 }
1069 await loadShared($, list, at).catch(() => undefined)
1070}
1071
1072// A notification through the person's own channel, or a toast where no
1073// channel sent it (or Claude Code is older than $.ui.notify); nothing where
1074// they turned notifications off.
1075async function tellWaiting($: EngineInterface, s: Session) {
1076 const text = waitingNote(s)
1077 let sent: Awaited<ReturnType<EngineInterface['ui']['notify']>> | null
1078 try {
1079 sent = await $.ui.notify(text)
1080 } catch {
1081 sent = null
1082 }
1083 if (sent === null || (!sent.isSent && sent.reason !== 'disabled' && sent.reason !== 'refused')) {
1084 $.ui.toast(text)
1085 }
1086}
1087
1088// Opening a session, in the list or by going into it, takes the bold off
1089// its name.
1090async function markSeen($: EngineInterface, sessionId: string) {
1091 await update($, phases, all => {
1092 const phase = all[sessionId]
1093 return phase?.isUnseen === true ? { ...all, [sessionId]: { ...phase, isUnseen: false } } : all
1094 })
1095}
1096
1097// What the listed sessions' glimts share, fresh ones only. The shares of
1098// sessions no longer listed, and names asked of them, are cleared away;
1099// this session's own keys are never touched here.
1100async function loadShared($: EngineInterface, list: Session[], at: number) {
1101 const listed = new Set(list.map(s => s.sessionId))
1102 const selfId = await read($, self)
1103 const fresh: Record<string, Shared> = {}
1104 for (const key of await $.store.keys()) {
1105 const [kind = '', ...rest] = key.split(':')
1106 const id = rest.join(':')
1107 if ((kind !== 'agents' && kind !== 'rename') || id === selfId) {
1108 continue
1109 }
1110 if (!listed.has(id)) {
1111 await $.store.delete(key)
1112 continue
1113 }
1114 const value = kind === 'agents' ? await $.store.get(key) : undefined
1115 if (isShared(value) && at - value.at <= STALE_MS) {
1116 fresh[id] = value
1117 }
1118 }
1119 await update($, shared, () => fresh)
1120}
1121
1122// The session's own id: at start, and again after /clear, /resume or /branch.
1123async function learnSelf($: EngineInterface) {
1124 const id = await $.session.id().catch(() => null)
1125 await update($, self, () => id)
1126}
1127
1128// Every POLL_MS: this session's running agents into the store for the other
1129// sessions' glimts, when they changed or a heartbeat is due; and any name
1130// another session's glimt asked this one to take.
1131async function share($: EngineInterface) {
1132 const id = await read($, self)
1133 if (id === null) {
1134 return
1135 }
1136
1137 const at = await $.clock.now()
1138 const running: SharedAgent[] = (await read($, agents))
1139 .filter(a => a.state === 'running')
1140 .map(a => ({
1141 id: a.id,
1142 type: a.type,
1143 description: a.description,
1144 task: fit(a.task, SHARED_TASK),
1145 startedAt: a.startedAt,
1146 tools: a.tools,
1147 tool: a.tool,
1148 }))
1149 const text = JSON.stringify(running)
1150 if (text !== runtime.lastShared || at - runtime.lastSharedAt >= HEARTBEAT_MS) {
1151 await $.store.set(`agents:${id}`, { at, agents: running })
1152 runtime.lastShared = text
1153 runtime.lastSharedAt = at
1154 }
1155
1156 const asked = await $.store.get(`rename:${id}`)
1157 if (typeof asked === 'string' && asked.trim() !== '') {
1158 await $.store.delete(`rename:${id}`)
1159 await renameSelf($, asked.trim())
1160 }
1161}
1162
1163// A subagent as it starts: its own tasks nest under the plan step in
1164// progress then; one spawned by another agent nests where that agent does.
1165async function recordAgent($: EngineInterface, spawned: Spawned) {
1166 runtime.isBusy = true
1167 const at = await $.clock.now()
1168 const spawner = (await read($, agents)).find(a => a.agentId !== undefined && a.agentId === spawned.parentId)
1169 const stepId =
1170 spawner !== undefined ? spawner.stepId : (await read($, steps)).find(step => step.status === 'in_progress' && !step.id.includes('/'))?.id
1171 const agent: Agent = {
1172 id: spawned.agentId ?? `${spawned.fallbackId}@${at}`,
1173 agentId: spawned.agentId,
1174 parentId: spawned.parentId,
1175 stepId,
1176 type: spawned.type,
1177 description: spawned.description,
1178 task: spawned.task,
1179 state: 'running',
1180 startedAt: at,
1181 tools: 0,
1182 }
1183 await update($, agents, list => [...list.filter(a => a.id !== agent.id), agent].slice(-50))
1184 await update($, now, () => at)
1185}
1186
1187// Claude Code's own /rename, so the name is the one /resume and the prompt
1188// box show; the list is read again to show it.
1189async function renameSelf($: EngineInterface, name: string) {
1190 await $.command.run({ command: 'rename', args: name }).catch(() => undefined)
1191 await readSessions($, true)
1192}
1193
1194// r: renames this session, or asks another session's glimt to take the
1195// name through the store: it does within POLL_MS.
1196async function renameSession($: EngineInterface, sessionId: string, name: string) {
1197 await update($, composer, () => null)
1198 if (name.trim() === '') {
1199 return
1200 }hooks/agents.tsx 310 lines1// This session's agents: the main conversation and its subagents as a tree.
2
3import type { ModelUsage } from 'claude-code'
4import type { Agent, AgentState, Focus } from '../types'
5import { cells, clip, duration, fit, fitStart, minutes, spin, tokenNumber, toolName, wrap } from './text'
6import { heading } from './view'
7import type { View } from './view'
8
9// Finished agents stay on show below the running ones until a new request,
10// the newest this many, as room allows.
11export const FINISHED_SHOWN = 6
12
13// Below this many columns an agent's time and tools take a line of their own.
14export const NARROW = 50
15
16// A row's title keeps at least this many cells: a count in words that would
17// leave less is shortened.
18export const MIN_TITLE = 12
19
20export type Spawned = {
21 agentId?: string | undefined
22 fallbackId: string
23 parentId?: string | undefined
24 type: string
25 description: string
26 task: string
27}
28
29export function isTurnRunning(request: Focus | null): boolean {
30 return request?.startedAt !== undefined && request.endedAt === undefined
31}
32
33// The main conversation drawn as the first agent, once a turn has started.
34export function mainAgent(request: Focus | null): Agent | undefined {
35 if (request?.startedAt === undefined) {
36 return undefined
37 }
38
39 return {
40 id: 'main',
41 type: 'Claude',
42 description: 'main conversation',
43 task: '',
44 state: isTurnRunning(request) ? 'running' : (request.outcome ?? 'done'),
45 startedAt: request.startedAt,
46 endedAt: request.endedAt,
47 tools: request.tools ?? 0,
48 tool: request.tool,
49 doing: request.doing,
50 tokens: request.tokens,
51 percent: request.percent,
52 }
53}
54
55export function contextTokens(usage: ModelUsage): number {
56 return usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens + usage.output_tokens
57}
58
59// What runs spins; a finished agent keeps a still mark: ✓ done, and a failed
60// or stopped one a shape of its own, so each reads without its color.
61export function runningMark(at: number) {
62 return { mark: spin(at), color: 'claude' }
63}
64
65export const AGENT_MARK: Record<Exclude<AgentState, 'running'>, { mark: string; color: string }> = {
66 done: { mark: '✓', color: 'success' },
67 failed: { mark: '✗', color: 'error' },
68 stopped: { mark: '■', color: 'warning' },
69}
70
71// The most lines an opened agent's task takes.
72export const TASK_LINES = 12
73
74// One agent as the tree draws it: `branch` leads its row, `stem` leads the
75// rows of its children, and the lines under it continue the stem.
76export type AgentNode = { agent: Agent; branch: string; stem: string; isMain: boolean; hasChildren: boolean }
77
78// The running agents, the newest finished, and every agent above one of
79// those, so no row hangs without the agent that spawned it.
80export function shownAgents(team: Agent[]): Agent[] {
81 const byAgentId = new Map<string, Agent>()
82 for (const agent of team) {
83 if (agent.agentId !== undefined) {
84 byAgentId.set(agent.agentId, agent)
85 }
86 }
87
88 const wanted = [...team.filter(a => a.state === 'running'), ...team.filter(a => a.state !== 'running').slice(-FINISHED_SHOWN)]
89 const keep = new Set<string>()
90 for (const agent of wanted) {
91 let at: Agent | undefined = agent
92 while (at !== undefined && !keep.has(at.id)) {
93 keep.add(at.id)
94 at = at.parentId === undefined ? undefined : byAgentId.get(at.parentId)
95 }
96 }
97
98 return team.filter(a => keep.has(a.id))
99}
100
101// The tree in spawn order: the main conversation at the root, each agent
102// under the one that spawned it; one whose parent is not on show hangs from
103// the root.
104export function agentTree(main: Agent | undefined, shown: Agent[]): AgentNode[] {
105 const ids = new Set(shown.flatMap(a => (a.agentId === undefined ? [] : [a.agentId])))
106 const parentOf = (a: Agent) => (a.parentId !== undefined && ids.has(a.parentId) ? a.parentId : undefined)
107 const childrenOf = (parent: string | undefined) => shown.filter(a => parentOf(a) === parent)
108 const nodes: AgentNode[] = []
109
110 const grow = (parent: string | undefined, stem: string, isBranched: boolean) => {
111 const children = childrenOf(parent)
112 children.forEach((agent, i) => {
113 const isLast = i === children.length - 1
114 const branch = isBranched ? `${stem}${isLast ? '└─ ' : '├─ '}` : stem
115 const below = isBranched ? `${stem}${isLast ? ' ' : '│ '}` : stem
116 const hasChildren = agent.agentId !== undefined && childrenOf(agent.agentId).length > 0
117 nodes.push({ agent, branch, stem: below, isMain: false, hasChildren })
118 if (agent.agentId !== undefined) {
119 grow(agent.agentId, below, true)
120 }
121 })
122 }
123
124 if (main !== undefined) {
125 nodes.push({ agent: main, branch: '', stem: '', isMain: true, hasChildren: childrenOf(undefined).length > 0 })
126 }
127 grow(undefined, '', main !== undefined)
128
129 return nodes
130}
131
132// The lines an agent's row carries under it, each led by the stem: its time
133// and tools on a narrow pane, what its running call is on (after ⎿, as
134// Claude Code nests a call's lines), its opened task.
135export function agentLines(view: View, node: AgentNode, open: readonly string[], isTight = false) {
136 const { agent } = node
137 const under = underOf(node)
138 const width = view.columns - cells(under)
139 const lines: string[] = []
140
141 if (view.columns < NARROW) {
142 lines.push(fit(tallyOf(view, node), width))
143 }
144 // Short of room, the ⎿ lines go, so more running agents fit.
145 if (agent.state === 'running' && agent.tool !== undefined && !isTight) {
146 const tool = toolName(agent.tool)
147 const room = width - cells(tool) - 5
148 const on = agent.doing === undefined ? '' : /^[/~]/.test(agent.doing) ? fitStart(agent.doing, room) : fit(agent.doing, room)
149 lines.push(on === '' ? `⎿ ${tool}` : `⎿ ${tool} · ${on}`)
150 }
151 const isOpen = !node.isMain && open.includes(`agent:${agent.id}`)
152 const task = isOpen
153 ? clip(
154 agent.task.split('\n').flatMap(paragraph => wrap(paragraph, width)),
155 TASK_LINES,
156 )
157 : []
158
159 return { under, status: lines, task: isOpen && task.length === 0 ? ['No task text.'] : task }
160}
161
162// What leads the lines under an agent's row: its stem, carrying on down to
163// its children when it has any.
164export function underOf(node: AgentNode): string {
165 return `${node.stem}${node.hasChildren ? '│ ' : ' '}`
166}
167
168// What comes before an agent's title: its branch, its toggle, its mark.
169export function leadOf(node: AgentNode): number {
170 return cells(node.branch) + (node.isMain ? 0 : 2) + 2
171}
172
173// Its time, its tool calls and its context: "45k tokens", and for the main
174// conversation "74% context", while there is room for the words beside a
175// title of MIN_TITLE cells (or on a line of its own on a narrow pane);
176// "45k tok" and "74%" when there isn't.
177export function tallyOf(view: View, node: AgentNode): string {
178 const words = agentTally(view, node.agent, true)
179 const room = view.columns < NARROW ? view.columns - cells(underOf(node)) : view.columns - leadOf(node) - MIN_TITLE - 1
180
181 return cells(words) <= room ? words : agentTally(view, node.agent, false)
182}
183
184export function agentTally(view: View, agent: Agent, isInWords: boolean): string {
185 const time = agent.endedAt === undefined ? minutes(view.at - agent.startedAt) : duration(agent.endedAt - agent.startedAt)
186 const parts = [time, `${agent.tools} ${agent.tools === 1 ? 'tool' : 'tools'}`]
187 if (agent.tokens !== undefined) {
188 parts.push(`${tokenNumber(agent.tokens)} ${isInWords ? 'tokens' : 'tok'}`)
189 }
190 if (agent.percent !== undefined) {
191 parts.push(isInWords ? `${Math.round(agent.percent)}% context` : `${Math.round(agent.percent)}%`)
192 }
193
194 return parts.join(' · ')
195}
196
197export function nodeRows(view: View, node: AgentNode, open: readonly string[], isTight: boolean): number {
198 const { status, task } = agentLines(view, node, open, isTight)
199
200 return 1 + status.length + task.length
201}
202
203// The agents in `room` rows. Past the room, finished agents with nothing
204// under them go first, oldest first; then the rest are counted on one line.
205export function agentSection(view: View, main: Agent | undefined, team: Agent[], open: readonly string[], room: number) {
206 const { Box, Button, Text } = view.ui
207 const isNarrow = view.columns < NARROW
208 let shown = shownAgents(team)
209 let nodes = agentTree(main, shown)
210 let isTight = false
211 const rowsOf = (all: AgentNode[]) => all.reduce((sum, node) => sum + nodeRows(view, node, open, isTight), 0)
212 // The rows for `count` agents of the `total` on show: the heading takes
213 // one, and once an agent is left out, so does "+N more".
214 const total = nodes.length
215 const limit = (count: number) => room - 1 - (count < total ? 1 : 0)
216
217 // Short of room: finished agents with nothing under them go first, oldest
218 // first; then the ⎿ lines; only then running agents.
219 while (rowsOf(nodes) > limit(nodes.length)) {
220 const leaves = nodes.filter(node => !node.isMain && node.agent.state !== 'running' && !node.hasChildren)
221 const oldest = leaves.toSorted((a, b) => (a.agent.endedAt ?? 0) - (b.agent.endedAt ?? 0))[0]
222 if (oldest === undefined) {
223 break
224 }
225 shown = shown.filter(a => a.id !== oldest.agent.id)
226 nodes = agentTree(main, shown)
227 }
228 isTight = rowsOf(nodes) > limit(nodes.length)
229
230 let kept = nodes
231 if (rowsOf(kept) > limit(kept.length)) {
232 kept = []
233 for (const node of nodes) {
234 if (rowsOf([...kept, node]) > limit(kept.length + 1)) {
235 break
236 }
237 kept.push(node)
238 }
239 }
240 const hidden = total - kept.length
241 const running = (main?.state === 'running' ? 1 : 0) + team.filter(a => a.state === 'running').length
242
243 return {
244 rows: 1 + Math.max(1, rowsOf(kept) + (hidden > 0 ? 1 : 0)),
245 toggles: kept.flatMap(node => (node.isMain ? [] : [{ key: `toggle-agent-${node.agent.id}`, target: `agent:${node.agent.id}` }])),
246 node: (
247 <Box flexDirection="column">
248 {heading(view, 'agents-heading', 'Agents', running === 0 ? '' : `${running} running`)}
249 {kept.length === 0 && hidden === 0 && (
250 <Box key="no-agents">
251 <Text dimColor>No agents running.</Text>
252 </Box>
253 )}
254 {kept.map(node => {
255 const { agent: a, branch, isMain } = node
256 const { under, status, task } = agentLines(view, node, open, isTight)
257 const isRunning = a.state === 'running'
258 const { mark, color } = a.state === 'running' ? runningMark(view.at) : AGENT_MARK[a.state]
259 const tally = tallyOf(view, node)
260 const title = fit(`${a.type} ${a.description}`, view.columns - leadOf(node) - (isNarrow ? 0 : cells(tally) + 1))
261 const type = title.startsWith(a.type) ? a.type : title
262
263 return (
264 <Box key={`agent-${a.id}`} flexDirection="column">
265 <Box flexDirection="row">
266 {branch !== '' && <Text dimColor>{branch}</Text>}
267 {!isMain && (
268 <Button key={`toggle-agent-${a.id}`} plain label={task.length > 0 ? '▾' : '▸'} onPress={() => view.toggle(`agent:${a.id}`)} />
269 )}
270 {!isMain && <Text> </Text>}
271 <Box flexGrow={1}>
272 <Text dimColor={!isRunning}>
273 <Text color={color}>{mark}</Text>{' '}
274 <Text inverse={view.cursor === `toggle-agent-${a.id}`}>
275 <Text bold>{type}</Text>
276 {title.slice(type.length)}
277 </Text>
278 </Text>
279 </Box>
280 {!isNarrow && <Text dimColor> {tally}</Text>}
281 </Box>
282 {status.map(line => (
283 <Text dimColor>
284 {under}
285 {line}
286 </Text>
287 ))}
288 {task.length > 0 && (
289 <Box key={`task-${a.id}`} flexDirection="column">
290 {task.map(line => (
291 <Text dimColor>
292 {under}
293 {line}
294 </Text>
295 ))}
296 </Box>
297 )}
298 </Box>
299 )
300 })}
301 {hidden > 0 && (
302 <Box key="more-agents">
303 <Text dimColor>+{hidden} more</Text>
304 </Box>
305 )}
306 </Box>
307 ),
308 }
309}
310hooks/drill.tsx 362 lines1// Going into an agent or a session with l: what it is doing, read from its
2// transcript, drawn alone in the pane with a field to write to it.
3
4import type { Activity, Agent, Feed, Opened, Phase, RemoteAgent, RemoteList, Session, SharedAgent } from '../types'
5import { AGENT_MARK, agentTally, runningMark } from './agents'
6import { isReachable, isRemoteActive, placeOf, remoteSection, remoteTally, sessionMark, stateText } from './sessions'
7import { cells, clip, describeCall, fit, fitStart, isRecord, spin, toolName, wrap } from './text'
8import { heading, keyButton, keyWidth, wrappedRows } from './view'
9import type { View } from './view'
10
11export const TAIL_BYTES = 131_072
12export const FEED_KEPT = 200
13
14// The most lines a drilled-into agent's task takes above its activity.
15export const TASK_SHOWN = 4
16
17// The items in a transcript's lines: the person's prompts (or a subagent's
18// task), Claude's text and its calls. A line cut by the tail, a line Claude
19// Code added itself, text it wrapped in a tag (a command, a reminder, a
20// notification) and, in a session's own transcript, a subagent's line are
21// left out.
22export function parseTranscript(text: string, isSubagent = false): Activity[] {
23 return text.split('\n').flatMap(line => {
24 let entry: unknown
25 try {
26 entry = JSON.parse(line)
27 } catch {
28 return []
29 }
30 if (!isRecord(entry)) {
31 return []
32 }
33
34 const { type, message, isSidechain, isMeta } = entry
35 if ((isSidechain === true && !isSubagent) || isMeta === true || (type !== 'user' && type !== 'assistant')) {
36 return []
37 }
38 const content = isRecord(message) ? message.content : undefined
39 const blocks: unknown[] = typeof content === 'string' ? [{ type: 'text', text: content }] : Array.isArray(content) ? content : []
40
41 return blocks.flatMap((block): Activity[] => {
42 const fields = isRecord(block) ? block : {}
43 if (fields.type === 'text' && typeof fields.text === 'string' && isShownText(fields.text)) {
44 return [{ kind: type === 'user' ? 'asked' : 'said', text: fields.text.trim() }]
45 }
46 if (type === 'assistant' && fields.type === 'tool_use' && typeof fields.name === 'string') {
47 return [{ kind: 'call', text: callLine(fields.name, fields.input) }]
48 }
49 return []
50 })
51 })
52}
53
54export function isShownText(text: string): boolean {
55 const trimmed = text.trim()
56
57 return trimmed !== '' && !trimmed.startsWith('<')
58}
59
60// "Read · /a/b.ts": the tool, and what the call is on when it says.
61export function callLine(tool: string, input: unknown): string {
62 const on = describeCall(typeof input === 'object' && input !== null ? input : {})
63
64 return on === undefined ? toolName(tool) : `${toolName(tool)} · ${on}`
65}
66
67export type Drill = {
68 into: Opened
69 // Where h goes: the overview, or the session the pane went into first.
70 backTo: string
71 // What the pane is in, each undefined once it is gone: one of this
72 // session's agents, another session (also the one of an agent of it), or
73 // another session's agent.
74 agent: Agent | undefined
75 session: Session | undefined
76 // The session's state as glimt watched it.
77 phase: Phase | undefined
78 remoteAgent: RemoteAgent | undefined
79 // The session's agents as read, what its glimt shares, and whether it
80 // runs one at all (to take a new name).
81 remote: RemoteList | undefined
82 shared: SharedAgent[]
83 isSharing: boolean
84 tasks: Record<string, string>
85 open: readonly string[]
86 feed: Feed | null
87 asked: string | null
88 isFocused: boolean
89 // The rows docked; undefined inline, where nothing is cut.
90 room: number | undefined
91 act: {
92 back: () => void
93 into: () => void
94 down: () => void
95 up: () => void
96 open: () => void
97 message: () => void
98 send: (to: { sessionId: string } | { agentId: string }, name: string, text: string) => void
99 rename: () => void
100 attach: () => void
101 stop: () => void
102 answer: (isYes: boolean) => void
103 help: () => void
104 }
105}
106
107// The most activity items drawn inline, where the pane has no height to fill.
108export const FEED_INLINE = 20
109
110// What l went into, alone in the pane as Claude Code's agent view shows a
111// session: the way back, the agent or the session with what it does now, a
112// session's agents (j, k, o and l work on them), an agent's task, its latest
113// activity (newest last, as much as the rows left hold), a field for a
114// message to it, and the keys while the pane holds the keyboard.
115export function drillSection(view: View, drill: Drill) {
116 const { Box, Button, Text } = view.ui
117 const { Input } = view
118 const { into, agent, session, remoteAgent, shared, feed: got, asked, act } = drill
119 const width = view.columns
120 const isGone = into.kind === 'agent' ? agent === undefined : into.kind === 'session' ? session === undefined : remoteAgent === undefined
121
122 // The title, and the dim lines under it: what it does now, and where.
123 let title: { mark: string; color: string; name: string } | undefined
124 let facts: string[] = []
125 let taskText: string | undefined
126 if (into.kind === 'agent' && agent !== undefined) {
127 const { mark, color } = agent.state === 'running' ? runningMark(view.at) : AGENT_MARK[agent.state]
128 title = { mark, color, name: `${agent.type} ${agent.description}` }
129 facts = [agentTally(view, agent, true)]
130 if (agent.state === 'running' && agent.tool !== undefined) {
131 const tool = toolName(agent.tool)
132 const room = width - 2 - cells(tool) - 5
133 const on = agent.doing === undefined ? '' : /^[/~]/.test(agent.doing) ? fitStart(agent.doing, room) : fit(agent.doing, room)
134 facts.push(on === '' ? `⎿ ${tool}` : `⎿ ${tool} · ${on}`)
135 }
136 taskText = agent.task
137 } else if (into.kind === 'session' && session !== undefined) {
138 const { mark, color, word } = sessionMark(session, view.at)
139 title = { mark, color, name: session.name }
140 facts = [stateText(word, drill.phase, view.at), fitStart(`⎿ ${placeOf(session)}`, width - 2)]
141 } else if (into.kind === 'remote' && remoteAgent !== undefined) {
142 const share = shared.find(one => one.id === remoteAgent.id)
143 const isActive = isRemoteActive(view, remoteAgent, share)
144 title = { mark: isActive ? spin(view.at) : '○', color: isActive ? 'claude' : 'subtle', name: `${remoteAgent.type} ${remoteAgent.description}` }
145 facts = [remoteTally(view, remoteAgent, share), fit(`⎿ in ${session?.name ?? 'another session'}`, width - 2)]
146 taskText = drill.tasks[`${into.sessionId}:${remoteAgent.id}`] ?? share?.task
147 }
148 const task =
149 into.kind === 'session' || isGone
150 ? []
151 : taskText === undefined
152 ? ['Loading…']
153 : clip(
154 taskText.split('\n').flatMap(paragraph => wrap(paragraph, width - 2)),
155 TASK_SHOWN,
156 )
157 const crew =
158 into.kind === 'session' && session !== undefined
159 ? remoteSection(view, session, { list: drill.remote, shared, tasks: drill.tasks, open: drill.open })
160 : undefined
161 const isAsking = into.kind === 'session' && session !== undefined && asked === session.sessionId
162
163 const canMessage =
164 Input !== undefined &&
165 !isGone &&
166 (into.kind === 'session' || (into.kind === 'agent' && agent?.state === 'running' && agent.agentId !== undefined))
167 const canWalk = crew !== undefined && crew.toggles.length > 0
168 const canReach = into.kind === 'session' && session !== undefined && isReachable(session)
169 const canRename = into.kind === 'session' && session !== undefined && drill.isSharing
170 // The keys, as the overview pairs them: j, k, l and o only where there are
171 // agents to walk, h always; each with the cells it takes, to count the
172 // rows the row wraps to.
173 const pair = keyWidth('↓') + 1 + keyWidth('↑')
174 const keyItems = drill.isFocused
175 ? [
176 ...(canWalk
177 ? [
178 {
179 width: pair,
180 node: (
181 <Box key="key-walk" flexDirection="row" columnGap={1}>
182 {keyButton(view, 'down', 'j', '↓', act.down)}
183 {keyButton(view, 'up', 'k', '↑', act.up)}
184 </Box>
185 ),
186 },
187 {
188 width: pair,
189 node: (
190 <Box key="key-go" flexDirection="row" columnGap={1}>
191 {keyButton(view, 'back', 'h', '←', act.back)}
192 {keyButton(view, 'into', 'l', '→', act.into)}
193 </Box>
194 ),
195 },
196 { width: keyWidth('open'), node: keyButton(view, 'open', 'o', 'open', act.open) },
197 ]
198 : [{ width: keyWidth('← back'), node: keyButton(view, 'back', 'h', '← back', act.back) }]),
199 ...(canMessage ? [{ width: keyWidth('message'), node: keyButton(view, 'message', 'm', 'message', act.message) }] : []),
200 ...(canRename ? [{ width: keyWidth('rename'), node: keyButton(view, 'rename', 'r', 'rename', act.rename) }] : []),
201 ...(canReach
202 ? [
203 { width: keyWidth('attach'), node: keyButton(view, 'attach', 'a', 'attach', act.attach) },
204 { width: keyWidth('stop'), node: keyButton(view, 'stop', 'x', 'stop', act.stop) },
205 ]
206 : []),
207 { width: keyWidth('keys'), node: keyButton(view, 'help', 'i', 'keys', act.help) },
208 ]
209 : undefined
210 const keyRows =
211 keyItems === undefined
212 ? 0
213 : 1 +
214 wrappedRows(
215 keyItems.map(item => item.width),
216 width,
217 3,
218 )
219
220 // The activity takes the rows the rest leaves: the way back and a blank,
221 // the title and its lines, a session's agents or an agent's task under a
222 // heading, the activity's heading, the field and the keys, each with a
223 // blank above.
224 const fixed =
225 2 +
226 (isGone ? 1 : 1 + facts.length + (isAsking ? 1 : 0)) +
227 (crew === undefined ? 0 : 2 + crew.rows) +
228 (task.length === 0 ? 0 : 2 + task.length) +
229 2 +
230 (canMessage ? 2 : 0) +
231 keyRows
232 const room = drill.room === undefined ? Infinity : Math.max(3, drill.room - fixed)
233 const items = got === null ? [] : drill.room === undefined ? got.items.slice(-FEED_INLINE) : got.items
234 const shown = lastItems(items, width, room)
235
236 return {
237 toggles: crew?.toggles ?? [],
238 node: (
239 <Box key="drill" flexDirection="column">
240 <Button key="back" plain dimColor label={fit(`← ${drill.backTo}`, width)} onPress={act.back} />
241 <Box key="drill-title" flexDirection="column" marginTop={1}>
242 {title === undefined ? (
243 <Text dimColor>{into.kind === 'session' ? 'This session is no longer listed.' : 'This agent is no longer listed.'}</Text>
244 ) : (
245 <Text>
246 <Text color={title.color}>{title.mark}</Text> <Text bold>{fit(title.name, width - 2)}</Text>
247 </Text>
248 )}
249 {facts.map(line => (
250 <Text dimColor>
251 {' '}
252 {line}
253 </Text>
254 ))}
255 {isAsking && session !== undefined && (
256 <Box key={`stop-${session.sessionId}`} flexDirection="row" columnGap={2} paddingLeft={2}>
257 <Text color="warning">{fit(`Stop ${session.name}?`, width - 20)}</Text>
258 <Button key="stop-yes" plain hotkey="y" label="yes" onPress={() => act.answer(true)} />
259 <Button key="stop-no" plain hotkey="n" label="no" onPress={() => act.answer(false)} />
260 </Box>
261 )}
262 </Box>
263 {crew !== undefined && (
264 <Box key="drill-agents" flexDirection="column" marginTop={1}>
265 {heading(
266 view,
267 'drill-agents-heading',
268 'Agents',
269 drill.remote === undefined || drill.remote.total === 0 ? '' : String(drill.remote.total),
270 )}
271 {crew.node}
272 </Box>
273 )}
274 {task.length > 0 && (
275 <Box key="drill-task" flexDirection="column" marginTop={1}>
276 {heading(view, 'task-heading', 'Task', '')}
277 {task.map(line => (
278 <Text dimColor>
279 {' '}
280 {line}
281 </Text>
282 ))}
283 </Box>
284 )}
285 <Box flexDirection="column" marginTop={1}>
286 {heading(view, 'activity-heading', 'Activity', '')}
287 <Box key="activity" flexDirection="column">
288 {got === null ? (
289 <Text dimColor>Reading…</Text>
290 ) : got.error !== undefined ? (
291 <Text color="error">{fit(got.error, width)}</Text>
292 ) : shown.length === 0 ? (
293 <Text dimColor>Nothing yet.</Text>
294 ) : (
295 shown.flatMap(({ item, lines }) =>
296 lines.map(line => (
297 <Text bold={item.kind === 'asked'} dimColor={item.kind === 'call'}>
298 {line}
299 </Text>
300 )),
301 )
302 )}
303 </Box>
304 </Box>
305 {canMessage && Input !== undefined && (
306 <Box marginTop={1}>
307 <Input
308 key="message-field"
309 label="Message"
310 placeholder="Type and press Enter"
311 value=""
312 submitLabel="send"
313 onSubmit={text =>
314 agent?.agentId !== undefined
315 ? act.send({ agentId: agent.agentId }, `${agent.type} ${agent.description}`, text)
316 : session !== undefined && act.send({ sessionId: session.sessionId }, session.name, text)
317 }
318 />
319 </Box>
320 )}
321 {keyItems !== undefined && (
322 <Box key="keys" flexDirection="row" flexWrap="wrap" columnGap={3} marginTop={1}>
323 {keyItems.map(item => item.node)}
324 </Box>
325 )}
326 </Box>
327 ),
328 }
329}
330
331// An activity item's lines: a call on one, dim, under ⎿ as Claude Code nests
332// a call; what was asked (›) on up to two and what Claude said (●) on up to
333// three, wrapped.
334export function itemLines(item: Activity, width: number): string[] {
335 if (item.kind === 'call') {
336 return [fit(` ⎿ ${item.text}`, width)]
337 }
338
339 const lead = item.kind === 'asked' ? '› ' : '● '
340 return wrap(item.text, width - 2, item.kind === 'asked' ? 2 : 3).map((line, i) => `${i === 0 ? lead : ' '}${line}`)
341}
342
343// The newest items that fit in `room` rows, oldest first; the newest alone
344// cut to the room when even it does not fit.
345export function lastItems(items: Activity[], width: number, room: number): { item: Activity; lines: string[] }[] {
346 const shown: { item: Activity; lines: string[] }[] = []
347 let used = 0
348 for (const item of items.toReversed()) {
349 const lines = itemLines(item, width)
350 if (used + lines.length > room) {
351 if (shown.length === 0) {
352 shown.push({ item, lines: lines.slice(0, room) })
353 }
354 break
355 }
356 shown.unshift({ item, lines })
357 used += lines.length
358 }
359
360 return shown
361}
362hooks/keys.tsx 192 lines1// The keys and the forms: the key row, the key list i opens, this session's
2// row, and the form n, s and r open.
3
4import type { Composer, Session } from '../types'
5import { runtime } from './state'
6import { cells, fit, fitStart } from './text'
7import { keyButton, keyWidth, wrappedRows } from './view'
8import type { View } from './view'
9
10export type Keys = Record<'down' | 'up' | 'into' | 'back' | 'open' | 'spawn' | 'help' | 'rename' | 'clear' | 'attach' | 'stop', () => void>
11
12// What the cursor is on allows: o on any row, c on this session, r on this
13// session or one whose glimt shares, a and x on a background session.
14export type KeyContext = { canOpen: boolean; canClear: boolean; canRename: boolean; canReach: boolean }
15
16// The keys while the pane holds the keyboard: moving, n, and i for the full
17// list always; the rest only where the cursor's row allows them, so the row
18// stays short. A key works only while drawn, so s and q live in the list.
19// Paired keys sit close and groups apart. Up, Down, Tab and Enter work as
20// they do everywhere.
21export function keySection(view: View, can: KeyContext, keys: Keys) {
22 const { Box } = view.ui
23 const key = (name: string, hotkey: string, label: string, press: () => void) => keyButton(view, name, hotkey, label, press)
24 const pair = keyWidth('↓') + 1 + keyWidth('↑')
25 const labels = [
26 can.canOpen && 'open',
27 can.canRename && 'rename',
28 can.canClear && 'clear',
29 can.canReach && 'attach',
30 can.canReach && 'stop',
31 'new',
32 'keys',
33 ].filter((label): label is string => label !== false)
34
35 return {
36 rows: wrappedRows([pair, pair, ...labels.map(keyWidth)], view.columns, 3),
37 node: (
38 <Box key="keys" flexDirection="row" flexWrap="wrap" columnGap={3}>
39 <Box flexDirection="row" columnGap={1}>
40 {key('down', 'j', '↓', keys.down)}
41 {key('up', 'k', '↑', keys.up)}
42 </Box>
43 <Box flexDirection="row" columnGap={1}>
44 {key('back', 'h', '←', keys.back)}
45 {key('into', 'l', '→', keys.into)}
46 </Box>
47 {can.canOpen && key('open', 'o', 'open', keys.open)}
48 {can.canRename && key('rename', 'r', 'rename', keys.rename)}
49 {can.canClear && key('clear', 'c', 'clear', keys.clear)}
50 {can.canReach && key('attach', 'a', 'attach', keys.attach)}
51 {can.canReach && key('stop', 'x', 'stop', keys.stop)}
52 {key('spawn', 'n', 'new', keys.spawn)}
53 {key('help', 'i', 'keys', keys.help)}
54 </Box>
55 ),
56 }
57}
58
59// What each key does, for the list i opens; the keys that start something or
60// close the pane are pressable there.
61export const KEY_LIST: readonly (readonly [string, string])[] = [
62 ['j k', 'move down and up (↓ ↑ and Tab too)'],
63 ['l', 'go into an agent or a session'],
64 ['h', 'back out'],
65 ['o', 'open or close the row'],
66 ['r', 'rename this session, or one running glimt'],
67 ['c', 'clear this conversation'],
68 ['m', 'write to the agent or session you are in'],
69 ['a', "copy a background session's attach command"],
70 ['x', 'stop a background session'],
71]
72
73export type HelpActions = { spawn: () => void; session: () => void; close: () => void; back: () => void }
74
75// i: every key and what it does, under the name and where it lives, in
76// place of the pane's body; n, s and q work from here, i goes back.
77export function helpSection(view: View, act: HelpActions) {
78 const { Box, Button, Text } = view.ui
79 const line = (keys: string, what: string) => (
80 <Text>
81 <Text color="claude">{keys}</Text>
82 {fit(`: ${what}`, view.columns - cells(keys))}
83 </Text>
84 )
85
86 return (
87 <Box key="help" flexDirection="column">
88 <Text dimColor>{fit('glimt · github.com/mmedum/glimt', view.columns)}</Text>
89 <Box key="help-keys" flexDirection="column" marginTop={1}>
90 {KEY_LIST.map(([keys, what]) => line(keys, what))}
91 <Button key="help-spawn" plain hotkey="n" label="new agent here" onPress={act.spawn} />
92 <Button key="help-session" plain hotkey="s" label="new background session" onPress={act.session} />
93 <Button key="help-close" plain hotkey="q" label="close the pane" onPress={act.close} />
94 </Box>
95 <Box key="keys" marginTop={1}>
96 {keyButton(view, 'help', 'i', 'back', act.back)}
97 </Box>
98 </Box>
99 )
100}
101
102export type SelfState = { session: Session | undefined; isOpen: boolean; isClearing: boolean; answer: (isYes: boolean) => void }
103
104// This session at the top: its name, opened its folder and id, and after c
105// the question whether to clear the conversation.
106export function selfSection(view: View, state: SelfState) {
107 const { Box, Button, Text } = view.ui
108 const { session, isOpen, isClearing } = state
109 // Until the session list names it, the row just says what it is.
110 const name = session?.name
111 const where =
112 session === undefined
113 ? ''
114 : runtime.home !== '' && session.cwd.startsWith(runtime.home)
115 ? `~${session.cwd.slice(runtime.home.length)}`
116 : session.cwd
117
118 return {
119 rows: 1 + (isOpen && session !== undefined ? 1 : 0) + (isClearing ? 1 : 0),
120 node: (
121 <Box key="self" flexDirection="column">
122 <Box flexDirection="row">
123 <Button key="toggle-self" plain label={isOpen ? '▾' : '▸'} onPress={() => view.toggle('self')} />
124 <Text>
125 {' '}
126 <Text bold inverse={view.cursor === 'toggle-self'}>
127 {fit(name ?? 'this session', view.columns - 16)}
128 </Text>
129 {name !== undefined && <Text dimColor>{' this session'}</Text>}
130 </Text>
131 </Box>
132 {isOpen && session !== undefined && (
133 <Text dimColor>
134 {' ⎿ '}
135 {fitStart(`${where} · ${session.id ?? session.sessionId}`, view.columns - 4)}
136 </Text>
137 )}
138 {isClearing && (
139 <Box key="clear-ask" flexDirection="row" columnGap={2} paddingLeft={2}>
140 <Text color="warning">Clear this conversation?</Text>
141 <Button key="clear-yes" plain hotkey="y" label="yes" onPress={() => state.answer(true)} />
142 <Button key="clear-no" plain hotkey="n" label="no" onPress={() => state.answer(false)} />
143 </Box>
144 )}
145 </Box>
146 ),
147 }
148}
149
150export type ComposerActions = {
151 spawn: (task: string, where: 'here' | 'session') => void
152 rename: (sessionId: string, name: string) => void
153 cancel: () => void
154}
155
156// The rows the form takes, the blank above it included.
157export const FORM_ROWS = 3
158
159// The form n, s or r opens, where the keys were, so nothing else moves: a
160// task for a new agent here (n) or in a new background session (s), or a new
161// name for a session (r). Its label says which, so nothing is left to choose.
162// Its field holds the keyboard: Enter starts, Enter on an empty field or
163// leaving it (Tab, or a click on cancel) closes it.
164export function composerSection(view: View, form: Composer, list: Session[], act: ComposerActions) {
165 const { Box, Button, Text } = view.ui
166 const { Input } = view
167 if (Input === undefined) {
168 return <Text dimColor>Typing here needs the terminal or the desktop app.</Text>
169 }
170
171 const isSpawn = form.kind === 'spawn'
172 const name = isSpawn ? '' : (list.find(s => s.sessionId === form.sessionId)?.name ?? 'the session')
173 const verb = isSpawn ? 'start' : 'rename'
174 return (
175 <Box key="composer" flexDirection="column">
176 <Input
177 key="composer-field"
178 label={isSpawn ? (form.where === 'here' ? 'New agent' : 'New session') : `Rename ${fit(name, 24)}`}
179 placeholder={isSpawn ? 'What should it do?' : 'New name'}
180 value=""
181 submitLabel={verb}
182 autoFocus
183 onSubmit={text => (isSpawn ? act.spawn(text, form.where) : act.rename(form.sessionId, text))}
184 />
185 <Box flexDirection="row" columnGap={3}>
186 <Text dimColor>{`↵ ${verb}`}</Text>
187 <Button key="composer-cancel" plain dimColor label="tab cancel" onPress={act.cancel} />
188 </Box>
189 </Box>
190 )
191}
192hooks/plan.tsx 286 lines1// The plan: Claude's task list as steps and sub-steps, and what is in
2// progress now.
3
4import type { Plan, Step, StepStatus } from '../types'
5import { clip, duration, fit, spin, wrap } from './text'
6import { heading } from './view'
7import type { View } from './view'
8
9// How far a sub-step is indented under its step, per level.
10export const INDENT = 2
11
12export type StepUpdate = {
13 taskId: string
14 subject?: string | undefined
15 description?: string | undefined
16 activeForm?: string | undefined
17 status?: StepStatus | 'deleted' | undefined
18 // A new parent, or null to make it a step of its own.
19 parent?: string | null | undefined
20}
21
22// A line of the plan as drawn: a step at its depth under the top level, with
23// its description when opened, or a line standing for steps folded or cut.
24export type StepLine = { kind: 'step'; step: Step; depth: number; detail: string[] }
25export type PlanLine = StepLine | { kind: 'done'; count: number } | { kind: 'more'; count: number }
26
27// The task a sub-step belongs to, from its metadata.parent; null when the
28// update clears it, undefined when the call does not say.
29export function parentFrom(metadata: unknown): string | null | undefined {
30 if (typeof metadata !== 'object' || metadata === null || !('parent' in metadata)) {
31 return undefined
32 }
33
34 const parent: unknown = metadata.parent
35 if (parent === null) {
36 return null
37 }
38
39 return typeof parent === 'string' || typeof parent === 'number' ? String(parent) : undefined
40}
41
42// A step's time in progress starts when it goes in progress, at `at`, and
43// is dropped when it leaves.
44export function applyUpdate(list: Step[], change: StepUpdate, at: number): Step[] {
45 if (change.status === 'deleted') {
46 return list.filter(step => step.id !== change.taskId)
47 }
48
49 const status = change.status
50 const known = list.some(step => step.id === change.taskId)
51 const updated = list.map(step => {
52 if (step.id !== change.taskId) {
53 return step
54 }
55
56 const next = status ?? step.status
57 return {
58 ...step,
59 subject: change.subject ?? step.subject,
60 description: change.description ?? step.description,
61 activeForm: change.activeForm ?? step.activeForm,
62 status: next,
63 startedAt: next !== 'in_progress' ? undefined : step.status === 'in_progress' ? step.startedAt : at,
64 parentId: change.parent === undefined ? step.parentId : (change.parent ?? undefined),
65 }
66 })
67
68 // A task made before this pane was watching: show it under what is known.
69 return known
70 ? updated
71 : [
72 ...updated,
73 {
74 id: change.taskId,
75 subject: change.subject ?? `Task ${change.taskId}`,
76 description: change.description,
77 activeForm: change.activeForm,
78 status: status ?? 'pending',
79 startedAt: status === 'in_progress' ? at : undefined,
80 parentId: change.parent ?? undefined,
81 },
82 ]
83}
84
85// The plan as units to draw: each top-level step with the steps under it,
86// depth first. A step whose parent is gone, or whose parents loop back to
87// it, stands at the top level.
88export function planUnits(list: Step[], open: readonly string[], columns: number): StepLine[][] {
89 const ids = new Set(list.map(step => step.id))
90 const parentOf = (step: Step) => (step.parentId !== undefined && ids.has(step.parentId) ? step.parentId : undefined)
91 const seen = new Set<string>()
92 const lineOf = (step: Step, depth: number): StepLine => ({
93 kind: 'step',
94 step,
95 depth,
96 detail: open.includes(step.id) ? detailLines(step, columns - 4 - depth * INDENT) : [],
97 })
98 const under = (parent: string, depth: number): StepLine[] =>
99 list
100 .filter(step => parentOf(step) === parent && !seen.has(step.id))
101 .flatMap(step => {
102 seen.add(step.id)
103 return [lineOf(step, depth), ...under(step.id, depth + 1)]
104 })
105 const unitOf = (step: Step): StepLine[] => {
106 seen.add(step.id)
107 return [lineOf(step, 0), ...under(step.id, 1)]
108 }
109
110 const units = list.filter(step => parentOf(step) === undefined).map(unitOf)
111 for (const step of list) {
112 if (!seen.has(step.id)) {
113 units.push(unitOf(step))
114 }
115 }
116
117 return units
118}
119
120// The plan's lines in `room` rows. A step and its sub-steps move together:
121// finished ones fold into one line, oldest first, until the rest fits; then
122// the tail is cut and its steps counted, so the step in progress stays in view.
123export function planLines(units: StepLine[][], room: number): PlanLine[] {
124 const all = units.flat()
125 if (rowCount(all) <= room) {
126 return all
127 }
128
129 const finished = units.filter(unit => unit.every(line => line.step.status === 'completed'))
130 let rest = units
131 let shown: PlanLine[] = all
132 for (let folded = 1; folded <= finished.length && rowCount(shown) > room; folded += 1) {
133 const hidden = new Set(finished.slice(0, folded))
134 rest = units.filter(unit => !hidden.has(unit))
135 shown = [{ kind: 'done', count: finished.slice(0, folded).flat().length }, ...rest.flat()]
136 }
137 if (rowCount(shown) <= room) {
138 return shown
139 }
140
141 const head = shown.filter(line => line.kind === 'done')
142 for (let kept = rest.length - 1; kept >= 1; kept -= 1) {
143 const cut: PlanLine[] = [...head, ...rest.slice(0, kept).flat(), { kind: 'more', count: rest.slice(kept).flat().length }]
144 if (rowCount(cut) <= room) {
145 return cut
146 }
147 }
148
149 // Room for less than the first step and its sub-steps: its lines from the
150 // top, the last one's description cut to fit.
151 const budget = room - head.length - 1
152 const taken: StepLine[] = []
153 for (const line of rest[0] ?? []) {
154 const used = rowCount(taken)
155 if (used + 1 > budget) {
156 break
157 }
158 taken.push({ ...line, detail: clip(line.detail, budget - used - 1) })
159 }
160 const left = rest.flat().length - taken.length
161
162 return [...head, ...taken, ...(left > 0 ? [{ kind: 'more' as const, count: left }] : [])]
163}
164
165export function rowCount(lines: readonly PlanLine[]): number {
166 return lines.reduce((sum, line) => sum + 1 + (line.kind === 'step' ? line.detail.length : 0), 0)
167}
168
169export function detailLines(step: Step, width: number): string[] {
170 if (step.description === undefined) {
171 return ['Loading…']
172 }
173
174 const lines = step.description.split('\n').flatMap(paragraph => wrap(paragraph, width))
175
176 return lines.length === 0 ? ['No description.'] : lines
177}
178
179// The plan's first heading, or its first line when it has none.
180export function planTitle(text: string): string {
181 const lines = text
182 .split('\n')
183 .map(line => line.trim())
184 .filter(line => line !== '')
185 const title = lines.find(line => line.startsWith('#')) ?? lines[0] ?? 'Plan'
186
187 return title.replace(/^#+\s*/, '')
188}
189
190// The steps in progress, and how long the first has been: a step left in
191// progress while nothing runs shows its age here.
192export function nowSection(view: View, list: Step[]) {
193 const { Box, Text } = view.ui
194 const active = list.filter(step => step.status === 'in_progress')
195 const since = active[0]?.startedAt
196 const mark = view.isTurnRunning ? spin(view.at) : '◐'
197
198 return {
199 rows: 1 + Math.max(1, active.length),
200 node: (
201 <Box flexDirection="column">
202 {heading(view, 'now-heading', 'Now', since === undefined ? '' : duration(view.at - since))}
203 <Box key="now" flexDirection="column">
204 {active.length === 0 ? (
205 <Text dimColor>No step in progress.</Text>
206 ) : (
207 active.map(step => <Text color="claude">{fit(`${mark} ${step.activeForm ?? step.subject}`, view.columns)}</Text>)
208 )}
209 </Box>
210 </Box>
211 ),
212 }
213}
214
215// Claude Code's checklist marks: ☒ done, ☐ to do; ◐ for the step in progress.
216export const STEP_MARK: Record<StepStatus, { mark: string; color: string }> = {
217 completed: { mark: '☒', color: 'success' },
218 in_progress: { mark: '◐', color: 'claude' },
219 pending: { mark: '☐', color: 'subtle' },
220}
221
222// Empty, the plan says why where it knows: Claude has no task list here,
223// which depends on the model.
224export function planSection(view: View, approved: Plan | null, list: Step[], lines: PlanLine[], hasTaskList: boolean | null) {
225 const { Box, Button, Text } = view.ui
226 const done = list.filter(step => step.status === 'completed').length
227
228 return (
229 <Box flexDirection="column">
230 {heading(view, 'plan-heading', approved === null ? 'Plan' : `Plan · ${approved.title}`, list.length === 0 ? '' : `${done}/${list.length}`)}
231 {list.length === 0 && (
232 <Box key="no-steps">
233 <Text dimColor>{hasTaskList === false ? 'No task list in this session.' : 'No steps yet.'}</Text>
234 </Box>
235 )}
236 {lines.map(line => {
237 if (line.kind === 'done') {
238 return (
239 <Box key="folded">
240 <Text dimColor>
241 {' '}
242 <Text color="success">☒</Text> {line.count} done
243 </Text>
244 </Box>
245 )
246 }
247 if (line.kind === 'more') {
248 return (
249 <Box key="more">
250 <Text dimColor>
251 {' '}☐ {line.count} more
252 </Text>
253 </Box>
254 )
255 }
256
257 const { step, detail, depth } = line
258 const indent = ' '.repeat(depth * INDENT)
259 const isOpen = detail.length > 0
260 const { mark, color } = STEP_MARK[step.status]
261
262 return (
263 <Box key={`step-${step.id}`} flexDirection="column">
264 <Box flexDirection="row">
265 {indent !== '' && <Text>{indent}</Text>}
266 <Button key={`toggle-${step.id}`} plain label={isOpen ? '▾' : '▸'} onPress={() => view.toggle(step.id)} />
267 <Text dimColor={step.status === 'completed'} bold={step.status === 'in_progress'}>
268 {' '}
269 <Text color={color}>{mark}</Text>{' '}
270 <Text inverse={view.cursor === `toggle-${step.id}`}>{fit(step.subject, view.columns - 4 - indent.length)}</Text>
271 </Text>
272 </Box>
273 {isOpen && (
274 <Box key={`detail-${step.id}`} flexDirection="column" paddingLeft={4 + indent.length}>
275 {detail.map(text => (
276 <Text dimColor>{text}</Text>
277 ))}
278 </Box>
279 )}
280 </Box>
281 )
282 })}
283 </Box>
284 )
285}
286hooks/sessions.tsx 404 lines1// The other Claude Code sessions on the machine, and their agents: how they
2// are listed, sorted and drawn.
3
4import type { Phase, RemoteAgent, RemoteList, Session, SessionState, Shared, SharedAgent } from '../types'
5import { TASK_LINES } from './agents'
6import { runtime } from './state'
7import { cells, clip, fit, fitStart, isRecord, minutes, spin, toolName, wrap } from './text'
8import { heading } from './view'
9import type { View } from './view'
10
11// Shared agents are written again this often unchanged, so a reader can tell
12// a quiet session from one whose glimt has gone: older than STALE_MS, a
13// share is not shown.
14export const HEARTBEAT_MS = 30_000
15export const STALE_MS = 60_000
16
17// The most of an agent's task another session is shown.
18export const SHARED_TASK = 400
19
20// Another session's subagents: the newest REMOTE_KEPT are read, REMOTE_SHOWN
21// drawn; one whose transcript was written within ACTIVE_MS counts as at
22// work, unless its session's glimt says which run.
23export const REMOTE_KEPT = 20
24export const REMOTE_SHOWN = 6
25export const ACTIVE_MS = 60_000
26
27export function isShared(value: unknown): value is Shared {
28 if (typeof value !== 'object' || value === null) {
29 return false
30 }
31
32 const share = value as { at?: unknown; agents?: unknown }
33 return typeof share.at === 'number' && Array.isArray(share.agents)
34}
35
36// Waiting for its person first, then working, then idle; the list's order
37// within each.
38export function sortSessions(list: Session[]): Session[] {
39 const rank: Record<SessionState, number> = { waiting: 0, working: 1, idle: 2 }
40
41 return list.toSorted((a, b) => rank[sessionState(a)] - rank[sessionState(b)])
42}
43
44// The sessions in what `claude agents --json` printed; an entry with no
45// sessionId is left out.
46export function parseSessions(value: unknown): Session[] {
47 if (!Array.isArray(value)) {
48 throw new Error('not a list')
49 }
50
51 const text = (field: unknown) => (typeof field === 'string' ? field : undefined)
52 return value.flatMap((item: unknown) => {
53 const fields = isRecord(item) ? item : {}
54 const sessionId = text(fields.sessionId)
55 if (sessionId === undefined) {
56 return []
57 }
58
59 return [
60 {
61 sessionId,
62 id: text(fields.id),
63 name: text(fields.name) ?? sessionId.slice(0, 8),
64 cwd: text(fields.cwd) ?? '',
65 kind: text(fields.kind) ?? 'session',
66 status: text(fields.status),
67 state: text(fields.state),
68 waitingFor: text(fields.waitingFor),
69 pid: typeof fields.pid === 'number' ? fields.pid : undefined,
70 startedAt: typeof fields.startedAt === 'number' ? fields.startedAt : 0,
71 },
72 ]
73 })
74}
75
76// A background session can be attached to and stopped from here; one in a
77// terminal of its own is reached in that terminal. The footer offers a and x
78// for the first kind only.
79export function isReachable(s: Session): s is Session & { id: string } {
80 return s.kind === 'background' && s.id !== undefined
81}
82
83export type Listing = {
84 // The other sessions, sorted; null before the first read.
85 list: Session[] | null
86 error: string | null
87 // What their glimts share, by session id.
88 shared: Record<string, Shared>
89 // Their subagents as read, by session id, and the tasks of those opened.
90 remote: Record<string, RemoteList>
91 tasks: Record<string, string>
92 open: readonly string[]
93 asked: string | null
94 answer: (isYes: boolean) => void
95 phases: Record<string, Phase>
96}
97
98// The other Claude Code sessions on the machine in `room` rows, one line
99// each, the ones waiting for their person first, with how long they have
100// been in that state and how many agents their glimt says run; a name in
101// bold stopped since the person last opened it. Opened, a session shows
102// where it runs and its id, then its agents (l goes into the session, or
103// into one of them); after x, it asks whether to stop it. Past the room,
104// the idle ones fold into one line, then the rest are counted.
105export function sessionSection(view: View, listing: Listing, room: number) {
106 const { Box, Button, Text } = view.ui
107 const { list, error, open, asked } = listing
108 const all = list ?? []
109 const waiting = all.filter(isSessionWaiting).length
110 const working = all.filter(isSessionBusy).length
111 const count = [waiting > 0 ? `${waiting} waiting` : '', working > 0 ? `${working} working` : ''].filter(part => part !== '').join(' · ')
112
113 const sharedOf = (s: Session) => listing.shared[s.sessionId]?.agents ?? []
114 const crews = new Map(
115 all.map(s => [
116 s.sessionId,
117 open.includes(`session:${s.sessionId}`)
118 ? remoteSection(view, s, { list: listing.remote[s.sessionId], shared: sharedOf(s), tasks: listing.tasks, open })
119 : undefined,
120 ]),
121 )
122 const rowsOf = (s: Session) => {
123 const crew = crews.get(s.sessionId)
124 return 1 + (crew === undefined ? 0 : 1 + crew.rows) + (asked === s.sessionId ? 1 : 0)
125 }
126 const total = (shown: Session[]) => shown.reduce((sum, s) => sum + rowsOf(s), 0)
127 const budget = room - 1 - (error === null ? 0 : 1)
128 // An opened session stays on show, whatever else folds.
129 const isOpen = (s: Session) => crews.get(s.sessionId) !== undefined
130 let shown = all
131 let idle = 0
132 if (total(shown) > budget) {
133 shown = all.filter(s => sessionState(s) !== 'idle' || isOpen(s))
134 idle = all.length - shown.length
135 }
136 let hidden = 0
137 const extra = () => (idle > 0 ? 1 : 0) + (hidden > 0 ? 1 : 0)
138 while (total(shown) + extra() > budget) {
139 const last = shown.map(isOpen).lastIndexOf(false)
140 if (last === -1) {
141 break
142 }
143 shown = shown.filter((_, i) => i !== last)
144 hidden += 1
145 }
146 const notes = (error === null ? 0 : 1) + (list === null && error === null ? 1 : 0) + (list !== null && list.length === 0 ? 1 : 0)
147
148 return {
149 rows: 1 + Math.max(1, notes + total(shown) + extra()),
150 toggles: shown.flatMap(s => [
151 { key: `toggle-session-${s.sessionId}`, target: `session:${s.sessionId}` },
152 ...(crews.get(s.sessionId)?.toggles ?? []),
153 ]),
154 node: (
155 <Box flexDirection="column">
156 {heading(view, 'sessions-heading', 'Sessions', count)}
157 {error !== null && (
158 <Box key="sessions-error">
159 <Text color="error">{fit(error, view.columns)}</Text>
160 </Box>
161 )}
162 {list === null && error === null && (
163 <Box key="no-sessions">
164 <Text dimColor>Reading the sessions…</Text>
165 </Box>
166 )}
167 {list !== null && list.length === 0 && (
168 <Box key="no-sessions">
169 <Text dimColor>No other sessions.</Text>
170 </Box>
171 )}
172 {shown.map(s => {
173 const crew = crews.get(s.sessionId)
174 const { mark, color, word } = sessionMark(s, view.at)
175 const phase = listing.phases[s.sessionId]
176 const isUnseen = phase?.isUnseen === true
177 const running = sharedOf(s).length
178 const tally = `${stateText(word, phase, view.at)}${running === 0 ? '' : ` · ${running} ${running === 1 ? 'agent' : 'agents'}`}`
179
180 return (
181 <Box key={`session-${s.sessionId}`} flexDirection="column">
182 <Box flexDirection="row">
183 <Button
184 key={`toggle-session-${s.sessionId}`}
185 plain
186 label={crew === undefined ? '▸' : '▾'}
187 onPress={() => view.toggle(`session:${s.sessionId}`)}
188 />
189 <Box flexGrow={1}>
190 <Text dimColor={word === 'idle' && !isUnseen}>
191 {' '}
192 <Text color={color}>{mark}</Text>{' '}
193 <Text bold={isUnseen} inverse={view.cursor === `toggle-session-${s.sessionId}`}>
194 {fit(s.name, view.columns - 4 - cells(tally) - 1)}
195 </Text>
196 </Text>
197 </Box>
198 <Text dimColor> {tally}</Text>
199 </Box>
200 {asked === s.sessionId && (
201 <Box key={`stop-${s.sessionId}`} flexDirection="row" columnGap={2} paddingLeft={4}>
202 <Text color="warning">{fit(`Stop ${s.name}?`, view.columns - 20)}</Text>
203 <Button key="stop-yes" plain hotkey="y" label="yes" onPress={() => listing.answer(true)} />
204 <Button key="stop-no" plain hotkey="n" label="no" onPress={() => listing.answer(false)} />
205 </Box>
206 )}
207 {crew !== undefined && (
208 <Box key={`session-detail-${s.sessionId}`} flexDirection="column">
209 <Text dimColor>
210 {' ⎿ '}
211 {fitStart(placeOf(s), view.columns - 4)}
212 </Text>
213 {crew.node}
214 </Box>
215 )}
216 </Box>
217 )
218 })}
219 {idle > 0 && (
220 <Box key="sessions-idle">
221 <Text dimColor>
222 {' '}○ {idle} idle
223 </Text>
224 </Box>
225 )}
226 {hidden > 0 && (
227 <Box key="sessions-more">
228 <Text dimColor>+{hidden} more</Text>
229 </Box>
230 )}
231 </Box>
232 ),
233 }
234}
235
236// Where a session runs, what kind it is and its id: "~/code · background · a1b2c3d4".
237export function placeOf(s: Session): string {
238 const where = runtime.home !== '' && s.cwd.startsWith(runtime.home) ? `~${s.cwd.slice(runtime.home.length)}` : s.cwd
239
240 return `${where} · ${s.kind === 'background' ? 'background' : 'terminal'} · ${s.id ?? s.sessionId}`
241}
242
243export type RemoteState = { list: RemoteList | undefined; shared: SharedAgent[]; tasks: Record<string, string>; open: readonly string[] }
244
245// Another session's subagents, under it: the newest REMOTE_SHOWN written,
246// then how many are older. One at work spins: its session's glimt says it
247// runs, or its transcript changed within ACTIVE_MS; the rest say when they
248// last wrote. Opened, an agent shows its task.
249export function remoteSection(view: View, s: Session, state: RemoteState) {
250 const { Box, Button, Text } = view.ui
251 const { list, shared, tasks, open } = state
252 const sid = s.sessionId
253 if (list === undefined || list.total === 0) {
254 return {
255 rows: 1,
256 toggles: [],
257 node: (
258 <Box key={`remote-${sid}`}>
259 <Text dimColor>{list === undefined ? ' └─ Reading its agents…' : ' └─ No agents.'}</Text>
260 </Box>
261 ),
262 }
263 }
264
265 const shown = list.agents.slice(0, REMOTE_SHOWN)
266 const older = list.total - shown.length
267 const lines = shown.map((a, i) => {
268 const share = shared.find(one => one.id === a.id)
269 const target = `remote:${sid}:${a.id}`
270 const text = tasks[`${sid}:${a.id}`] ?? share?.task
271 const task = !open.includes(target)
272 ? []
273 : text === undefined
274 ? ['Loading…']
275 : text === ''
276 ? ['No task text.']
277 : clip(wrap(text, view.columns - 7), TASK_LINES)
278 return {
279 a,
280 target,
281 task,
282 key: `toggle-remote-${sid}-${a.id}`,
283 isActive: isRemoteActive(view, a, share),
284 tally: remoteTally(view, a, share),
285 branch: i === shown.length - 1 && older === 0 ? ' └─ ' : ' ├─ ',
286 }
287 })
288
289 return {
290 rows: lines.reduce((sum, line) => sum + 1 + line.task.length, 0) + (older > 0 ? 1 : 0),
291 toggles: lines.map(line => ({ key: line.key, target: line.target })),
292 node: (
293 <Box key={`remote-${sid}`} flexDirection="column">
294 {lines.map(line => (
295 <Box key={`remote-${sid}-${line.a.id}`} flexDirection="column">
296 <Box flexDirection="row">
297 <Text dimColor>{line.branch}</Text>
298 <Button key={line.key} plain label={line.task.length > 0 ? '▾' : '▸'} onPress={() => view.toggle(line.target)} />
299 <Box flexGrow={1}>
300 <Text dimColor={!line.isActive}>
301 {' '}
302 <Text color={line.isActive ? 'claude' : 'subtle'}>{line.isActive ? spin(view.at) : '○'}</Text>{' '}
303 <Text inverse={view.cursor === line.key}>
304 {fit(`${line.a.type} ${line.a.description}`, view.columns - 9 - cells(line.tally) - 1)}
305 </Text>
306 </Text>
307 </Box>
308 <Text dimColor> {line.tally}</Text>
309 </Box>
310 {line.task.map(text => (
311 <Text dimColor>
312 {' '}
313 {text}
314 </Text>
315 ))}
316 </Box>
317 ))}
318 {older > 0 && (
319 <Box key={`remote-older-${sid}`}>
320 <Text dimColor>{` └─ +${older} older`}</Text>
321 </Box>
322 )}
323 </Box>
324 ),
325 }
326}
327
328export function isRemoteActive(view: View, a: RemoteAgent, share: SharedAgent | undefined): boolean {
329 return share !== undefined || view.at - a.writtenAt <= ACTIVE_MS
330}
331
332// A running agent its glimt shares: its time and what it runs; any other,
333// when its transcript was last written.
334export function remoteTally(view: View, a: RemoteAgent, share: SharedAgent | undefined): string {
335 if (share === undefined) {
336 return `${minutes(view.at - a.writtenAt)} ago`
337 }
338
339 const doing = share.tool === undefined ? `${share.tools} ${share.tools === 1 ? 'tool' : 'tools'}` : toolName(share.tool)
340 return `${minutes(view.at - share.startedAt)} · ${doing}`
341}
342
343// Waiting for its person: a background session says so as its state, any
344// session as its status.
345export function isSessionWaiting(s: Session): boolean {
346 return s.state === 'blocked' || s.status === 'waiting'
347}
348
349export function isSessionBusy(s: Session): boolean {
350 return !isSessionWaiting(s) && (s.status === 'busy' || s.state === 'working')
351}
352
353export function sessionState(s: Session): SessionState {
354 return isSessionWaiting(s) ? 'waiting' : isSessionBusy(s) ? 'working' : 'idle'
355}
356
357// What a waiting session wants of its person, by why it waits: a
358// permission prompt or a sandbox request an approval, a question an answer.
359const WANTS: Record<string, 'approve' | 'answer'> = { 'permission prompt': 'approve', 'sandbox request': 'approve', 'input needed': 'answer' }
360
361// A session's mark and word: the spinner while it works, ◉ while it waits
362// for its person, ○ while it idles. The word says it again without color,
363// and a waiting session's says what it wants, where it says why it waits.
364export function sessionMark(s: Session, at: number): { mark: string; color: string; word: string } {
365 const state = sessionState(s)
366 if (state === 'waiting') {
367 return { mark: '◉', color: 'warning', word: WANTS[s.waitingFor ?? ''] ?? 'waiting' }
368 }
369
370 return state === 'working' ? { mark: spin(at), color: 'claude', word: 'working' } : { mark: '○', color: 'subtle', word: 'idle' }
371}
372
373// The word, and how long the session has been in its state when glimt saw
374// that state begin: "idle · 4m", or "idle".
375export function stateText(word: string, phase: Phase | undefined, at: number): string {
376 return phase?.since === undefined ? word : `${word} · ${minutes(at - phase.since)}`
377}
378
379// The sessions' phases after a read at `at`. A session glimt meets for the
380// first time is in a phase of unknown age; one whose state changed starts a
381// phase now, unseen if it stopped; a session no longer listed is dropped.
382export function nextPhases(before: Record<string, Phase>, list: Session[], at: number): Record<string, Phase> {
383 return Object.fromEntries(
384 list.map(s => {
385 const state = sessionState(s)
386 const was = before[s.sessionId]
387 const phase: Phase =
388 was === undefined ? { state, isUnseen: false } : was.state === state ? was : { state, since: at, isUnseen: state !== 'working' }
389 return [s.sessionId, phase]
390 }),
391 )
392}
393
394// What a notification says of a session that started waiting.
395export function waitingNote(s: Session): string {
396 const wants = WANTS[s.waitingFor ?? '']
397
398 return wants === 'approve'
399 ? `${s.name} needs your approval`
400 : wants === 'answer'
401 ? `${s.name} has a question for you`
402 : `${s.name} is waiting for you`
403}
404hooks/state.ts 24 lines1// What glimt's modules share: the pane's id, and the few values they share
2// as they run. The values drawing reads stay in register.tsx.
3
4export const PANE = 'glimt'
5
6// A toggle drawn in the pane and what it opens, in the order j and k walk.
7export type Toggle = { key: string; target: string }
8
9// The values glimt's modules share as they run: the toggles in the order
10// last drawn (for j and k), whether the pane held the keyboard when last
11// drawn, the home folder and Claude Code's own folder,
12// whether anything runs (so the clock knows to move) and how many frames
13// have passed, and what this session last shared, and when.
14export const runtime = {
15 toggles: [] as Toggle[],
16 isFocused: false,
17 home: '',
18 configDir: '',
19 isBusy: false,
20 ticks: 0,
21 lastShared: '',
22 lastSharedAt: 0,
23}
24hooks/text.ts 160 lines1// Text as the pane draws it: measured in cells, fitted, wrapped, and
2// times and counts written short.
3
4// The pane measures text in code points (see cells), so spreading a string
5// into its code points is how it is measured and cut here.
6/* oxlint-disable typescript/no-misused-spread */
7
8// The braille spinner the skins mod's spinner turns too, a frame every
9// FRAME_MS while something runs; the clock stands still when nothing does.
10// It turns only on what is working right now: a running agent, a working
11// session, the step in progress while a turn runs. What waits, idles or is
12// done keeps a still mark.
13export const FRAME_MS = 100
14export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
15
16// 950, 9.5k, 45k, 1.2M: a decimal only where a count has two digits or
17// fewer, and none that would round up to the next unit.
18export function tokenNumber(tokens: number): string {
19 if (tokens < 1000) {
20 return String(tokens)
21 }
22 if (tokens < 9_950) {
23 return `${(tokens / 1000).toFixed(1)}k`
24 }
25
26 return tokens < 999_500 ? `${Math.round(tokens / 1000)}k` : `${(tokens / 1_000_000).toFixed(1)}M`
27}
28
29// The fields that say what a call is on, in the order they are looked for:
30// a command, a file, a pattern, an address, a query, a task.
31export const CALL_FIELDS = ['command', 'file_path', 'notebook_path', 'path', 'pattern', 'url', 'query', 'description', 'subject', 'prompt']
32
33// What a call is on, from the first of those fields it has: its first line.
34export function describeCall(input: object): string | undefined {
35 const fields = isRecord(input) ? input : {}
36 for (const name of CALL_FIELDS) {
37 const value = fields[name]
38 if (typeof value === 'string' && value.trim() !== '') {
39 return value.trim().split('\n')[0]
40 }
41 }
42
43 return undefined
44}
45
46// Text is measured in code points, one cell each for the glyphs this pane
47// draws; a wide character in a prompt can overrun by a cell.
48export function cells(text: string): number {
49 return [...text].length
50}
51
52// Cut to `width` cells, an ellipsis marking the cut.
53export function fit(text: string, width: number): string {
54 const chars = [...text]
55 if (chars.length <= width) {
56 return text
57 }
58
59 return width <= 0 ? '' : `${chars.slice(0, width - 1).join('')}…`
60}
61
62// Cut from the start, for a path: its end is what tells files apart.
63export function fitStart(text: string, width: number): string {
64 const chars = [...text]
65 if (chars.length <= width) {
66 return text
67 }
68
69 return width <= 0 ? '' : `…${chars.slice(chars.length - width + 1).join('')}`
70}
71
72// Word-wrapped to `width`, at most `limit` lines, the last ending in an
73// ellipsis when there was more. A word longer than a line is split.
74export function wrap(text: string, width: number, limit = Infinity): string[] {
75 const lines: string[] = []
76 let line = ''
77 for (const word of text.split(/\s+/).filter(part => part !== '')) {
78 const joined = line === '' ? word : `${line} ${word}`
79 if (cells(joined) <= width) {
80 line = joined
81 continue
82 }
83 if (line !== '') {
84 lines.push(line)
85 }
86 let rest = [...word]
87 while (rest.length > width) {
88 lines.push(rest.slice(0, width).join(''))
89 rest = rest.slice(width)
90 }
91 line = rest.join('')
92 }
93 if (line !== '') {
94 lines.push(line)
95 }
96 if (lines.length <= limit) {
97 return lines
98 }
99
100 const kept = lines.slice(0, limit)
101 const last = kept[limit - 1] ?? ''
102 kept[limit - 1] = cells(last) < width ? `${last}…` : fit(last, width)
103
104 return kept
105}
106
107export function clip(lines: string[], count: number): string[] {
108 if (lines.length <= count) {
109 return lines
110 }
111
112 return count <= 0 ? [] : [...lines.slice(0, count - 1), '…']
113}
114
115export function spin(at: number): string {
116 return SPINNER[Math.floor(at / FRAME_MS) % SPINNER.length] ?? '◐'
117}
118
119export function duration(ms: number): string {
120 const seconds = Math.max(0, Math.floor(ms / 1000))
121 if (seconds < 60) {
122 return `${seconds}s`
123 }
124
125 const wholeMinutes = Math.floor(seconds / 60)
126 if (wholeMinutes < 60) {
127 return `${wholeMinutes}m${String(seconds % 60).padStart(2, '0')}s`
128 }
129
130 return `${Math.floor(wholeMinutes / 60)}h${String(wholeMinutes % 60).padStart(2, '0')}m`
131}
132
133// A running time on a row, by the minute, so the row stands still: <1m, 4m,
134// 1h05m. A finished one keeps its seconds (duration), as it no longer moves.
135export function minutes(ms: number): string {
136 const total = Math.max(0, Math.floor(ms / 60_000))
137 if (total < 1) {
138 return '<1m'
139 }
140
141 return total < 60 ? `${total}m` : `${Math.floor(total / 60)}h${String(total % 60).padStart(2, '0')}m`
142}
143
144// mcp__server__tool reads as tool.
145export function toolName(tool: string): string {
146 return tool.startsWith('mcp__') ? tool.split('__').slice(2).join('__') : tool
147}
148
149// A parsed JSON value's fields, when it is an object.
150export function isRecord(value: unknown): value is Record<string, unknown> {
151 return typeof value === 'object' && value !== null
152}
153
154// The first line of what a process printed, or undefined when it printed none.
155export function firstLine(text: string | undefined): string | undefined {
156 const line = text?.trim().split('\n')[0]
157
158 return line === undefined || line === '' ? undefined : line
159}
160hooks/view.tsx 61 lines1// What drawing needs beside the state, and the pieces every section draws.
2
3import type { ElementTable } from 'claude-code'
4import { cells, fit } from './text'
5
6export type Ui = Pick<ElementTable, 'Box' | 'Text' | 'Button'>
7export type InputElement = ElementTable<'terminal' | 'desktop'>['Input']
8
9// What drawing needs beside the state: the elements (an Input where the
10// surface has one), the width, the time, the toggle the cursor is on, and
11// what pressing a toggle does.
12export type View = {
13 ui: Ui
14 Input?: InputElement | undefined
15 columns: number
16 at: number
17 isTurnRunning: boolean
18 cursor: string | null
19 toggle: (id: string) => void
20}
21
22// A section's heading: its label, bold, and what the section counts right
23// beside it, dim. Nearness ties the count to its label; the blank row above
24// each section sets the sections apart.
25export function heading({ ui: { Box, Text }, columns }: View, key: string, label: string, count: string) {
26 const shown = fit(label, count === '' ? columns : Math.max(1, columns - cells(count) - 2))
27
28 return (
29 <Box key={key} flexDirection="row">
30 <Text bold>{shown}</Text>
31 {count !== '' && <Text dimColor>{fit(` ${count}`, columns - cells(shown))}</Text>}
32 </Box>
33 )
34}
35
36// The cells a key takes on its row: the hotkey, a colon and a space, the label.
37export function keyWidth(label: string): number {
38 return 3 + cells(label)
39}
40
41// How many rows a row of items `widths` wide takes once it wraps at
42// `columns`, `gap` cells apart.
43export function wrappedRows(widths: readonly number[], columns: number, gap: number): number {
44 let rows = 1
45 let used = 0
46 for (const width of widths) {
47 if (used > 0 && used + gap + width > columns) {
48 rows += 1
49 used = width
50 } else {
51 used = used === 0 ? width : used + gap + width
52 }
53 }
54
55 return rows
56}
57
58export function keyButton({ ui: { Button } }: View, name: string, hotkey: string, label: string, press: () => void) {
59 return <Button key={`key-${name}`} plain dimColor hotkey={hotkey} label={label} onPress={press} />
60}
61types/index.d.ts 173 lines1export type StepStatus = 'pending' | 'in_progress' | 'completed'
2
3// One step of the plan, as Claude's task list (or TodoWrite) holds it.
4// `description` is undefined until known: a task seeded from TaskList is
5// read with TaskGet the first time it is opened. `startedAt` is when it went
6// in progress, while it is; unknown for one seeded already in progress.
7// `parentId` is the step it is a sub-step of. A subagent's own tasks join the
8// plan with ids led by the agent's ("<agentId>/<taskId>").
9export type Step = {
10 id: string
11 subject: string
12 description?: string | undefined
13 activeForm?: string | undefined
14 status: StepStatus
15 startedAt?: number | undefined
16 parentId?: string | undefined
17}
18
19export type AgentState = 'running' | 'done' | 'failed' | 'stopped'
20
21// One subagent, from its spawn to the end of its run. `parentId` is the
22// agentId of the agent that spawned it, undefined when the main conversation
23// did; `stepId` is the plan step in progress when it started, which its own
24// tasks nest under; `task` is the prompt it was given; `tool` and `doing` say
25// what its running call is and what that call is on; `tokens` is the size of
26// its context: what its latest model request carried, and the reply.
27export type Agent = {
28 id: string
29 agentId?: string | undefined
30 parentId?: string | undefined
31 stepId?: string | undefined
32 type: string
33 description: string
34 task: string
35 state: AgentState
36 startedAt: number
37 endedAt?: number | undefined
38 tools: number
39 tool?: string | undefined
40 doing?: string | undefined
41 tokens?: number | undefined
42 // How full its context window is, in percent: known for the main
43 // conversation alone.
44 percent?: number | undefined
45}
46
47// The main conversation's latest turn: when it started and ended, how it
48// ended, the tool it runs now, how many calls it has made and its context.
49export type Focus = {
50 startedAt?: number | undefined
51 endedAt?: number | undefined
52 outcome?: AgentState | undefined
53 tools?: number | undefined
54 tool?: string | undefined
55 doing?: string | undefined
56 tokens?: number | undefined
57 percent?: number | undefined
58}
59
60// A plan approved in plan mode.
61export type Plan = { title: string; path?: string | undefined }
62
63// One Claude Code session on this machine, as `claude agents --json` lists
64// it. `id` is a background session's short id, what `claude attach` and
65// `claude stop` take; `status` is busy, waiting or idle for every session,
66// `state` working, blocked (waiting for the person) and the like for a
67// background one; `waitingFor` says why it waits ("permission prompt",
68// "input needed", ...).
69export type Session = {
70 sessionId: string
71 id?: string | undefined
72 name: string
73 cwd: string
74 kind: string
75 status?: string | undefined
76 state?: string | undefined
77 waitingFor?: string | undefined
78 pid?: number | undefined
79 startedAt: number
80}
81
82// What a session is doing, as its mark says it.
83export type SessionState = 'waiting' | 'working' | 'idle'
84
85// Another session's state as glimt has watched it: since when, undefined
86// while glimt has not seen it begin, and whether it stopped (went idle or
87// waiting) since the person last opened it.
88export type Phase = { state: SessionState; since?: number | undefined; isUnseen: boolean }
89
90// A running agent of another session, as that session's glimt shares it
91// through the store every session on the machine reads.
92export type SharedAgent = { id: string; type: string; description: string; task: string; startedAt: number; tools: number; tool?: string | undefined }
93
94// What a session's glimt shares: when it last did, and its running agents.
95export type Shared = { at: number; agents: SharedAgent[] }
96
97// The form open at the top of the pane: a new agent, here or as a new
98// background session, or a new name for a session.
99export type Composer = { kind: 'spawn'; where: 'here' | 'session' } | { kind: 'rename'; sessionId: string }
100
101// One step of the way l drilled in, in place of the overview: one of this
102// session's agents (by its Agent id), another session, or one of another
103// session's agents. `from` is the toggle the cursor was on, where h puts it
104// back.
105export type Opened = (
106 | { kind: 'agent'; id: string }
107 | { kind: 'session'; sessionId: string }
108 | { kind: 'remote'; sessionId: string; agentId: string }
109) & { from?: string | undefined }
110
111// One subagent of another session, as that session's transcript folder lists
112// it: its type and description from the agent's meta file, and when its
113// transcript was last written.
114export type RemoteAgent = { id: string; type: string; description: string; writtenAt: number }
115
116// Another session's subagents, the newest written first, as many as are
117// read; `total` counts every one in the folder.
118export type RemoteList = { agents: RemoteAgent[]; total: number }
119
120// One item of an agent's or a session's latest activity: what it was asked,
121// what Claude said, or a call it made ("Read · /a/b.ts").
122export type Activity = { kind: 'asked' | 'said' | 'call'; text: string }
123
124// The activity last read for what the pane is drilled into, oldest first, or
125// why it could not be read.
126export type Feed = { items: Activity[]; error?: string | undefined }
127
128declare module 'claude-code' {
129 interface PluginState {
130 glimt: {
131 focus: Focus | null
132 plan: Plan | null
133 steps: Step[]
134 agents: Agent[]
135 now: number
136 // Whether the pane is drawn, so the chat can leave out what it shows.
137 isShown: boolean
138 // The steps, and the agents ("agent:" and the id), opened to show more.
139 expanded: string[]
140 // The toggle j, k and o act on, drawn highlighted.
141 cursor: string | null
142 // Every session, as last read; null before the first read.
143 sessions: Session[] | null
144 // Why the last read failed, until one succeeds.
145 sessionsError: string | null
146 // The other sessions' states as glimt watched them, by session id.
147 phases: Record<string, Phase>
148 // Whether this session's requests offer Claude a task list; null
149 // before the first. Once one does, it stays true.
150 hasTaskList: boolean | null
151 // The session `x` asked to stop, waiting for y or n.
152 stopping: string | null
153 // This session's id, to leave it out of the list: the pane is about it.
154 self: string | null
155 // What the other sessions' glimts share, by session id, fresh ones only.
156 shared: Record<string, Shared>
157 composer: Composer | null
158 // Whether c asked to clear this conversation, waiting for y or n.
159 clearing: boolean
160 // The way l drilled in, the last step on show; empty for the overview.
161 opened: Opened[]
162 // The last step's latest activity; null until the first read.
163 feed: Feed | null
164 // The subagents of the sessions opened or drilled into, by session id.
165 remote: Record<string, RemoteList>
166 // Another session's agents' tasks as read, by "<sessionId>:<agentId>".
167 remoteTasks: Record<string, string>
168 // Whether i opened the key list in place of the pane's body.
169 help: boolean
170 }
171 }
172}
173