SLOPSHOPPER

glimt

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

newpanerowsguardcommandtoast
v0.2.0Apache-2.0updated 2026-10-09mmedum/glimt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glimt
│ ┃ glimt ✕ › fix the failing auth test and add an audit log call │ ┃ ▸ this session │ ┃ ⏺ Read(src/auth.ts) │ ┃ Now ⎿ Read 6 lines │ ┃ No step in progress. ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ Plan ⏺ Bash(bun test) │ ┃ No steps yet. ⎿ 3 pass, 1 fail │ ┃ │ ┃ Agents ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ✓ Claude main conversation │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Sessions │ ┃ claude agents --json printed something other › /glimt │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · glimt
▸ this session Now No step in progress. Plan No steps yet. Agents ✓ Claude main conversation 0s · 9 tools Sessions claude agents --json printed something other than a lis…
README

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

glimt beside a session: this session, Now, the plan with its steps, two agents at work, and three other sessions

Install

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

Keys

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.

KeyDoes
j kmove down and up (↓ ↑ and Tab work too)
lgo into an agent or a session: its task and live activity
hback out, one level at a time
oopen or close the row: a step's description, an agent's task, a session's agents
nnew agent here
snew background session (claude --bg), from the i list
rrename this session, or another session running glimt
cclear this conversation, after asking
mwrite to the agent or session you are in
acopy a background session's claude attach command
xstop a background session, after asking
iall keys
qclose 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.

How it works

Mods run with your permissions, so here is everything glimt touches. claude plugin validate lists the same.

  • This session: it follows the session's tool calls, tasks and subagents through the mod API. While the task tools are on offer, it adds one sentence to the system prompt asking Claude to file sub-steps under their step (metadata.parent).
  • Other sessions: it runs 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.
  • Between sessions: each session's glimt writes its running agents, and any rename it is asked to make, to Claude Code's plugin store.
  • Notifications: when a background session starts waiting for you, glimt raises one notification through your own notification setting, or a toast where your terminal shows none; nothing if you turned notifications off. A session in a terminal of its own notifies from there.
  • Actions: 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.
  • The chat: while the pane is open, it leaves task-list rows out of the chat and keeps a running agent's row to one line.

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.

Contributing

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.

License

Apache-2.0 © 2026 Mark Medum Bundgaard

Source 10 files
hooks/register.tsx 1344 lines
1// 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 lines
1// 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}
310
hooks/drill.tsx 362 lines
1// 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}
362
hooks/keys.tsx 192 lines
1// 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}
192
hooks/plan.tsx 286 lines
1// 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}
286
hooks/sessions.tsx 404 lines
1// 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}
404
hooks/state.ts 24 lines
1// 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}
24
hooks/text.ts 160 lines
1// 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}
160
hooks/view.tsx 61 lines
1// 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}
61
types/index.d.ts 173 lines
1export 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